feat: propagate pnpm-workspace.yaml load errors#404
Conversation
Pnpm's workspace-manifest-reader (workspace/workspace-manifest-reader/ src/index.ts at 8eb1be4988) treats only `ENOENT` as "no manifest" — every other error (read failure, invalid yaml) fails the process. Pacquet was silently swallowing all errors via `if let Ok(Some(...))`, which left typos in `pnpm-workspace.yaml` undetected and silently fell back to `.npmrc`/defaults, diverging from pnpm 11. Make `Npmrc::current` fallible. `find_and_load` already returns `Ok(None)` for the missing-file case (file presence is gated by `is_file()`); read/parse errors now bubble up wrapped in a new `LoadWorkspaceYamlError` that carries the offending path and implements `miette::Diagnostic` so the CLI prints a clean message. The variants box their inner payloads to keep the result small. Update the CLI call sites and add a test that asserts invalid yaml surfaces as `ParseYaml` instead of being silently dropped. Resolves #402. --- Written by an agent (Claude Code, claude-opus-4-7).
Micro-Benchmark ResultsLinux |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #404 +/- ##
==========================================
- Coverage 82.48% 82.34% -0.15%
==========================================
Files 65 65
Lines 3672 3705 +33
==========================================
+ Hits 3029 3051 +22
- Misses 643 654 +11 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Integrated-Benchmark Report (Linux)Scenario: Frozen Lockfile
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 2.51167743284,
"stddev": 0.09843723563692615,
"median": 2.49543235244,
"user": 2.40606828,
"system": 3.40550524,
"min": 2.38698792694,
"max": 2.75986619994,
"times": [
2.49675562194,
2.50772369194,
2.75986619994,
2.49410908294,
2.46416604694,
2.55008624194,
2.53537325494,
2.45553110094,
2.38698792694,
2.4661751599399997
]
},
{
"command": "pacquet@main",
"mean": 2.44693807484,
"stddev": 0.062076324082915565,
"median": 2.4470843474399997,
"user": 2.42600218,
"system": 3.39254184,
"min": 2.37005200794,
"max": 2.5705323509399998,
"times": [
2.50300709994,
2.37471567394,
2.47127673794,
2.5705323509399998,
2.46686938694,
2.42994439794,
2.46253733394,
2.43163136094,
2.38881439794,
2.37005200794
]
},
{
"command": "pnpm",
"mean": 6.29126821024,
"stddev": 0.0893190895790371,
"median": 6.283166507440001,
"user": 9.165774579999999,
"system": 4.464745540000001,
"min": 6.15235749194,
"max": 6.426093299940001,
"times": [
6.339622851940001,
6.426093299940001,
6.30177855294,
6.36796686894,
6.26208118094,
6.26455446194,
6.385540301940001,
6.15235749194,
6.22999360894,
6.18269348294
]
}
]
}Scenario: Frozen Lockfile (Hot Cache)
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 0.7449859527400001,
"stddev": 0.07023571220062716,
"median": 0.72233340864,
"user": 0.25649112,
"system": 1.3529243599999998,
"min": 0.69574791814,
"max": 0.93731430514,
"times": [
0.93731430514,
0.74812659514,
0.7016567261400001,
0.71306245214,
0.74737691214,
0.71374858614,
0.7481592151400001,
0.71691640014,
0.72775041714,
0.69574791814
]
},
{
"command": "pacquet@main",
"mean": 0.72737274444,
"stddev": 0.05566466751436794,
"median": 0.70493030914,
"user": 0.25702711999999994,
"system": 1.3552626600000002,
"min": 0.68553284614,
"max": 0.85878296714,
"times": [
0.77648746214,
0.68875855214,
0.75943014714,
0.69120777814,
0.68553284614,
0.6912330881400001,
0.85878296714,
0.69813645614,
0.71172416214,
0.71243398514
]
},
{
"command": "pnpm",
"mean": 2.64553453084,
"stddev": 0.09003315406655205,
"median": 2.66457920864,
"user": 3.1735991199999996,
"system": 2.1635155600000004,
"min": 2.49936765814,
"max": 2.80234692014,
"times": [
2.58465570814,
2.49936765814,
2.7005349351400003,
2.6257613611400004,
2.5261441921400003,
2.67383644514,
2.68704667714,
2.7003294391400003,
2.6553219721400003,
2.80234692014
]
}
]
} |
Apply CONTRIBUTING.md's writing-style guidance to the doc comments and inline comments added in 7077200: drop em dashes, restructure sentences so each clause stands alone, and shorten paragraphs that restated information already obvious from the surrounding code. https://claude.ai/code/session_015KiPxroFpb2pgBa92YeJM1
There was a problem hiding this comment.
Pull request overview
This PR aligns pacquet’s pnpm-workspace.yaml handling with pnpm v11 by treating a missing workspace manifest as non-fatal while propagating all other read/parse failures to callers, so configuration typos don’t silently fall back to defaults.
Changes:
- Introduces a structured
LoadWorkspaceYamlErrorthat implementsDisplay + Error + Diagnosticand carries the failing path. - Makes
Npmrc::currentfallible so YAML read/parse errors propagate, while.npmrcparsing remains best-effort. - Threads the new error path through CLI configuration loading and adds a regression test for invalid YAML.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| crates/npmrc/src/workspace_yaml.rs | Defines the new diagnostic error type and propagates read/parse failures from pnpm-workspace.yaml. |
| crates/npmrc/src/lib.rs | Makes Npmrc::current return Result and adds a regression test for invalid workspace YAML. |
| crates/npmrc/Cargo.toml | Adds derive_more and miette dependencies needed for the new error type. |
| crates/cli/src/cli_args.rs | Wraps config loading in miette and threads the new fallible Npmrc::current. |
| crates/cli/src/cli_args/store.rs | Makes the config provider closure fallible so store subcommands surface YAML errors. |
| Cargo.lock | Updates the lockfile for the added dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
`LoadWorkspaceYamlError::ParseYaml` is a tuple variant. Using the
tuple-form `ParseYaml(_)` rather than `ParseYaml { .. }` matches
Rust convention for tuple variants. The struct-form pattern compiles
and the test passes either way.
https://claude.ai/code/session_015KiPxroFpb2pgBa92YeJM1
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/npmrc/src/workspace_yaml.rs`:
- Around line 109-119: The read_to_string error for path is being
indiscriminately wrapped into LoadWorkspaceYamlError::ReadFile which converts an
ENOENT (from a TOCTOU race) into a hard error; change the read_to_string
handling so that if the mapped error's kind() == std::io::ErrorKind::NotFound
you return Ok(None) (treat as "no manifest") instead of mapping to ReadFile,
otherwise continue to map the error to ReadFileError and Box it as before;
update the block around fs::read_to_string (and the subsequent mapping to
LoadWorkspaceYamlError::ReadFile) to inspect error.kind() and short-circuit for
NotFound.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 71a97a01-c17d-4ec0-8a89-04abc0c7a6b2
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (5)
crates/cli/src/cli_args.rscrates/cli/src/cli_args/store.rscrates/npmrc/Cargo.tomlcrates/npmrc/src/lib.rscrates/npmrc/src/workspace_yaml.rs
`find_workspace_manifest` gates on `is_file()`, but the file can be removed between that check and `fs::read_to_string`. Pnpm's `workspace-manifest-reader` (8eb1be4988) treats every `ENOENT` from the read path as "no manifest". Pacquet was wrapping the racy `ENOENT` into `LoadWorkspaceYamlError::ReadFile` and erroring out. Match pnpm: short-circuit `ErrorKind::NotFound` to `Ok(None)` and keep the existing `pipe-trait` chain for every other read error and for the parse step. The early-return for `ENOENT` justifies wrapping the chain in a `match`, per the "Preserve existing method chains" exception in `AGENTS.md` for control-flow that the chain cannot express. https://claude.ai/code/session_015KiPxroFpb2pgBa92YeJM1
The previous shape wrapped every variant in a payload struct
(`ReadFileError`, `ParseYamlError`) whose own `Display` repeated
`{path}: {error}`. Rendered through miette this surfaced the path
twice — once in the variant's main message, once in the `Caused by:`
chain.
Drop the payload structs and store `path` + `source` directly on the
struct variants, mirroring `pacquet_modules_yaml::ReadModulesError`.
Only `serde_saphyr::Error` is large enough to need boxing; `io::Error`
fits the variant inline. The diagnostic now reads cleanly:
Failed to parse pnpm-workspace.yaml at /…/pnpm-workspace.yaml: <yaml error>
Caused by: <yaml error>
instead of repeating the path in the `Caused by:` line.
The test pattern moves back to struct-variant form
(`ParseYaml { .. }`) to match.
https://claude.ai/code/session_015KiPxroFpb2pgBa92YeJM1
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/npmrc/src/workspace_yaml.rs`:
- Around line 97-116: find_workspace_manifest currently filters candidates by
candidate.is_file(), which causes directory or non-regular entries named
pnpm-workspace.yaml to be treated as "no manifest" and hides read/parse errors;
update find_workspace_manifest so it returns the candidate path even if it's not
a regular file (remove or relax the is_file() filter) so that
read_to_string(&path) in this module can attempt the read and surface non-ENOENT
errors (which will then be mapped to LoadWorkspaceYamlError::ReadFile or
ParseYaml as intended for WorkspaceSettings parsing).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 9f8d72ef-e569-434e-bcef-0059ba7104e2
📒 Files selected for processing (2)
crates/npmrc/src/lib.rscrates/npmrc/src/workspace_yaml.rs
`find_and_load` previously routed candidate paths through
`find_workspace_manifest`, which gated each level on `is_file()` and
walked up otherwise. That filter swallowed cases where the entry
existed but was not a regular file (most notably a directory named
`pnpm-workspace.yaml`), so the stated "only `ENOENT` is silent"
contract — the one pnpm's `readManifestRaw` actually delivers — did
not hold.
Replace the find-then-read split with a single read-and-catch loop
over `start_dir.ancestors()`: at each level read
`pnpm-workspace.yaml`, treat `ErrorKind::NotFound` as "no manifest
here, walk up", and propagate every other error (`EISDIR`, permission
denied, etc.) through the existing `LoadWorkspaceYamlError`
variants. This also closes the TOCTOU window between the old
`is_file()` check and the read.
The private `find_workspace_manifest` helper stays in place because
`workspace_root_or` still uses it; only `find_and_load` switches to
the new shape.
Add a test that creates a directory named `pnpm-workspace.yaml` and
asserts the result is `Err(ReadFile { .. })` rather than the previous
silent `Ok(None)`.
https://claude.ai/code/session_015KiPxroFpb2pgBa92YeJM1
Resolves #402.
What this PR does
Pacquet already reads
pnpm-workspace.yaml(crates/npmrc/src/workspace_yaml.rsdefinesWorkspaceSettingsandNpmrc::currentwalks up from cwd to layer it on top of.npmrc). The remaining divergence from pnpm 11 was on the error path. Upstream'sworkspace-manifest-readersilently treatsENOENTas "no manifest" and propagates every other failure (read errors, parse errors,EISDIR, permission denied, …). Pacquet swallowed all errors viaif let Ok(Some(...)), and itsfind_workspace_manifesthelper used anis_file()filter that also masked non-regular entries (e.g. a directory namedpnpm-workspace.yaml).This PR closes both gaps.
Changes
crates/npmrc/src/workspace_yaml.rsDebug-onlyLoadWorkspaceYamlErrorwith aDisplay + Error + Diagnosticenum that storespath+sourcedirectly on each struct variant, mirroringpacquet_modules_yaml::ReadModulesError. Onlyserde_saphyr::Erroris boxed (io::Errorfits the variant inline). The single-placepathformatting keeps the mietteCaused by:chain free of duplication.find_and_loadas a single read-and-catch loop overstart_dir.ancestors(): at each level readpnpm-workspace.yaml, treat onlyErrorKind::NotFoundas "no manifest here, walk up", and propagate every other error throughLoadWorkspaceYamlError. This matches pnpm'sreadManifestRawsemantics, closes the previous TOCTOU window between the oldis_file()check and the read, and surfaces non-regular entries (directory, EISDIR, permission denied) as hard errors instead of silently walking up.crates/npmrc/src/lib.rsNpmrc::currentis nowResult<Self, LoadWorkspaceYamlError>. The.npmrcparsing stays best-effort; the yaml parsing fails loud.invalid_workspace_yaml_propagates_error(parse error) andfind_propagates_when_manifest_path_is_a_directory(EISDIR-class error). Existing tests adopt the fallible signature with.expect(...).crates/cli/src/cli_args.rsnpmrcclosure now returnsmiette::Result<&'static mut Npmrc>and is wrapped withwrap_err("load configuration"). Thestateclosure threads?.crates/cli/src/cli_args/store.rsStoreCommand::runacceptsimpl FnOnce() -> miette::Result<&Npmrc>soPruneandPathget the new error path;StoreandAddstill panic before loading config.crates/npmrc/Cargo.tomlderive_moreandmiettedeps for the new error type.Test plan
cargo fmtcargo check --workspace --all-targetscargo clippy --workspace --all-targets -- --deny warningstyposcargo test -p pacquet-npmrc(37 tests, including the two new regression tests)cargo test -p pacquet-cli --libandcargo test -p pacquet-cli --test storecargo test -p pacquet-cliintegration tests that needjust registry-mock launch(not exercised in this sandbox; pre-existing on main)Written by an agent (Claude Code, claude-opus-4-7).
Summary by CodeRabbit
Bug Fixes
Tests