diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index d33c960f9c..729804f99a 100644 --- a/packages/cli/binding/src/cli.rs +++ b/packages/cli/binding/src/cli.rs @@ -34,6 +34,9 @@ use vite_task::{ pub struct ResolvedUniversalViteConfig { #[serde(rename = "configFile")] pub config_file: Option, + /// Separate config file path for lint (merged JSON when sub-package overrides exist) + #[serde(rename = "lintConfigFile")] + pub lint_config_file: Option, pub lint: Option, pub fmt: Option, pub run: Option, @@ -205,7 +208,10 @@ impl SubcommandResolver { self } - async fn resolve_universal_vite_config(&self) -> anyhow::Result { + async fn resolve_universal_vite_config( + &self, + cwd: Option<&AbsolutePath>, + ) -> anyhow::Result { let cli_options = self .cli_options .as_ref() @@ -215,8 +221,12 @@ impl SubcommandResolver { .as_path() .to_str() .ok_or_else(|| anyhow::anyhow!("workspace path is not valid UTF-8"))?; - let vite_config_json = - (cli_options.resolve_universal_vite_config)(workspace_path_str.to_string()).await?; + let cwd_str = cwd + .and_then(|p| p.as_path().to_str()) + .ok_or_else(|| anyhow::anyhow!("cwd is not valid UTF-8")) + .ok(); + let arg = build_vite_config_resolver_arg(workspace_path_str, cwd_str); + let vite_config_json = (cli_options.resolve_universal_vite_config)(arg).await?; Ok(serde_json::from_str(&vite_config_json).inspect_err(|_| { tracing::error!("Failed to parse vite config: {vite_config_json}"); @@ -246,13 +256,19 @@ impl SubcommandResolver { let resolved_vite_config = if let Some(config) = resolved_vite_config { config } else { - owned_resolved_vite_config = self.resolve_universal_vite_config().await?; + owned_resolved_vite_config = + self.resolve_universal_vite_config(Some(cwd)).await?; &owned_resolved_vite_config }; - if let (Some(_), Some(config_file)) = - (&resolved_vite_config.lint, &resolved_vite_config.config_file) - { + // Use lint-specific config file (merged JSON) if available, + // otherwise fall back to vite.config.ts + if let Some(config_file) = resolved_vite_config.lint.as_ref().and( + resolved_vite_config + .lint_config_file + .as_ref() + .or(resolved_vite_config.config_file.as_ref()), + ) { args.insert(0, "-c".to_string()); args.insert(1, config_file.clone()); } @@ -285,7 +301,8 @@ impl SubcommandResolver { let resolved_vite_config = if let Some(config) = resolved_vite_config { config } else { - owned_resolved_vite_config = self.resolve_universal_vite_config().await?; + owned_resolved_vite_config = + self.resolve_universal_vite_config(Some(cwd)).await?; &owned_resolved_vite_config }; @@ -647,6 +664,22 @@ impl UserConfigLoader for VitePlusConfigLoader { } } +/// Build the argument string for `resolve_universal_vite_config` JS function. +/// +/// When `cwd` differs from `workspace_path`, returns a JSON string with both +/// paths so the JS side can merge root and sub-package configs. +/// Otherwise returns the workspace path as a plain string. +fn build_vite_config_resolver_arg(workspace_path: &str, cwd: Option<&str>) -> String { + match cwd { + Some(cwd) if cwd != workspace_path => serde_json::json!({ + "workspacePath": workspace_path, + "cwd": cwd + }) + .to_string(), + _ => workspace_path.to_string(), + } +} + /// Resolve a subcommand into a prepared `tokio::process::Command`. async fn resolve_and_build_command( resolver: &SubcommandResolver, @@ -1020,7 +1053,8 @@ async fn execute_direct_subcommand( let has_paths = !paths.is_empty(); let mut fmt_fix_started: Option = None; let mut deferred_lint_pass: Option<(String, String)> = None; - let resolved_vite_config = resolver.resolve_universal_vite_config().await?; + let resolved_vite_config = + resolver.resolve_universal_vite_config(Some(&cwd_arc)).await?; if !no_fmt { let mut args = if fix { vec![] } else { vec!["--check".to_string()] }; @@ -1537,8 +1571,9 @@ mod tests { use vite_task::config::UserRunConfig; use super::{ - CLIArgs, LintMessageKind, SynthesizableSubcommand, extract_unknown_argument, - has_pass_as_value_suggestion, should_prepend_vitest_run, should_suppress_subcommand_stdout, + CLIArgs, LintMessageKind, SynthesizableSubcommand, build_vite_config_resolver_arg, + extract_unknown_argument, has_pass_as_value_suggestion, should_prepend_vitest_run, + should_suppress_subcommand_stdout, }; #[test] @@ -1686,4 +1721,25 @@ mod tests { ); } } + + #[test] + fn vite_config_resolver_arg_returns_plain_string_when_no_cwd() { + let arg = build_vite_config_resolver_arg("/workspace", None); + assert_eq!(arg, "/workspace"); + } + + #[test] + fn vite_config_resolver_arg_returns_plain_string_when_cwd_same_as_workspace() { + let arg = build_vite_config_resolver_arg("/workspace", Some("/workspace")); + assert_eq!(arg, "/workspace"); + } + + #[test] + fn vite_config_resolver_arg_returns_json_when_cwd_differs() { + let arg = + build_vite_config_resolver_arg("/workspace", Some("/workspace/packages/some-package")); + let parsed: serde_json::Value = serde_json::from_str(&arg).expect("should be valid JSON"); + assert_eq!(parsed["workspacePath"], "/workspace"); + assert_eq!(parsed["cwd"], "/workspace/packages/some-package"); + } } diff --git a/packages/cli/snap-tests/workspace-lint-ignore-patterns/package.json b/packages/cli/snap-tests/workspace-lint-ignore-patterns/package.json new file mode 100644 index 0000000000..3e13b25675 --- /dev/null +++ b/packages/cli/snap-tests/workspace-lint-ignore-patterns/package.json @@ -0,0 +1,6 @@ +{ + "name": "workspace-lint-ignore-patterns", + "version": "1.0.0", + "private": true, + "packageManager": "pnpm@10.19.0" +} diff --git a/packages/cli/snap-tests/workspace-lint-ignore-patterns/packages/some-package/package.json b/packages/cli/snap-tests/workspace-lint-ignore-patterns/packages/some-package/package.json new file mode 100644 index 0000000000..874a848039 --- /dev/null +++ b/packages/cli/snap-tests/workspace-lint-ignore-patterns/packages/some-package/package.json @@ -0,0 +1,6 @@ +{ + "name": "some-package", + "version": "1.0.0", + "private": true, + "type": "module" +} diff --git a/packages/cli/snap-tests/workspace-lint-ignore-patterns/packages/some-package/src/index.ts b/packages/cli/snap-tests/workspace-lint-ignore-patterns/packages/some-package/src/index.ts new file mode 100644 index 0000000000..152ccbb2cd --- /dev/null +++ b/packages/cli/snap-tests/workspace-lint-ignore-patterns/packages/some-package/src/index.ts @@ -0,0 +1 @@ +export const hello = 'hello'; diff --git a/packages/cli/snap-tests/workspace-lint-ignore-patterns/packages/some-package/tests/fixtures/should-ignore-this.cjs b/packages/cli/snap-tests/workspace-lint-ignore-patterns/packages/some-package/tests/fixtures/should-ignore-this.cjs new file mode 100644 index 0000000000..8c5e1da67e --- /dev/null +++ b/packages/cli/snap-tests/workspace-lint-ignore-patterns/packages/some-package/tests/fixtures/should-ignore-this.cjs @@ -0,0 +1,3 @@ +module.exports = { + name: 'should-ignore-this', +}; diff --git a/packages/cli/snap-tests/workspace-lint-ignore-patterns/packages/some-package/vite.config.ts b/packages/cli/snap-tests/workspace-lint-ignore-patterns/packages/some-package/vite.config.ts new file mode 100644 index 0000000000..64131e4b94 --- /dev/null +++ b/packages/cli/snap-tests/workspace-lint-ignore-patterns/packages/some-package/vite.config.ts @@ -0,0 +1,5 @@ +export default { + lint: { + ignorePatterns: ['tests/fixtures/**/*'], + }, +}; diff --git a/packages/cli/snap-tests/workspace-lint-ignore-patterns/pnpm-workspace.yaml b/packages/cli/snap-tests/workspace-lint-ignore-patterns/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/packages/cli/snap-tests/workspace-lint-ignore-patterns/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/packages/cli/snap-tests/workspace-lint-ignore-patterns/snap.txt b/packages/cli/snap-tests/workspace-lint-ignore-patterns/snap.txt new file mode 100644 index 0000000000..72557b943a --- /dev/null +++ b/packages/cli/snap-tests/workspace-lint-ignore-patterns/snap.txt @@ -0,0 +1,3 @@ +> cd packages/some-package && vp lint # sub-package ignorePatterns should exclude fixtures +Found 0 warnings and 0 errors. +Finished in ms on 2 files with rules using threads. diff --git a/packages/cli/snap-tests/workspace-lint-ignore-patterns/steps.json b/packages/cli/snap-tests/workspace-lint-ignore-patterns/steps.json new file mode 100644 index 0000000000..a6e9f0fc93 --- /dev/null +++ b/packages/cli/snap-tests/workspace-lint-ignore-patterns/steps.json @@ -0,0 +1,6 @@ +{ + "ignoredPlatforms": ["win32"], + "commands": [ + "cd packages/some-package && vp lint # sub-package ignorePatterns should exclude fixtures" + ] +} diff --git a/packages/cli/snap-tests/workspace-lint-ignore-patterns/vite.config.ts b/packages/cli/snap-tests/workspace-lint-ignore-patterns/vite.config.ts new file mode 100644 index 0000000000..0de4ca3f65 --- /dev/null +++ b/packages/cli/snap-tests/workspace-lint-ignore-patterns/vite.config.ts @@ -0,0 +1,8 @@ +export default { + lint: { + plugins: ['import'], + rules: { + 'import/no-commonjs': 'error', + }, + }, +}; diff --git a/packages/cli/snap-tests/workspace-lint-subpackage/snap.txt b/packages/cli/snap-tests/workspace-lint-subpackage/snap.txt index 1f7571585f..08e3c4a21b 100644 --- a/packages/cli/snap-tests/workspace-lint-subpackage/snap.txt +++ b/packages/cli/snap-tests/workspace-lint-subpackage/snap.txt @@ -1,13 +1,3 @@ > cd packages/app-a && vp lint # sub-workspace has no-console:off but root has no-console:warn - - ⚠ eslint(no-console): Unexpected console statement. - ╭─[src/index.js:2:3] - 1 │ function hello() { - 2 │ console.log('hello from app-a'); - · ─────────── - 3 │ return 'hello'; - ╰──── - help: Delete this console statement. - -Found 1 warning and 0 errors. +Found 0 warnings and 0 errors. Finished in ms on 2 files with rules using threads. diff --git a/packages/cli/src/__tests__/resolve-vite-config.spec.ts b/packages/cli/src/__tests__/resolve-vite-config.spec.ts index a1205f1d05..d5af9ebff8 100644 --- a/packages/cli/src/__tests__/resolve-vite-config.spec.ts +++ b/packages/cli/src/__tests__/resolve-vite-config.spec.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { findViteConfigUp } from '../resolve-vite-config'; +import { findViteConfigUp, mergeLintConfig } from '../resolve-vite-config'; describe('findViteConfigUp', () => { let tempDir: string; @@ -118,3 +118,83 @@ describe('findViteConfigUp', () => { expect(result).toBe(path.join(tempDir, 'vite.config.mjs')); }); }); + +describe('mergeLintConfig', () => { + it('should return rootLint when cwdLint is undefined', () => { + const root = { plugins: ['import'], rules: { 'no-console': 'warn' } }; + expect(mergeLintConfig(root, undefined)).toEqual(root); + }); + + it('should return cwdLint when rootLint is undefined', () => { + const cwd = { ignorePatterns: ['tests/**'] }; + expect(mergeLintConfig(undefined, cwd)).toEqual(cwd); + }); + + it('should return undefined when both are undefined', () => { + expect(mergeLintConfig(undefined, undefined)).toBeUndefined(); + }); + + it('should merge plugins as union with dedup', () => { + const root = { plugins: ['import', 'react'] }; + const cwd = { plugins: ['import', 'unicorn'] }; + const merged = mergeLintConfig(root, cwd)!; + expect(merged.plugins).toEqual(['import', 'react', 'unicorn']); + }); + + it('should shallow merge rules with cwd taking priority', () => { + const root = { rules: { 'no-console': 'warn', 'import/no-commonjs': 'error' } }; + const cwd = { rules: { 'no-console': 'off' } }; + const merged = mergeLintConfig(root, cwd)!; + expect(merged.rules).toEqual({ + 'no-console': 'off', + 'import/no-commonjs': 'error', + }); + }); + + it('should use cwd ignorePatterns when present', () => { + const root = { ignorePatterns: ['dist/**'] }; + const cwd = { ignorePatterns: ['tests/fixtures/**/*'] }; + const merged = mergeLintConfig(root, cwd)!; + expect(merged.ignorePatterns).toEqual(['tests/fixtures/**/*']); + }); + + it('should keep root ignorePatterns when cwd has none', () => { + const root = { ignorePatterns: ['dist/**'], rules: { 'no-console': 'warn' } }; + const cwd = { rules: { 'no-console': 'off' } }; + const merged = mergeLintConfig(root, cwd)!; + expect(merged.ignorePatterns).toEqual(['dist/**']); + }); + + it('should shallow merge options with cwd taking priority', () => { + const root = { options: { typeAware: false, denyWarnings: true } }; + const cwd = { options: { typeAware: true } }; + const merged = mergeLintConfig(root, cwd)!; + expect(merged.options).toEqual({ typeAware: true, denyWarnings: true }); + }); + + it('should concatenate overrides from root and cwd', () => { + const root = { overrides: [{ files: ['*.ts'], rules: { a: 'off' } }] }; + const cwd = { overrides: [{ files: ['*.tsx'], rules: { b: 'off' } }] }; + const merged = mergeLintConfig(root, cwd)!; + expect(merged.overrides).toEqual([ + { files: ['*.ts'], rules: { a: 'off' } }, + { files: ['*.tsx'], rules: { b: 'off' } }, + ]); + }); + + it('should merge all fields together (issue #997 scenario)', () => { + const root = { + plugins: ['import'], + rules: { 'import/no-commonjs': 'error' }, + }; + const cwd = { + options: { typeAware: true, typeCheck: true }, + ignorePatterns: ['tests/fixtures/**/*'], + }; + const merged = mergeLintConfig(root, cwd)!; + expect(merged.plugins).toEqual(['import']); + expect(merged.rules).toEqual({ 'import/no-commonjs': 'error' }); + expect(merged.ignorePatterns).toEqual(['tests/fixtures/**/*']); + expect(merged.options).toEqual({ typeAware: true, typeCheck: true }); + }); +}); diff --git a/packages/cli/src/resolve-vite-config.ts b/packages/cli/src/resolve-vite-config.ts index 085a77529e..57167c4352 100644 --- a/packages/cli/src/resolve-vite-config.ts +++ b/packages/cli/src/resolve-vite-config.ts @@ -94,19 +94,147 @@ export async function resolveViteConfig(cwd: string, options?: ResolveViteConfig return resolveConfig({ root: cwd }, 'build'); } -export async function resolveUniversalViteConfig(err: null | Error, viteConfigCwd: string) { +/** + * Merge two lint configs. The cwd config overrides the root config. + * - plugins: union (deduplicated) + * - rules: shallow merge (cwd wins) + * - ignorePatterns: cwd overrides entirely + * - options: shallow merge (cwd wins) + * - overrides: concatenated + * - other fields: cwd wins if present + */ +export function mergeLintConfig( + rootLint: Record | undefined, + cwdLint: Record | undefined, +): Record | undefined { + if (!cwdLint) { + return rootLint; + } + if (!rootLint) { + return cwdLint; + } + + const merged: Record = { ...rootLint, ...cwdLint }; + + // plugins: union with dedup + const rootPlugins = (rootLint.plugins as string[]) ?? []; + const cwdPlugins = (cwdLint.plugins as string[]) ?? []; + if (rootPlugins.length > 0 || cwdPlugins.length > 0) { + merged.plugins = [...new Set([...rootPlugins, ...cwdPlugins])]; + } + + // rules: shallow merge + if (rootLint.rules || cwdLint.rules) { + merged.rules = { + ...(rootLint.rules as Record), + ...(cwdLint.rules as Record), + }; + } + + // options: shallow merge + if (rootLint.options || cwdLint.options) { + merged.options = { + ...(rootLint.options as Record), + ...(cwdLint.options as Record), + }; + } + + // overrides: concatenate + const rootOverrides = (rootLint.overrides as unknown[]) ?? []; + const cwdOverrides = (cwdLint.overrides as unknown[]) ?? []; + if (rootOverrides.length > 0 || cwdOverrides.length > 0) { + merged.overrides = [...rootOverrides, ...cwdOverrides]; + } + + return merged; +} + +/** + * Write merged lint config to a fixed path in node_modules/.cache/vite-plus/. + * Using a fixed path ensures vite-task's cache key (which includes CLI args) + * stays stable across runs. + */ +function writeMergedLintConfig(cwd: string, mergedLint: Record): string { + const cacheDir = path.join(cwd, 'node_modules', '.cache', 'vite-plus'); + fs.mkdirSync(cacheDir, { recursive: true }); + const configPath = path.join(cacheDir, 'merged-lint-config.json'); + fs.writeFileSync(configPath, JSON.stringify(mergedLint, null, 2)); + return configPath; +} + +/** + * Resolve vite config for lint/fmt/staged commands. + * + * The argument can be either: + * - A plain string (workspace path) for backward compatibility + * - A JSON string `{"workspacePath": "...", "cwd": "..."}` when cwd differs + * from workspace root (e.g., running `vp lint` in a sub-package) + * + * When cwd differs from workspacePath: + * 1. Resolve root config (from workspacePath) + * 2. Resolve cwd config (from cwd) + * 3. Merge lint configs (root as base, cwd overrides) + * 4. Write merged lint to a fixed cache file + * 5. Return the cache file as configFile + */ +export async function resolveUniversalViteConfig(err: null | Error, arg: string) { if (err) { throw err; } try { - const config = await resolveViteConfig(viteConfigCwd); + let workspacePath: string; + let cwd: string | undefined; + + // Parse argument: plain string or JSON with workspacePath + cwd + if (arg.startsWith('{')) { + const parsed = JSON.parse(arg) as { workspacePath: string; cwd: string }; + workspacePath = parsed.workspacePath; + cwd = parsed.cwd; + } else { + workspacePath = arg; + } + + const rootConfig = await resolveViteConfig(workspacePath); + + // If cwd is different from workspace root and has its own vite config, + // merge lint configs + if (cwd && cwd !== workspacePath && hasViteConfig(cwd)) { + const cwdConfig = await resolveViteConfig(cwd); + const mergedLint = mergeLintConfig( + rootConfig.lint as Record | undefined, + cwdConfig.lint as Record | undefined, + ); + const mergedFmt = cwdConfig.fmt ?? rootConfig.fmt; + + const configFile = cwdConfig.configFile ?? rootConfig.configFile; + + if (mergedLint) { + const mergedConfigPath = writeMergedLintConfig(cwd, mergedLint); + return JSON.stringify({ + configFile, + lintConfigFile: mergedConfigPath, + lint: mergedLint, + fmt: mergedFmt, + run: cwdConfig.run ?? rootConfig.run, + staged: cwdConfig.staged ?? rootConfig.staged, + }); + } + + return JSON.stringify({ + configFile, + lint: cwdConfig.lint ?? rootConfig.lint, + fmt: mergedFmt, + run: cwdConfig.run ?? rootConfig.run, + staged: cwdConfig.staged ?? rootConfig.staged, + }); + } return JSON.stringify({ - configFile: config.configFile, - lint: config.lint, - fmt: config.fmt, - run: config.run, - staged: config.staged, + configFile: rootConfig.configFile, + lint: rootConfig.lint, + fmt: rootConfig.fmt, + run: rootConfig.run, + staged: rootConfig.staged, }); } catch (resolveErr) { console.error('[Vite+] resolve universal vite config error:', resolveErr);