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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 67 additions & 11 deletions packages/cli/binding/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ use vite_task::{
pub struct ResolvedUniversalViteConfig {
#[serde(rename = "configFile")]
pub config_file: Option<String>,
/// Separate config file path for lint (merged JSON when sub-package overrides exist)
#[serde(rename = "lintConfigFile")]
pub lint_config_file: Option<String>,
pub lint: Option<serde_json::Value>,
pub fmt: Option<serde_json::Value>,
pub run: Option<serde_json::Value>,
Expand Down Expand Up @@ -205,7 +208,10 @@ impl SubcommandResolver {
self
}

async fn resolve_universal_vite_config(&self) -> anyhow::Result<ResolvedUniversalViteConfig> {
async fn resolve_universal_vite_config(
&self,
cwd: Option<&AbsolutePath>,
) -> anyhow::Result<ResolvedUniversalViteConfig> {
let cli_options = self
.cli_options
.as_ref()
Expand All @@ -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}");
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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
};

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1020,7 +1053,8 @@ async fn execute_direct_subcommand(
let has_paths = !paths.is_empty();
let mut fmt_fix_started: Option<Instant> = 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()] };
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "workspace-lint-ignore-patterns",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@10.19.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "some-package",
"version": "1.0.0",
"private": true,
"type": "module"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const hello = 'hello';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
name: 'should-ignore-this',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
lint: {
ignorePatterns: ['tests/fixtures/**/*'],
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
packages:
- packages/*
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
> cd packages/some-package && vp lint # sub-package ignorePatterns should exclude fixtures
Found 0 warnings and 0 errors.
Finished in <variable>ms on 2 files with <variable> rules using <variable> threads.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"ignoredPlatforms": ["win32"],
"commands": [
"cd packages/some-package && vp lint # sub-package ignorePatterns should exclude fixtures"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default {
lint: {
plugins: ['import'],
rules: {
'import/no-commonjs': 'error',
},
},
};
12 changes: 1 addition & 11 deletions packages/cli/snap-tests/workspace-lint-subpackage/snap.txt
Original file line number Diff line number Diff line change
@@ -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 <variable>ms on 2 files with <variable> rules using <variable> threads.
82 changes: 81 additions & 1 deletion packages/cli/src/__tests__/resolve-vite-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
});
});
Loading
Loading