From f2bec9dbbac93bc29b81663d3ecb4b0cb9e1cffd Mon Sep 17 00:00:00 2001 From: leaysgur <6259812+leaysgur@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:08:13 +0900 Subject: [PATCH 01/18] export resolveConfigFile --- docs/guide/migrate-rules.md | 2 +- .../src/__tests__/resolve-vite-config.spec.ts | 125 ++++++++++++++++-- packages/cli/src/define-config.ts | 60 ++++++++- packages/cli/src/index.cts | 3 +- packages/cli/src/index.ts | 4 +- packages/cli/src/resolve-vite-config.ts | 30 ++--- 6 files changed, 192 insertions(+), 32 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 503c2f401e..1292b5c015 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -200,7 +200,7 @@ Object-valued nested npm and Bun overrides are preserved: they are user-defined `vite` and `vite/*` imports are rewritten to `vite-plus` **only in config entry files**: `vite.config.*`, `vitest.config.*`, and any config file the migration resolved. Every other file keeps its `vite` imports, for two reasons: -- `vite-plus` is not a guaranteed superset of Vite's exposed surface. It owns only `defineConfig`, `defineProject`, and `lazyPlugins`, so rewriting a pass-through symbol such as `createBuilder` or `loadConfigFromFile` (including in `typeof import('vite')` type positions) can break. +- `vite-plus` is not a guaranteed superset of Vite's exposed surface. It owns only `defineConfig`, `defineProject`, `lazyPlugins`, and `resolveConfigFile`, so rewriting a pass-through symbol such as `createBuilder` or `loadConfigFromFile` (including in `typeof import('vite')` type positions) can break. - An unrewritten `vite` import still resolves through the `@voidzero-dev/vite-plus-core` alias in a Vite+ project. Plugin packages (an unscoped name starting with `vite-plugin-` or `unplugin-`, or `vite` in `peerDependencies`/`dependencies`) skip the rewrite even in config files. Only the `vite` specifier is in scope for this rule. diff --git a/packages/cli/src/__tests__/resolve-vite-config.spec.ts b/packages/cli/src/__tests__/resolve-vite-config.spec.ts index 9dc4b89427..c6b0a06e2e 100644 --- a/packages/cli/src/__tests__/resolve-vite-config.spec.ts +++ b/packages/cli/src/__tests__/resolve-vite-config.spec.ts @@ -1,24 +1,29 @@ import fs from 'node:fs'; import { mkdtempSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { normalizePath } from 'vite'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { findViteConfigUp } from '../resolve-vite-config.js'; +import { resolveConfigFile } from '../index.js'; +import { findViteConfigUp, resolveViteConfig } from '../resolve-vite-config.js'; -describe('findViteConfigUp', () => { - let tempDir: string; +let tempDir: string; - beforeEach(() => { - // Resolve symlinks (macOS /var -> /private/var) to match path.resolve behavior - tempDir = fs.realpathSync(mkdtempSync(path.join(tmpdir(), 'vite-config-test-'))); - }); +beforeEach(() => { + // Resolve symlinks (macOS /var -> /private/var) to match path.resolve behavior + tempDir = fs.realpathSync(mkdtempSync(path.join(tmpdir(), 'vite-config-test-'))); +}); - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); +afterEach(async () => { + // Vite removes its bundled config asynchronously. Allow transient Windows + // file locks to clear; persistent cleanup errors still fail the test. + await rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); +describe('findViteConfigUp', () => { it('should find config in the start directory', () => { fs.writeFileSync(path.join(tempDir, 'vite.config.ts'), ''); const result = findViteConfigUp(tempDir, tempDir); @@ -91,3 +96,103 @@ describe('findViteConfigUp', () => { expect(result).toBe(path.join(tempDir, `vite.config.${extension}`)); }); }); + +// Plugins read `root` in their `config` hook, as TanStack Start does to find +// its entries, so the fixture plugin records the value it sees there. +function writeConfig(dir: string, fields = ''): string { + const configFile = path.join(dir, 'vite.config.mjs'); + fs.writeFileSync( + configFile, + `export default { + ${fields} + plugins: [{ name: 'record-root', config: (config) => ({ seenRoot: config.root }) }], +}; +`, + ); + return configFile; +} + +// Vite stores the real path, which may differ from `tempDir` in casing or +// short names on Windows. +function realPath(file: string): string { + return fs.realpathSync.native(file); +} + +describe('resolveConfigFile', () => { + it('resolves root to the config directory regardless of cwd', async () => { + const configFile = writeConfig(tempDir); + + const config = await resolveConfigFile(configFile); + + expect(config).toHaveProperty('seenRoot', tempDir); + expect(realPath(config.root)).toBe(realPath(tempDir)); + }); + + it('resolves a relative root from the config directory', async () => { + const configFile = writeConfig(tempDir, "root: 'app',"); + fs.mkdirSync(path.join(tempDir, 'app')); + + const config = await resolveConfigFile(configFile); + + expect(config).toHaveProperty('seenRoot', path.join(tempDir, 'app')); + expect(realPath(config.root)).toBe(realPath(path.join(tempDir, 'app'))); + }); + + it('keeps the config file and its dependencies', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync(path.join(tempDir, 'lint.mjs'), 'export const lint = { rules: {} };\n'); + fs.writeFileSync(configFile, "import { lint } from './lint.mjs';\nexport default { lint };\n"); + + const config = await resolveConfigFile(configFile); + + expect(config.lint).toEqual({ rules: {} }); + expect(config.configFile).toBe(normalizePath(configFile)); + expect(config.configFileDependencies.map(realPath)).toContain( + realPath(path.join(tempDir, 'lint.mjs')), + ); + }); + + it('evaluates the config as a config-metadata resolution', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync( + configFile, + 'export default () => ({ metadata: process.env.VP_RESOLVING_CONFIG_METADATA });\n', + ); + + const config = await resolveConfigFile(configFile); + + expect(config).toHaveProperty('metadata', '1'); + }); +}); + +describe('resolveViteConfig', () => { + it('keeps a relative root from the config', async () => { + writeConfig(tempDir, "root: 'app',"); + fs.mkdirSync(path.join(tempDir, 'app')); + + const config = await resolveViteConfig(tempDir); + + expect(config).toHaveProperty('seenRoot', path.join(tempDir, 'app')); + }); + + it('resolves a workspace config found by traverseUp from its own directory', async () => { + fs.writeFileSync(path.join(tempDir, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); + fs.writeFileSync(path.join(tempDir, 'package.json'), '{"name":"fixture"}'); + const configFile = writeConfig(tempDir); + const packageDir = path.join(tempDir, 'packages', 'lib'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(path.join(packageDir, 'package.json'), '{"name":"lib"}'); + + const config = await resolveViteConfig(packageDir, { traverseUp: true }); + + expect(config.configFile).toBe(normalizePath(configFile)); + expect(config).toHaveProperty('seenRoot', tempDir); + }); + + it('resolves without a config file', async () => { + const config = await resolveViteConfig(tempDir); + + expect(config.configFile).toBeUndefined(); + expect(config.lint).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 930c2ca9e4..6e8d2c4b23 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -1,13 +1,13 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ignoreInput, ignoreOutput } from '@voidzero-dev/vite-task-client'; import type { OxfmtConfig } from 'oxfmt'; import type { OxlintConfig } from 'oxlint'; -import { searchForWorkspaceRoot } from 'vite'; -import type { Alias, Plugin, PluginOption, UserConfig } from 'vite'; +import { loadConfigFromFile, normalizePath, resolveConfig, searchForWorkspaceRoot } from 'vite'; +import type { Alias, Plugin, PluginOption, ResolvedConfig, UserConfig } from 'vite'; import { defineConfig as viteDefineConfig, defineProject as viteDefineProject, @@ -1049,6 +1049,60 @@ export async function withConfigMetadataResolution(fn: () => Promise): Pro } } +/** + * Resolve a Vite config file to read a non-plugin block (`lint`, `fmt`, + * `pack`, …) from it, for tools such as oxlint, oxfmt, and Vite+ itself. + * + * Unlike `resolveConfig({ configFile }, 'build')`, the result does not depend + * on `process.cwd()`: a relative `root` in the config is resolved from the + * config's directory, and without one `root` is that directory. Editor + * integrations rely on this, since one process serves every workspace folder. + * + * The config is evaluated once, as a config-metadata resolution, so + * `lazyPlugins` skips its factory. Plugins outside `lazyPlugins` still run + * their hooks and side effects, e.g. TanStack Start's router generator scans + * `src/routes`. + */ +export async function resolveConfigFile(configFile: string): Promise { + return withConfigMetadataResolution(async () => { + // Load the config before resolving it to read the user's `root`. Neither + // plain `resolveConfig` call resolves `root` from the config's directory: + // - `resolveConfig({ configFile })` falls back to `process.cwd()`, which + // an editor may set to another workspace folder. + // - `resolveConfig({ root: dirname(configFile), configFile })` overrides + // the user's `root`, e.g. `root: 'app'` with sources in `app/src`. + const configEnv: ConfigEnv = { + command: 'build', + mode: 'development', + isSsrBuild: false, + isPreview: false, + }; + const loaded = await loadConfigFromFile(configEnv, configFile); + if (!loaded) { + throw new Error(`Failed to load config file: ${configFile}`); + } + // An absolute `root` is kept as is. + const root = resolve(dirname(loaded.path), loaded.config.root ?? ''); + // Passing the loaded config inline evaluates it only once, but differs + // from `resolveConfig({ configFile }, 'build')`: + // - The config file is evaluated before `resolveConfig` defaults + // `NODE_ENV` to `development` or reads it from Vite Task, so the first + // evaluation in a process may see it unset. + // - `configEnv.isSsrBuild` in plugin hooks follows the user's `build.ssr`. + // - `resolved.inlineConfig` holds the user config. + // - Plugin hooks do not see the config file path; it is restored below. + // Loading the config twice, once to read `root` and then through + // `resolveConfig({ root, configFile })`, avoids these differences but runs + // the config and its side effects twice. + const config = await resolveConfig({ ...loaded.config, root, configFile: false }, 'build'); + // Restore what `configFile: false` drops, e.g. for `vp pack` to watch the config. + return Object.assign(config, { + configFile: loaded.path, + configFileDependencies: loaded.dependencies.map((file) => normalizePath(resolve(file))), + }); + }); +} + export function lazyPlugins(cb: () => PluginOption[]): PluginOption[] | undefined; export function lazyPlugins(cb: () => Promise): PluginOption[] | undefined; export function lazyPlugins( diff --git a/packages/cli/src/index.cts b/packages/cli/src/index.cts index 49a1821ca8..a52d75654d 100644 --- a/packages/cli/src/index.cts +++ b/packages/cli/src/index.cts @@ -8,7 +8,7 @@ const { defaultInclude, } = require('vitest/config'); -const { defineConfig, defineProject, lazyPlugins } = require('./define-config'); +const { defineConfig, defineProject, lazyPlugins, resolveConfigFile } = require('./define-config'); module.exports = { ...vite, @@ -20,4 +20,5 @@ module.exports = { defineProject, defineConfig, lazyPlugins, + resolveConfigFile, }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2c4b0237f7..8559dd1d7a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,4 @@ -import { defineConfig, defineProject, lazyPlugins } from './define-config.ts'; +import { defineConfig, defineProject, lazyPlugins, resolveConfigFile } from './define-config.ts'; export * from 'vite'; @@ -26,4 +26,4 @@ export type { WatcherTriggerPattern, } from 'vitest/config'; -export { defineConfig, defineProject, lazyPlugins }; +export { defineConfig, defineProject, lazyPlugins, resolveConfigFile }; diff --git a/packages/cli/src/resolve-vite-config.ts b/packages/cli/src/resolve-vite-config.ts index 4e3b177e91..bc38000c5e 100644 --- a/packages/cli/src/resolve-vite-config.ts +++ b/packages/cli/src/resolve-vite-config.ts @@ -2,7 +2,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { findMonorepoRoot } from '../binding/index.js'; -import { withConfigMetadataResolution } from './define-config.ts'; import { VITE_CONFIG_FILES } from './utils/constants.ts'; /** @@ -56,23 +55,24 @@ export interface ResolveViteConfigOptions { * Resolve vite.config.ts and return the config object. */ export async function resolveViteConfig(cwd: string, options?: ResolveViteConfigOptions) { - const { resolveConfig } = await import('./index.js'); + const { resolveConfig, resolveConfigFile } = await import('./index.js'); - // This loads the config purely to read a non-plugin block (lint/fmt/pack/run/ - // staged/create…), so skip the user's plugin factory while it evaluates. - return withConfigMetadataResolution(async () => { - if (options?.traverseUp && !hasViteConfig(cwd)) { - const workspaceRoot = findMonorepoRoot(cwd); - if (workspaceRoot) { - const configFile = findViteConfigUp(path.dirname(cwd), workspaceRoot); - if (configFile) { - return resolveConfig({ root: cwd, configFile }, 'build'); - } - } + let configFile = findViteConfig(cwd); + if (!configFile && options?.traverseUp) { + const workspaceRoot = findMonorepoRoot(cwd); + if (workspaceRoot) { + configFile = findViteConfigUp(path.dirname(cwd), workspaceRoot); } + } - return resolveConfig({ root: cwd }, 'build'); - }); + // This loads the config purely to read a non-plugin block (lint/fmt/pack/run/ + // staged/create…), so `resolveConfigFile` skips the user's plugin factory. + // `root` resolves from the config's directory, not `cwd`, including for a + // workspace config found from a package directory by `traverseUp`. + if (configFile) { + return resolveConfigFile(configFile); + } + return resolveConfig({ root: cwd, configFile: false }, 'build'); } export async function resolveUniversalViteConfig(err: null | Error, viteConfigCwd: string) { From 02c767bb215dbd9416fa0145b856bc70e844f462 Mon Sep 17 00:00:00 2001 From: leaysgur <6259812+leaysgur@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:37:42 +0900 Subject: [PATCH 02/18] f --- packages/cli/src/define-config.ts | 45 ++++++++++++------------- packages/cli/src/resolve-vite-config.ts | 13 ++++--- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 6e8d2c4b23..803cf7554e 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -1050,27 +1050,27 @@ export async function withConfigMetadataResolution(fn: () => Promise): Pro } /** - * Resolve a Vite config file to read a non-plugin block (`lint`, `fmt`, - * `pack`, …) from it, for tools such as oxlint, oxfmt, and Vite+ itself. + * Resolve a Vite config file to read a non-plugin block (`lint`, `fmt`, `pack`, …) from it, + * for tools such as oxlint, oxfmt, and Vite+ itself. * - * Unlike `resolveConfig({ configFile }, 'build')`, the result does not depend - * on `process.cwd()`: a relative `root` in the config is resolved from the - * config's directory, and without one `root` is that directory. Editor - * integrations rely on this, since one process serves every workspace folder. + * Unlike `resolveConfig({ configFile }, 'build')`, the result does not depend on `process.cwd()`: + * a relative `root` in the config is resolved from the config's directory, + * and without one `root` is that directory. + * Editor integrations rely on this, since one process serves every workspace folder. * - * The config is evaluated once, as a config-metadata resolution, so - * `lazyPlugins` skips its factory. Plugins outside `lazyPlugins` still run - * their hooks and side effects, e.g. TanStack Start's router generator scans - * `src/routes`. + * The config is evaluated once, as a config-metadata resolution, + * so `lazyPlugins` skips its factory. + * Plugins outside `lazyPlugins` still run their hooks and side effects, + * e.g. TanStack Start's router generator scans `src/routes`. */ export async function resolveConfigFile(configFile: string): Promise { return withConfigMetadataResolution(async () => { - // Load the config before resolving it to read the user's `root`. Neither - // plain `resolveConfig` call resolves `root` from the config's directory: - // - `resolveConfig({ configFile })` falls back to `process.cwd()`, which - // an editor may set to another workspace folder. - // - `resolveConfig({ root: dirname(configFile), configFile })` overrides - // the user's `root`, e.g. `root: 'app'` with sources in `app/src`. + // Load the config before resolving it to read the user's `root`. + // Neither plain `resolveConfig` call resolves `root` from the config's directory: + // - `resolveConfig({ configFile })` falls back to `process.cwd()`, + // which an editor may set to another workspace folder. + // - `resolveConfig({ root: dirname(configFile), configFile })` overrides the user's `root`, + // e.g. `root: 'app'` with sources in `app/src`. const configEnv: ConfigEnv = { command: 'build', mode: 'development', @@ -1083,17 +1083,16 @@ export async function resolveConfigFile(configFile: string): Promise Date: Tue, 27 Oct 2026 16:25:41 +0900 Subject: [PATCH 03/18] fix(config): preserve Vite resolution for config files --- CONTRIBUTING.md | 19 ++ .../config_resolution_hooks/app/index.js | 1 + .../config_resolution_hooks/input.txt | 1 + .../config_resolution_hooks/package.json | 5 + .../config_resolution_hooks/settings.mjs | 3 + .../config_resolution_hooks/snapshots.toml | 13 + .../staged_preserves_config_hooks.md | 30 ++ .../config_resolution_hooks/verify-staged.mjs | 8 + .../config_resolution_hooks/vite.config.mjs | 45 +++ package.json | 2 +- .../src/__tests__/resolve-vite-config.spec.ts | 264 ++++++++++++++++++ packages/cli/src/define-config.ts | 87 +++--- .../__tests__/resolve-config-root.spec.ts | 158 +++++++++++ packages/core/build.ts | 12 + .../tools/src/__tests__/brand-vite.spec.ts | 90 ++++-- .../src/__tests__/patch-vite-config.spec.ts | 99 +++++++ packages/tools/src/brand-vite.ts | 42 ++- packages/tools/src/patch-vite-config.ts | 104 +++++++ 18 files changed, 904 insertions(+), 79 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/app/index.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/input.txt create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/staged_preserves_config_hooks.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-staged.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs create mode 100644 packages/core/__tests__/resolve-config-root.spec.ts create mode 100644 packages/tools/src/__tests__/patch-vite-config.spec.ts create mode 100644 packages/tools/src/patch-vite-config.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a8202a5c5..6429bc36df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,6 +93,25 @@ To create a release build of Vite+ and all upstream dependencies, run: just build ``` +The build first applies the Vite+ source patches to the pinned Vite checkout. +These include the internal configuration root option used by `resolveConfigFile()`. +Keep changes to these patches in `packages/tools/src/`; `brand-vite` restores the +Vite files it manages before it applies them again. + +For an incremental configuration-loading change, with dependencies and native +bindings already built, run these commands from the repository root: + +```bash +node packages/tools/src/index.ts brand-vite +pnpm --filter vite build-types +pnpm --filter @voidzero-dev/vite-plus-core build +pnpm --filter vite-plus build-ts +``` + +Run the preparation and type build before a filtered core build. The core build +rejects a Vite checkout without the configuration patch. Build the CLI against +the matching core output so its helper uses the new option at runtime. + ## Git Hooks (optional) Pre-commit checks are opt-in. Run `vp config` after a build to install the hook dispatcher; each commit then runs `vp staged` on staged files. `vp staged` loads its config from `vite.config.ts` and needs the built JS packages, so build first. Opt out with `vp hooks disable`, or skip one run with `VP_GIT_HOOKS=0`. If husky was active in your clone before, run `git config --unset core.hooksPath` once first. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/app/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/app/index.js new file mode 100644 index 0000000000..36b8c6d193 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/app/index.js @@ -0,0 +1 @@ +export const app = 'fixture'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/input.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/input.txt new file mode 100644 index 0000000000..e79c5e8f96 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/input.txt @@ -0,0 +1 @@ +initial diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/package.json new file mode 100644 index 0000000000..2da2125db0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/package.json @@ -0,0 +1,5 @@ +{ + "name": "config-resolution-hooks", + "private": true, + "type": "module" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs new file mode 100644 index 0000000000..76cf8d67c6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs @@ -0,0 +1,3 @@ +export const staged = { + '*.txt': 'node verify-staged.mjs', +}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml new file mode 100644 index 0000000000..6e0bc0d7a2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml @@ -0,0 +1,13 @@ +[[case]] +name = "staged_preserves_config_hooks" +vp = "local" +unset-env = ["NODE_ENV"] +steps = [ + { argv = ["git", "init", "--quiet"], snapshot = false }, + { argv = ["git", "add", "input.txt"], snapshot = false }, + { argv = ["git", "commit", "--quiet", "-m", "initial"], snapshot = false }, + { argv = ["vpt", "write-file", "input.txt", "changed\n"], snapshot = false }, + { argv = ["git", "add", "input.txt"], snapshot = false }, + { argv = ["vp", "staged", "--quiet", "--no-stash"], comment = "Run staged configuration supplied by a Vite plugin with normal config metadata." }, + ["vpt", "print-file", "result.txt"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/staged_preserves_config_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/staged_preserves_config_hooks.md new file mode 100644 index 0000000000..6ab03219d2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/staged_preserves_config_hooks.md @@ -0,0 +1,30 @@ +# staged_preserves_config_hooks + +## `git init --quiet` + + +## `git add input.txt` + + +## `git commit --quiet -m initial` + + +## `vpt write-file input.txt 'changed +'` + + +## `git add input.txt` + + +## `vp staged --quiet --no-stash` + +Run staged configuration supplied by a Vite plugin with normal config metadata. + +``` +``` + +## `vpt print-file result.txt` + +``` +staged command from plugin passed +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-staged.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-staged.mjs new file mode 100644 index 0000000000..aa4b0de342 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-staged.mjs @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { basename } from 'node:path'; + +assert.equal(readFileSync('resolved.txt', 'utf8'), 'configResolved passed\n'); +assert.equal(process.argv.length, 3); +assert.equal(basename(process.argv[2]), 'input.txt'); +writeFileSync('result.txt', 'staged command from plugin passed\n'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs new file mode 100644 index 0000000000..b52ec5aa9c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { realpathSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { defineConfig, lazyPlugins, normalizePath } from 'vite-plus'; + +import { staged } from './settings.mjs'; + +const configFile = fileURLToPath(import.meta.url); +const configDir = dirname(configFile); +const root = join(configDir, 'app'); + +assert.equal(process.env.NODE_ENV, 'development'); + +export default defineConfig({ + root: 'app', + plugins: [ + lazyPlugins(() => { + throw new Error('metadata resolution must skip lazy plugin factories'); + }), + { + name: 'provide-staged-config', + apply(config) { + assert.equal(config.root, root); + return true; + }, + config: { + order: 'pre', + handler(config) { + assert.equal(config.root, root); + return { staged }; + }, + }, + async configResolved(config) { + assert.equal(config.configFile, normalizePath(configFile)); + assert.equal(realpathSync.native(config.root), realpathSync.native(root)); + assert.ok( + config.configFileDependencies.includes(normalizePath(join(configDir, 'settings.mjs'))), + ); + writeFileSync(join(configDir, 'resolved.txt'), 'configResolved passed\n'); + }, + }, + ], +}); diff --git a/package.json b/package.json index fd4ce34eb3..c982dd7058 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MIT", "type": "module", "scripts": { - "build": "pnpm -F rolldown build-binding:release && pnpm -F rolldown build-node && pnpm -F vite build-types && pnpm -F @voidzero-dev/* -F vite-plus build", + "build": "node packages/tools/src/index.ts brand-vite && pnpm -F rolldown build-binding:release && pnpm -F rolldown build-node && pnpm -F vite build-types && pnpm -F @voidzero-dev/* -F vite-plus build", "bootstrap-cli": "pnpm build && cargo build -p vp_global_cli --release && node packages/tools/src/build-trampoline.ts --release && pnpm install-global-cli", "bootstrap-cli:ci": "pnpm install-global-cli", "install-global-cli": "tool install-global-cli", diff --git a/packages/cli/src/__tests__/resolve-vite-config.spec.ts b/packages/cli/src/__tests__/resolve-vite-config.spec.ts index c6b0a06e2e..c100f3c687 100644 --- a/packages/cli/src/__tests__/resolve-vite-config.spec.ts +++ b/packages/cli/src/__tests__/resolve-vite-config.spec.ts @@ -1,8 +1,11 @@ +import { execFile } from 'node:child_process'; import fs from 'node:fs'; import { mkdtempSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; import { normalizePath } from 'vite'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -11,6 +14,7 @@ import { resolveConfigFile } from '../index.js'; import { findViteConfigUp, resolveViteConfig } from '../resolve-vite-config.js'; let tempDir: string; +const exec = promisify(execFile); beforeEach(() => { // Resolve symlinks (macOS /var -> /private/var) to match path.resolve behavior @@ -119,6 +123,10 @@ function realPath(file: string): string { } describe('resolveConfigFile', () => { + it('rejects an empty path instead of searching cwd', async () => { + await expect(resolveConfigFile('')).rejects.toThrow('Config file path must not be empty.'); + }); + it('resolves root to the config directory regardless of cwd', async () => { const configFile = writeConfig(tempDir); @@ -152,6 +160,198 @@ describe('resolveConfigFile', () => { ); }); + it('provides the root before plugin filtering and early config hooks', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + const root = path.join(tempDir, 'app'); + fs.mkdirSync(root); + fs.writeFileSync( + configFile, + `import assert from 'node:assert/strict'; +export default { + root: 'app', + plugins: [{ + name: 'check-early-root', + apply(config) { + assert.equal(config.root, ${JSON.stringify(root)}); + return true; + }, + config: { + order: 'pre', + handler(config) { + assert.equal(config.root, ${JSON.stringify(root)}); + return { lint: { rules: { 'no-console': 'error' } } }; + }, + }, + }], +};\n`, + ); + const cwd = process.cwd(); + + const config = await resolveConfigFile(configFile); + + expect(config.lint).toEqual({ rules: { 'no-console': 'error' } }); + expect(process.cwd()).toBe(cwd); + }); + + it('provides file metadata to sync and async hooks and retains added dependencies', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + const dependency = path.join(tempDir, 'lint.mjs'); + const pluginDependency = path.join(tempDir, 'plugin-settings.json'); + fs.writeFileSync(dependency, 'export const lint = { rules: {} };\n'); + fs.writeFileSync(pluginDependency, '{}'); + fs.writeFileSync( + configFile, + `import assert from 'node:assert/strict'; +import { realpathSync } from 'node:fs'; +import { lint } from './lint.mjs'; +function checkMetadata(config) { + assert.equal(config.configFile, ${JSON.stringify(normalizePath(configFile))}); + assert.ok(config.configFileDependencies.map((file) => realpathSync.native(file)).includes( + realpathSync.native(${JSON.stringify(dependency)}), + )); + assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, '1'); +} +export default { + lint, + plugins: [{ + name: 'check-sync-metadata', + configResolved: { + order: 'pre', + handler(config) { + checkMetadata(config); + config.configFileDependencies.push(${JSON.stringify(normalizePath(pluginDependency))}); + }, + }, + }, { + name: 'check-async-metadata', + async configResolved(config) { + await Promise.resolve(); + checkMetadata(config); + }, + }], +};\n`, + ); + + const config = await resolveConfigFile(configFile); + + expect(config.configFileDependencies.map(realPath)).toContain(realPath(pluginDependency)); + }); + + it('preserves Vite config environments and keeps the file config out of inlineConfig', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync( + configFile, + `export default (env) => ({ + mode: 'custom', + build: { ssr: true }, + functionEnv: { ...env }, + lint: { rules: {} }, + plugins: [{ + name: 'record-environment', + config(_config, hookEnv) { return { hookEnv: { ...hookEnv } }; }, + }], +});\n`, + ); + + const config = await resolveConfigFile(configFile); + + expect(config).toHaveProperty('functionEnv', { + command: 'build', + mode: 'development', + isSsrBuild: false, + isPreview: false, + }); + expect(config).toHaveProperty('hookEnv', { + command: 'build', + mode: 'custom', + isSsrBuild: false, + isPreview: false, + }); + expect(config.mode).toBe('custom'); + expect(config.inlineConfig.configFile).toBe(configFile); + expect(config.inlineConfig.lint).toBeUndefined(); + expect(config.inlineConfig.build?.ssr).toBeUndefined(); + }); + + it.each(['object', 'function', 'async function'])( + 'evaluates a %s config only once', + async (kind) => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + const evaluationsFile = path.join(tempDir, 'evaluations.log'); + const exportValue = + kind === 'object' + ? '{ lint: { rules: {} } }' + : `${kind === 'async function' ? 'async ' : ''}() => { + appendFileSync(${JSON.stringify(evaluationsFile)}, 'function\\n'); + return { lint: { rules: {} } }; +}`; + fs.writeFileSync( + configFile, + `import { appendFileSync } from 'node:fs'; +appendFileSync(${JSON.stringify(evaluationsFile)}, 'module\\n'); +export default ${exportValue};\n`, + ); + + await resolveConfigFile(configFile); + + expect(fs.readFileSync(evaluationsFile, 'utf8')).toBe( + kind === 'object' ? 'module\n' : 'module\nfunction\n', + ); + }, + ); + + it.each([ + { name: 'unset', nodeEnv: undefined, taskNodeEnv: undefined, expected: 'development' }, + { name: 'explicit', nodeEnv: 'production', taskNodeEnv: undefined, expected: 'production' }, + { + name: 'provided by Vite Task', + nodeEnv: undefined, + taskNodeEnv: 'production', + expected: 'production', + }, + ])( + 'initializes NODE_ENV before evaluation when $name', + async ({ nodeEnv, taskNodeEnv, expected }) => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync(configFile, 'export default () => ({ nodeEnv: process.env.NODE_ENV });\n'); + const env = { ...process.env }; + delete env.NODE_ENV; + delete env.VP_RUN_NODE_CLIENT_PATH; + if (nodeEnv !== undefined) { + env.NODE_ENV = nodeEnv; + } + if (taskNodeEnv !== undefined) { + const clientFile = path.join(tempDir, 'task-client.cjs'); + fs.writeFileSync( + clientFile, + `exports.load = () => ({ + getEnv: (name) => name === 'NODE_ENV' ? ${JSON.stringify(taskNodeEnv)} : undefined, + getEnvs: () => ({}), + ignoreInput() {}, + ignoreOutput() {}, +});\n`, + ); + env.VP_RUN_NODE_CLIENT_PATH = clientFile; + } + + // Start outside Vitest's NODE_ENV=test and cached Vite Task client state. + await exec( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; +import { resolveConfigFile } from ${JSON.stringify(new URL('../define-config.ts', import.meta.url).href)}; +const first = await resolveConfigFile(${JSON.stringify(configFile)}); +const second = await resolveConfigFile(${JSON.stringify(configFile)}); +assert.equal(first.nodeEnv, ${JSON.stringify(expected)}); +assert.equal(second.nodeEnv, ${JSON.stringify(expected)});`, + ], + { env, cwd: tempDir }, + ); + }, + ); + it('evaluates the config as a config-metadata resolution', async () => { const configFile = path.join(tempDir, 'vite.config.mjs'); fs.writeFileSync( @@ -163,6 +363,70 @@ describe('resolveConfigFile', () => { expect(config).toHaveProperty('metadata', '1'); }); + + it('restores the metadata marker when a plugin rejects', async () => { + const originalMetadata = process.env.VP_RESOLVING_CONFIG_METADATA; + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync( + configFile, + `export default { + plugins: [{ name: 'reject-config', configResolved() { throw new Error('plugin rejected'); } }], +};\n`, + ); + + await expect(resolveConfigFile(configFile)).rejects.toThrow('plugin rejected'); + + expect(process.env.VP_RESOLVING_CONFIG_METADATA).toBe(originalMetadata); + }); + + it.each(['import', 'require'])('resolves through the built %s entry', async (entryType) => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + const esmEntry = new URL('../../dist/index.js', import.meta.url); + const entry = new URL( + entryType === 'import' ? '../../dist/index.js' : '../../dist/index.cjs', + import.meta.url, + ); + fs.writeFileSync( + configFile, + `import assert from 'node:assert/strict'; +import { realpathSync } from 'node:fs'; +import { defineConfig, lazyPlugins } from ${JSON.stringify(normalizePath(fileURLToPath(esmEntry)))}; +export default defineConfig({ + lint: { rules: {} }, + plugins: [ + lazyPlugins(() => { throw new Error('lazy plugin factory must not run'); }), + { + name: 'check-published-helper', + configResolved(config) { + assert.equal(config.configFile, ${JSON.stringify(normalizePath(configFile))}); + assert.equal(realpathSync.native(config.root), realpathSync.native(${JSON.stringify(tempDir)})); + assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, '1'); + }, + }, + ], +});\n`, + ); + + await exec( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +const api = ${ + entryType === 'import' + ? `await import(${JSON.stringify(entry.href)})` + : `createRequire(import.meta.url)(${JSON.stringify(fileURLToPath(entry))})` + }; +const marker = process.env.VP_RESOLVING_CONFIG_METADATA; +const config = await api.resolveConfigFile(${JSON.stringify(configFile)}); +assert.deepEqual(config.lint, { rules: {} }); +assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker);`, + ], + { cwd: tempDir }, + ); + }); }); describe('resolveViteConfig', () => { diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 803cf7554e..203eddad71 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -1,13 +1,20 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; -import { dirname, join, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ignoreInput, ignoreOutput } from '@voidzero-dev/vite-task-client'; import type { OxfmtConfig } from 'oxfmt'; import type { OxlintConfig } from 'oxlint'; -import { loadConfigFromFile, normalizePath, resolveConfig, searchForWorkspaceRoot } from 'vite'; -import type { Alias, Plugin, PluginOption, ResolvedConfig, UserConfig } from 'vite'; +import { resolveConfig, searchForWorkspaceRoot } from 'vite'; +import type { + Alias, + InlineConfig as ViteInlineConfig, + Plugin, + PluginOption, + ResolvedConfig, + UserConfig, +} from 'vite'; import { defineConfig as viteDefineConfig, defineProject as viteDefineProject, @@ -1049,57 +1056,51 @@ export async function withConfigMetadataResolution(fn: () => Promise): Pro } } +// The bundled core's internal parameters are not part of its public type +// contract. Keep this bridge private to the helper. +type ResolveConfigWithInternalOptions = ( + inlineConfig: ViteInlineConfig, + command: 'build' | 'serve', + defaultMode?: string, + defaultNodeEnv?: string, + isPreview?: boolean, + patchConfig?: (config: ResolvedConfig) => void, + patchPlugins?: (plugins: Plugin[]) => void, + internalOptions?: { rootResolution?: 'cwd' | 'config-file' }, +) => Promise; + +const resolveConfigWithInternalOptions = resolveConfig as ResolveConfigWithInternalOptions; + /** * Resolve a Vite config file to read a non-plugin block (`lint`, `fmt`, `pack`, …) from it, * for tools such as oxlint, oxfmt, and Vite+ itself. * - * Unlike `resolveConfig({ configFile }, 'build')`, the result does not depend on `process.cwd()`: - * a relative `root` in the config is resolved from the config's directory, - * and without one `root` is that directory. + * Unlike `resolveConfig({ configFile }, 'build')`, a relative `root` in the config + * is resolved from the config's directory. Without one, `root` is that directory. * Editor integrations rely on this, since one process serves every workspace folder. * - * The config is evaluated once, as a config-metadata resolution, + * A relative config file path is still resolved from `process.cwd()`. + * The resolver loads the config once, as a config-metadata resolution, * so `lazyPlugins` skips its factory. * Plugins outside `lazyPlugins` still run their hooks and side effects, * e.g. TanStack Start's router generator scans `src/routes`. */ export async function resolveConfigFile(configFile: string): Promise { - return withConfigMetadataResolution(async () => { - // Load the config before resolving it to read the user's `root`. - // Neither plain `resolveConfig` call resolves `root` from the config's directory: - // - `resolveConfig({ configFile })` falls back to `process.cwd()`, - // which an editor may set to another workspace folder. - // - `resolveConfig({ root: dirname(configFile), configFile })` overrides the user's `root`, - // e.g. `root: 'app'` with sources in `app/src`. - const configEnv: ConfigEnv = { - command: 'build', - mode: 'development', - isSsrBuild: false, - isPreview: false, - }; - const loaded = await loadConfigFromFile(configEnv, configFile); - if (!loaded) { - throw new Error(`Failed to load config file: ${configFile}`); - } - // An absolute `root` is kept as is. - const root = resolve(dirname(loaded.path), loaded.config.root ?? ''); - // Passing the loaded config inline evaluates it only once, - // but differs from `resolveConfig({ configFile }, 'build')`: - // - The config file is evaluated before `resolveConfig` defaults - // `NODE_ENV` to `development` or reads it from Vite Task, - // so the first evaluation in a process may see it unset. - // - `configEnv.isSsrBuild` in plugin hooks follows the user's `build.ssr`. - // - `resolved.inlineConfig` holds the user config. - // - Plugin hooks do not see the config file path; it is restored below. - // Loading the config twice (once to read `root` and then through `resolveConfig({ root, configFile })`) - // avoids these differences but runs the config and its side effects twice. - const config = await resolveConfig({ ...loaded.config, root, configFile: false }, 'build'); - // Restore what `configFile: false` drops, e.g. for `vp pack` to watch the config. - return Object.assign(config, { - configFile: loaded.path, - configFileDependencies: loaded.dependencies.map((file) => normalizePath(resolve(file))), - }); - }); + if (configFile.length === 0) { + throw new Error('Config file path must not be empty.'); + } + return withConfigMetadataResolution(() => + resolveConfigWithInternalOptions( + { configFile }, + 'build', + undefined, + undefined, + undefined, + undefined, + undefined, + { rootResolution: 'config-file' }, + ), + ); } export function lazyPlugins(cb: () => PluginOption[]): PluginOption[] | undefined; diff --git a/packages/core/__tests__/resolve-config-root.spec.ts b/packages/core/__tests__/resolve-config-root.spec.ts new file mode 100644 index 0000000000..be704f564a --- /dev/null +++ b/packages/core/__tests__/resolve-config-root.spec.ts @@ -0,0 +1,158 @@ +import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; + +// Import the built core directly: a source-only test cannot detect a missing +// branding patch or a CLI running against an older core artifact. +import { normalizePath, resolveConfig } from '../dist/vite/node/index.js'; +import type { InlineConfig, ResolvedConfig } from '../dist/vite/node/index.js'; + +type RootResolution = 'cwd' | 'config-file'; +const resolveWithOptions = resolveConfig as ( + config: InlineConfig, + command: 'build' | 'serve', + defaultMode: undefined, + defaultNodeEnv: undefined, + isPreview: undefined, + patchConfig: undefined, + patchPlugins: undefined, + internalOptions?: { rootResolution?: RootResolution }, +) => Promise; + +function resolve(config: InlineConfig, rootResolution?: RootResolution) { + return resolveWithOptions( + config, + 'build', + undefined, + undefined, + undefined, + undefined, + undefined, + rootResolution === undefined ? undefined : { rootResolution }, + ); +} + +let tempDir: string; +let configDir: string; + +beforeEach(() => { + tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), 'vite-core-config-root-'))); + configDir = path.join(tempDir, 'project'); + mkdirSync(path.join(configDir, 'app'), { recursive: true }); +}); + +afterEach(async () => { + // Config bundles can remain open briefly on Windows. + await rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +function expectedRoot(root: string): string { + try { + return normalizePath(realpathSync.native(root)); + } catch { + // Vite also permits a root directory that does not exist yet. + return normalizePath(root); + } +} + +function writeConfig(config: Record, dir = configDir) { + const file = path.join(dir, 'vite.config.mjs'); + writeFileSync(file, `export default ${JSON.stringify(config)};\n`); + return file; +} + +describe('built core config root resolution', () => { + it.each([undefined, 'cwd'] as const)( + 'preserves cwd-relative defaults with rootResolution=%s', + async (rootResolution) => { + const cwd = process.cwd(); + for (const root of [undefined, '', 'app']) { + const configFile = writeConfig({ root }); + const config = await resolve({ configFile }, rootResolution); + expect(config.root).toBe(expectedRoot(path.resolve(cwd, root ?? ''))); + } + expect(process.cwd()).toBe(cwd); + }, + ); + + it.each([undefined, '', '.', '..', 'app'])( + 'resolves file root %s from the config directory when enabled', + async (root) => { + const cwd = process.cwd(); + const configFile = writeConfig({ root }); + const config = await resolve({ configFile }, 'config-file'); + + expect(config.root).toBe(expectedRoot(path.resolve(configDir, root ?? ''))); + expect(config.configFile).toBe(normalizePath(configFile)); + expect(process.cwd()).toBe(cwd); + }, + ); + + it('preserves an absolute root from the config file', async () => { + const root = path.join(tempDir, 'absolute'); + mkdirSync(root); + const config = await resolve({ configFile: writeConfig({ root }) }, 'config-file'); + + expect(config.root).toBe(expectedRoot(root)); + }); + + it.each(['', 'inline-app', 'absolute'])( + 'keeps explicit inline root precedence and cwd semantics for %s', + async (value) => { + const root = value === 'absolute' ? tempDir : value; + const configFile = writeConfig({ root: 'app' }); + const config = await resolve({ configFile, root }, 'config-file'); + + expect(config.root).toBe(expectedRoot(path.resolve(root))); + }, + ); + + it('leaves no-file resolution unchanged', async () => { + const config = await resolve({ root: tempDir }, 'config-file'); + + expect(config.configFile).toBeUndefined(); + expect(config.root).toBe(expectedRoot(tempDir)); + }); + + it('does not load a file when configFile is false', async () => { + writeFileSync(path.join(configDir, 'vite.config.mjs'), "throw new Error('must not load');\n"); + const config = await resolve({ configFile: false, root: configDir }, 'config-file'); + + expect(config.configFile).toBeUndefined(); + expect(config.root).toBe(expectedRoot(configDir)); + }); + + it('honors a root supplied by a config hook after file root resolution', async () => { + const configFile = writeConfig({ root: 'app' }); + const config = await resolve( + { + configFile, + plugins: [{ name: 'change-root', config: () => ({ root: 'plugin-app' }) }], + }, + 'config-file', + ); + + expect(config.root).toBe(expectedRoot(path.resolve('plugin-app'))); + }); + + it.each([false, true])( + 'respects preserveSymlinks=%s for a config loaded through a directory link', + async (preserveSymlinks) => { + writeConfig({ root: 'app', resolve: { preserveSymlinks } }); + const link = path.join(tempDir, 'linked-project'); + symlinkSync(configDir, link, process.platform === 'win32' ? 'junction' : 'dir'); + const configFile = path.join(link, 'vite.config.mjs'); + const config = await resolve({ configFile }, 'config-file'); + + expect(config.configFile).toBe(normalizePath(configFile)); + expect(config.root).toBe( + preserveSymlinks + ? normalizePath(path.join(link, 'app')) + : expectedRoot(path.join(configDir, 'app')), + ); + }, + ); +}); diff --git a/packages/core/build.ts b/packages/core/build.ts index 203addbd8d..5a2cdc63f5 100644 --- a/packages/core/build.ts +++ b/packages/core/build.ts @@ -19,6 +19,7 @@ import { glob } from 'tinyglobby'; import { generateLicenseFile } from '../../scripts/generate-license.js'; import viteRolldownConfig from '../../vite/packages/vite/rolldown.config.js'; import cliPkgJson from '../cli/package.json' with { type: 'json' }; +import { assertViteConfigPatched } from '../tools/src/patch-vite-config.ts'; import { buildCjsDeps } from './build-support/build-cjs-deps.js'; import { replaceThirdPartyCjsRequires } from './build-support/find-create-require.js'; import { RewriteImportsPlugin } from './build-support/rewrite-imports.js'; @@ -52,6 +53,17 @@ const rolldownViteSourceDir = resolve(projectDir, '..', '..', 'vite', 'packages' const tsdownSourceDir = resolve(projectDir, 'node_modules/tsdown'); // Main build orchestration +try { + assertViteConfigPatched( + await readFile(join(rolldownViteSourceDir, 'src/node/config.ts'), 'utf-8'), + ); +} catch (cause) { + throw new Error( + 'Prepare the Vite source before building core. From the repository root, run ' + + '`node packages/tools/src/index.ts brand-vite` and `pnpm --filter vite build-types`.', + { cause }, + ); +} await bundleRolldownPluginutils(); await bundleRolldown(); await buildVite(); diff --git a/packages/tools/src/__tests__/brand-vite.spec.ts b/packages/tools/src/__tests__/brand-vite.spec.ts index 1364658cad..6f33018cbf 100644 --- a/packages/tools/src/__tests__/brand-vite.spec.ts +++ b/packages/tools/src/__tests__/brand-vite.spec.ts @@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'; import { expect, test } from 'vitest'; import { brandVite } from '../brand-vite.ts'; +import { assertViteConfigPatched } from '../patch-vite-config.ts'; import { alignVendoredVitestDependencies } from '../vendored-vitest.ts'; const upstreamSources = { @@ -37,6 +38,23 @@ const upstreamSources = { '', ].join('\n'), 'config.ts': [ + 'export async function resolveConfig(', + ' inlineConfig: InlineConfig,', + " command: 'build' | 'serve',", + " defaultMode = 'development',", + " defaultNodeEnv = 'development',", + ' isPreview = false,', + ' /** @internal */', + ' patchConfig?: (config: ResolvedConfig) => void,', + ' /** @internal */', + ' patchPlugins?: (resolvedPlugins: Plugin[]) => void,', + '): Promise {', + ' if (loadResult) {', + ' config = mergeConfig(loadResult.config, config)', + ' configFile = loadResult.path', + ' configFileDependencies = loadResult.dependencies', + ' }', + '}', ' !process.env.VITE_CONFIG_NATIVE_IGNORE_WARNING &&', ' createNativeConfigCompatPlugin(nativeIncompatibilities),', '', @@ -48,37 +66,42 @@ function write(file: string, source: string) { writeFileSync(file, source); } -test('branding preserves aligned dependencies and unrelated changes across repeated builds', () => { +function createUpstreamFixture(configSource = upstreamSources['config.ts']) { const root = mkdtempSync(join(tmpdir(), 'vp-brand-vite-')); const viteDir = join(root, 'vite'); const nodeDir = join(viteDir, 'packages/vite/src/node'); const git = (...args: string[]) => execFileSync('git', args, { cwd: viteDir, stdio: 'pipe' }); - try { - for (const [file, source] of Object.entries(upstreamSources)) { - write(join(nodeDir, file), source); - } - const manifest = join(viteDir, 'packages/vite/package.json'); - const lockfile = join(viteDir, 'pnpm-lock.yaml'); - const unrelatedSource = join(nodeDir, 'server.ts'); - write(manifest, '{"devDependencies":{"@vitest/utils":"4.1.10"}}\n'); - write(lockfile, '# upstream lockfile\n'); - write(unrelatedSource, '// upstream source\n'); - git('init'); - git('config', 'core.autocrlf', 'false'); - git('config', 'core.hooksPath', join(root, 'no-hooks')); - git('add', '.'); - git( - '-c', - 'user.name=Test', - '-c', - 'user.email=test@example.com', - '-c', - 'commit.gpgsign=false', - 'commit', - '-m', - 'upstream fixture', - ); + for (const [file, source] of Object.entries(upstreamSources)) { + write(join(nodeDir, file), file === 'config.ts' ? configSource : source); + } + const manifest = join(viteDir, 'packages/vite/package.json'); + const lockfile = join(viteDir, 'pnpm-lock.yaml'); + const unrelatedSource = join(nodeDir, 'server.ts'); + write(manifest, '{"devDependencies":{"@vitest/utils":"4.1.10"}}\n'); + write(lockfile, '# upstream lockfile\n'); + write(unrelatedSource, '// upstream source\n'); + git('init'); + git('config', 'core.autocrlf', 'false'); + git('config', 'core.hooksPath', join(root, 'no-hooks')); + git('add', '.'); + git( + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + '-c', + 'commit.gpgsign=false', + 'commit', + '-m', + 'upstream fixture', + ); + return { root, nodeDir, manifest, lockfile, unrelatedSource }; +} + +test('branding preserves aligned dependencies and unrelated changes across repeated builds', () => { + const { root, nodeDir, manifest, lockfile, unrelatedSource } = createUpstreamFixture(); + try { alignVendoredVitestDependencies(root, '5.0.0'); const alignedManifest = readFileSync(manifest, 'utf8'); expect(JSON.parse(alignedManifest).devDependencies['@vitest/utils']).toBe('5.0.0'); @@ -94,6 +117,10 @@ test('branding preserves aligned dependencies and unrelated changes across repea expect(readFileSync(unrelatedSource, 'utf8')).toBe('// unrelated local source change\n'); expect(readFileSync(join(nodeDir, 'cli.ts'), 'utf8')).toContain("cac('vp')"); expect(readFileSync(join(nodeDir, 'build.ts'), 'utf8')).not.toContain('logger.info('); + const configSource = readFileSync(join(nodeDir, 'config.ts'), 'utf8'); + expect(() => assertViteConfigPatched(configSource)).not.toThrow(); + expect(configSource).toContain('false &&\n createNativeConfigCompatPlugin'); + expect(configSource).not.toContain('!process.env.VITE_CONFIG_NATIVE_IGNORE_WARNING'); const sources = Object.entries(upstreamSources).map(([file, upstream]) => { const source = readFileSync(join(nodeDir, file), 'utf8'); expect(source).not.toBe(upstream); @@ -108,3 +135,14 @@ test('branding preserves aligned dependencies and unrelated changes across repea rmSync(root, { recursive: true, force: true }); } }); + +test('branding validates both config patches before writing config.ts', () => { + const source = upstreamSources['config.ts'].replace('defaultNodeEnv', 'changedUpstreamParameter'); + const { root, nodeDir } = createUpstreamFixture(source); + try { + expect(() => brandVite(root)).toThrow(/changed, duplicate, or partial patch anchors/); + expect(readFileSync(join(nodeDir, 'config.ts'), 'utf8')).toBe(source); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/tools/src/__tests__/patch-vite-config.spec.ts b/packages/tools/src/__tests__/patch-vite-config.spec.ts new file mode 100644 index 0000000000..50c285aa52 --- /dev/null +++ b/packages/tools/src/__tests__/patch-vite-config.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'vitest'; + +import { assertViteConfigPatched, patchViteConfig } from '../patch-vite-config.ts'; + +const upstream = `import path from 'node:path' + +export async function resolveConfig( + inlineConfig: InlineConfig, + command: 'build' | 'serve', + defaultMode = 'development', + defaultNodeEnv = 'development', + isPreview = false, + /** @internal */ + patchConfig?: (config: ResolvedConfig) => void, + /** @internal */ + patchPlugins?: (resolvedPlugins: Plugin[]) => void, +): Promise { + let config = inlineConfig + let { configFile } = config + let configFileDependencies: string[] = [] + if (configFile !== false) { + const loadResult = await loadConfigFromFile(configEnv, configFile) + if (loadResult) { + config = mergeConfig(loadResult.config, config) + configFile = loadResult.path + configFileDependencies = loadResult.dependencies + } + } + mode = inlineConfig.mode || config.mode || mode + config = await runConfigHook(config, userPlugins, configEnv) +} +`; + +const internalParameter = ` /** @internal */ + internalOptions?: { + rootResolution?: 'cwd' | 'config-file' + }, +`; + +const rootBlock = ` if ( + internalOptions?.rootResolution === 'config-file' && + inlineConfig.root == null + ) { + config.root = path.resolve( + path.dirname(loadResult.path), + config.root ?? '', + ) + } +`; + +describe('patchViteConfig', () => { + test('adds an internal option and resolves root after recording loaded config metadata', () => { + const patched = patchViteConfig(upstream); + + expect(patched).toContain(internalParameter); + expect(patched).toContain( + ` configFileDependencies = loadResult.dependencies\n${rootBlock}`, + ); + // All other source, including the first seven parameters and plugin order, stays intact. + expect(patched.replace(internalParameter, '').replace(rootBlock, '')).toBe(upstream); + expect(() => assertViteConfigPatched(patched)).not.toThrow(); + expect(patchViteConfig(patched)).toBe(patched); + }); + + test.each([ + ['changed signature', upstream.replace('defaultNodeEnv', 'defaultEnvironment')], + [ + 'changed load branch', + upstream.replace( + 'mergeConfig(loadResult.config, config)', + 'mergeConfig(config, loadResult.config)', + ), + ], + ['duplicate resolver', upstream + upstream], + ['missing resolver', 'export const unrelated = true\n'], + ['stray partial patch', `${upstream}\nconst rootResolution = 'config-file'\n`], + ])('rejects %s', (_name, source) => { + expect(() => patchViteConfig(source)).toThrow(/changed, duplicate, or partial patch anchors/); + }); + + test('rejects partially applied or modified patches', () => { + const patched = patchViteConfig(upstream); + const incomplete = [ + patched.replace(internalParameter, ''), + patched.replace(rootBlock, ''), + patched.replace('inlineConfig.root == null', '!inlineConfig.root'), + patched + patched, + ]; + for (const source of incomplete) { + expect(() => patchViteConfig(source)).toThrow(/changed, duplicate, or partial patch anchors/); + expect(() => assertViteConfigPatched(source)).toThrow(); + } + }); +}); + +test('the build assertion rejects unpatched source with pin-update guidance', () => { + expect(() => assertViteConfigPatched(upstream)).toThrow(/has not been applied/); + expect(() => assertViteConfigPatched(upstream)).toThrow(/\.upstream-versions\.json/); +}); diff --git a/packages/tools/src/brand-vite.ts b/packages/tools/src/brand-vite.ts index 228dbf6069..1be484af81 100644 --- a/packages/tools/src/brand-vite.ts +++ b/packages/tools/src/brand-vite.ts @@ -1,5 +1,5 @@ /** - * Apply Vite+ branding patches to vite source after sync. + * Apply Vite+ branding and config-resolution patches to vite source after sync. * * This script modifies user-visible branding strings in the vite * source to show "VITE+" instead of "VITE". It is called automatically @@ -11,12 +11,15 @@ * 3. build.ts: Remove startup build banner and change error prefix * 4. logger.ts: Change default prefix from '[vite]' to '[vite+]' * 5. plugins/reporter.ts: Suppress redundant "vite v" native reporter line + * 6. config.ts: Suppress the native-loader notice and support config-file-relative roots */ import { execFileSync } from 'node:child_process'; import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { patchViteConfig } from './patch-vite-config.ts'; + const VITE_DIR = 'vite'; const VITE_NODE_DIR = join(VITE_DIR, 'packages', 'vite', 'src', 'node'); @@ -36,15 +39,27 @@ function replaceInFile( replacement: string, ): 'patched' | 'already' { const content = readFileSync(filePath, 'utf-8'); + const newContent = replaceInSource(filePath, content, search, replacement); + if (content === newContent) { + return 'already'; + } + writeFileSync(filePath, newContent, 'utf-8'); + return 'patched'; +} + +function replaceInSource( + filePath: string, + content: string, + search: string, + replacement: string, +): string { // Check replacement first: the search string may be a substring of the replacement // (e.g. constants.ts where the replacement appends lines after the search string). if (content.includes(replacement)) { - return 'already'; + return content; } if (content.includes(search)) { - const newContent = content.replace(search, replacement); - writeFileSync(filePath, newContent, 'utf-8'); - return 'patched'; + return content.replace(search, replacement); } throw new Error( `[brand-vite] Patch failed in ${filePath}:\n` + @@ -199,15 +214,24 @@ export function brandVite(rootDir: string = process.cwd()) { // `nativeIncompatibilities` list empty and no warning is emitted. The `false` // guard keeps the import referenced so the type-check stays clean. const configFile = join(nodeDir, 'config.ts'); - logPatch( - 'config.ts', - 'Suppressed native config loader migration notice', - replaceInFile( + const configSource = readFileSync(configFile, 'utf-8'); + // Validate both config patches in memory before writing either edit. + const patchedConfig = patchViteConfig( + replaceInSource( configFile, + configSource, ' !process.env.VITE_CONFIG_NATIVE_IGNORE_WARNING &&\n createNativeConfigCompatPlugin(nativeIncompatibilities),', " // Vite+ manages config loading, so Vite's native-config-loader\n // migration notice is noise. Never register the compat-check plugin.\n false &&\n createNativeConfigCompatPlugin(nativeIncompatibilities),", ), ); + if (patchedConfig !== configSource) { + writeFileSync(configFile, patchedConfig, 'utf-8'); + } + logPatch( + 'config.ts', + 'Suppressed native config loader notice and enabled config-file-relative roots', + patchedConfig === configSource ? 'already' : 'patched', + ); log('Done!'); } diff --git a/packages/tools/src/patch-vite-config.ts b/packages/tools/src/patch-vite-config.ts new file mode 100644 index 0000000000..a69f532256 --- /dev/null +++ b/packages/tools/src/patch-vite-config.ts @@ -0,0 +1,104 @@ +/** + * Extend the pinned Vite resolver without replacing its config-loading pipeline. + * Keep this transform free of dependencies: branding also runs before installation. + */ +const upstreamSignature = `export async function resolveConfig( + inlineConfig: InlineConfig, + command: 'build' | 'serve', + defaultMode = 'development', + defaultNodeEnv = 'development', + isPreview = false, + /** @internal */ + patchConfig?: (config: ResolvedConfig) => void, + /** @internal */ + patchPlugins?: (resolvedPlugins: Plugin[]) => void, +): Promise {`; + +const patchedSignature = upstreamSignature.replace( + '): Promise {', + ` /** @internal */ + internalOptions?: { + rootResolution?: 'cwd' | 'config-file' + }, +): Promise {`, +); + +const upstreamLoadResult = ` if (loadResult) { + config = mergeConfig(loadResult.config, config) + configFile = loadResult.path + configFileDependencies = loadResult.dependencies + }`; + +const patchedLoadResult = ` if (loadResult) { + config = mergeConfig(loadResult.config, config) + configFile = loadResult.path + configFileDependencies = loadResult.dependencies + if ( + internalOptions?.rootResolution === 'config-file' && + inlineConfig.root == null + ) { + config.root = path.resolve( + path.dirname(loadResult.path), + config.root ?? '', + ) + } + }`; + +function patchError(detail: string): Error { + return new Error( + `[patch-vite-config] ${detail} in vite/packages/vite/src/node/config.ts. ` + + 'Check the Vite pin in packages/tools/.upstream-versions.json and update patch-vite-config.ts.', + ); +} + +function count(source: string, anchor: string): number { + return source.split(anchor).length - 1; +} + +function patchState(source: string): 'upstream' | 'patched' { + const upstreamSignatures = count(source, upstreamSignature); + const patchedSignatures = count(source, patchedSignature); + const upstreamLoads = count(source, upstreamLoadResult); + const patchedLoads = count(source, patchedLoadResult); + if ( + upstreamSignatures === 1 && + upstreamLoads === 1 && + patchedSignatures === 0 && + patchedLoads === 0 && + !/\b(?:internalOptions|rootResolution)\b/.test(source) + ) { + return 'upstream'; + } + if ( + patchedSignatures === 1 && + patchedLoads === 1 && + upstreamSignatures === 0 && + upstreamLoads === 0 && + source.match(/\binternalOptions\b/g)?.length === 2 && + source.match(/\brootResolution\b/g)?.length === 2 + ) { + return 'patched'; + } + throw patchError( + 'Expected one complete upstream or patched resolver; found changed, duplicate, or partial patch anchors', + ); +} + +/** Validate the complete patch before a build can produce a core that ignores it. */ +export function assertViteConfigPatched(source: string): void { + if (patchState(source) !== 'patched') { + throw patchError('The config-file root resolution patch has not been applied'); + } +} + +/** Apply both edits together; reject upstream drift and incomplete prior edits. */ +export function patchViteConfig(source: string): string { + if (patchState(source) === 'patched') { + return source; + } + const patched = source + .replace(upstreamSignature, patchedSignature) + .replace(upstreamLoadResult, patchedLoadResult); + assertViteConfigPatched(patched); + return patched; +} From d57ec71cc9368dd4bae689d0bd9c3086dc78f67e Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Oct 2026 16:27:35 +0900 Subject: [PATCH 04/18] refactor(build): extract Vite branding script --- CONTRIBUTING.md | 2 +- package.json | 3 ++- packages/core/build.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6429bc36df..8b427928ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,7 @@ For an incremental configuration-loading change, with dependencies and native bindings already built, run these commands from the repository root: ```bash -node packages/tools/src/index.ts brand-vite +pnpm brand-vite pnpm --filter vite build-types pnpm --filter @voidzero-dev/vite-plus-core build pnpm --filter vite-plus build-ts diff --git a/package.json b/package.json index c982dd7058..9860586137 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "license": "MIT", "type": "module", "scripts": { - "build": "node packages/tools/src/index.ts brand-vite && pnpm -F rolldown build-binding:release && pnpm -F rolldown build-node && pnpm -F vite build-types && pnpm -F @voidzero-dev/* -F vite-plus build", + "brand-vite": "node packages/tools/src/index.ts brand-vite", + "build": "pnpm brand-vite && pnpm -F rolldown build-binding:release && pnpm -F rolldown build-node && pnpm -F vite build-types && pnpm -F @voidzero-dev/* -F vite-plus build", "bootstrap-cli": "pnpm build && cargo build -p vp_global_cli --release && node packages/tools/src/build-trampoline.ts --release && pnpm install-global-cli", "bootstrap-cli:ci": "pnpm install-global-cli", "install-global-cli": "tool install-global-cli", diff --git a/packages/core/build.ts b/packages/core/build.ts index 5a2cdc63f5..52b824f688 100644 --- a/packages/core/build.ts +++ b/packages/core/build.ts @@ -60,7 +60,7 @@ try { } catch (cause) { throw new Error( 'Prepare the Vite source before building core. From the repository root, run ' + - '`node packages/tools/src/index.ts brand-vite` and `pnpm --filter vite build-types`.', + '`pnpm brand-vite` and `pnpm --filter vite build-types`.', { cause }, ); } From d9c79a6277fd3ce2ed7182c6933131e883c80233 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Oct 2026 16:28:41 +0900 Subject: [PATCH 05/18] docs: remove redundant configuration build instructions --- CONTRIBUTING.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b427928ff..7a8202a5c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,25 +93,6 @@ To create a release build of Vite+ and all upstream dependencies, run: just build ``` -The build first applies the Vite+ source patches to the pinned Vite checkout. -These include the internal configuration root option used by `resolveConfigFile()`. -Keep changes to these patches in `packages/tools/src/`; `brand-vite` restores the -Vite files it manages before it applies them again. - -For an incremental configuration-loading change, with dependencies and native -bindings already built, run these commands from the repository root: - -```bash -pnpm brand-vite -pnpm --filter vite build-types -pnpm --filter @voidzero-dev/vite-plus-core build -pnpm --filter vite-plus build-ts -``` - -Run the preparation and type build before a filtered core build. The core build -rejects a Vite checkout without the configuration patch. Build the CLI against -the matching core output so its helper uses the new option at runtime. - ## Git Hooks (optional) Pre-commit checks are opt-in. Run `vp config` after a build to install the hook dispatcher; each commit then runs `vp staged` on staged files. `vp staged` loads its config from `vite.config.ts` and needs the built JS packages, so build first. Opt out with `vp hooks disable`, or skip one run with `VP_GIT_HOOKS=0`. If husky was active in your clone before, run `git config --unset core.hooksPath` once first. From fca9636f70d72d85fc0e42f64cf9e13cfc8d3e13 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Oct 2026 16:29:52 +0900 Subject: [PATCH 06/18] test: remove redundant Vite patch unit tests --- .../src/__tests__/patch-vite-config.spec.ts | 99 ------------------- 1 file changed, 99 deletions(-) delete mode 100644 packages/tools/src/__tests__/patch-vite-config.spec.ts diff --git a/packages/tools/src/__tests__/patch-vite-config.spec.ts b/packages/tools/src/__tests__/patch-vite-config.spec.ts deleted file mode 100644 index 50c285aa52..0000000000 --- a/packages/tools/src/__tests__/patch-vite-config.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { assertViteConfigPatched, patchViteConfig } from '../patch-vite-config.ts'; - -const upstream = `import path from 'node:path' - -export async function resolveConfig( - inlineConfig: InlineConfig, - command: 'build' | 'serve', - defaultMode = 'development', - defaultNodeEnv = 'development', - isPreview = false, - /** @internal */ - patchConfig?: (config: ResolvedConfig) => void, - /** @internal */ - patchPlugins?: (resolvedPlugins: Plugin[]) => void, -): Promise { - let config = inlineConfig - let { configFile } = config - let configFileDependencies: string[] = [] - if (configFile !== false) { - const loadResult = await loadConfigFromFile(configEnv, configFile) - if (loadResult) { - config = mergeConfig(loadResult.config, config) - configFile = loadResult.path - configFileDependencies = loadResult.dependencies - } - } - mode = inlineConfig.mode || config.mode || mode - config = await runConfigHook(config, userPlugins, configEnv) -} -`; - -const internalParameter = ` /** @internal */ - internalOptions?: { - rootResolution?: 'cwd' | 'config-file' - }, -`; - -const rootBlock = ` if ( - internalOptions?.rootResolution === 'config-file' && - inlineConfig.root == null - ) { - config.root = path.resolve( - path.dirname(loadResult.path), - config.root ?? '', - ) - } -`; - -describe('patchViteConfig', () => { - test('adds an internal option and resolves root after recording loaded config metadata', () => { - const patched = patchViteConfig(upstream); - - expect(patched).toContain(internalParameter); - expect(patched).toContain( - ` configFileDependencies = loadResult.dependencies\n${rootBlock}`, - ); - // All other source, including the first seven parameters and plugin order, stays intact. - expect(patched.replace(internalParameter, '').replace(rootBlock, '')).toBe(upstream); - expect(() => assertViteConfigPatched(patched)).not.toThrow(); - expect(patchViteConfig(patched)).toBe(patched); - }); - - test.each([ - ['changed signature', upstream.replace('defaultNodeEnv', 'defaultEnvironment')], - [ - 'changed load branch', - upstream.replace( - 'mergeConfig(loadResult.config, config)', - 'mergeConfig(config, loadResult.config)', - ), - ], - ['duplicate resolver', upstream + upstream], - ['missing resolver', 'export const unrelated = true\n'], - ['stray partial patch', `${upstream}\nconst rootResolution = 'config-file'\n`], - ])('rejects %s', (_name, source) => { - expect(() => patchViteConfig(source)).toThrow(/changed, duplicate, or partial patch anchors/); - }); - - test('rejects partially applied or modified patches', () => { - const patched = patchViteConfig(upstream); - const incomplete = [ - patched.replace(internalParameter, ''), - patched.replace(rootBlock, ''), - patched.replace('inlineConfig.root == null', '!inlineConfig.root'), - patched + patched, - ]; - for (const source of incomplete) { - expect(() => patchViteConfig(source)).toThrow(/changed, duplicate, or partial patch anchors/); - expect(() => assertViteConfigPatched(source)).toThrow(); - } - }); -}); - -test('the build assertion rejects unpatched source with pin-update guidance', () => { - expect(() => assertViteConfigPatched(upstream)).toThrow(/has not been applied/); - expect(() => assertViteConfigPatched(upstream)).toThrow(/\.upstream-versions\.json/); -}); From 8fa9211ce3cf858d5361c521277f9454218c6ece Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Oct 2026 16:38:46 +0900 Subject: [PATCH 07/18] test(pack): cover config metadata in plugin hooks --- .../fixtures/config_resolution_hooks/settings.mjs | 6 ++++++ .../config_resolution_hooks/snapshots.toml | 9 +++++++++ .../snapshots/pack_preserves_config_hooks.md | 15 +++++++++++++++ .../config_resolution_hooks/verify-pack.mjs | 8 ++++++++ .../config_resolution_hooks/vite.config.mjs | 6 +++--- 5 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/pack_preserves_config_hooks.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-pack.mjs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs index 76cf8d67c6..895643f128 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs @@ -1,3 +1,9 @@ export const staged = { '*.txt': 'node verify-staged.mjs', }; + +export const pack = { + entry: ['app/index.js'], + dts: false, + format: 'esm', +}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml index 6e0bc0d7a2..8596e8d219 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml @@ -11,3 +11,12 @@ steps = [ { argv = ["vp", "staged", "--quiet", "--no-stash"], comment = "Run staged configuration supplied by a Vite plugin with normal config metadata." }, ["vpt", "print-file", "result.txt"], ] + +[[case]] +name = "pack_preserves_config_hooks" +vp = "local" +env = { NODE_ENV = "development" } +steps = [ + { argv = ["vp", "pack", "--logLevel", "silent"], comment = "Build with plugin-supplied pack options and configuration metadata available in configResolved." }, + ["node", "verify-pack.mjs"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/pack_preserves_config_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/pack_preserves_config_hooks.md new file mode 100644 index 0000000000..c2c4bfc26a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/pack_preserves_config_hooks.md @@ -0,0 +1,15 @@ +# pack_preserves_config_hooks + +## `vp pack --logLevel silent` + +Build with plugin-supplied pack options and configuration metadata available in configResolved. + +``` +ℹ Build start +``` + +## `node verify-pack.mjs` + +``` +pack command from plugin passed +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-pack.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-pack.mjs new file mode 100644 index 0000000000..b237e1af1a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-pack.mjs @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { app } from './dist/index.mjs'; + +assert.equal(app, 'fixture'); +assert.equal(readFileSync('resolved.txt', 'utf8'), 'configResolved passed\n'); +console.log('pack command from plugin passed'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs index b52ec5aa9c..b5980b8618 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'; import { defineConfig, lazyPlugins, normalizePath } from 'vite-plus'; -import { staged } from './settings.mjs'; +import { pack, staged } from './settings.mjs'; const configFile = fileURLToPath(import.meta.url); const configDir = dirname(configFile); @@ -20,7 +20,7 @@ export default defineConfig({ throw new Error('metadata resolution must skip lazy plugin factories'); }), { - name: 'provide-staged-config', + name: 'provide-tool-config', apply(config) { assert.equal(config.root, root); return true; @@ -29,7 +29,7 @@ export default defineConfig({ order: 'pre', handler(config) { assert.equal(config.root, root); - return { staged }; + return { staged, pack }; }, }, async configResolved(config) { From e8b9c270f0e64a0aa3249ef6729bfbf1f55a0884 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Oct 2026 16:57:48 +0900 Subject: [PATCH 08/18] docs(core): describe Vite config source preparation --- packages/core/BUNDLING.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md index 6a024b268b..fcd0e68ea4 100644 --- a/packages/core/BUNDLING.md +++ b/packages/core/BUNDLING.md @@ -19,6 +19,14 @@ This approach enables users to access Vite, Rolldown, and related tools through ## Build Steps +### Vite Source Preparation + +Before core bundling, [`brand-vite.ts`](../tools/src/brand-vite.ts) applies branding and the [`patch-vite-config.ts`](../tools/src/patch-vite-config.ts) resolver patch to the vendored Vite source. `sync-remote` runs this preparation. The root build script also runs it before generating Vite types and building core. + +The resolver patch adds an optional internal eighth argument to `resolveConfig`. The `resolveConfigFile()` helper exported by `vite-plus` selects `rootResolution: 'config-file'` through a private type bridge. After a config file loads, this mode resolves an unset or relative `root` from that file's directory, before plugin `apply` and `config` hooks. Absolute roots are preserved, and an inline `root` retains priority. Calls without this option keep their existing behavior. Vite still handles config loading, environment initialization, configuration metadata, and plugin hooks. + +[`build.ts`](build.ts) calls `assertViteConfigPatched()` before any bundling starts. It rejects missing, changed, duplicate, or partially applied patch anchors. When the Vite pin changes, review the patch against the new source. The guard validates `src/node/config.ts`; it does not check whether generated type declarations are current. + The build process executes 5 steps in sequence: ### Step 1: Bundle Rolldown Pluginutils (`bundleRolldownPluginutils`) @@ -299,8 +307,8 @@ dist/ ### Updating Vite -1. Update the `vite` git submodule to the new version -2. Run `pnpm -C packages/core build` +1. Update the Vite pin in [`packages/tools/.upstream-versions.json`](../tools/.upstream-versions.json) and review the resolver patch against the new source +2. Follow the [upstream sync and build instructions](../../CONTRIBUTING.md#pull-upstream-dependencies) to prepare the source, regenerate Vite types, and rebuild the packages 3. Verify `bundledVersions.vite` in `package.json` is updated 4. Test with `pnpm test` @@ -324,6 +332,8 @@ dist/ ## Build Commands +These direct core builds require prepared Vite sources and current Vite types. The [standard repository build](../../CONTRIBUTING.md#build-vite-and-upstream-dependencies) includes both prerequisites. + ```bash # Build the core package pnpm -C packages/core build @@ -339,6 +349,7 @@ RELEASE_BUILD=1 pnpm -C packages/core build ### Build Flow ``` + assertViteConfigPatched() Validate prepared Vite source before bundling 1. bundleRolldownPluginutils() Copy pre-built dist 2. bundleRolldown() Copy + rewrite module specifiers 3. buildVite() Full Rolldown build with transforms From 5b3b07b1d3b8b663e62598b30b9273cc0570aff2 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Oct 2026 18:04:26 +0900 Subject: [PATCH 09/18] fix(config): make root base explicit and preserve CLI behavior --- .../config_resolution_hooks/vite.config.mjs | 3 +- .../configured-root/src/index.js | 1 + .../pack_shared_config_cwd/package.json | 6 + .../packages/lib/package.json | 5 + .../packages/lib/src/index.js | 1 + .../pnpm-workspace.yaml | 2 + .../pack_shared_config_cwd/snapshots.toml | 8 + ...ack_shared_config_preserves_package_cwd.md | 15 ++ .../pack_shared_config_cwd/src/index.js | 1 + .../pack_shared_config_cwd/verify-pack.mjs | 16 ++ .../pack_shared_config_cwd/vite.config.mjs | 46 ++++++ .../src/__tests__/resolve-vite-config.spec.ts | 147 ++++++++++++++---- packages/cli/src/define-config.ts | 25 ++- packages/cli/src/index.ts | 1 + packages/cli/src/resolve-vite-config.ts | 35 ++--- packages/core/BUNDLING.md | 4 +- .../__tests__/resolve-config-root.spec.ts | 67 ++++---- packages/tools/src/brand-vite.ts | 4 +- packages/tools/src/patch-vite-config.ts | 20 +-- 19 files changed, 307 insertions(+), 100 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/configured-root/src/index.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/src/index.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/pnpm-workspace.yaml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots/pack_shared_config_preserves_package_cwd.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/src/index.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/verify-pack.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/vite.config.mjs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs index b5980b8618..46beaadb37 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs @@ -9,7 +9,8 @@ import { pack, staged } from './settings.mjs'; const configFile = fileURLToPath(import.meta.url); const configDir = dirname(configFile); -const root = join(configDir, 'app'); +// CLI metadata reads retain the command's root override, even when the file sets root. +const root = configDir; assert.equal(process.env.NODE_ENV, 'development'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/configured-root/src/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/configured-root/src/index.js new file mode 100644 index 0000000000..b6e9253f80 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/configured-root/src/index.js @@ -0,0 +1 @@ +export const source = 'configured Vite root source must not be packed'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/package.json new file mode 100644 index 0000000000..590f4ca302 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/package.json @@ -0,0 +1,6 @@ +{ + "name": "pack-shared-config-cwd", + "private": true, + "type": "module", + "workspaces": ["packages/*"] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/package.json new file mode 100644 index 0000000000..3d4d6d5acf --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/package.json @@ -0,0 +1,5 @@ +{ + "name": "shared-config-library", + "private": true, + "type": "module" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/src/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/src/index.js new file mode 100644 index 0000000000..89a643707d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/src/index.js @@ -0,0 +1 @@ +export const source = 'package source'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots.toml new file mode 100644 index 0000000000..82694b2b50 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots.toml @@ -0,0 +1,8 @@ +[[case]] +name = "pack_shared_config_preserves_package_cwd" +vp = "local" +cwd = "packages/lib" +steps = [ + { argv = ["vp", "pack", "--logLevel", "silent"], comment = "Load the parent config through traverseUp while plugin hooks and pack paths stay at the package cwd, overriding the file's root." }, + ["node", "../../verify-pack.mjs"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots/pack_shared_config_preserves_package_cwd.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots/pack_shared_config_preserves_package_cwd.md new file mode 100644 index 0000000000..b7b51f54d0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots/pack_shared_config_preserves_package_cwd.md @@ -0,0 +1,15 @@ +# pack_shared_config_preserves_package_cwd + +## `vp pack --logLevel silent` + +Load the parent config through traverseUp while plugin hooks and pack paths stay at the package cwd, overriding the file's root. + +``` +ℹ Build start +``` + +## `node ../../verify-pack.mjs` + +``` +shared config keeps plugin root and pack paths in the package +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/src/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/src/index.js new file mode 100644 index 0000000000..dd1e3f1cff --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/src/index.js @@ -0,0 +1 @@ +export const source = 'workspace source must not be packed'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/verify-pack.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/verify-pack.mjs new file mode 100644 index 0000000000..fafa7c68f5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/verify-pack.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { source } from './packages/lib/dist/index.mjs'; + +const workspaceDir = dirname(fileURLToPath(import.meta.url)); +assert.equal(source, 'package source'); +assert.equal( + readFileSync(join(workspaceDir, 'packages', 'lib', 'resolved.txt'), 'utf8'), + 'shared config hooks used package cwd\n', +); +assert.equal(existsSync(join(workspaceDir, 'dist')), false); +assert.equal(existsSync(join(workspaceDir, 'configured-root', 'dist')), false); +console.log('shared config keeps plugin root and pack paths in the package'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/vite.config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/vite.config.mjs new file mode 100644 index 0000000000..316e1aa43f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/vite.config.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { realpathSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { defineConfig, normalizePath } from 'vite-plus'; + +const configFile = fileURLToPath(import.meta.url); +const workspaceDir = dirname(configFile); +const packageDir = join(workspaceDir, 'packages', 'lib'); + +function assertPackageRoot(root) { + assert.equal(realpathSync.native(root), realpathSync.native(packageDir)); + assert.equal(realpathSync.native(process.cwd()), realpathSync.native(packageDir)); +} + +export default defineConfig({ + // The existing CLI contract overrides this with the package cwd when reading pack settings. + root: 'configured-root', + plugins: [ + { + name: 'verify-shared-config-package-root', + apply(config) { + assertPackageRoot(config.root); + return true; + }, + config: { + order: 'pre', + handler(config) { + assertPackageRoot(config.root); + }, + }, + configResolved(config) { + assertPackageRoot(config.root); + assert.equal(config.configFile, normalizePath(configFile)); + writeFileSync('resolved.txt', 'shared config hooks used package cwd\n'); + }, + }, + ], + pack: { + entry: ['src/index.js'], + outDir: 'dist', + dts: false, + format: 'esm', + }, +}); diff --git a/packages/cli/src/__tests__/resolve-vite-config.spec.ts b/packages/cli/src/__tests__/resolve-vite-config.spec.ts index c100f3c687..fa2df6b80b 100644 --- a/packages/cli/src/__tests__/resolve-vite-config.spec.ts +++ b/packages/cli/src/__tests__/resolve-vite-config.spec.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; -import { normalizePath } from 'vite'; +import { normalizePath, resolveConfig } from 'vite'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { resolveConfigFile } from '../index.js'; @@ -127,25 +127,73 @@ describe('resolveConfigFile', () => { await expect(resolveConfigFile('')).rejects.toThrow('Config file path must not be empty.'); }); - it('resolves root to the config directory regardless of cwd', async () => { - const configFile = writeConfig(tempDir); + it.each(['unset', 'empty', 'relative', 'absolute'])( + 'preserves normal Vite root resolution when root is %s and rootBase is omitted', + async (kind) => { + const root = + kind === 'unset' + ? undefined + : kind === 'empty' + ? '' + : kind === 'relative' + ? 'app' + : tempDir; + const configFile = writeConfig( + tempDir, + root === undefined ? '' : `root: ${JSON.stringify(root)},`, + ); + const cwd = process.cwd(); - const config = await resolveConfigFile(configFile); + const expected = await resolveConfig({ configFile }, 'build'); + const config = await resolveConfigFile(configFile); + + expect(config.root).toBe(expected.root); + if (root === undefined) { + expect(config).not.toHaveProperty('seenRoot'); + } else { + expect(config).toHaveProperty('seenRoot', root); + } + expect(config.inlineConfig.root).toBeUndefined(); + expect(process.cwd()).toBe(cwd); + }, + ); + + it('uses an explicit workspace base when the config is in a subdirectory', async () => { + const configDir = path.join(tempDir, 'config'); + fs.mkdirSync(configDir); + const configFile = writeConfig(configDir); + + const config = await resolveConfigFile(configFile, { rootBase: tempDir }); expect(config).toHaveProperty('seenRoot', tempDir); expect(realPath(config.root)).toBe(realPath(tempDir)); }); - it('resolves a relative root from the config directory', async () => { - const configFile = writeConfig(tempDir, "root: 'app',"); + it('resolves a relative root from the workspace base instead of the config directory', async () => { + const configDir = path.join(tempDir, 'config'); + fs.mkdirSync(configDir); + const configFile = writeConfig(configDir, "root: 'app',"); fs.mkdirSync(path.join(tempDir, 'app')); - const config = await resolveConfigFile(configFile); + const config = await resolveConfigFile(configFile, { rootBase: tempDir }); expect(config).toHaveProperty('seenRoot', path.join(tempDir, 'app')); expect(realPath(config.root)).toBe(realPath(path.join(tempDir, 'app'))); }); + it('preserves an absolute config root when a workspace base is supplied', async () => { + const root = path.join(tempDir, 'app'); + const rootBase = path.join(tempDir, 'workspace'); + fs.mkdirSync(root); + fs.mkdirSync(rootBase); + const configFile = writeConfig(tempDir, `root: ${JSON.stringify(root)},`); + + const config = await resolveConfigFile(configFile, { rootBase }); + + expect(config).toHaveProperty('seenRoot', root); + expect(realPath(config.root)).toBe(realPath(root)); + }); + it('keeps the config file and its dependencies', async () => { const configFile = path.join(tempDir, 'vite.config.mjs'); fs.writeFileSync(path.join(tempDir, 'lint.mjs'), 'export const lint = { rules: {} };\n'); @@ -187,7 +235,7 @@ export default { ); const cwd = process.cwd(); - const config = await resolveConfigFile(configFile); + const config = await resolveConfigFile(configFile, { rootBase: tempDir }); expect(config.lint).toEqual({ rules: { 'no-console': 'error' } }); expect(process.cwd()).toBe(cwd); @@ -253,7 +301,7 @@ export default { });\n`, ); - const config = await resolveConfigFile(configFile); + const config = await resolveConfigFile(configFile, { rootBase: tempDir }); expect(config).toHaveProperty('functionEnv', { command: 'build', @@ -269,6 +317,7 @@ export default { }); expect(config.mode).toBe('custom'); expect(config.inlineConfig.configFile).toBe(configFile); + expect(config.inlineConfig.root).toBeUndefined(); expect(config.inlineConfig.lint).toBeUndefined(); expect(config.inlineConfig.build?.ssr).toBeUndefined(); }); @@ -380,7 +429,12 @@ assert.equal(second.nodeEnv, ${JSON.stringify(expected)});`, }); it.each(['import', 'require'])('resolves through the built %s entry', async (entryType) => { - const configFile = path.join(tempDir, 'vite.config.mjs'); + const launcher = path.join(tempDir, 'launcher'); + const workspace = path.join(tempDir, 'workspace'); + const configFile = path.join(workspace, 'config', 'vite.config.mjs'); + fs.mkdirSync(path.dirname(configFile), { recursive: true }); + fs.mkdirSync(path.join(launcher, 'app'), { recursive: true }); + fs.mkdirSync(path.join(workspace, 'app')); const esmEntry = new URL('../../dist/index.js', import.meta.url); const entry = new URL( entryType === 'import' ? '../../dist/index.js' : '../../dist/index.cjs', @@ -389,17 +443,17 @@ assert.equal(second.nodeEnv, ${JSON.stringify(expected)});`, fs.writeFileSync( configFile, `import assert from 'node:assert/strict'; -import { realpathSync } from 'node:fs'; import { defineConfig, lazyPlugins } from ${JSON.stringify(normalizePath(fileURLToPath(esmEntry)))}; export default defineConfig({ + root: 'app', lint: { rules: {} }, plugins: [ lazyPlugins(() => { throw new Error('lazy plugin factory must not run'); }), { name: 'check-published-helper', + config(config) { return { seenRoot: config.root }; }, configResolved(config) { assert.equal(config.configFile, ${JSON.stringify(normalizePath(configFile))}); - assert.equal(realpathSync.native(config.root), realpathSync.native(${JSON.stringify(tempDir)})); assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, '1'); }, }, @@ -413,6 +467,7 @@ export default defineConfig({ '--input-type=module', '--eval', `import assert from 'node:assert/strict'; +import { realpathSync } from 'node:fs'; import { createRequire } from 'node:module'; const api = ${ entryType === 'import' @@ -420,43 +475,73 @@ const api = ${ : `createRequire(import.meta.url)(${JSON.stringify(fileURLToPath(entry))})` }; const marker = process.env.VP_RESOLVING_CONFIG_METADATA; -const config = await api.resolveConfigFile(${JSON.stringify(configFile)}); +const cwd = process.cwd(); +const configFile = ${JSON.stringify(path.relative(launcher, configFile))}; +const defaults = await api.resolveConfigFile(configFile); +assert.equal(realpathSync.native(defaults.root), realpathSync.native(${JSON.stringify(path.join(launcher, 'app'))})); +assert.equal(defaults.seenRoot, 'app'); +assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker); +const config = await api.resolveConfigFile(configFile, { rootBase: ${JSON.stringify(path.relative(launcher, workspace))} }); +assert.equal(realpathSync.native(config.root), realpathSync.native(${JSON.stringify(path.join(workspace, 'app'))})); +assert.equal(config.seenRoot, ${JSON.stringify(path.join(workspace, 'app'))}); assert.deepEqual(config.lint, { rules: {} }); +assert.equal(config.inlineConfig.root, undefined); +assert.equal(process.cwd(), cwd); assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker);`, ], - { cwd: tempDir }, + { cwd: launcher }, ); }); }); describe('resolveViteConfig', () => { - it('keeps a relative root from the config', async () => { - writeConfig(tempDir, "root: 'app',"); - fs.mkdirSync(path.join(tempDir, 'app')); + it.each(['unset', 'relative', 'absolute'])( + 'uses the caller root when the local config root is %s', + async (kind) => { + const root = kind === 'relative' ? 'app' : path.join(tempDir, 'app'); + const configFile = writeConfig( + tempDir, + kind === 'unset' ? '' : `root: ${JSON.stringify(root)},`, + ); - const config = await resolveViteConfig(tempDir); + const config = await resolveViteConfig(tempDir); - expect(config).toHaveProperty('seenRoot', path.join(tempDir, 'app')); - }); + expect(config.configFile).toBe(normalizePath(configFile)); + expect(config).toHaveProperty('seenRoot', tempDir); + expect(realPath(config.root)).toBe(realPath(tempDir)); + expect(config.inlineConfig.root).toBe(tempDir); + }, + ); - it('resolves a workspace config found by traverseUp from its own directory', async () => { - fs.writeFileSync(path.join(tempDir, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); - fs.writeFileSync(path.join(tempDir, 'package.json'), '{"name":"fixture"}'); - const configFile = writeConfig(tempDir); - const packageDir = path.join(tempDir, 'packages', 'lib'); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync(path.join(packageDir, 'package.json'), '{"name":"lib"}'); + it.each(['unset', 'relative', 'absolute'])( + 'uses the package root when traverseUp finds a config with root %s', + async (kind) => { + fs.writeFileSync(path.join(tempDir, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); + fs.writeFileSync(path.join(tempDir, 'package.json'), '{"name":"fixture"}'); + const root = kind === 'relative' ? 'app' : path.join(tempDir, 'app'); + const configFile = writeConfig( + tempDir, + kind === 'unset' ? '' : `root: ${JSON.stringify(root)},`, + ); + const packageDir = path.join(tempDir, 'packages', 'lib'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(path.join(packageDir, 'package.json'), '{"name":"lib"}'); - const config = await resolveViteConfig(packageDir, { traverseUp: true }); + const config = await resolveViteConfig(packageDir, { traverseUp: true }); - expect(config.configFile).toBe(normalizePath(configFile)); - expect(config).toHaveProperty('seenRoot', tempDir); - }); + expect(config.configFile).toBe(normalizePath(configFile)); + expect(config).toHaveProperty('seenRoot', packageDir); + expect(realPath(config.root)).toBe(realPath(packageDir)); + expect(config.inlineConfig.root).toBe(packageDir); + }, + ); it('resolves without a config file', async () => { const config = await resolveViteConfig(tempDir); expect(config.configFile).toBeUndefined(); expect(config.lint).toBeUndefined(); + expect(realPath(config.root)).toBe(realPath(tempDir)); + expect(config.inlineConfig.root).toBe(tempDir); }); }); diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 203eddad71..837f1ae617 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -1066,18 +1066,27 @@ type ResolveConfigWithInternalOptions = ( isPreview?: boolean, patchConfig?: (config: ResolvedConfig) => void, patchPlugins?: (plugins: Plugin[]) => void, - internalOptions?: { rootResolution?: 'cwd' | 'config-file' }, + internalOptions?: { rootBase?: string }, ) => Promise; const resolveConfigWithInternalOptions = resolveConfig as ResolveConfigWithInternalOptions; +export interface ResolveConfigFileOptions { + /** + * Base directory for an unset or relative `root` in the config file. + * Relative paths resolve from `process.cwd()`. Does not change the process cwd. + * Without this option, Vite keeps its normal root resolution behavior. + */ + rootBase?: string; +} + /** * Resolve a Vite config file to read a non-plugin block (`lint`, `fmt`, `pack`, …) from it, * for tools such as oxlint, oxfmt, and Vite+ itself. * - * Unlike `resolveConfig({ configFile }, 'build')`, a relative `root` in the config - * is resolved from the config's directory. Without one, `root` is that directory. - * Editor integrations rely on this, since one process serves every workspace folder. + * By default, root resolution matches `resolveConfig({ configFile }, 'build')`. + * Editor integrations can supply `rootBase` to resolve an unset or relative `root` + * from the intended workspace directory, even when the config is in a subdirectory. * * A relative config file path is still resolved from `process.cwd()`. * The resolver loads the config once, as a config-metadata resolution, @@ -1085,10 +1094,14 @@ const resolveConfigWithInternalOptions = resolveConfig as ResolveConfigWithInter * Plugins outside `lazyPlugins` still run their hooks and side effects, * e.g. TanStack Start's router generator scans `src/routes`. */ -export async function resolveConfigFile(configFile: string): Promise { +export async function resolveConfigFile( + configFile: string, + options: ResolveConfigFileOptions = {}, +): Promise { if (configFile.length === 0) { throw new Error('Config file path must not be empty.'); } + const rootBase = options.rootBase === undefined ? undefined : resolve(options.rootBase); return withConfigMetadataResolution(() => resolveConfigWithInternalOptions( { configFile }, @@ -1098,7 +1111,7 @@ export async function resolveConfigFile(configFile: string): Promise { + if (options?.traverseUp && !hasViteConfig(cwd)) { + const workspaceRoot = findMonorepoRoot(cwd); + if (workspaceRoot) { + const configFile = findViteConfigUp(path.dirname(cwd), workspaceRoot); + if (configFile) { + return resolveConfig({ root: cwd, configFile }, 'build'); + } + } } - } - // This loads the config purely to read a non-plugin block (lint/fmt/pack/run/ staged/create…), - // so `resolveConfigFile` skips the user's plugin factory. - // `root` resolves from the config's directory, not `cwd`, - // including for a workspace config found from a package directory by `traverseUp`. - if (configFile) { - return resolveConfigFile(configFile); - } - // Without a config, callers still expect a resolved config with every block unset. - // The lookup above already covered Vite's config names, - // so `configFile: false` skips a second search and runs no user code. - return resolveConfig({ root: cwd, configFile: false }, 'build'); + return resolveConfig({ root: cwd }, 'build'); + }); } export async function resolveUniversalViteConfig(err: null | Error, viteConfigCwd: string) { diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md index fcd0e68ea4..d1d366efb3 100644 --- a/packages/core/BUNDLING.md +++ b/packages/core/BUNDLING.md @@ -23,7 +23,9 @@ This approach enables users to access Vite, Rolldown, and related tools through Before core bundling, [`brand-vite.ts`](../tools/src/brand-vite.ts) applies branding and the [`patch-vite-config.ts`](../tools/src/patch-vite-config.ts) resolver patch to the vendored Vite source. `sync-remote` runs this preparation. The root build script also runs it before generating Vite types and building core. -The resolver patch adds an optional internal eighth argument to `resolveConfig`. The `resolveConfigFile()` helper exported by `vite-plus` selects `rootResolution: 'config-file'` through a private type bridge. After a config file loads, this mode resolves an unset or relative `root` from that file's directory, before plugin `apply` and `config` hooks. Absolute roots are preserved, and an inline `root` retains priority. Calls without this option keep their existing behavior. Vite still handles config loading, environment initialization, configuration metadata, and plugin hooks. +The resolver patch adds an optional internal eighth argument to `resolveConfig`. The `resolveConfigFile(configFile, { rootBase })` helper exported by `vite-plus` passes an explicit base directory through a private type bridge. After a config file loads, an unset or relative `root` resolves from that base, before plugin `apply` and `config` hooks. Absolute roots are preserved, and an inline `root` retains priority. Without `rootBase`, the helper keeps Vite's normal root resolution behavior. + +Editor integrations can pass their workspace directory explicitly; the config file can be in a subdirectory. Existing CLI callers keep their inline `root: cwd`, including when `vp pack` finds a parent config. Vite still handles config loading, environment initialization, configuration metadata, and plugin hooks. [`build.ts`](build.ts) calls `assertViteConfigPatched()` before any bundling starts. It rejects missing, changed, duplicate, or partially applied patch anchors. When the Vite pin changes, review the patch against the new source. The guard validates `src/node/config.ts`; it does not check whether generated type declarations are current. diff --git a/packages/core/__tests__/resolve-config-root.spec.ts b/packages/core/__tests__/resolve-config-root.spec.ts index be704f564a..39514fca3d 100644 --- a/packages/core/__tests__/resolve-config-root.spec.ts +++ b/packages/core/__tests__/resolve-config-root.spec.ts @@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; import { normalizePath, resolveConfig } from '../dist/vite/node/index.js'; import type { InlineConfig, ResolvedConfig } from '../dist/vite/node/index.js'; -type RootResolution = 'cwd' | 'config-file'; const resolveWithOptions = resolveConfig as ( config: InlineConfig, command: 'build' | 'serve', @@ -19,10 +18,10 @@ const resolveWithOptions = resolveConfig as ( isPreview: undefined, patchConfig: undefined, patchPlugins: undefined, - internalOptions?: { rootResolution?: RootResolution }, + internalOptions?: { rootBase?: string }, ) => Promise; -function resolve(config: InlineConfig, rootResolution?: RootResolution) { +function resolve(config: InlineConfig, rootBase?: string) { return resolveWithOptions( config, 'build', @@ -31,7 +30,7 @@ function resolve(config: InlineConfig, rootResolution?: RootResolution) { undefined, undefined, undefined, - rootResolution === undefined ? undefined : { rootResolution }, + rootBase === undefined ? undefined : { rootBase }, ); } @@ -65,25 +64,24 @@ function writeConfig(config: Record, dir = configDir) { } describe('built core config root resolution', () => { - it.each([undefined, 'cwd'] as const)( - 'preserves cwd-relative defaults with rootResolution=%s', - async (rootResolution) => { - const cwd = process.cwd(); - for (const root of [undefined, '', 'app']) { - const configFile = writeConfig({ root }); - const config = await resolve({ configFile }, rootResolution); - expect(config.root).toBe(expectedRoot(path.resolve(cwd, root ?? ''))); - } - expect(process.cwd()).toBe(cwd); - }, - ); + it('preserves normal Vite root resolution when rootBase is omitted', async () => { + const cwd = process.cwd(); + for (const root of [undefined, '', 'app']) { + const configFile = writeConfig({ root }); + const normal = await resolveConfig({ configFile }, 'build'); + const config = await resolve({ configFile }); + expect(config.root).toBe(normal.root); + expect(config.root).toBe(expectedRoot(path.resolve(cwd, root ?? ''))); + } + expect(process.cwd()).toBe(cwd); + }); it.each([undefined, '', '.', '..', 'app'])( - 'resolves file root %s from the config directory when enabled', + 'resolves file root %s from the supplied config directory base', async (root) => { const cwd = process.cwd(); const configFile = writeConfig({ root }); - const config = await resolve({ configFile }, 'config-file'); + const config = await resolve({ configFile }, configDir); expect(config.root).toBe(expectedRoot(path.resolve(configDir, root ?? ''))); expect(config.configFile).toBe(normalizePath(configFile)); @@ -91,10 +89,25 @@ describe('built core config root resolution', () => { }, ); + it.each([undefined, 'app'])( + 'uses an independent workspace base for root %s in a nested config file', + async (root) => { + const nestedDir = path.join(configDir, 'configs', 'nested'); + const workspaceBase = path.join(tempDir, 'workspace'); + mkdirSync(nestedDir, { recursive: true }); + mkdirSync(path.join(workspaceBase, 'app'), { recursive: true }); + const configFile = writeConfig({ root }, nestedDir); + const config = await resolve({ configFile }, workspaceBase); + + expect(config.root).toBe(expectedRoot(path.resolve(workspaceBase, root ?? ''))); + expect(config.configFile).toBe(normalizePath(configFile)); + }, + ); + it('preserves an absolute root from the config file', async () => { const root = path.join(tempDir, 'absolute'); mkdirSync(root); - const config = await resolve({ configFile: writeConfig({ root }) }, 'config-file'); + const config = await resolve({ configFile: writeConfig({ root }) }, configDir); expect(config.root).toBe(expectedRoot(root)); }); @@ -104,14 +117,14 @@ describe('built core config root resolution', () => { async (value) => { const root = value === 'absolute' ? tempDir : value; const configFile = writeConfig({ root: 'app' }); - const config = await resolve({ configFile, root }, 'config-file'); + const config = await resolve({ configFile, root }, configDir); expect(config.root).toBe(expectedRoot(path.resolve(root))); }, ); it('leaves no-file resolution unchanged', async () => { - const config = await resolve({ root: tempDir }, 'config-file'); + const config = await resolve({ root: tempDir }, configDir); expect(config.configFile).toBeUndefined(); expect(config.root).toBe(expectedRoot(tempDir)); @@ -119,10 +132,12 @@ describe('built core config root resolution', () => { it('does not load a file when configFile is false', async () => { writeFileSync(path.join(configDir, 'vite.config.mjs'), "throw new Error('must not load');\n"); - const config = await resolve({ configFile: false, root: configDir }, 'config-file'); + for (const root of [undefined, configDir]) { + const config = await resolve({ configFile: false, root }, configDir); - expect(config.configFile).toBeUndefined(); - expect(config.root).toBe(expectedRoot(configDir)); + expect(config.configFile).toBeUndefined(); + expect(config.root).toBe(expectedRoot(root ?? process.cwd())); + } }); it('honors a root supplied by a config hook after file root resolution', async () => { @@ -132,7 +147,7 @@ describe('built core config root resolution', () => { configFile, plugins: [{ name: 'change-root', config: () => ({ root: 'plugin-app' }) }], }, - 'config-file', + configDir, ); expect(config.root).toBe(expectedRoot(path.resolve('plugin-app'))); @@ -145,7 +160,7 @@ describe('built core config root resolution', () => { const link = path.join(tempDir, 'linked-project'); symlinkSync(configDir, link, process.platform === 'win32' ? 'junction' : 'dir'); const configFile = path.join(link, 'vite.config.mjs'); - const config = await resolve({ configFile }, 'config-file'); + const config = await resolve({ configFile }, link); expect(config.configFile).toBe(normalizePath(configFile)); expect(config.root).toBe( diff --git a/packages/tools/src/brand-vite.ts b/packages/tools/src/brand-vite.ts index 1be484af81..b5c550d83b 100644 --- a/packages/tools/src/brand-vite.ts +++ b/packages/tools/src/brand-vite.ts @@ -11,7 +11,7 @@ * 3. build.ts: Remove startup build banner and change error prefix * 4. logger.ts: Change default prefix from '[vite]' to '[vite+]' * 5. plugins/reporter.ts: Suppress redundant "vite v" native reporter line - * 6. config.ts: Suppress the native-loader notice and support config-file-relative roots + * 6. config.ts: Suppress the native-loader notice and support an explicit root base */ import { execFileSync } from 'node:child_process'; @@ -229,7 +229,7 @@ export function brandVite(rootDir: string = process.cwd()) { } logPatch( 'config.ts', - 'Suppressed native config loader notice and enabled config-file-relative roots', + 'Suppressed native config loader notice and enabled an explicit root base', patchedConfig === configSource ? 'already' : 'patched', ); diff --git a/packages/tools/src/patch-vite-config.ts b/packages/tools/src/patch-vite-config.ts index a69f532256..4b1ec92ca7 100644 --- a/packages/tools/src/patch-vite-config.ts +++ b/packages/tools/src/patch-vite-config.ts @@ -18,7 +18,7 @@ const patchedSignature = upstreamSignature.replace( '): Promise {', ` /** @internal */ internalOptions?: { - rootResolution?: 'cwd' | 'config-file' + rootBase?: string }, ): Promise {`, ); @@ -33,14 +33,8 @@ const patchedLoadResult = ` if (loadResult) { config = mergeConfig(loadResult.config, config) configFile = loadResult.path configFileDependencies = loadResult.dependencies - if ( - internalOptions?.rootResolution === 'config-file' && - inlineConfig.root == null - ) { - config.root = path.resolve( - path.dirname(loadResult.path), - config.root ?? '', - ) + if (internalOptions?.rootBase !== undefined && inlineConfig.root == null) { + config.root = path.resolve(internalOptions.rootBase, config.root ?? '') } }`; @@ -65,7 +59,7 @@ function patchState(source: string): 'upstream' | 'patched' { upstreamLoads === 1 && patchedSignatures === 0 && patchedLoads === 0 && - !/\b(?:internalOptions|rootResolution)\b/.test(source) + !/\b(?:internalOptions|rootBase)\b/.test(source) ) { return 'upstream'; } @@ -74,8 +68,8 @@ function patchState(source: string): 'upstream' | 'patched' { patchedLoads === 1 && upstreamSignatures === 0 && upstreamLoads === 0 && - source.match(/\binternalOptions\b/g)?.length === 2 && - source.match(/\brootResolution\b/g)?.length === 2 + source.match(/\binternalOptions\b/g)?.length === 3 && + source.match(/\brootBase\b/g)?.length === 3 ) { return 'patched'; } @@ -87,7 +81,7 @@ function patchState(source: string): 'upstream' | 'patched' { /** Validate the complete patch before a build can produce a core that ignores it. */ export function assertViteConfigPatched(source: string): void { if (patchState(source) !== 'patched') { - throw patchError('The config-file root resolution patch has not been applied'); + throw patchError('The root base resolution patch has not been applied'); } } From 9fa7ac52880a7e3122110f242f810567af70d03f Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Oct 2026 23:36:48 +0900 Subject: [PATCH 10/18] refactor(config): rename helper to resolveConfigFromFile --- docs/guide/migrate-rules.md | 2 +- .../src/__tests__/resolve-vite-config.spec.ts | 38 +++++++++---------- packages/cli/src/define-config.ts | 6 +-- packages/cli/src/index.cts | 9 ++++- packages/cli/src/index.ts | 11 ++++-- packages/core/BUNDLING.md | 2 +- 6 files changed, 39 insertions(+), 29 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 1292b5c015..c1839d8028 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -200,7 +200,7 @@ Object-valued nested npm and Bun overrides are preserved: they are user-defined `vite` and `vite/*` imports are rewritten to `vite-plus` **only in config entry files**: `vite.config.*`, `vitest.config.*`, and any config file the migration resolved. Every other file keeps its `vite` imports, for two reasons: -- `vite-plus` is not a guaranteed superset of Vite's exposed surface. It owns only `defineConfig`, `defineProject`, `lazyPlugins`, and `resolveConfigFile`, so rewriting a pass-through symbol such as `createBuilder` or `loadConfigFromFile` (including in `typeof import('vite')` type positions) can break. +- `vite-plus` is not a guaranteed superset of Vite's exposed surface. It owns only `defineConfig`, `defineProject`, `lazyPlugins`, and `resolveConfigFromFile`, so rewriting a pass-through symbol such as `createBuilder` or `loadConfigFromFile` (including in `typeof import('vite')` type positions) can break. - An unrewritten `vite` import still resolves through the `@voidzero-dev/vite-plus-core` alias in a Vite+ project. Plugin packages (an unscoped name starting with `vite-plugin-` or `unplugin-`, or `vite` in `peerDependencies`/`dependencies`) skip the rewrite even in config files. Only the `vite` specifier is in scope for this rule. diff --git a/packages/cli/src/__tests__/resolve-vite-config.spec.ts b/packages/cli/src/__tests__/resolve-vite-config.spec.ts index fa2df6b80b..a954f341a6 100644 --- a/packages/cli/src/__tests__/resolve-vite-config.spec.ts +++ b/packages/cli/src/__tests__/resolve-vite-config.spec.ts @@ -10,7 +10,7 @@ import { promisify } from 'node:util'; import { normalizePath, resolveConfig } from 'vite'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { resolveConfigFile } from '../index.js'; +import { resolveConfigFromFile } from '../index.js'; import { findViteConfigUp, resolveViteConfig } from '../resolve-vite-config.js'; let tempDir: string; @@ -122,9 +122,9 @@ function realPath(file: string): string { return fs.realpathSync.native(file); } -describe('resolveConfigFile', () => { +describe('resolveConfigFromFile', () => { it('rejects an empty path instead of searching cwd', async () => { - await expect(resolveConfigFile('')).rejects.toThrow('Config file path must not be empty.'); + await expect(resolveConfigFromFile('')).rejects.toThrow('Config file path must not be empty.'); }); it.each(['unset', 'empty', 'relative', 'absolute'])( @@ -145,7 +145,7 @@ describe('resolveConfigFile', () => { const cwd = process.cwd(); const expected = await resolveConfig({ configFile }, 'build'); - const config = await resolveConfigFile(configFile); + const config = await resolveConfigFromFile(configFile); expect(config.root).toBe(expected.root); if (root === undefined) { @@ -163,7 +163,7 @@ describe('resolveConfigFile', () => { fs.mkdirSync(configDir); const configFile = writeConfig(configDir); - const config = await resolveConfigFile(configFile, { rootBase: tempDir }); + const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); expect(config).toHaveProperty('seenRoot', tempDir); expect(realPath(config.root)).toBe(realPath(tempDir)); @@ -175,7 +175,7 @@ describe('resolveConfigFile', () => { const configFile = writeConfig(configDir, "root: 'app',"); fs.mkdirSync(path.join(tempDir, 'app')); - const config = await resolveConfigFile(configFile, { rootBase: tempDir }); + const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); expect(config).toHaveProperty('seenRoot', path.join(tempDir, 'app')); expect(realPath(config.root)).toBe(realPath(path.join(tempDir, 'app'))); @@ -188,7 +188,7 @@ describe('resolveConfigFile', () => { fs.mkdirSync(rootBase); const configFile = writeConfig(tempDir, `root: ${JSON.stringify(root)},`); - const config = await resolveConfigFile(configFile, { rootBase }); + const config = await resolveConfigFromFile(configFile, { rootBase }); expect(config).toHaveProperty('seenRoot', root); expect(realPath(config.root)).toBe(realPath(root)); @@ -199,7 +199,7 @@ describe('resolveConfigFile', () => { fs.writeFileSync(path.join(tempDir, 'lint.mjs'), 'export const lint = { rules: {} };\n'); fs.writeFileSync(configFile, "import { lint } from './lint.mjs';\nexport default { lint };\n"); - const config = await resolveConfigFile(configFile); + const config = await resolveConfigFromFile(configFile); expect(config.lint).toEqual({ rules: {} }); expect(config.configFile).toBe(normalizePath(configFile)); @@ -235,7 +235,7 @@ export default { ); const cwd = process.cwd(); - const config = await resolveConfigFile(configFile, { rootBase: tempDir }); + const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); expect(config.lint).toEqual({ rules: { 'no-console': 'error' } }); expect(process.cwd()).toBe(cwd); @@ -280,7 +280,7 @@ export default { };\n`, ); - const config = await resolveConfigFile(configFile); + const config = await resolveConfigFromFile(configFile); expect(config.configFileDependencies.map(realPath)).toContain(realPath(pluginDependency)); }); @@ -301,7 +301,7 @@ export default { });\n`, ); - const config = await resolveConfigFile(configFile, { rootBase: tempDir }); + const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); expect(config).toHaveProperty('functionEnv', { command: 'build', @@ -341,7 +341,7 @@ appendFileSync(${JSON.stringify(evaluationsFile)}, 'module\\n'); export default ${exportValue};\n`, ); - await resolveConfigFile(configFile); + await resolveConfigFromFile(configFile); expect(fs.readFileSync(evaluationsFile, 'utf8')).toBe( kind === 'object' ? 'module\n' : 'module\nfunction\n', @@ -390,9 +390,9 @@ export default ${exportValue};\n`, '--input-type=module', '--eval', `import assert from 'node:assert/strict'; -import { resolveConfigFile } from ${JSON.stringify(new URL('../define-config.ts', import.meta.url).href)}; -const first = await resolveConfigFile(${JSON.stringify(configFile)}); -const second = await resolveConfigFile(${JSON.stringify(configFile)}); +import { resolveConfigFromFile } from ${JSON.stringify(new URL('../define-config.ts', import.meta.url).href)}; +const first = await resolveConfigFromFile(${JSON.stringify(configFile)}); +const second = await resolveConfigFromFile(${JSON.stringify(configFile)}); assert.equal(first.nodeEnv, ${JSON.stringify(expected)}); assert.equal(second.nodeEnv, ${JSON.stringify(expected)});`, ], @@ -408,7 +408,7 @@ assert.equal(second.nodeEnv, ${JSON.stringify(expected)});`, 'export default () => ({ metadata: process.env.VP_RESOLVING_CONFIG_METADATA });\n', ); - const config = await resolveConfigFile(configFile); + const config = await resolveConfigFromFile(configFile); expect(config).toHaveProperty('metadata', '1'); }); @@ -423,7 +423,7 @@ assert.equal(second.nodeEnv, ${JSON.stringify(expected)});`, };\n`, ); - await expect(resolveConfigFile(configFile)).rejects.toThrow('plugin rejected'); + await expect(resolveConfigFromFile(configFile)).rejects.toThrow('plugin rejected'); expect(process.env.VP_RESOLVING_CONFIG_METADATA).toBe(originalMetadata); }); @@ -477,11 +477,11 @@ const api = ${ const marker = process.env.VP_RESOLVING_CONFIG_METADATA; const cwd = process.cwd(); const configFile = ${JSON.stringify(path.relative(launcher, configFile))}; -const defaults = await api.resolveConfigFile(configFile); +const defaults = await api.resolveConfigFromFile(configFile); assert.equal(realpathSync.native(defaults.root), realpathSync.native(${JSON.stringify(path.join(launcher, 'app'))})); assert.equal(defaults.seenRoot, 'app'); assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker); -const config = await api.resolveConfigFile(configFile, { rootBase: ${JSON.stringify(path.relative(launcher, workspace))} }); +const config = await api.resolveConfigFromFile(configFile, { rootBase: ${JSON.stringify(path.relative(launcher, workspace))} }); assert.equal(realpathSync.native(config.root), realpathSync.native(${JSON.stringify(path.join(workspace, 'app'))})); assert.equal(config.seenRoot, ${JSON.stringify(path.join(workspace, 'app'))}); assert.deepEqual(config.lint, { rules: {} }); diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 837f1ae617..904676e831 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -1071,7 +1071,7 @@ type ResolveConfigWithInternalOptions = ( const resolveConfigWithInternalOptions = resolveConfig as ResolveConfigWithInternalOptions; -export interface ResolveConfigFileOptions { +export interface ResolveConfigFromFileOptions { /** * Base directory for an unset or relative `root` in the config file. * Relative paths resolve from `process.cwd()`. Does not change the process cwd. @@ -1094,9 +1094,9 @@ export interface ResolveConfigFileOptions { * Plugins outside `lazyPlugins` still run their hooks and side effects, * e.g. TanStack Start's router generator scans `src/routes`. */ -export async function resolveConfigFile( +export async function resolveConfigFromFile( configFile: string, - options: ResolveConfigFileOptions = {}, + options: ResolveConfigFromFileOptions = {}, ): Promise { if (configFile.length === 0) { throw new Error('Config file path must not be empty.'); diff --git a/packages/cli/src/index.cts b/packages/cli/src/index.cts index a52d75654d..bb154a1da0 100644 --- a/packages/cli/src/index.cts +++ b/packages/cli/src/index.cts @@ -8,7 +8,12 @@ const { defaultInclude, } = require('vitest/config'); -const { defineConfig, defineProject, lazyPlugins, resolveConfigFile } = require('./define-config'); +const { + defineConfig, + defineProject, + lazyPlugins, + resolveConfigFromFile, +} = require('./define-config'); module.exports = { ...vite, @@ -20,5 +25,5 @@ module.exports = { defineProject, defineConfig, lazyPlugins, - resolveConfigFile, + resolveConfigFromFile, }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b89b9d9a15..d6b397e00e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,9 @@ -import { defineConfig, defineProject, lazyPlugins, resolveConfigFile } from './define-config.ts'; +import { + defineConfig, + defineProject, + lazyPlugins, + resolveConfigFromFile, +} from './define-config.ts'; export * from 'vite'; @@ -26,5 +31,5 @@ export type { WatcherTriggerPattern, } from 'vitest/config'; -export { defineConfig, defineProject, lazyPlugins, resolveConfigFile }; -export type { ResolveConfigFileOptions } from './define-config.ts'; +export { defineConfig, defineProject, lazyPlugins, resolveConfigFromFile }; +export type { ResolveConfigFromFileOptions } from './define-config.ts'; diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md index d1d366efb3..dbfc38e21d 100644 --- a/packages/core/BUNDLING.md +++ b/packages/core/BUNDLING.md @@ -23,7 +23,7 @@ This approach enables users to access Vite, Rolldown, and related tools through Before core bundling, [`brand-vite.ts`](../tools/src/brand-vite.ts) applies branding and the [`patch-vite-config.ts`](../tools/src/patch-vite-config.ts) resolver patch to the vendored Vite source. `sync-remote` runs this preparation. The root build script also runs it before generating Vite types and building core. -The resolver patch adds an optional internal eighth argument to `resolveConfig`. The `resolveConfigFile(configFile, { rootBase })` helper exported by `vite-plus` passes an explicit base directory through a private type bridge. After a config file loads, an unset or relative `root` resolves from that base, before plugin `apply` and `config` hooks. Absolute roots are preserved, and an inline `root` retains priority. Without `rootBase`, the helper keeps Vite's normal root resolution behavior. +The resolver patch adds an optional internal eighth argument to `resolveConfig`. The `resolveConfigFromFile(configFile, { rootBase })` helper exported by `vite-plus` passes an explicit base directory through a private type bridge. After a config file loads, an unset or relative `root` resolves from that base, before plugin `apply` and `config` hooks. Absolute roots are preserved, and an inline `root` retains priority. Without `rootBase`, the helper keeps Vite's normal root resolution behavior. Editor integrations can pass their workspace directory explicitly; the config file can be in a subdirectory. Existing CLI callers keep their inline `root: cwd`, including when `vp pack` finds a parent config. Vite still handles config loading, environment initialization, configuration metadata, and plugin hooks. From 996210cec88377bfd7bc99cd59d418361f12e27a Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Oct 2026 23:43:02 +0900 Subject: [PATCH 11/18] test(pack): show root and output checks in snapshots --- .../pack_shared_config_cwd/snapshots.toml | 3 ++- ...ack_shared_config_preserves_package_cwd.md | 20 ++++++++++++-- .../pack_shared_config_cwd/verify-pack.mjs | 16 ------------ .../pack_shared_config_cwd/vite.config.mjs | 26 ++++++------------- 4 files changed, 28 insertions(+), 37 deletions(-) delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/verify-pack.mjs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots.toml index 82694b2b50..cb47bf3fe2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots.toml @@ -4,5 +4,6 @@ vp = "local" cwd = "packages/lib" steps = [ { argv = ["vp", "pack", "--logLevel", "silent"], comment = "Load the parent config through traverseUp while plugin hooks and pack paths stay at the package cwd, overriding the file's root." }, - ["node", "../../verify-pack.mjs"], + ["vpt", "print-file", "dist/index.mjs"], + ["vpt", "stat-file", "../../dist", "../../configured-root/dist"], ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots/pack_shared_config_preserves_package_cwd.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots/pack_shared_config_preserves_package_cwd.md index b7b51f54d0..fc61220783 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots/pack_shared_config_preserves_package_cwd.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots/pack_shared_config_preserves_package_cwd.md @@ -5,11 +5,27 @@ Load the parent config through traverseUp while plugin hooks and pack paths stay at the package cwd, overriding the file's root. ``` +apply root: /packages/lib +apply cwd: /packages/lib +config root: /packages/lib +config cwd: /packages/lib +configResolved root: /packages/lib +configResolved cwd: /packages/lib +configResolved configFile: /vite.config.mjs ℹ Build start ``` -## `node ../../verify-pack.mjs` +## `vpt print-file dist/index.mjs` ``` -shared config keeps plugin root and pack paths in the package +// src/index.js +const source = "package source"; +export { source }; +``` + +## `vpt stat-file ../../dist ../../configured-root/dist` + +``` +../../dist: missing +../../configured-root/dist: missing ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/verify-pack.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/verify-pack.mjs deleted file mode 100644 index fafa7c68f5..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/verify-pack.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import assert from 'node:assert/strict'; -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { source } from './packages/lib/dist/index.mjs'; - -const workspaceDir = dirname(fileURLToPath(import.meta.url)); -assert.equal(source, 'package source'); -assert.equal( - readFileSync(join(workspaceDir, 'packages', 'lib', 'resolved.txt'), 'utf8'), - 'shared config hooks used package cwd\n', -); -assert.equal(existsSync(join(workspaceDir, 'dist')), false); -assert.equal(existsSync(join(workspaceDir, 'configured-root', 'dist')), false); -console.log('shared config keeps plugin root and pack paths in the package'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/vite.config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/vite.config.mjs index 316e1aa43f..e9c7a0370b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/vite.config.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/vite.config.mjs @@ -1,17 +1,8 @@ -import assert from 'node:assert/strict'; -import { realpathSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - import { defineConfig, normalizePath } from 'vite-plus'; -const configFile = fileURLToPath(import.meta.url); -const workspaceDir = dirname(configFile); -const packageDir = join(workspaceDir, 'packages', 'lib'); - -function assertPackageRoot(root) { - assert.equal(realpathSync.native(root), realpathSync.native(packageDir)); - assert.equal(realpathSync.native(process.cwd()), realpathSync.native(packageDir)); +function printRoot(hook, config) { + console.log(`${hook} root: ${normalizePath(String(config.root))}`); + console.log(`${hook} cwd: ${normalizePath(process.cwd())}`); } export default defineConfig({ @@ -19,21 +10,20 @@ export default defineConfig({ root: 'configured-root', plugins: [ { - name: 'verify-shared-config-package-root', + name: 'print-shared-config-package-root', apply(config) { - assertPackageRoot(config.root); + printRoot('apply', config); return true; }, config: { order: 'pre', handler(config) { - assertPackageRoot(config.root); + printRoot('config', config); }, }, configResolved(config) { - assertPackageRoot(config.root); - assert.equal(config.configFile, normalizePath(configFile)); - writeFileSync('resolved.txt', 'shared config hooks used package cwd\n'); + printRoot('configResolved', config); + console.log(`configResolved configFile: ${config.configFile}`); }, }, ], From a9f58ef09c5d71822c1e9b2ff43debf53ce75232 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Oct 2026 23:49:56 +0900 Subject: [PATCH 12/18] test(config): show hook checks in snapshots --- .../config_resolution_hooks/settings.mjs | 2 +- .../config_resolution_hooks/snapshots.toml | 5 ++-- .../snapshots/pack_preserves_config_hooks.md | 14 +++++++-- .../staged_preserves_config_hooks.md | 24 ++++++++++----- .../config_resolution_hooks/verify-pack.mjs | 8 ----- .../config_resolution_hooks/verify-staged.mjs | 8 ----- .../config_resolution_hooks/vite.config.mjs | 29 +++++++------------ 7 files changed, 41 insertions(+), 49 deletions(-) delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-pack.mjs delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-staged.mjs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs index 895643f128..7fd44a1ea8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs @@ -1,5 +1,5 @@ export const staged = { - '*.txt': 'node verify-staged.mjs', + '*.txt': 'vpt stat-file', }; export const pack = { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml index 8596e8d219..e0a0b18510 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml @@ -8,8 +8,7 @@ steps = [ { argv = ["git", "commit", "--quiet", "-m", "initial"], snapshot = false }, { argv = ["vpt", "write-file", "input.txt", "changed\n"], snapshot = false }, { argv = ["git", "add", "input.txt"], snapshot = false }, - { argv = ["vp", "staged", "--quiet", "--no-stash"], comment = "Run staged configuration supplied by a Vite plugin with normal config metadata." }, - ["vpt", "print-file", "result.txt"], + { argv = ["vp", "staged", "--verbose", "--no-stash"], comment = "Run staged configuration supplied by a Vite plugin and print the matched files." }, ] [[case]] @@ -18,5 +17,5 @@ vp = "local" env = { NODE_ENV = "development" } steps = [ { argv = ["vp", "pack", "--logLevel", "silent"], comment = "Build with plugin-supplied pack options and configuration metadata available in configResolved." }, - ["node", "verify-pack.mjs"], + ["vpt", "print-file", "dist/index.mjs"], ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/pack_preserves_config_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/pack_preserves_config_hooks.md index c2c4bfc26a..8dad7ecdc1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/pack_preserves_config_hooks.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/pack_preserves_config_hooks.md @@ -5,11 +5,21 @@ Build with plugin-supplied pack options and configuration metadata available in configResolved. ``` +config NODE_ENV: development +apply root: +config root: +configResolved root: +configResolved configFile: /vite.config.mjs +configResolved dependencies: + /settings.mjs + /vite.config.mjs ℹ Build start ``` -## `node verify-pack.mjs` +## `vpt print-file dist/index.mjs` ``` -pack command from plugin passed +// app/index.js +const app = "fixture"; +export { app }; ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/staged_preserves_config_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/staged_preserves_config_hooks.md index 6ab03219d2..48448c2332 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/staged_preserves_config_hooks.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/staged_preserves_config_hooks.md @@ -16,15 +16,23 @@ ## `git add input.txt` -## `vp staged --quiet --no-stash` +## `vp staged --verbose --no-stash` -Run staged configuration supplied by a Vite plugin with normal config metadata. +Run staged configuration supplied by a Vite plugin and print the matched files. ``` -``` - -## `vpt print-file result.txt` - -``` -staged command from plugin passed +config NODE_ENV: development +apply root: +config root: +configResolved root: +configResolved configFile: /vite.config.mjs +configResolved dependencies: + /settings.mjs + /vite.config.mjs +⚠ Skipping backup because `--no-stash` was used. This might result in data loss. + +✔ vpt stat-file + +→ vpt stat-file: +/input.txt: file ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-pack.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-pack.mjs deleted file mode 100644 index b237e1af1a..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-pack.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; - -import { app } from './dist/index.mjs'; - -assert.equal(app, 'fixture'); -assert.equal(readFileSync('resolved.txt', 'utf8'), 'configResolved passed\n'); -console.log('pack command from plugin passed'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-staged.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-staged.mjs deleted file mode 100644 index aa4b0de342..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/verify-staged.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync, writeFileSync } from 'node:fs'; -import { basename } from 'node:path'; - -assert.equal(readFileSync('resolved.txt', 'utf8'), 'configResolved passed\n'); -assert.equal(process.argv.length, 3); -assert.equal(basename(process.argv[2]), 'input.txt'); -writeFileSync('result.txt', 'staged command from plugin passed\n'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs index 46beaadb37..967b15f3b2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs @@ -1,20 +1,11 @@ -import assert from 'node:assert/strict'; -import { realpathSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - import { defineConfig, lazyPlugins, normalizePath } from 'vite-plus'; import { pack, staged } from './settings.mjs'; -const configFile = fileURLToPath(import.meta.url); -const configDir = dirname(configFile); -// CLI metadata reads retain the command's root override, even when the file sets root. -const root = configDir; - -assert.equal(process.env.NODE_ENV, 'development'); +console.log(`config NODE_ENV: ${process.env.NODE_ENV}`); export default defineConfig({ + // CLI metadata reads retain the command's root override, even when the file sets root. root: 'app', plugins: [ lazyPlugins(() => { @@ -23,23 +14,23 @@ export default defineConfig({ { name: 'provide-tool-config', apply(config) { - assert.equal(config.root, root); + console.log(`apply root: ${normalizePath(String(config.root))}`); return true; }, config: { order: 'pre', handler(config) { - assert.equal(config.root, root); + console.log(`config root: ${normalizePath(String(config.root))}`); return { staged, pack }; }, }, async configResolved(config) { - assert.equal(config.configFile, normalizePath(configFile)); - assert.equal(realpathSync.native(config.root), realpathSync.native(root)); - assert.ok( - config.configFileDependencies.includes(normalizePath(join(configDir, 'settings.mjs'))), - ); - writeFileSync(join(configDir, 'resolved.txt'), 'configResolved passed\n'); + console.log(`configResolved root: ${normalizePath(config.root)}`); + console.log(`configResolved configFile: ${config.configFile}`); + console.log('configResolved dependencies:'); + for (const dependency of config.configFileDependencies.map(normalizePath).sort()) { + console.log(` ${dependency}`); + } }, }, ], From 99af4125f56ba6d30781a90cf0639d4a8934ada9 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Oct 2026 23:51:44 +0900 Subject: [PATCH 13/18] test(config): separate resolveConfigFromFile coverage --- .../resolve-config-from-file.spec.ts | 420 ++++++++++++++++++ .../src/__tests__/resolve-vite-config.spec.ts | 379 +--------------- 2 files changed, 421 insertions(+), 378 deletions(-) create mode 100644 packages/cli/src/__tests__/resolve-config-from-file.spec.ts diff --git a/packages/cli/src/__tests__/resolve-config-from-file.spec.ts b/packages/cli/src/__tests__/resolve-config-from-file.spec.ts new file mode 100644 index 0000000000..042e857212 --- /dev/null +++ b/packages/cli/src/__tests__/resolve-config-from-file.spec.ts @@ -0,0 +1,420 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import { mkdtempSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { normalizePath, resolveConfig } from 'vite'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { resolveConfigFromFile } from '../index.js'; + +let tempDir: string; +const exec = promisify(execFile); + +beforeEach(() => { + // Resolve symlinks (macOS /var -> /private/var) to match path.resolve behavior + tempDir = fs.realpathSync(mkdtempSync(path.join(tmpdir(), 'vite-config-from-file-test-'))); +}); + +afterEach(async () => { + // Vite removes its bundled config asynchronously. Allow transient Windows + // file locks to clear; persistent cleanup errors still fail the test. + await rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +// Plugins read `root` in their `config` hook, as TanStack Start does to find +// its entries, so the fixture plugin records the value it sees there. +function writeConfig(dir: string, fields = ''): string { + const configFile = path.join(dir, 'vite.config.mjs'); + fs.writeFileSync( + configFile, + `export default { + ${fields} + plugins: [{ name: 'record-root', config: (config) => ({ seenRoot: config.root }) }], +}; +`, + ); + return configFile; +} + +// Vite stores the real path, which may differ from `tempDir` in casing or +// short names on Windows. +function realPath(file: string): string { + return fs.realpathSync.native(file); +} + +describe('resolveConfigFromFile', () => { + it('rejects an empty path instead of searching cwd', async () => { + await expect(resolveConfigFromFile('')).rejects.toThrow('Config file path must not be empty.'); + }); + + it.each(['unset', 'empty', 'relative', 'absolute'])( + 'preserves normal Vite root resolution when root is %s and rootBase is omitted', + async (kind) => { + const root = + kind === 'unset' + ? undefined + : kind === 'empty' + ? '' + : kind === 'relative' + ? 'app' + : tempDir; + const configFile = writeConfig( + tempDir, + root === undefined ? '' : `root: ${JSON.stringify(root)},`, + ); + const cwd = process.cwd(); + + const expected = await resolveConfig({ configFile }, 'build'); + const config = await resolveConfigFromFile(configFile); + + expect(config.root).toBe(expected.root); + if (root === undefined) { + expect(config).not.toHaveProperty('seenRoot'); + } else { + expect(config).toHaveProperty('seenRoot', root); + } + expect(config.inlineConfig.root).toBeUndefined(); + expect(process.cwd()).toBe(cwd); + }, + ); + + it('uses an explicit workspace base when the config is in a subdirectory', async () => { + const configDir = path.join(tempDir, 'config'); + fs.mkdirSync(configDir); + const configFile = writeConfig(configDir); + + const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); + + expect(config).toHaveProperty('seenRoot', tempDir); + expect(realPath(config.root)).toBe(realPath(tempDir)); + }); + + it('resolves a relative root from the workspace base instead of the config directory', async () => { + const configDir = path.join(tempDir, 'config'); + fs.mkdirSync(configDir); + const configFile = writeConfig(configDir, "root: 'app',"); + fs.mkdirSync(path.join(tempDir, 'app')); + + const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); + + expect(config).toHaveProperty('seenRoot', path.join(tempDir, 'app')); + expect(realPath(config.root)).toBe(realPath(path.join(tempDir, 'app'))); + }); + + it('preserves an absolute config root when a workspace base is supplied', async () => { + const root = path.join(tempDir, 'app'); + const rootBase = path.join(tempDir, 'workspace'); + fs.mkdirSync(root); + fs.mkdirSync(rootBase); + const configFile = writeConfig(tempDir, `root: ${JSON.stringify(root)},`); + + const config = await resolveConfigFromFile(configFile, { rootBase }); + + expect(config).toHaveProperty('seenRoot', root); + expect(realPath(config.root)).toBe(realPath(root)); + }); + + it('keeps the config file and its dependencies', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync(path.join(tempDir, 'lint.mjs'), 'export const lint = { rules: {} };\n'); + fs.writeFileSync(configFile, "import { lint } from './lint.mjs';\nexport default { lint };\n"); + + const config = await resolveConfigFromFile(configFile); + + expect(config.lint).toEqual({ rules: {} }); + expect(config.configFile).toBe(normalizePath(configFile)); + expect(config.configFileDependencies.map(realPath)).toContain( + realPath(path.join(tempDir, 'lint.mjs')), + ); + }); + + it('provides the root before plugin filtering and early config hooks', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + const root = path.join(tempDir, 'app'); + fs.mkdirSync(root); + fs.writeFileSync( + configFile, + `import assert from 'node:assert/strict'; +export default { + root: 'app', + plugins: [{ + name: 'check-early-root', + apply(config) { + assert.equal(config.root, ${JSON.stringify(root)}); + return true; + }, + config: { + order: 'pre', + handler(config) { + assert.equal(config.root, ${JSON.stringify(root)}); + return { lint: { rules: { 'no-console': 'error' } } }; + }, + }, + }], +};\n`, + ); + const cwd = process.cwd(); + + const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); + + expect(config.lint).toEqual({ rules: { 'no-console': 'error' } }); + expect(process.cwd()).toBe(cwd); + }); + + it('provides file metadata to sync and async hooks and retains added dependencies', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + const dependency = path.join(tempDir, 'lint.mjs'); + const pluginDependency = path.join(tempDir, 'plugin-settings.json'); + fs.writeFileSync(dependency, 'export const lint = { rules: {} };\n'); + fs.writeFileSync(pluginDependency, '{}'); + fs.writeFileSync( + configFile, + `import assert from 'node:assert/strict'; +import { realpathSync } from 'node:fs'; +import { lint } from './lint.mjs'; +function checkMetadata(config) { + assert.equal(config.configFile, ${JSON.stringify(normalizePath(configFile))}); + assert.ok(config.configFileDependencies.map((file) => realpathSync.native(file)).includes( + realpathSync.native(${JSON.stringify(dependency)}), + )); + assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, '1'); +} +export default { + lint, + plugins: [{ + name: 'check-sync-metadata', + configResolved: { + order: 'pre', + handler(config) { + checkMetadata(config); + config.configFileDependencies.push(${JSON.stringify(normalizePath(pluginDependency))}); + }, + }, + }, { + name: 'check-async-metadata', + async configResolved(config) { + await Promise.resolve(); + checkMetadata(config); + }, + }], +};\n`, + ); + + const config = await resolveConfigFromFile(configFile); + + expect(config.configFileDependencies.map(realPath)).toContain(realPath(pluginDependency)); + }); + + it('preserves Vite config environments and keeps the file config out of inlineConfig', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync( + configFile, + `export default (env) => ({ + mode: 'custom', + build: { ssr: true }, + functionEnv: { ...env }, + lint: { rules: {} }, + plugins: [{ + name: 'record-environment', + config(_config, hookEnv) { return { hookEnv: { ...hookEnv } }; }, + }], +});\n`, + ); + + const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); + + expect(config).toHaveProperty('functionEnv', { + command: 'build', + mode: 'development', + isSsrBuild: false, + isPreview: false, + }); + expect(config).toHaveProperty('hookEnv', { + command: 'build', + mode: 'custom', + isSsrBuild: false, + isPreview: false, + }); + expect(config.mode).toBe('custom'); + expect(config.inlineConfig.configFile).toBe(configFile); + expect(config.inlineConfig.root).toBeUndefined(); + expect(config.inlineConfig.lint).toBeUndefined(); + expect(config.inlineConfig.build?.ssr).toBeUndefined(); + }); + + it.each(['object', 'function', 'async function'])( + 'evaluates a %s config only once', + async (kind) => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + const evaluationsFile = path.join(tempDir, 'evaluations.log'); + const exportValue = + kind === 'object' + ? '{ lint: { rules: {} } }' + : `${kind === 'async function' ? 'async ' : ''}() => { + appendFileSync(${JSON.stringify(evaluationsFile)}, 'function\\n'); + return { lint: { rules: {} } }; +}`; + fs.writeFileSync( + configFile, + `import { appendFileSync } from 'node:fs'; +appendFileSync(${JSON.stringify(evaluationsFile)}, 'module\\n'); +export default ${exportValue};\n`, + ); + + await resolveConfigFromFile(configFile); + + expect(fs.readFileSync(evaluationsFile, 'utf8')).toBe( + kind === 'object' ? 'module\n' : 'module\nfunction\n', + ); + }, + ); + + it.each([ + { name: 'unset', nodeEnv: undefined, taskNodeEnv: undefined, expected: 'development' }, + { name: 'explicit', nodeEnv: 'production', taskNodeEnv: undefined, expected: 'production' }, + { + name: 'provided by Vite Task', + nodeEnv: undefined, + taskNodeEnv: 'production', + expected: 'production', + }, + ])( + 'initializes NODE_ENV before evaluation when $name', + async ({ nodeEnv, taskNodeEnv, expected }) => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync(configFile, 'export default () => ({ nodeEnv: process.env.NODE_ENV });\n'); + const env = { ...process.env }; + delete env.NODE_ENV; + delete env.VP_RUN_NODE_CLIENT_PATH; + if (nodeEnv !== undefined) { + env.NODE_ENV = nodeEnv; + } + if (taskNodeEnv !== undefined) { + const clientFile = path.join(tempDir, 'task-client.cjs'); + fs.writeFileSync( + clientFile, + `exports.load = () => ({ + getEnv: (name) => name === 'NODE_ENV' ? ${JSON.stringify(taskNodeEnv)} : undefined, + getEnvs: () => ({}), + ignoreInput() {}, + ignoreOutput() {}, +});\n`, + ); + env.VP_RUN_NODE_CLIENT_PATH = clientFile; + } + + // Start outside Vitest's NODE_ENV=test and cached Vite Task client state. + await exec( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; +import { resolveConfigFromFile } from ${JSON.stringify(new URL('../define-config.ts', import.meta.url).href)}; +const first = await resolveConfigFromFile(${JSON.stringify(configFile)}); +const second = await resolveConfigFromFile(${JSON.stringify(configFile)}); +assert.equal(first.nodeEnv, ${JSON.stringify(expected)}); +assert.equal(second.nodeEnv, ${JSON.stringify(expected)});`, + ], + { env, cwd: tempDir }, + ); + }, + ); + + it('evaluates the config as a config-metadata resolution', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync( + configFile, + 'export default () => ({ metadata: process.env.VP_RESOLVING_CONFIG_METADATA });\n', + ); + + const config = await resolveConfigFromFile(configFile); + + expect(config).toHaveProperty('metadata', '1'); + }); + + it('restores the metadata marker when a plugin rejects', async () => { + const originalMetadata = process.env.VP_RESOLVING_CONFIG_METADATA; + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync( + configFile, + `export default { + plugins: [{ name: 'reject-config', configResolved() { throw new Error('plugin rejected'); } }], +};\n`, + ); + + await expect(resolveConfigFromFile(configFile)).rejects.toThrow('plugin rejected'); + + expect(process.env.VP_RESOLVING_CONFIG_METADATA).toBe(originalMetadata); + }); + + it.each(['import', 'require'])('resolves through the built %s entry', async (entryType) => { + const launcher = path.join(tempDir, 'launcher'); + const workspace = path.join(tempDir, 'workspace'); + const configFile = path.join(workspace, 'config', 'vite.config.mjs'); + fs.mkdirSync(path.dirname(configFile), { recursive: true }); + fs.mkdirSync(path.join(launcher, 'app'), { recursive: true }); + fs.mkdirSync(path.join(workspace, 'app')); + const esmEntry = new URL('../../dist/index.js', import.meta.url); + const entry = new URL( + entryType === 'import' ? '../../dist/index.js' : '../../dist/index.cjs', + import.meta.url, + ); + fs.writeFileSync( + configFile, + `import assert from 'node:assert/strict'; +import { defineConfig, lazyPlugins } from ${JSON.stringify(normalizePath(fileURLToPath(esmEntry)))}; +export default defineConfig({ + root: 'app', + lint: { rules: {} }, + plugins: [ + lazyPlugins(() => { throw new Error('lazy plugin factory must not run'); }), + { + name: 'check-published-helper', + config(config) { return { seenRoot: config.root }; }, + configResolved(config) { + assert.equal(config.configFile, ${JSON.stringify(normalizePath(configFile))}); + assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, '1'); + }, + }, + ], +});\n`, + ); + + await exec( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; +import { realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +const api = ${ + entryType === 'import' + ? `await import(${JSON.stringify(entry.href)})` + : `createRequire(import.meta.url)(${JSON.stringify(fileURLToPath(entry))})` + }; +const marker = process.env.VP_RESOLVING_CONFIG_METADATA; +const cwd = process.cwd(); +const configFile = ${JSON.stringify(path.relative(launcher, configFile))}; +const defaults = await api.resolveConfigFromFile(configFile); +assert.equal(realpathSync.native(defaults.root), realpathSync.native(${JSON.stringify(path.join(launcher, 'app'))})); +assert.equal(defaults.seenRoot, 'app'); +assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker); +const config = await api.resolveConfigFromFile(configFile, { rootBase: ${JSON.stringify(path.relative(launcher, workspace))} }); +assert.equal(realpathSync.native(config.root), realpathSync.native(${JSON.stringify(path.join(workspace, 'app'))})); +assert.equal(config.seenRoot, ${JSON.stringify(path.join(workspace, 'app'))}); +assert.deepEqual(config.lint, { rules: {} }); +assert.equal(config.inlineConfig.root, undefined); +assert.equal(process.cwd(), cwd); +assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker);`, + ], + { cwd: launcher }, + ); + }); +}); diff --git a/packages/cli/src/__tests__/resolve-vite-config.spec.ts b/packages/cli/src/__tests__/resolve-vite-config.spec.ts index a954f341a6..921b8f269a 100644 --- a/packages/cli/src/__tests__/resolve-vite-config.spec.ts +++ b/packages/cli/src/__tests__/resolve-vite-config.spec.ts @@ -1,20 +1,15 @@ -import { execFile } from 'node:child_process'; import fs from 'node:fs'; import { mkdtempSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; -import { normalizePath, resolveConfig } from 'vite'; +import { normalizePath } from 'vite'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { resolveConfigFromFile } from '../index.js'; import { findViteConfigUp, resolveViteConfig } from '../resolve-vite-config.js'; let tempDir: string; -const exec = promisify(execFile); beforeEach(() => { // Resolve symlinks (macOS /var -> /private/var) to match path.resolve behavior @@ -122,378 +117,6 @@ function realPath(file: string): string { return fs.realpathSync.native(file); } -describe('resolveConfigFromFile', () => { - it('rejects an empty path instead of searching cwd', async () => { - await expect(resolveConfigFromFile('')).rejects.toThrow('Config file path must not be empty.'); - }); - - it.each(['unset', 'empty', 'relative', 'absolute'])( - 'preserves normal Vite root resolution when root is %s and rootBase is omitted', - async (kind) => { - const root = - kind === 'unset' - ? undefined - : kind === 'empty' - ? '' - : kind === 'relative' - ? 'app' - : tempDir; - const configFile = writeConfig( - tempDir, - root === undefined ? '' : `root: ${JSON.stringify(root)},`, - ); - const cwd = process.cwd(); - - const expected = await resolveConfig({ configFile }, 'build'); - const config = await resolveConfigFromFile(configFile); - - expect(config.root).toBe(expected.root); - if (root === undefined) { - expect(config).not.toHaveProperty('seenRoot'); - } else { - expect(config).toHaveProperty('seenRoot', root); - } - expect(config.inlineConfig.root).toBeUndefined(); - expect(process.cwd()).toBe(cwd); - }, - ); - - it('uses an explicit workspace base when the config is in a subdirectory', async () => { - const configDir = path.join(tempDir, 'config'); - fs.mkdirSync(configDir); - const configFile = writeConfig(configDir); - - const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); - - expect(config).toHaveProperty('seenRoot', tempDir); - expect(realPath(config.root)).toBe(realPath(tempDir)); - }); - - it('resolves a relative root from the workspace base instead of the config directory', async () => { - const configDir = path.join(tempDir, 'config'); - fs.mkdirSync(configDir); - const configFile = writeConfig(configDir, "root: 'app',"); - fs.mkdirSync(path.join(tempDir, 'app')); - - const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); - - expect(config).toHaveProperty('seenRoot', path.join(tempDir, 'app')); - expect(realPath(config.root)).toBe(realPath(path.join(tempDir, 'app'))); - }); - - it('preserves an absolute config root when a workspace base is supplied', async () => { - const root = path.join(tempDir, 'app'); - const rootBase = path.join(tempDir, 'workspace'); - fs.mkdirSync(root); - fs.mkdirSync(rootBase); - const configFile = writeConfig(tempDir, `root: ${JSON.stringify(root)},`); - - const config = await resolveConfigFromFile(configFile, { rootBase }); - - expect(config).toHaveProperty('seenRoot', root); - expect(realPath(config.root)).toBe(realPath(root)); - }); - - it('keeps the config file and its dependencies', async () => { - const configFile = path.join(tempDir, 'vite.config.mjs'); - fs.writeFileSync(path.join(tempDir, 'lint.mjs'), 'export const lint = { rules: {} };\n'); - fs.writeFileSync(configFile, "import { lint } from './lint.mjs';\nexport default { lint };\n"); - - const config = await resolveConfigFromFile(configFile); - - expect(config.lint).toEqual({ rules: {} }); - expect(config.configFile).toBe(normalizePath(configFile)); - expect(config.configFileDependencies.map(realPath)).toContain( - realPath(path.join(tempDir, 'lint.mjs')), - ); - }); - - it('provides the root before plugin filtering and early config hooks', async () => { - const configFile = path.join(tempDir, 'vite.config.mjs'); - const root = path.join(tempDir, 'app'); - fs.mkdirSync(root); - fs.writeFileSync( - configFile, - `import assert from 'node:assert/strict'; -export default { - root: 'app', - plugins: [{ - name: 'check-early-root', - apply(config) { - assert.equal(config.root, ${JSON.stringify(root)}); - return true; - }, - config: { - order: 'pre', - handler(config) { - assert.equal(config.root, ${JSON.stringify(root)}); - return { lint: { rules: { 'no-console': 'error' } } }; - }, - }, - }], -};\n`, - ); - const cwd = process.cwd(); - - const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); - - expect(config.lint).toEqual({ rules: { 'no-console': 'error' } }); - expect(process.cwd()).toBe(cwd); - }); - - it('provides file metadata to sync and async hooks and retains added dependencies', async () => { - const configFile = path.join(tempDir, 'vite.config.mjs'); - const dependency = path.join(tempDir, 'lint.mjs'); - const pluginDependency = path.join(tempDir, 'plugin-settings.json'); - fs.writeFileSync(dependency, 'export const lint = { rules: {} };\n'); - fs.writeFileSync(pluginDependency, '{}'); - fs.writeFileSync( - configFile, - `import assert from 'node:assert/strict'; -import { realpathSync } from 'node:fs'; -import { lint } from './lint.mjs'; -function checkMetadata(config) { - assert.equal(config.configFile, ${JSON.stringify(normalizePath(configFile))}); - assert.ok(config.configFileDependencies.map((file) => realpathSync.native(file)).includes( - realpathSync.native(${JSON.stringify(dependency)}), - )); - assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, '1'); -} -export default { - lint, - plugins: [{ - name: 'check-sync-metadata', - configResolved: { - order: 'pre', - handler(config) { - checkMetadata(config); - config.configFileDependencies.push(${JSON.stringify(normalizePath(pluginDependency))}); - }, - }, - }, { - name: 'check-async-metadata', - async configResolved(config) { - await Promise.resolve(); - checkMetadata(config); - }, - }], -};\n`, - ); - - const config = await resolveConfigFromFile(configFile); - - expect(config.configFileDependencies.map(realPath)).toContain(realPath(pluginDependency)); - }); - - it('preserves Vite config environments and keeps the file config out of inlineConfig', async () => { - const configFile = path.join(tempDir, 'vite.config.mjs'); - fs.writeFileSync( - configFile, - `export default (env) => ({ - mode: 'custom', - build: { ssr: true }, - functionEnv: { ...env }, - lint: { rules: {} }, - plugins: [{ - name: 'record-environment', - config(_config, hookEnv) { return { hookEnv: { ...hookEnv } }; }, - }], -});\n`, - ); - - const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); - - expect(config).toHaveProperty('functionEnv', { - command: 'build', - mode: 'development', - isSsrBuild: false, - isPreview: false, - }); - expect(config).toHaveProperty('hookEnv', { - command: 'build', - mode: 'custom', - isSsrBuild: false, - isPreview: false, - }); - expect(config.mode).toBe('custom'); - expect(config.inlineConfig.configFile).toBe(configFile); - expect(config.inlineConfig.root).toBeUndefined(); - expect(config.inlineConfig.lint).toBeUndefined(); - expect(config.inlineConfig.build?.ssr).toBeUndefined(); - }); - - it.each(['object', 'function', 'async function'])( - 'evaluates a %s config only once', - async (kind) => { - const configFile = path.join(tempDir, 'vite.config.mjs'); - const evaluationsFile = path.join(tempDir, 'evaluations.log'); - const exportValue = - kind === 'object' - ? '{ lint: { rules: {} } }' - : `${kind === 'async function' ? 'async ' : ''}() => { - appendFileSync(${JSON.stringify(evaluationsFile)}, 'function\\n'); - return { lint: { rules: {} } }; -}`; - fs.writeFileSync( - configFile, - `import { appendFileSync } from 'node:fs'; -appendFileSync(${JSON.stringify(evaluationsFile)}, 'module\\n'); -export default ${exportValue};\n`, - ); - - await resolveConfigFromFile(configFile); - - expect(fs.readFileSync(evaluationsFile, 'utf8')).toBe( - kind === 'object' ? 'module\n' : 'module\nfunction\n', - ); - }, - ); - - it.each([ - { name: 'unset', nodeEnv: undefined, taskNodeEnv: undefined, expected: 'development' }, - { name: 'explicit', nodeEnv: 'production', taskNodeEnv: undefined, expected: 'production' }, - { - name: 'provided by Vite Task', - nodeEnv: undefined, - taskNodeEnv: 'production', - expected: 'production', - }, - ])( - 'initializes NODE_ENV before evaluation when $name', - async ({ nodeEnv, taskNodeEnv, expected }) => { - const configFile = path.join(tempDir, 'vite.config.mjs'); - fs.writeFileSync(configFile, 'export default () => ({ nodeEnv: process.env.NODE_ENV });\n'); - const env = { ...process.env }; - delete env.NODE_ENV; - delete env.VP_RUN_NODE_CLIENT_PATH; - if (nodeEnv !== undefined) { - env.NODE_ENV = nodeEnv; - } - if (taskNodeEnv !== undefined) { - const clientFile = path.join(tempDir, 'task-client.cjs'); - fs.writeFileSync( - clientFile, - `exports.load = () => ({ - getEnv: (name) => name === 'NODE_ENV' ? ${JSON.stringify(taskNodeEnv)} : undefined, - getEnvs: () => ({}), - ignoreInput() {}, - ignoreOutput() {}, -});\n`, - ); - env.VP_RUN_NODE_CLIENT_PATH = clientFile; - } - - // Start outside Vitest's NODE_ENV=test and cached Vite Task client state. - await exec( - process.execPath, - [ - '--input-type=module', - '--eval', - `import assert from 'node:assert/strict'; -import { resolveConfigFromFile } from ${JSON.stringify(new URL('../define-config.ts', import.meta.url).href)}; -const first = await resolveConfigFromFile(${JSON.stringify(configFile)}); -const second = await resolveConfigFromFile(${JSON.stringify(configFile)}); -assert.equal(first.nodeEnv, ${JSON.stringify(expected)}); -assert.equal(second.nodeEnv, ${JSON.stringify(expected)});`, - ], - { env, cwd: tempDir }, - ); - }, - ); - - it('evaluates the config as a config-metadata resolution', async () => { - const configFile = path.join(tempDir, 'vite.config.mjs'); - fs.writeFileSync( - configFile, - 'export default () => ({ metadata: process.env.VP_RESOLVING_CONFIG_METADATA });\n', - ); - - const config = await resolveConfigFromFile(configFile); - - expect(config).toHaveProperty('metadata', '1'); - }); - - it('restores the metadata marker when a plugin rejects', async () => { - const originalMetadata = process.env.VP_RESOLVING_CONFIG_METADATA; - const configFile = path.join(tempDir, 'vite.config.mjs'); - fs.writeFileSync( - configFile, - `export default { - plugins: [{ name: 'reject-config', configResolved() { throw new Error('plugin rejected'); } }], -};\n`, - ); - - await expect(resolveConfigFromFile(configFile)).rejects.toThrow('plugin rejected'); - - expect(process.env.VP_RESOLVING_CONFIG_METADATA).toBe(originalMetadata); - }); - - it.each(['import', 'require'])('resolves through the built %s entry', async (entryType) => { - const launcher = path.join(tempDir, 'launcher'); - const workspace = path.join(tempDir, 'workspace'); - const configFile = path.join(workspace, 'config', 'vite.config.mjs'); - fs.mkdirSync(path.dirname(configFile), { recursive: true }); - fs.mkdirSync(path.join(launcher, 'app'), { recursive: true }); - fs.mkdirSync(path.join(workspace, 'app')); - const esmEntry = new URL('../../dist/index.js', import.meta.url); - const entry = new URL( - entryType === 'import' ? '../../dist/index.js' : '../../dist/index.cjs', - import.meta.url, - ); - fs.writeFileSync( - configFile, - `import assert from 'node:assert/strict'; -import { defineConfig, lazyPlugins } from ${JSON.stringify(normalizePath(fileURLToPath(esmEntry)))}; -export default defineConfig({ - root: 'app', - lint: { rules: {} }, - plugins: [ - lazyPlugins(() => { throw new Error('lazy plugin factory must not run'); }), - { - name: 'check-published-helper', - config(config) { return { seenRoot: config.root }; }, - configResolved(config) { - assert.equal(config.configFile, ${JSON.stringify(normalizePath(configFile))}); - assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, '1'); - }, - }, - ], -});\n`, - ); - - await exec( - process.execPath, - [ - '--input-type=module', - '--eval', - `import assert from 'node:assert/strict'; -import { realpathSync } from 'node:fs'; -import { createRequire } from 'node:module'; -const api = ${ - entryType === 'import' - ? `await import(${JSON.stringify(entry.href)})` - : `createRequire(import.meta.url)(${JSON.stringify(fileURLToPath(entry))})` - }; -const marker = process.env.VP_RESOLVING_CONFIG_METADATA; -const cwd = process.cwd(); -const configFile = ${JSON.stringify(path.relative(launcher, configFile))}; -const defaults = await api.resolveConfigFromFile(configFile); -assert.equal(realpathSync.native(defaults.root), realpathSync.native(${JSON.stringify(path.join(launcher, 'app'))})); -assert.equal(defaults.seenRoot, 'app'); -assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker); -const config = await api.resolveConfigFromFile(configFile, { rootBase: ${JSON.stringify(path.relative(launcher, workspace))} }); -assert.equal(realpathSync.native(config.root), realpathSync.native(${JSON.stringify(path.join(workspace, 'app'))})); -assert.equal(config.seenRoot, ${JSON.stringify(path.join(workspace, 'app'))}); -assert.deepEqual(config.lint, { rules: {} }); -assert.equal(config.inlineConfig.root, undefined); -assert.equal(process.cwd(), cwd); -assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker);`, - ], - { cwd: launcher }, - ); - }); -}); - describe('resolveViteConfig', () => { it.each(['unset', 'relative', 'absolute'])( 'uses the caller root when the local config root is %s', From 4cee0492027aaa81ce98036ad71213a80b327d38 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 28 Oct 2026 00:01:33 +0900 Subject: [PATCH 14/18] fix(config): default file resolver root to config directory --- .../resolve-config-from-file.spec.ts | 97 ++++++++++++------- packages/cli/src/define-config.ts | 16 +-- packages/core/BUNDLING.md | 6 +- 3 files changed, 74 insertions(+), 45 deletions(-) diff --git a/packages/cli/src/__tests__/resolve-config-from-file.spec.ts b/packages/cli/src/__tests__/resolve-config-from-file.spec.ts index 042e857212..25fba8847c 100644 --- a/packages/cli/src/__tests__/resolve-config-from-file.spec.ts +++ b/packages/cli/src/__tests__/resolve-config-from-file.spec.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; -import { normalizePath, resolveConfig } from 'vite'; +import { normalizePath } from 'vite'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { resolveConfigFromFile } from '../index.js'; @@ -52,37 +52,48 @@ describe('resolveConfigFromFile', () => { await expect(resolveConfigFromFile('')).rejects.toThrow('Config file path must not be empty.'); }); - it.each(['unset', 'empty', 'relative', 'absolute'])( - 'preserves normal Vite root resolution when root is %s and rootBase is omitted', + it.each(['unset', 'empty', 'relative', 'parent-relative', 'absolute'] as const)( + 'resolves a %s root from the config directory when rootBase is omitted', async (kind) => { - const root = - kind === 'unset' - ? undefined - : kind === 'empty' - ? '' - : kind === 'relative' - ? 'app' - : tempDir; + const configDir = path.join(tempDir, 'config'); + fs.mkdirSync(configDir); + const root = { + unset: undefined, + empty: '', + relative: 'app', + 'parent-relative': '../app', + absolute: tempDir, + }[kind]; + const expectedRoot = path.resolve(configDir, root ?? ''); + fs.mkdirSync(expectedRoot, { recursive: true }); const configFile = writeConfig( - tempDir, + configDir, root === undefined ? '' : `root: ${JSON.stringify(root)},`, ); const cwd = process.cwd(); - const expected = await resolveConfig({ configFile }, 'build'); const config = await resolveConfigFromFile(configFile); - expect(config.root).toBe(expected.root); - if (root === undefined) { - expect(config).not.toHaveProperty('seenRoot'); - } else { - expect(config).toHaveProperty('seenRoot', root); - } + expect(realPath(config.root)).toBe(realPath(expectedRoot)); + expect(config).toHaveProperty('seenRoot', expectedRoot); expect(config.inlineConfig.root).toBeUndefined(); expect(process.cwd()).toBe(cwd); }, ); + it.each([ + { name: 'undefined options', options: undefined }, + { name: 'empty options', options: {} }, + { name: 'undefined rootBase', options: { rootBase: undefined } }, + ])('uses the config directory with $name', async ({ options }) => { + const configFile = writeConfig(tempDir); + + const config = await resolveConfigFromFile(configFile, options); + + expect(realPath(config.root)).toBe(realPath(tempDir)); + expect(config).toHaveProperty('seenRoot', tempDir); + }); + it('uses an explicit workspace base when the config is in a subdirectory', async () => { const configDir = path.join(tempDir, 'config'); fs.mkdirSync(configDir); @@ -133,13 +144,17 @@ describe('resolveConfigFromFile', () => { ); }); - it('provides the root before plugin filtering and early config hooks', async () => { - const configFile = path.join(tempDir, 'vite.config.mjs'); - const root = path.join(tempDir, 'app'); - fs.mkdirSync(root); - fs.writeFileSync( - configFile, - `import assert from 'node:assert/strict'; + it.each(['default', 'explicit'])( + 'provides the %s base root before plugin filtering and early config hooks', + async (base) => { + const configDir = path.join(tempDir, 'config'); + fs.mkdirSync(configDir); + const configFile = path.join(configDir, 'vite.config.mjs'); + const root = path.join(base === 'default' ? configDir : tempDir, 'app'); + fs.mkdirSync(root); + fs.writeFileSync( + configFile, + `import assert from 'node:assert/strict'; export default { root: 'app', plugins: [{ @@ -157,14 +172,18 @@ export default { }, }], };\n`, - ); - const cwd = process.cwd(); + ); + const cwd = process.cwd(); - const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); + const config = await resolveConfigFromFile( + configFile, + base === 'default' ? undefined : { rootBase: tempDir }, + ); - expect(config.lint).toEqual({ rules: { 'no-console': 'error' } }); - expect(process.cwd()).toBe(cwd); - }); + expect(config.lint).toEqual({ rules: { 'no-console': 'error' } }); + expect(process.cwd()).toBe(cwd); + }, + ); it('provides file metadata to sync and async hooks and retains added dependencies', async () => { const configFile = path.join(tempDir, 'vite.config.mjs'); @@ -356,8 +375,9 @@ assert.equal(second.nodeEnv, ${JSON.stringify(expected)});`, it.each(['import', 'require'])('resolves through the built %s entry', async (entryType) => { const launcher = path.join(tempDir, 'launcher'); const workspace = path.join(tempDir, 'workspace'); - const configFile = path.join(workspace, 'config', 'vite.config.mjs'); - fs.mkdirSync(path.dirname(configFile), { recursive: true }); + const configDir = path.join(workspace, 'config'); + const configFile = path.join(configDir, 'vite.config.mjs'); + fs.mkdirSync(path.join(configDir, 'app'), { recursive: true }); fs.mkdirSync(path.join(launcher, 'app'), { recursive: true }); fs.mkdirSync(path.join(workspace, 'app')); const esmEntry = new URL('../../dist/index.js', import.meta.url); @@ -403,14 +423,19 @@ const marker = process.env.VP_RESOLVING_CONFIG_METADATA; const cwd = process.cwd(); const configFile = ${JSON.stringify(path.relative(launcher, configFile))}; const defaults = await api.resolveConfigFromFile(configFile); -assert.equal(realpathSync.native(defaults.root), realpathSync.native(${JSON.stringify(path.join(launcher, 'app'))})); -assert.equal(defaults.seenRoot, 'app'); +assert.equal(realpathSync.native(defaults.root), realpathSync.native(${JSON.stringify(path.join(configDir, 'app'))})); +assert.equal(defaults.seenRoot, ${JSON.stringify(path.join(configDir, 'app'))}); assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker); const config = await api.resolveConfigFromFile(configFile, { rootBase: ${JSON.stringify(path.relative(launcher, workspace))} }); assert.equal(realpathSync.native(config.root), realpathSync.native(${JSON.stringify(path.join(workspace, 'app'))})); assert.equal(config.seenRoot, ${JSON.stringify(path.join(workspace, 'app'))}); assert.deepEqual(config.lint, { rules: {} }); assert.equal(config.inlineConfig.root, undefined); +for (const rootBase of [cwd, '']) { + const fromCwd = await api.resolveConfigFromFile(configFile, { rootBase }); + assert.equal(realpathSync.native(fromCwd.root), realpathSync.native(${JSON.stringify(path.join(launcher, 'app'))})); + assert.equal(fromCwd.seenRoot, ${JSON.stringify(path.join(launcher, 'app'))}); +} assert.equal(process.cwd(), cwd); assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker);`, ], diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 904676e831..8e0aea2534 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ignoreInput, ignoreOutput } from '@voidzero-dev/vite-task-client'; @@ -1074,8 +1074,8 @@ const resolveConfigWithInternalOptions = resolveConfig as ResolveConfigWithInter export interface ResolveConfigFromFileOptions { /** * Base directory for an unset or relative `root` in the config file. + * Defaults to the config file's parent directory. * Relative paths resolve from `process.cwd()`. Does not change the process cwd. - * Without this option, Vite keeps its normal root resolution behavior. */ rootBase?: string; } @@ -1084,11 +1084,13 @@ export interface ResolveConfigFromFileOptions { * Resolve a Vite config file to read a non-plugin block (`lint`, `fmt`, `pack`, …) from it, * for tools such as oxlint, oxfmt, and Vite+ itself. * - * By default, root resolution matches `resolveConfig({ configFile }, 'build')`. - * Editor integrations can supply `rootBase` to resolve an unset or relative `root` - * from the intended workspace directory, even when the config is in a subdirectory. + * With an absolute `configFile`, default root resolution is independent of `process.cwd()`: + * an unset or relative `root` resolves from the config file's parent directory. + * Callers can supply `rootBase` to use a different project directory, + * such as when the config is in a subdirectory. Absolute roots are preserved. * - * A relative config file path is still resolved from `process.cwd()`. + * Relative `configFile` and explicit `rootBase` paths still resolve from `process.cwd()`. + * The helper does not change the process cwd; config code and plugins can still read it. * The resolver loads the config once, as a config-metadata resolution, * so `lazyPlugins` skips its factory. * Plugins outside `lazyPlugins` still run their hooks and side effects, @@ -1101,7 +1103,7 @@ export async function resolveConfigFromFile( if (configFile.length === 0) { throw new Error('Config file path must not be empty.'); } - const rootBase = options.rootBase === undefined ? undefined : resolve(options.rootBase); + const rootBase = resolve(options.rootBase ?? dirname(resolve(configFile))); return withConfigMetadataResolution(() => resolveConfigWithInternalOptions( { configFile }, diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md index dbfc38e21d..b49c4599c0 100644 --- a/packages/core/BUNDLING.md +++ b/packages/core/BUNDLING.md @@ -23,9 +23,11 @@ This approach enables users to access Vite, Rolldown, and related tools through Before core bundling, [`brand-vite.ts`](../tools/src/brand-vite.ts) applies branding and the [`patch-vite-config.ts`](../tools/src/patch-vite-config.ts) resolver patch to the vendored Vite source. `sync-remote` runs this preparation. The root build script also runs it before generating Vite types and building core. -The resolver patch adds an optional internal eighth argument to `resolveConfig`. The `resolveConfigFromFile(configFile, { rootBase })` helper exported by `vite-plus` passes an explicit base directory through a private type bridge. After a config file loads, an unset or relative `root` resolves from that base, before plugin `apply` and `config` hooks. Absolute roots are preserved, and an inline `root` retains priority. Without `rootBase`, the helper keeps Vite's normal root resolution behavior. +The resolver patch adds an optional internal eighth argument to `resolveConfig`. The `resolveConfigFromFile(configFile, options?)` helper exported by `vite-plus` passes a base directory through a private type bridge. By default, this base is the config file's parent directory. With an absolute `configFile`, default root resolution is independent of `process.cwd()`. Callers can pass `{ rootBase: projectRoot }` to use a different project directory, including when the config is in a subdirectory. Relative `configFile` and explicit `rootBase` paths resolve from `process.cwd()`; the helper does not change the process cwd. -Editor integrations can pass their workspace directory explicitly; the config file can be in a subdirectory. Existing CLI callers keep their inline `root: cwd`, including when `vp pack` finds a parent config. Vite still handles config loading, environment initialization, configuration metadata, and plugin hooks. +After a config file loads, an unset or relative `root` resolves from the selected base, before plugin `apply` and `config` hooks. Absolute roots are preserved, and an inline `root` retains priority. Vite still handles config loading, environment initialization, configuration metadata, and plugin hooks. + +The helper's default does not change ordinary `resolveConfig` calls, which retain Vite's cwd-based root resolution. Existing CLI callers keep their inline `root: cwd`, including when `vp pack` finds a parent config. Adopting the new helper is a separate integration choice for callers such as Oxlint and Oxfmt; it is not restricted to LSP. [`build.ts`](build.ts) calls `assertViteConfigPatched()` before any bundling starts. It rejects missing, changed, duplicate, or partially applied patch anchors. When the Vite pin changes, review the patch against the new source. The guard validates `src/node/config.ts`; it does not check whether generated type declarations are current. From ee06b437dfe2ff7fb360ad5bacf057fa8881d3cb Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 28 Oct 2026 08:45:01 +0900 Subject: [PATCH 15/18] refactor(config): simplify patches and resolver tests --- .../resolve-config-from-file.spec.ts | 54 ++++++++++--------- .../src/__tests__/resolve-vite-config.spec.ts | 31 ++++++----- packages/cli/src/index.ts | 6 +-- .../__tests__/resolve-config-root.spec.ts | 4 +- .../tools/src/__tests__/brand-vite.spec.ts | 8 ++- packages/tools/src/brand-vite.ts | 15 +++--- packages/tools/src/patch-vite-config.ts | 10 ++-- 7 files changed, 68 insertions(+), 60 deletions(-) diff --git a/packages/cli/src/__tests__/resolve-config-from-file.spec.ts b/packages/cli/src/__tests__/resolve-config-from-file.spec.ts index 25fba8847c..3c0afc0860 100644 --- a/packages/cli/src/__tests__/resolve-config-from-file.spec.ts +++ b/packages/cli/src/__tests__/resolve-config-from-file.spec.ts @@ -28,12 +28,13 @@ afterEach(async () => { // Plugins read `root` in their `config` hook, as TanStack Start does to find // its entries, so the fixture plugin records the value it sees there. -function writeConfig(dir: string, fields = ''): string { +function writeConfig(dir: string, root?: string): string { const configFile = path.join(dir, 'vite.config.mjs'); + const rootField = root === undefined ? '' : `root: ${JSON.stringify(root)},`; fs.writeFileSync( configFile, `export default { - ${fields} + ${rootField} plugins: [{ name: 'record-root', config: (config) => ({ seenRoot: config.root }) }], }; `, @@ -66,10 +67,7 @@ describe('resolveConfigFromFile', () => { }[kind]; const expectedRoot = path.resolve(configDir, root ?? ''); fs.mkdirSync(expectedRoot, { recursive: true }); - const configFile = writeConfig( - configDir, - root === undefined ? '' : `root: ${JSON.stringify(root)},`, - ); + const configFile = writeConfig(configDir, root); const cwd = process.cwd(); const config = await resolveConfigFromFile(configFile); @@ -108,7 +106,7 @@ describe('resolveConfigFromFile', () => { it('resolves a relative root from the workspace base instead of the config directory', async () => { const configDir = path.join(tempDir, 'config'); fs.mkdirSync(configDir); - const configFile = writeConfig(configDir, "root: 'app',"); + const configFile = writeConfig(configDir, 'app'); fs.mkdirSync(path.join(tempDir, 'app')); const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); @@ -122,7 +120,7 @@ describe('resolveConfigFromFile', () => { const rootBase = path.join(tempDir, 'workspace'); fs.mkdirSync(root); fs.mkdirSync(rootBase); - const configFile = writeConfig(tempDir, `root: ${JSON.stringify(root)},`); + const configFile = writeConfig(tempDir, root); const config = await resolveConfigFromFile(configFile, { rootBase }); @@ -271,13 +269,14 @@ export default { async (kind) => { const configFile = path.join(tempDir, 'vite.config.mjs'); const evaluationsFile = path.join(tempDir, 'evaluations.log'); - const exportValue = - kind === 'object' - ? '{ lint: { rules: {} } }' - : `${kind === 'async function' ? 'async ' : ''}() => { + let exportValue = '{ lint: { rules: {} } }'; + if (kind !== 'object') { + const asyncKeyword = kind === 'async function' ? 'async ' : ''; + exportValue = `${asyncKeyword}() => { appendFileSync(${JSON.stringify(evaluationsFile)}, 'function\\n'); return { lint: { rules: {} } }; }`; + } fs.writeFileSync( configFile, `import { appendFileSync } from 'node:fs'; @@ -377,14 +376,21 @@ assert.equal(second.nodeEnv, ${JSON.stringify(expected)});`, const workspace = path.join(tempDir, 'workspace'); const configDir = path.join(workspace, 'config'); const configFile = path.join(configDir, 'vite.config.mjs'); - fs.mkdirSync(path.join(configDir, 'app'), { recursive: true }); - fs.mkdirSync(path.join(launcher, 'app'), { recursive: true }); - fs.mkdirSync(path.join(workspace, 'app')); + const configRoot = path.join(configDir, 'app'); + const workspaceRoot = path.join(workspace, 'app'); + const launcherRoot = path.join(launcher, 'app'); + fs.mkdirSync(configRoot, { recursive: true }); + fs.mkdirSync(launcherRoot, { recursive: true }); + fs.mkdirSync(workspaceRoot); const esmEntry = new URL('../../dist/index.js', import.meta.url); const entry = new URL( entryType === 'import' ? '../../dist/index.js' : '../../dist/index.cjs', import.meta.url, ); + const loadApi = + entryType === 'import' + ? `await import(${JSON.stringify(entry.href)})` + : `createRequire(import.meta.url)(${JSON.stringify(fileURLToPath(entry))})`; fs.writeFileSync( configFile, `import assert from 'node:assert/strict'; @@ -414,27 +420,23 @@ export default defineConfig({ `import assert from 'node:assert/strict'; import { realpathSync } from 'node:fs'; import { createRequire } from 'node:module'; -const api = ${ - entryType === 'import' - ? `await import(${JSON.stringify(entry.href)})` - : `createRequire(import.meta.url)(${JSON.stringify(fileURLToPath(entry))})` - }; +const api = ${loadApi}; const marker = process.env.VP_RESOLVING_CONFIG_METADATA; const cwd = process.cwd(); const configFile = ${JSON.stringify(path.relative(launcher, configFile))}; const defaults = await api.resolveConfigFromFile(configFile); -assert.equal(realpathSync.native(defaults.root), realpathSync.native(${JSON.stringify(path.join(configDir, 'app'))})); -assert.equal(defaults.seenRoot, ${JSON.stringify(path.join(configDir, 'app'))}); +assert.equal(realpathSync.native(defaults.root), realpathSync.native(${JSON.stringify(configRoot)})); +assert.equal(defaults.seenRoot, ${JSON.stringify(configRoot)}); assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker); const config = await api.resolveConfigFromFile(configFile, { rootBase: ${JSON.stringify(path.relative(launcher, workspace))} }); -assert.equal(realpathSync.native(config.root), realpathSync.native(${JSON.stringify(path.join(workspace, 'app'))})); -assert.equal(config.seenRoot, ${JSON.stringify(path.join(workspace, 'app'))}); +assert.equal(realpathSync.native(config.root), realpathSync.native(${JSON.stringify(workspaceRoot)})); +assert.equal(config.seenRoot, ${JSON.stringify(workspaceRoot)}); assert.deepEqual(config.lint, { rules: {} }); assert.equal(config.inlineConfig.root, undefined); for (const rootBase of [cwd, '']) { const fromCwd = await api.resolveConfigFromFile(configFile, { rootBase }); - assert.equal(realpathSync.native(fromCwd.root), realpathSync.native(${JSON.stringify(path.join(launcher, 'app'))})); - assert.equal(fromCwd.seenRoot, ${JSON.stringify(path.join(launcher, 'app'))}); + assert.equal(realpathSync.native(fromCwd.root), realpathSync.native(${JSON.stringify(launcherRoot)})); + assert.equal(fromCwd.seenRoot, ${JSON.stringify(launcherRoot)}); } assert.equal(process.cwd(), cwd); assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker);`, diff --git a/packages/cli/src/__tests__/resolve-vite-config.spec.ts b/packages/cli/src/__tests__/resolve-vite-config.spec.ts index 921b8f269a..11ed35ee35 100644 --- a/packages/cli/src/__tests__/resolve-vite-config.spec.ts +++ b/packages/cli/src/__tests__/resolve-vite-config.spec.ts @@ -98,12 +98,13 @@ describe('findViteConfigUp', () => { // Plugins read `root` in their `config` hook, as TanStack Start does to find // its entries, so the fixture plugin records the value it sees there. -function writeConfig(dir: string, fields = ''): string { +function writeConfig(dir: string, root?: string): string { const configFile = path.join(dir, 'vite.config.mjs'); + const rootField = root === undefined ? '' : `root: ${JSON.stringify(root)},`; fs.writeFileSync( configFile, `export default { - ${fields} + ${rootField} plugins: [{ name: 'record-root', config: (config) => ({ seenRoot: config.root }) }], }; `, @@ -118,14 +119,15 @@ function realPath(file: string): string { } describe('resolveViteConfig', () => { - it.each(['unset', 'relative', 'absolute'])( + it.each(['unset', 'relative', 'absolute'] as const)( 'uses the caller root when the local config root is %s', async (kind) => { - const root = kind === 'relative' ? 'app' : path.join(tempDir, 'app'); - const configFile = writeConfig( - tempDir, - kind === 'unset' ? '' : `root: ${JSON.stringify(root)},`, - ); + const root = { + unset: undefined, + relative: 'app', + absolute: path.join(tempDir, 'app'), + }[kind]; + const configFile = writeConfig(tempDir, root); const config = await resolveViteConfig(tempDir); @@ -136,16 +138,17 @@ describe('resolveViteConfig', () => { }, ); - it.each(['unset', 'relative', 'absolute'])( + it.each(['unset', 'relative', 'absolute'] as const)( 'uses the package root when traverseUp finds a config with root %s', async (kind) => { fs.writeFileSync(path.join(tempDir, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); fs.writeFileSync(path.join(tempDir, 'package.json'), '{"name":"fixture"}'); - const root = kind === 'relative' ? 'app' : path.join(tempDir, 'app'); - const configFile = writeConfig( - tempDir, - kind === 'unset' ? '' : `root: ${JSON.stringify(root)},`, - ); + const root = { + unset: undefined, + relative: 'app', + absolute: path.join(tempDir, 'app'), + }[kind]; + const configFile = writeConfig(tempDir, root); const packageDir = path.join(tempDir, 'packages', 'lib'); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync(path.join(packageDir, 'package.json'), '{"name":"lib"}'); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d6b397e00e..d8f7b7fff8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,9 +1,10 @@ -import { +export { defineConfig, defineProject, lazyPlugins, resolveConfigFromFile, } from './define-config.ts'; +export type { ResolveConfigFromFileOptions } from './define-config.ts'; export * from 'vite'; @@ -30,6 +31,3 @@ export type { ViteUserConfigFnPromise, WatcherTriggerPattern, } from 'vitest/config'; - -export { defineConfig, defineProject, lazyPlugins, resolveConfigFromFile }; -export type { ResolveConfigFromFileOptions } from './define-config.ts'; diff --git a/packages/core/__tests__/resolve-config-root.spec.ts b/packages/core/__tests__/resolve-config-root.spec.ts index 39514fca3d..9d18dcab41 100644 --- a/packages/core/__tests__/resolve-config-root.spec.ts +++ b/packages/core/__tests__/resolve-config-root.spec.ts @@ -21,7 +21,7 @@ const resolveWithOptions = resolveConfig as ( internalOptions?: { rootBase?: string }, ) => Promise; -function resolve(config: InlineConfig, rootBase?: string) { +function resolve(config: InlineConfig, rootBase?: string): Promise { return resolveWithOptions( config, 'build', @@ -57,7 +57,7 @@ function expectedRoot(root: string): string { } } -function writeConfig(config: Record, dir = configDir) { +function writeConfig(config: Record, dir = configDir): string { const file = path.join(dir, 'vite.config.mjs'); writeFileSync(file, `export default ${JSON.stringify(config)};\n`); return file; diff --git a/packages/tools/src/__tests__/brand-vite.spec.ts b/packages/tools/src/__tests__/brand-vite.spec.ts index 6f33018cbf..d6833c7af0 100644 --- a/packages/tools/src/__tests__/brand-vite.spec.ts +++ b/packages/tools/src/__tests__/brand-vite.spec.ts @@ -66,7 +66,13 @@ function write(file: string, source: string) { writeFileSync(file, source); } -function createUpstreamFixture(configSource = upstreamSources['config.ts']) { +function createUpstreamFixture(configSource = upstreamSources['config.ts']): { + root: string; + nodeDir: string; + manifest: string; + lockfile: string; + unrelatedSource: string; +} { const root = mkdtempSync(join(tmpdir(), 'vp-brand-vite-')); const viteDir = join(root, 'vite'); const nodeDir = join(viteDir, 'packages/vite/src/node'); diff --git a/packages/tools/src/brand-vite.ts b/packages/tools/src/brand-vite.ts index b5c550d83b..c06229c141 100644 --- a/packages/tools/src/brand-vite.ts +++ b/packages/tools/src/brand-vite.ts @@ -101,7 +101,7 @@ function logPatch(file: string, desc: string, result: 'patched' | 'already') { } } -export function brandVite(rootDir: string = process.cwd()) { +export function brandVite(rootDir: string = process.cwd()): void { log('Applying Vite+ branding patches...'); const nodeDir = join(rootDir, VITE_NODE_DIR); @@ -216,14 +216,13 @@ export function brandVite(rootDir: string = process.cwd()) { const configFile = join(nodeDir, 'config.ts'); const configSource = readFileSync(configFile, 'utf-8'); // Validate both config patches in memory before writing either edit. - const patchedConfig = patchViteConfig( - replaceInSource( - configFile, - configSource, - ' !process.env.VITE_CONFIG_NATIVE_IGNORE_WARNING &&\n createNativeConfigCompatPlugin(nativeIncompatibilities),', - " // Vite+ manages config loading, so Vite's native-config-loader\n // migration notice is noise. Never register the compat-check plugin.\n false &&\n createNativeConfigCompatPlugin(nativeIncompatibilities),", - ), + const configWithoutNotice = replaceInSource( + configFile, + configSource, + ' !process.env.VITE_CONFIG_NATIVE_IGNORE_WARNING &&\n createNativeConfigCompatPlugin(nativeIncompatibilities),', + " // Vite+ manages config loading, so Vite's native-config-loader\n // migration notice is noise. Never register the compat-check plugin.\n false &&\n createNativeConfigCompatPlugin(nativeIncompatibilities),", ); + const patchedConfig = patchViteConfig(configWithoutNotice); if (patchedConfig !== configSource) { writeFileSync(configFile, patchedConfig, 'utf-8'); } diff --git a/packages/tools/src/patch-vite-config.ts b/packages/tools/src/patch-vite-config.ts index 4b1ec92ca7..2499ebc41b 100644 --- a/packages/tools/src/patch-vite-config.ts +++ b/packages/tools/src/patch-vite-config.ts @@ -29,14 +29,14 @@ const upstreamLoadResult = ` if (loadResult) { configFileDependencies = loadResult.dependencies }`; -const patchedLoadResult = ` if (loadResult) { - config = mergeConfig(loadResult.config, config) - configFile = loadResult.path - configFileDependencies = loadResult.dependencies +const patchedLoadResult = upstreamLoadResult.replace( + '\n }', + ` if (internalOptions?.rootBase !== undefined && inlineConfig.root == null) { config.root = path.resolve(internalOptions.rootBase, config.root ?? '') } - }`; + }`, +); function patchError(detail: string): Error { return new Error( From ac41bdfa2028f066203c2fa93f64d34bf4f737c7 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 28 Oct 2026 10:37:51 +0900 Subject: [PATCH 16/18] build: keep Vite branding in source preparation --- package.json | 2 +- packages/core/BUNDLING.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 9860586137..fe5326a20f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "brand-vite": "node packages/tools/src/index.ts brand-vite", - "build": "pnpm brand-vite && pnpm -F rolldown build-binding:release && pnpm -F rolldown build-node && pnpm -F vite build-types && pnpm -F @voidzero-dev/* -F vite-plus build", + "build": "pnpm -F rolldown build-binding:release && pnpm -F rolldown build-node && pnpm -F vite build-types && pnpm -F @voidzero-dev/* -F vite-plus build", "bootstrap-cli": "pnpm build && cargo build -p vp_global_cli --release && node packages/tools/src/build-trampoline.ts --release && pnpm install-global-cli", "bootstrap-cli:ci": "pnpm install-global-cli", "install-global-cli": "tool install-global-cli", diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md index b49c4599c0..af58add7c5 100644 --- a/packages/core/BUNDLING.md +++ b/packages/core/BUNDLING.md @@ -21,7 +21,9 @@ This approach enables users to access Vite, Rolldown, and related tools through ### Vite Source Preparation -Before core bundling, [`brand-vite.ts`](../tools/src/brand-vite.ts) applies branding and the [`patch-vite-config.ts`](../tools/src/patch-vite-config.ts) resolver patch to the vendored Vite source. `sync-remote` runs this preparation. The root build script also runs it before generating Vite types and building core. +Before core bundling, [`brand-vite.ts`](../tools/src/brand-vite.ts) applies branding and the [`patch-vite-config.ts`](../tools/src/patch-vite-config.ts) resolver patch to the vendored Vite source. `sync-remote` runs this preparation. [CI](../../.github/actions/build-upstream/action.yml) also applies it after checking out upstream sources. + +Regular `pnpm build` runs use the prepared sources. If the patch code changes after synchronization, run `pnpm brand-vite` once, then `pnpm build` to regenerate Vite types and rebuild the packages. The resolver patch adds an optional internal eighth argument to `resolveConfig`. The `resolveConfigFromFile(configFile, options?)` helper exported by `vite-plus` passes a base directory through a private type bridge. By default, this base is the config file's parent directory. With an absolute `configFile`, default root resolution is independent of `process.cwd()`. Callers can pass `{ rootBase: projectRoot }` to use a different project directory, including when the config is in a subdirectory. Relative `configFile` and explicit `rootBase` paths resolve from `process.cwd()`; the helper does not change the process cwd. @@ -336,7 +338,7 @@ dist/ ## Build Commands -These direct core builds require prepared Vite sources and current Vite types. The [standard repository build](../../CONTRIBUTING.md#build-vite-and-upstream-dependencies) includes both prerequisites. +These direct core builds require [prepared Vite sources](#vite-source-preparation) and current Vite types. The [standard repository build](../../CONTRIBUTING.md#build-vite-and-upstream-dependencies) regenerates Vite types before building core. For a direct core build after source changes, run `pnpm --filter vite build-types` first. ```bash # Build the core package From a1eb70048906e25053933bec0a96c61bc01c5385 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 28 Oct 2026 13:19:44 +0900 Subject: [PATCH 17/18] build: remove redundant brand-vite script --- package.json | 1 - packages/core/BUNDLING.md | 2 +- packages/core/build.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index fe5326a20f..fd4ce34eb3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "license": "MIT", "type": "module", "scripts": { - "brand-vite": "node packages/tools/src/index.ts brand-vite", "build": "pnpm -F rolldown build-binding:release && pnpm -F rolldown build-node && pnpm -F vite build-types && pnpm -F @voidzero-dev/* -F vite-plus build", "bootstrap-cli": "pnpm build && cargo build -p vp_global_cli --release && node packages/tools/src/build-trampoline.ts --release && pnpm install-global-cli", "bootstrap-cli:ci": "pnpm install-global-cli", diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md index af58add7c5..bdc0ea2d81 100644 --- a/packages/core/BUNDLING.md +++ b/packages/core/BUNDLING.md @@ -23,7 +23,7 @@ This approach enables users to access Vite, Rolldown, and related tools through Before core bundling, [`brand-vite.ts`](../tools/src/brand-vite.ts) applies branding and the [`patch-vite-config.ts`](../tools/src/patch-vite-config.ts) resolver patch to the vendored Vite source. `sync-remote` runs this preparation. [CI](../../.github/actions/build-upstream/action.yml) also applies it after checking out upstream sources. -Regular `pnpm build` runs use the prepared sources. If the patch code changes after synchronization, run `pnpm brand-vite` once, then `pnpm build` to regenerate Vite types and rebuild the packages. +Regular `pnpm build` runs use the prepared sources. If the patch code changes after synchronization, run `pnpm exec tool brand-vite` once, then `pnpm build` to regenerate Vite types and rebuild the packages. The resolver patch adds an optional internal eighth argument to `resolveConfig`. The `resolveConfigFromFile(configFile, options?)` helper exported by `vite-plus` passes a base directory through a private type bridge. By default, this base is the config file's parent directory. With an absolute `configFile`, default root resolution is independent of `process.cwd()`. Callers can pass `{ rootBase: projectRoot }` to use a different project directory, including when the config is in a subdirectory. Relative `configFile` and explicit `rootBase` paths resolve from `process.cwd()`; the helper does not change the process cwd. diff --git a/packages/core/build.ts b/packages/core/build.ts index 52b824f688..c3bf11b33a 100644 --- a/packages/core/build.ts +++ b/packages/core/build.ts @@ -60,7 +60,7 @@ try { } catch (cause) { throw new Error( 'Prepare the Vite source before building core. From the repository root, run ' + - '`pnpm brand-vite` and `pnpm --filter vite build-types`.', + '`pnpm exec tool brand-vite` and `pnpm --filter vite build-types`.', { cause }, ); } From 511d4ee812d95a0cb5cbd417738151c0799cdd2b Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 28 Oct 2026 13:46:58 +0900 Subject: [PATCH 18/18] docs(config): clarify initial root resolution semantics --- packages/cli/src/define-config.ts | 7 +++++-- packages/core/BUNDLING.md | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 8e0aea2534..8ecf080232 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -1084,10 +1084,13 @@ export interface ResolveConfigFromFileOptions { * Resolve a Vite config file to read a non-plugin block (`lint`, `fmt`, `pack`, …) from it, * for tools such as oxlint, oxfmt, and Vite+ itself. * - * With an absolute `configFile`, default root resolution is independent of `process.cwd()`: - * an unset or relative `root` resolves from the config file's parent directory. + * With an absolute `configFile`, default resolution of the initial `root` from the + * config file is independent of `process.cwd()`: an unset or relative `root` + * resolves from the config file's parent directory. * Callers can supply `rootBase` to use a different project directory, * such as when the config is in a subdirectory. Absolute roots are preserved. + * Plugin hooks can later change `root` using Vite's normal semantics. For example, + * a relative `root` returned by a `config` hook resolves from `process.cwd()`. * * Relative `configFile` and explicit `rootBase` paths still resolve from `process.cwd()`. * The helper does not change the process cwd; config code and plugins can still read it. diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md index bdc0ea2d81..e946fb9f3a 100644 --- a/packages/core/BUNDLING.md +++ b/packages/core/BUNDLING.md @@ -25,9 +25,9 @@ Before core bundling, [`brand-vite.ts`](../tools/src/brand-vite.ts) applies bran Regular `pnpm build` runs use the prepared sources. If the patch code changes after synchronization, run `pnpm exec tool brand-vite` once, then `pnpm build` to regenerate Vite types and rebuild the packages. -The resolver patch adds an optional internal eighth argument to `resolveConfig`. The `resolveConfigFromFile(configFile, options?)` helper exported by `vite-plus` passes a base directory through a private type bridge. By default, this base is the config file's parent directory. With an absolute `configFile`, default root resolution is independent of `process.cwd()`. Callers can pass `{ rootBase: projectRoot }` to use a different project directory, including when the config is in a subdirectory. Relative `configFile` and explicit `rootBase` paths resolve from `process.cwd()`; the helper does not change the process cwd. +The resolver patch adds an optional internal eighth argument to `resolveConfig`. The `resolveConfigFromFile(configFile, options?)` helper exported by `vite-plus` passes a base directory through a private type bridge. By default, this base is the config file's parent directory. With an absolute `configFile`, default resolution of the initial `root` from the config file is independent of `process.cwd()`. Callers can pass `{ rootBase: projectRoot }` to use a different project directory, including when the config is in a subdirectory. Relative `configFile` and explicit `rootBase` paths resolve from `process.cwd()`; the helper does not change the process cwd. -After a config file loads, an unset or relative `root` resolves from the selected base, before plugin `apply` and `config` hooks. Absolute roots are preserved, and an inline `root` retains priority. Vite still handles config loading, environment initialization, configuration metadata, and plugin hooks. +After a config file loads, an unset or relative `root` resolves from the selected base, before plugin `apply` and `config` hooks. Absolute roots are preserved, and an inline `root` retains priority. Plugin hooks can later change `root` using Vite's normal semantics. For example, a relative `root` returned by a `config` hook resolves from `process.cwd()`. Vite still handles config loading, environment initialization, configuration metadata, and plugin hooks. The helper's default does not change ordinary `resolveConfig` calls, which retain Vite's cwd-based root resolution. Existing CLI callers keep their inline `root: cwd`, including when `vp pack` finds a parent config. Adopting the new helper is a separate integration choice for callers such as Oxlint and Oxfmt; it is not restricted to LSP.