From 6b6ffc02ce38c1f5d4562c7e8989f8594e1b3c3d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 10:32:18 +0000 Subject: [PATCH 1/4] fix: strip UTF-8 BOM from package.json before parsing package.json files written with a UTF-8 BOM (EF BB BF) by some editors and tools failed serde_json parsing, aborting the entire package graph load with "Failed to parse JSON file". Strip a leading BOM at every package.json/workspace JSON parse site before deserializing. https://claude.ai/code/session_017dv1DPTTkkt65M9s55wPHb --- CHANGELOG.md | 1 + crates/vite_workspace/src/lib.rs | 97 ++++++++++++++++++-- crates/vite_workspace/src/package_manager.rs | 12 ++- 3 files changed, 95 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 163199a58..666813074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** `vp run` no longer fails to load the package graph when a `package.json` is encoded with a UTF-8 byte order mark (BOM), as written by some editors and tools. The BOM is now stripped before parsing - **Changed** `vp run --filter ` now exits 0 with a warning when the filter matches no packages, matching pnpm. Use `--fail-if-no-match` to restore the previous strict behavior ([#393](https://github.com/voidzero-dev/vite-task/pull/393)) - **Added** task command shorthands for defining tasks as command strings or command string arrays ([#391](https://github.com/voidzero-dev/vite-task/pull/391)) - **Changed** Cached logs are stored with colors intact (`FORCE_COLOR=1` is auto-injected into spawned tasks). Colors are then stripped at display time when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default. Opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) diff --git a/crates/vite_workspace/src/lib.rs b/crates/vite_workspace/src/lib.rs index ab145a3e1..e41c45e94 100644 --- a/crates/vite_workspace/src/lib.rs +++ b/crates/vite_workspace/src/lib.rs @@ -23,6 +23,16 @@ pub use crate::{ }, }; +/// Strip a leading UTF-8 byte order mark (BOM) from `bytes`, if present. +/// +/// Some editors and tools (notably on Windows, e.g. Notepad or PowerShell's +/// `>` redirection) write `package.json` with a UTF-8 BOM (`EF BB BF`). +/// `serde_json` does not accept a leading BOM and fails with a parse error, so +/// we trim it before parsing. +pub(crate) fn strip_bom(bytes: &[u8]) -> &[u8] { + bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes) +} + /// The workspace configuration for pnpm. #[derive(Debug, Deserialize)] struct PnpmWorkspace { @@ -256,19 +266,23 @@ pub fn load_package_graph( workspace.packages } WorkspaceFile::NpmWorkspaceJson(file_with_path) => { - let workspace: NpmWorkspace = serde_json::from_slice(file_with_path.content()) - .map_err(|e| Error::SerdeJson { - file_path: Arc::clone(file_with_path.path()), - serde_json_error: e, + let workspace: NpmWorkspace = + serde_json::from_slice(strip_bom(file_with_path.content())).map_err(|e| { + Error::SerdeJson { + file_path: Arc::clone(file_with_path.path()), + serde_json_error: e, + } })?; workspace.workspaces.into_packages() } WorkspaceFile::NonWorkspacePackage(file_with_path) => { // For non-workspace packages, add the package.json to the graph as a root package - let package_json: PackageJson = serde_json::from_slice(file_with_path.content()) - .map_err(|e| Error::SerdeJson { - file_path: Arc::clone(file_with_path.path()), - serde_json_error: e, + let package_json: PackageJson = + serde_json::from_slice(strip_bom(file_with_path.content())).map_err(|e| { + Error::SerdeJson { + file_path: Arc::clone(file_with_path.path()), + serde_json_error: e, + } })?; graph_builder.add_package( RelativePathBuf::default(), @@ -285,7 +299,7 @@ pub fn load_package_graph( for package_json_path in member_globs.get_package_json_paths(&*workspace_root.path)? { let package_json_path: Arc = package_json_path.clone().into(); let package_json: PackageJson = - serde_json::from_slice(&fs::read(&*package_json_path)?).map_err(|e| { + serde_json::from_slice(strip_bom(&fs::read(&*package_json_path)?)).map_err(|e| { Error::SerdeJson { file_path: Arc::clone(&package_json_path), serde_json_error: e } })?; let absolute_path = package_json_path.parent().unwrap(); @@ -305,7 +319,7 @@ pub fn load_package_graph( let package_json = match fs::read(&package_json_path) { Ok(content) => { let package_json_path: Arc = package_json_path.into(); - serde_json::from_slice(&content).map_err(|e| Error::SerdeJson { + serde_json::from_slice(strip_bom(&content)).map_err(|e| Error::SerdeJson { file_path: package_json_path, serde_json_error: e, })? @@ -363,6 +377,69 @@ mod tests { assert_eq!(node.path.as_str(), ""); } + #[test] + fn test_strip_bom() { + // Leading UTF-8 BOM is stripped. + assert_eq!(strip_bom(b"\xEF\xBB\xBF{}"), b"{}"); + // Content without a BOM is returned unchanged. + assert_eq!(strip_bom(b"{}"), b"{}"); + // Only a leading BOM is stripped, not occurrences elsewhere. + assert_eq!(strip_bom(b"{}\xEF\xBB\xBF"), b"{}\xEF\xBB\xBF"); + // Empty input is handled. + assert_eq!(strip_bom(b""), b""); + } + + /// Regression test for + /// follow-up: a `package.json` written with a UTF-8 BOM (e.g. by some + /// editors on Windows) must still parse instead of failing the whole graph. + #[test] + fn test_get_package_graph_package_json_with_bom() { + let temp_dir = TempDir::new().unwrap(); + let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap(); + + // pnpm workspace so package.json files are read via `fs::read` + parse. + let workspace_yaml = "packages:\n - \"packages/*\"\n"; + fs::write(temp_dir_path.join("pnpm-workspace.yaml"), workspace_yaml).unwrap(); + + // Root package.json with a BOM. + let root_package = serde_json::json!({ "name": "monorepo-root", "private": true }); + let mut root_bytes = b"\xEF\xBB\xBF".to_vec(); + root_bytes.extend_from_slice(root_package.to_string().as_bytes()); + fs::write(temp_dir_path.join("package.json"), root_bytes).unwrap(); + + // Member package.json with a BOM. + fs::create_dir_all(temp_dir_path.join("packages/pkg-a")).unwrap(); + let pkg_a = serde_json::json!({ "name": "pkg-a" }); + let mut pkg_a_bytes = b"\xEF\xBB\xBF".to_vec(); + pkg_a_bytes.extend_from_slice(pkg_a.to_string().as_bytes()); + fs::write(temp_dir_path.join("packages/pkg-a/package.json"), pkg_a_bytes).unwrap(); + + let graph = discover_package_graph(temp_dir_path).unwrap(); + + // Both the root and the member package should be present. + assert_eq!(graph.node_count(), 2); + let names: FxHashSet<_> = + graph.node_weights().map(|n| n.package_json.name.as_str()).collect(); + assert!(names.contains("monorepo-root")); + assert!(names.contains("pkg-a")); + } + + #[test] + fn test_get_package_graph_single_package_with_bom() { + let temp_dir = TempDir::new().unwrap(); + let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap(); + + // Single non-workspace package.json with a BOM (NonWorkspacePackage path). + let package_json = serde_json::json!({ "name": "my-app" }); + let mut bytes = b"\xEF\xBB\xBF".to_vec(); + bytes.extend_from_slice(package_json.to_string().as_bytes()); + fs::write(temp_dir_path.join("package.json"), bytes).unwrap(); + + let graph = discover_package_graph(temp_dir_path).unwrap(); + assert_eq!(graph.node_count(), 1); + assert_eq!(graph.node_weight(NodeIndex::new(0)).unwrap().package_json.name, "my-app"); + } + #[test] fn test_get_package_graph_pnpm_workspace() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vite_workspace/src/package_manager.rs b/crates/vite_workspace/src/package_manager.rs index 995d3762c..bbff33277 100644 --- a/crates/vite_workspace/src/package_manager.rs +++ b/crates/vite_workspace/src/package_manager.rs @@ -155,11 +155,13 @@ pub fn find_workspace_root( // Check for package.json with workspaces field for npm/yarn workspace let package_json_path: Arc = cwd.join("package.json").into(); if let Some(file_with_path) = FileWithPath::open_if_exists(package_json_path)? { - let package_json: serde_json::Value = serde_json::from_slice(file_with_path.content()) - .map_err(|e| Error::SerdeJson { - file_path: Arc::clone(file_with_path.path()), - serde_json_error: e, - })?; + let package_json: serde_json::Value = serde_json::from_slice(crate::strip_bom( + file_with_path.content(), + )) + .map_err(|e| Error::SerdeJson { + file_path: Arc::clone(file_with_path.path()), + serde_json_error: e, + })?; if package_json.get("workspaces").is_some() { let relative_cwd = original_cwd.strip_prefix(cwd)?.expect("cwd must be within the workspace"); From b29e37e2d9b05365c1c78c544baff5bfb7658528 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 10:35:32 +0000 Subject: [PATCH 2/4] docs: simplify changelog entry and add PR link --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 666813074..3001e5cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** `vp run` no longer fails to load the package graph when a `package.json` is encoded with a UTF-8 byte order mark (BOM), as written by some editors and tools. The BOM is now stripped before parsing +- **Fixed** `package.json` files with a UTF-8 BOM no longer fail to parse ([#424](https://github.com/voidzero-dev/vite-task/pull/424)) - **Changed** `vp run --filter ` now exits 0 with a warning when the filter matches no packages, matching pnpm. Use `--fail-if-no-match` to restore the previous strict behavior ([#393](https://github.com/voidzero-dev/vite-task/pull/393)) - **Added** task command shorthands for defining tasks as command strings or command string arrays ([#391](https://github.com/voidzero-dev/vite-task/pull/391)) - **Changed** Cached logs are stored with colors intact (`FORCE_COLOR=1` is auto-injected into spawned tasks). Colors are then stripped at display time when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default. Opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) From c07427e1f7a31f6668b09e2cf52690c0ccf5bab0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 10:39:02 +0000 Subject: [PATCH 3/4] fix: strip UTF-8 BOM from pnpm-workspace.yaml and vite-task.json Extend BOM handling beyond package.json: strip a leading UTF-8 BOM from pnpm-workspace.yaml before YAML parsing and from vite-task.json before JSONC parsing, so config files saved with a BOM load correctly. https://claude.ai/code/session_017dv1DPTTkkt65M9s55wPHb --- CHANGELOG.md | 2 +- crates/vite_task_bin/src/lib.rs | 28 ++++++++++++++++++++++++- crates/vite_workspace/src/lib.rs | 35 ++++++++++++++++++++++++++++---- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3001e5cfb..15e0830a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** `package.json` files with a UTF-8 BOM no longer fail to parse ([#424](https://github.com/voidzero-dev/vite-task/pull/424)) +- **Fixed** config files with a UTF-8 BOM (`package.json`, `pnpm-workspace.yaml`, `vite.config.*`) no longer fail to parse ([#424](https://github.com/voidzero-dev/vite-task/pull/424)) - **Changed** `vp run --filter ` now exits 0 with a warning when the filter matches no packages, matching pnpm. Use `--fail-if-no-match` to restore the previous strict behavior ([#393](https://github.com/voidzero-dev/vite-task/pull/393)) - **Added** task command shorthands for defining tasks as command strings or command string arrays ([#391](https://github.com/voidzero-dev/vite-task/pull/391)) - **Changed** Cached logs are stored with colors intact (`FORCE_COLOR=1` is auto-injected into spawned tasks). Colors are then stripped at display time when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default. Opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) diff --git a/crates/vite_task_bin/src/lib.rs b/crates/vite_task_bin/src/lib.rs index 43ccd08d1..3dcfed9c4 100644 --- a/crates/vite_task_bin/src/lib.rs +++ b/crates/vite_task_bin/src/lib.rs @@ -128,8 +128,11 @@ impl vite_task::loader::UserConfigLoader for JsonUserConfigLoader { } Err(err) => return Err(err.into()), }; + // Strip a leading UTF-8 BOM, which some editors prepend when saving the + // config file; the JSONC parser would otherwise reject it. + let config_content = config_content.strip_prefix('\u{FEFF}').unwrap_or(&config_content); let json_value: Option = jsonc_parser::parse_to_serde_value( - &config_content, + config_content, &jsonc_parser::ParseOptions::default(), )?; let user_config: vite_task::config::UserRunConfig = @@ -153,3 +156,26 @@ impl OwnedSessionConfig { } } } + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + use vite_task::loader::UserConfigLoader as _; + + use super::*; + + /// A `vite-task.json` saved with a UTF-8 BOM must still load instead of + /// failing the JSONC parse. + #[tokio::test] + async fn json_user_config_loader_handles_bom() { + let temp_dir = TempDir::new().unwrap(); + let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap(); + + let config = "\u{FEFF}{ \"tasks\": { \"build\": { \"command\": \"echo hi\" } } }"; + std::fs::write(temp_dir_path.join("vite-task.json"), config).unwrap(); + + let loader = JsonUserConfigLoader::default(); + let user_config = loader.load_user_config_file(temp_dir_path).await.unwrap(); + assert!(user_config.is_some(), "config with a BOM should parse"); + } +} diff --git a/crates/vite_workspace/src/lib.rs b/crates/vite_workspace/src/lib.rs index e41c45e94..a5f170a9c 100644 --- a/crates/vite_workspace/src/lib.rs +++ b/crates/vite_workspace/src/lib.rs @@ -258,10 +258,12 @@ pub fn load_package_graph( let mut graph_builder = PackageGraphBuilder::default(); let workspaces = match &workspace_root.workspace_file { WorkspaceFile::PnpmWorkspaceYaml(file_with_path) => { - let workspace: PnpmWorkspace = serde_norway::from_slice(file_with_path.content()) - .map_err(|e| Error::SerdeYaml { - file_path: Arc::clone(file_with_path.path()), - serde_yaml_error: e, + let workspace: PnpmWorkspace = + serde_norway::from_slice(strip_bom(file_with_path.content())).map_err(|e| { + Error::SerdeYaml { + file_path: Arc::clone(file_with_path.path()), + serde_yaml_error: e, + } })?; workspace.packages } @@ -424,6 +426,31 @@ mod tests { assert!(names.contains("pkg-a")); } + #[test] + fn test_get_package_graph_pnpm_workspace_yaml_with_bom() { + let temp_dir = TempDir::new().unwrap(); + let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap(); + + // pnpm-workspace.yaml with a leading BOM. + let mut yaml_bytes = b"\xEF\xBB\xBF".to_vec(); + yaml_bytes.extend_from_slice(b"packages:\n - \"packages/*\"\n"); + fs::write(temp_dir_path.join("pnpm-workspace.yaml"), yaml_bytes).unwrap(); + + let root_package = serde_json::json!({ "name": "monorepo-root", "private": true }); + fs::write(temp_dir_path.join("package.json"), root_package.to_string()).unwrap(); + + fs::create_dir_all(temp_dir_path.join("packages/pkg-a")).unwrap(); + let pkg_a = serde_json::json!({ "name": "pkg-a" }); + fs::write(temp_dir_path.join("packages/pkg-a/package.json"), pkg_a.to_string()).unwrap(); + + let graph = discover_package_graph(temp_dir_path).unwrap(); + assert_eq!(graph.node_count(), 2); + let names: FxHashSet<_> = + graph.node_weights().map(|n| n.package_json.name.as_str()).collect(); + assert!(names.contains("monorepo-root")); + assert!(names.contains("pkg-a")); + } + #[test] fn test_get_package_graph_single_package_with_bom() { let temp_dir = TempDir::new().unwrap(); From 7b6e7c8e61eeeb71ce47ce7c832f406e470ff52b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 10:43:38 +0000 Subject: [PATCH 4/4] fix: drop vite-task.json BOM handling, remove vite_task_bin test Revert the vite-task.json BOM stripping and its test. The crate's lib target sets test = false, so the inline test module made cargo-shear's --deny-warnings fail. BOM handling remains for package.json and pnpm-workspace.yaml in vite_workspace. --- CHANGELOG.md | 2 +- crates/vite_task_bin/src/lib.rs | 28 +--------------------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e0830a6..d9e29a7b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** config files with a UTF-8 BOM (`package.json`, `pnpm-workspace.yaml`, `vite.config.*`) no longer fail to parse ([#424](https://github.com/voidzero-dev/vite-task/pull/424)) +- **Fixed** `package.json` and `pnpm-workspace.yaml` files with a UTF-8 BOM no longer fail to parse ([#424](https://github.com/voidzero-dev/vite-task/pull/424)) - **Changed** `vp run --filter ` now exits 0 with a warning when the filter matches no packages, matching pnpm. Use `--fail-if-no-match` to restore the previous strict behavior ([#393](https://github.com/voidzero-dev/vite-task/pull/393)) - **Added** task command shorthands for defining tasks as command strings or command string arrays ([#391](https://github.com/voidzero-dev/vite-task/pull/391)) - **Changed** Cached logs are stored with colors intact (`FORCE_COLOR=1` is auto-injected into spawned tasks). Colors are then stripped at display time when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default. Opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) diff --git a/crates/vite_task_bin/src/lib.rs b/crates/vite_task_bin/src/lib.rs index 3dcfed9c4..43ccd08d1 100644 --- a/crates/vite_task_bin/src/lib.rs +++ b/crates/vite_task_bin/src/lib.rs @@ -128,11 +128,8 @@ impl vite_task::loader::UserConfigLoader for JsonUserConfigLoader { } Err(err) => return Err(err.into()), }; - // Strip a leading UTF-8 BOM, which some editors prepend when saving the - // config file; the JSONC parser would otherwise reject it. - let config_content = config_content.strip_prefix('\u{FEFF}').unwrap_or(&config_content); let json_value: Option = jsonc_parser::parse_to_serde_value( - config_content, + &config_content, &jsonc_parser::ParseOptions::default(), )?; let user_config: vite_task::config::UserRunConfig = @@ -156,26 +153,3 @@ impl OwnedSessionConfig { } } } - -#[cfg(test)] -mod tests { - use tempfile::TempDir; - use vite_task::loader::UserConfigLoader as _; - - use super::*; - - /// A `vite-task.json` saved with a UTF-8 BOM must still load instead of - /// failing the JSONC parse. - #[tokio::test] - async fn json_user_config_loader_handles_bom() { - let temp_dir = TempDir::new().unwrap(); - let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap(); - - let config = "\u{FEFF}{ \"tasks\": { \"build\": { \"command\": \"echo hi\" } } }"; - std::fs::write(temp_dir_path.join("vite-task.json"), config).unwrap(); - - let loader = JsonUserConfigLoader::default(); - let user_config = loader.load_user_config_file(temp_dir_path).await.unwrap(); - assert!(user_config.is_some(), "config with a BOM should parse"); - } -}