Skip to content

Fix 15 open pnpm issues across hosted, vendored and agent modes - #1007

Draft
Mikola Lysenko (mikolalysenko) wants to merge 40 commits into
mainfrom
agent/fix-pnpm-open-issues
Draft

Mikola Lysenko (mikolalysenko) wants to merge 40 commits into
mainfrom
agent/fix-pnpm-open-issues

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This PR fixes 15 open pnpm issues across hosted, vendored and agent modes. In most of them a run exited 0 and reported success, but pnpm still installed unpatched bytes, frozen installs broke, or a rollback was not byte-exact. Every fix has a regression test that fails on main, and each one went through adversarial review rounds.

Fixes #1006
Fixes #919
Fixes #902
Fixes #854
Fixes #853
Fixes #830
Fixes #778
Fixes #734
Fixes #714
Fixes #713
Fixes #633
Fixes #556
Fixes #492
Fixes #466
Fixes #435

Issues at a glance

Issue Root cause Fix Key regression test
#1006 governing_workspace_file took the nearest ancestor pnpm-workspace.yaml without checking that its packages: globs list the project (regression from #888) The ancestor file governs only if its globs list the project, matched the way pnpm does: negations anywhere, no dot directories, packages: ~/empty means root-only hosted::governing_root::tests::pnpm_project_outside_the_workspace_globs_is_not_refused, in_process_redirect_pnpm::hosted_scan_from_pnpm_project_outside_workspace_globs_pins_and_nests_trust
#919 The pnpm restore fetched dists from the default registry, never from the .npmrc / workspace mirror New pnpm_lookup_registry follows the pnpm major's rules (@scope:registry, registry, workspace registries/registry:) and is used for both the fetch and the tarball decision in_process_redirect::pnpm_rollback_reads_the_npmrc_mirror_document_for_an_offpath_tarball
#902 The lockfileIncludeTarballUrl policy read both settings files whatever pnpm wrote the lock, and pnpm 11's env document gave false evidence Decided by the lock's own evidence first, then the settings file the installed or pinned pnpm major reads, then pnpm 10's reading with a warning in_process_redirect::pnpm_rollback_stays_bare_when_pnpm11_ignores_npmrc_include_tarball_url (plus pnpm 9 and pnpm 12 twins)
#854 Vendored pnpm classified only package.json overrides and compared raw YAML text Shared classify_override_entries also covers pnpm-workspace.yaml overrides:; values are compared as YAML reads them (quotes and comments ignored), and revert restores the original text byte for byte vendor::pnpm_lock::tests::ws_bare_key_exact_pin_is_taken_over_and_revert_restores_it
#853 The scan/get --mode vendored --dry-run preview never asked the pnpm backend about hosted pins The preview runs the pnpm lock-text gates on hosted candidates and lists refusals as would_refuse. preflight_refused_purls matches in_process_vendor_pnpm_takeover::scan_vendored_over_hosted_pnpm_catalog_dep_keeps_the_hosted_pin, vendor_flow::preview_tests::preview_refuses_hosted_pnpm_catalog_pin
#830 Reverting a vendored parent spliced its whole pre-vendor block back, and child ref records were keyed only by the parent's old key Three-way merge (merge_live_dep_refs) keeps refs that another entry moved, and ref lookup follows the parent's rekey (v9, 5.4, 6.0) in_process_vendor_pnpm_parent_child::remove_parent_keeps_the_vendored_child_wired
#778 The crawler keeps one copy per name@version, and the agent scan's PATH filter tested only that one path Purls that miss the first pass are resolved to every installed copy (find_all_packages_for_rollback_reusing), the same way rollback does scan_paths_e2e::paths_scope_selects_pnpm_member_linked_copy
#734 The root-only packages: ['.'] scaffold turns a project into a workspace on pnpm 9.0-10.4 (ERR_PNPM_ADDING_TO_ROOT) Neither mode creates the scaffold when every pnpm pin names 9.0-10.4. With no evidence it is still created, with a caveat warning (see decisions) in_process_redirect_pnpm::hosted_pnpm_9_through_10_4_project_gets_no_root_only_workspace, hosted_memory_engine::pnpm_9_pin_gets_no_root_only_workspace_in_memory
#714 redirect_rush_repo_state_stale was gated on the single common repo-state.json path Each rewritten Rush lock is paired with the repo-state.json beside it, in both the disk and memory engines in_process_redirect::rush_subspace_repo_state_stale_warning_fires_for_subspace_repo_state
#713 pnpm_trust didn't know which spliced locks were Rush locks, so it gave the generic remedy, which never reaches rush install A Rush-specific redirect_pnpm_trust_lockfile remedy (pnpm_config_trust_lockfile=true rush install, usePnpmFrozenLockfileForRushInstall), re-issued on re-runs over locks that are already redirected in_process_redirect::rush_pnpm_trust_warning_gives_rush_remedy
#633 merge_npm_copies deduped by literal path, so the store entry and the member link to it were visited twice Copies are collapsed to distinct real directories at the apply and rollback per-copy loops (distinct_npm_copies) in_process_npm_multicopy::apply_and_rollback_visit_a_pnpm_workspace_member_link_once
#556 Nothing knew about gitBranchLockfile, so the stale pnpm-lock.yaml was pinned Both modes refuse when the setting is on and a branch lock exists, at the root or in a member, reading the setting from the governing ancestor in_process_redirect_pnpm::hosted_scan_refuses_a_git_branch_lockfile_project
#492 The hosted engine and discovery read only root-relative locks and ignored sharedWorkspaceLockfile: false member locks member_locks reads the setting the way pnpm does and expands packages:. Member locks are pinned, discovered and rolled back. The trust key goes to the root. Ported to the in-memory engine in_process_redirect_pnpm::hosted_scan_pins_every_member_lock_with_shared_workspace_lockfile_false, hosted_memory_parity member-lock cases
#466 pnpm >= 11 two-document locks: section lookups landed in the env document split_project_document edits only the project document and writes the env document back byte for byte. Unrecognised multi-document locks are refused vendor::pnpm_lock::tests::two_document_lock_vendors_and_reverts_the_project_document, real-pnpm e2e_vendor_pnpm_build::pnpm12_package_manager_two_document_lock_vendors_and_reverts
#435 pnpm 11+ per-install global dirs were walked as one root, and the walk-wide store filter skipped later installs global/v<N> is split into one root per install for both --global and --global-prefix npm_crawler::tests::global_prefix_finds_every_pnpm_isolated_install_copy, in_process_npm_multicopy::apply_global_prefix_patches_every_pnpm_isolated_global_install

New contract codes

All of these are documented in CLI_CONTRACT.md. Hosted refusals keep the existing hosted semantics: they are warnings, the status stays success with exit 0 and redirected: 0, the same as redirect_pnpm_unsupported and the other existing refusals.

Refusals

Warnings

Narrowed

Maintainer decisions

Behavior changes worth a close look

Testing

Out of scope

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This was referenced Oct 7, 2026
A hosted scan on a Rush repo repoints common/config/rush/pnpm-lock.yaml
(and subspace locks) and reports success, but on pnpm >=11 the next
`rush install` either fails (ERR_PNPM_TARBALL_URL_MISMATCH) or, on pnpm
11, silently re-resolves the hosted entries to upstream with exit 0.

Root cause: `pnpm_trust` skips the trustLockfile write for Rush locks on
purpose (rush runs pnpm in common/temp with a pnpm-workspace.yaml it
generates), but it never knew which spliced locks were Rush locks, so
the run fell through to the generic manual guidance:
`pnpm install --trust-lockfile`, a repo-root `trustLockfile` key and a
`--store-dir` reinstall. None of those reach rush's install.

`rewrite()` now passes `rush_lock_keys` into `pnpm_trust`. When every
spliced pnpm lock is a Rush lock (and not a legacy 5.x/6.0 lock), the
`redirect_pnpm_trust_lockfile` warning carries a Rush remedy instead:
`pnpm_config_trust_lockfile=true rush install`, plus
`"usePnpmFrozenLockfileForRushInstall": true` in
common/config/rush/experiments.json on pnpm 11, `rush purge` before the
reinstall, and `socket-patch vex` to verify. It also says the pnpm 11
failure can be silent. A run that also spliced a non-Rush pnpm lock
keeps the generic text and appends a Rush note. The warning code and
file writes are unchanged. docs/ecosystems.md and CLI_CONTRACT.md
document the Rush pnpm >=11 remedy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…919)

The hosted pnpm unwind (rollback / remove, and the hosted -> vendored
takeover and eject that share it) restored each pinned pnpm-lock.yaml
resolution from the version document of the default registry
(SOCKET_NPM_REGISTRY, npmjs when unset). restore_pnpm_locks still called
the default-registry `fetch_dists`; the project-registry lookup added
for #908 (`fetch_dists_on`) was wired only into yarn berry and vlt. The
lock-sibling `.npmrc` `registry=` was read, but only to decide whether a
`tarball:` is written, never to pick the registry the dist comes from,
and `@scope:registry` was not read at all. So for a project resolving
against a mirror:

- a CDN-style mirror `tarball:` came back as a bare `{integrity}`,
  because npmjs's URL is conventional, and a cold frozen install 404s;
- under lockfile-include-tarball-url, npmjs's dist.tarball replaced the
  mirror URL pnpm had recorded.

Both exited 0.

pnpm resolves a name against its `.npmrc` `@scope:registry` when the
name is scoped and that key is set, otherwise against `registry`. The
new `pnpm_lookup_registry` encodes that rule, and the restore now uses
it for two things: as the per-name registry for `fetch_dists_on`, which
also gives the existing `upstream_registry_fallback` warning and
default-registry fallback when the mirror can't be read, and in the
`registry_derives_tarball` decision, so a scoped package's conventional
scope-registry URL stays derived. A value that still holds an unexpanded
`${VAR}` is read as unset (today's default-registry behaviour) and is
never fetched as a literal URL.

CLI_CONTRACT.md: the npm-family upstream bullet, the SOCKET_NPM_REGISTRY
row and the upstream_registry_fallback row now list pnpm's `.npmrc`
`registry` / `@scope:registry` next to berry and vlt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`scan --mode agent packages/a` in a pnpm workspace scanned 0 packages
and exited 0, while `rollback packages/a` selected the member's
dependencies.

Root cause: the npm crawler keeps one CrawledPackage per name@version
(`merge_scan_events`' `seen` set), recorded at the first copy the walk
meets. In a pnpm workspace that is the root `node_modules/.pnpm` store
entry, never the member's `packages/a/node_modules/<dep>` link; in a
yarn classic / npm workspace with a version conflict it is whichever
member's nested copy the readdir order reaches first. Scan's PATH filter
tested only that one recorded path, so the contract rule "in scope iff
ANY installed copy sits under a matching path" was never honored for
the other copies. Rollback resolves candidates through
`find_all_packages_for_rollback`, which returns every copy, hence the
divergence.

Fix: keep the cheap first pass over the recorded paths, then resolve
the purls that missed to every installed copy with the same
enumeration rollback's path targets use (new
`find_all_packages_for_rollback_reusing`, which reuses the crawl's npm
roots), and admit a purl when any copy matches. A path-scoped run now
keeps the npm crawl snapshot so the roots are not rediscovered. The
crawler's per-purl dedup is unchanged; apply already patches every copy
of a selected purl. Unscoped scans are untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#888 made hosted and vendored modes refuse a pnpm project with its own
v9 lock and no pnpm-workspace.yaml of its own whenever any ancestor held
a pnpm-workspace.yaml (redirect_pnpm_settings_elsewhere,
vendor_pnpm_settings_elsewhere). governing_workspace_file took the
nearest ancestor file without asking whether its `packages:` globs list
the project. pnpm 11.28+ and 12 install a directory the nearest file
does not list (an examples/ app, a checkout under an unrelated
workspace) standalone: its own lock, and settings read only from its own
pnpm-workspace.yaml. So both refusals pointed at remedies that do
nothing, and neither mode could patch a project 4.0.0 handled.

governing_workspace_file now reads the nearest regular ancestor file
(FIFO-safe reader) and returns it only when it lists the project, as
probed on pnpm 11.28.5 and 12.10.1:
- no `packages:`, a null or an empty list: root-only workspace, so no;
- otherwise some pattern matches and no `!` pattern does (pnpm's globber
  treats every negation as an ignore, wherever it sits).
A file that does not parse, or a pattern with braces, classes or
extglobs the matcher does not model, still counts as listing the
project, so #880/#881 stay refused. With no governing file, hosted
creates the project's own pnpm-workspace.yaml with trustLockfile: true
and vendored wires its override there, as before #888.

The glob matcher used by the package.json workspaces check (#884) moves
to utils/workspace_globs.rs so both checks share it. CLI_CONTRACT.md
scopes both codes to members listed by the root's `packages:` globs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…854)

Vendored pnpm takes over a user's exact-version override of the package
it vendors (the pin already forces that version, so redirecting the same
key keeps its meaning), but the pre-flight only classified package.json
`pnpm.overrides`. On pnpm 10.5+ (always on 11/12) the override lives in
pnpm-workspace.yaml `overrides:`. With no package.json override the
effective key fell back to our canonical `name@version`, so a bare-key
pin (`left-pad: 1.3.0`) in the workspace file and its lock mirror were
refused as `vendor_override_conflict`, with a detail claiming the lock
"does not match package.json's `left-pad@1.3.0`". The versioned-key pin
only worked because it happened to equal our canonical key.

- The per-entry Insert / Ours / Takeover / conflict rules are factored
  into classify_override_entries, shared by classify_pkg_override and a
  new classify_ws_override (modern locks only; legacy pnpm never reads
  the workspace file). The effective key comes from whichever file
  carries the override; package.json and the workspace file pinning
  under different keys refuses naming both files.
- check_lock_override names the file the effective key came from (or
  says it is the key vendoring would add), and conflict details name
  pnpm-workspace.yaml when that is where the override lives.
- workspace_overrides_govern now counts any workspace override key the
  lock records, vendored values included. Before, once a workspace-only
  takeover made the value ours, a re-run (or vendoring another package)
  saw no user key and wrote a package.json copy that shadows the
  workspace overrides on pnpm 10 (#360 twin).

The #853 CLI fixture that used a bare workspace exact pin as its
"refused" trigger now uses a range, and a new CLI test pins the
takeover. CLI_CONTRACT.md documents the takeover exception.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…633)

In a pnpm (or Bun isolated) workspace, agent-mode `apply` reported every
package a member links twice: once `applied`, then a phantom `skipped` /
`already_patched` for the same purl, and every re-run counted one extra
skip per member-linked package. `rollback` double-counted
`alreadyOriginal` the same way.

Root cause: the multi-copy resolver (`find_all_packages_for_purls` /
`find_all_packages_for_rollback`) runs `find_by_purls` once per
`node_modules` root. The workspace root pass records the store entry
`node_modules/.pnpm/<pkg>@<ver>/node_modules/<pkg>` and the member pass
records the member's `packages/a/node_modules/<pkg>` link to it.
`merge_npm_copies` dedupes by literal path, so both spellings of one
directory survive and the apply/rollback per-copy loops visited the same
physical copy twice.

Fix: collapse each npm purl's copies to distinct real directories at the
two sites that act per copy (apply's copy loop and rollback's restore
targets), keeping the first-found spelling, via a new
`distinct_npm_copies` built on the canonical-path helper
`distinct_install_dirs` (moved from apply.rs to ecosystem_dispatch.rs,
where PyPI apply keeps using it). The resolver map itself still carries
every spelling on purpose: path targets (`scan --mode agent packages/a`,
`rollback packages/a`, #778) match copies textually through the member's
link, and deduping there would make those scopes select nothing.
Genuinely distinct copies (nested duplicates, store peer variants,
bundled copies) have distinct real paths and are unaffected; a path that
can't be canonicalized is kept as-is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The hosted pnpm unwind (rollback / remove) decides whether a restored
pnpm-lock.yaml resolution gets a `tarball:` from PnpmTarballPolicy. That
policy read `lockfileIncludeTarballUrl` from pnpm-workspace.yaml, else
`lockfile-include-tarball-url` from .npmrc, whatever pnpm major wrote
the lock. But pnpm <= 9 ignores pnpm-workspace.yaml settings and pnpm
11/12 ignore pnpm settings in .npmrc, so in those projects the lock has
no `tarball:` fields, yet rollback/remove wrote the registry's
dist.tarball into every restored resolution: not byte-exact, and each
restored package hard-pinned to that registry.

The decision now follows what pnpm actually did, strongest signal first:

1. The lock's own unpinned registry resolutions (registry key per the
   shared pnpm_registry_key rule, integrity, no git/directory fields,
   tarball neither hosted nor `file:`). With the setting on pnpm writes
   `tarball:` on every one, so a single bare one proves it off; failing
   that, a tarball pnpm could have derived proves it on. Unconventional
   URLs are recorded either way and give no evidence.
2. The settings file the installed pnpm major reads: the major comes
   from node_modules/.modules.yaml `packageManager` (JSON on pnpm 10+,
   YAML before), else package.json's corepack `packageManager`, else a
   pre-9 lockfileVersion or a shrinkwrap.yaml means pnpm <= 8. <= 9 reads
   .npmrc only, 10 the workspace file then .npmrc, 11+ the workspace
   file only.
3. With neither (no install record, only pinned entries, e.g. a fresh
   clone or a Rush lock), pnpm 10's reading, as before.

The splice logic and the unconventional-URL branch (#557) are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pnpm's globber reads `packages:` with dot matching off: on pnpm 12.10.1,
`packages: ['**']` leaves `.github/actions/demo` standalone with its own
lock, as do `packages/**` for `packages/.x/demo` and `packages/*` for
`packages/.hidden`. The shared matcher let `*`, `?` and `**` match those
components, so governing_workspace_file still named the root file for
them and hosted / vendored refused with redirect_pnpm_settings_elsewhere
/ vendor_pnpm_settings_elsewhere, pointing at a file pnpm never reads
for that directory.

lists_as_member now uses glob_matches_no_dot: a wildcard component never
matches a path component starting with `.` unless the pattern component
itself starts with `.` (`.github/**` still lists it). The npm/yarn
`workspaces` caller keeps its current matching.

Tests: the probed dot cases in the core membership test and at the
refusal level; the refusal test's settings-only root now carries no
trust key (`trustLockfile: false` short-circuited the refusal on main
too, so it never exercised the membership rule); and the #880 doc
comment moves back onto its own test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With Rush subspaces enabled, each subspace keeps its pnpm lock and the
repo-state.json that carries its pnpmShrinkwrapHash side by side under
common/config/subspaces/<name>/, and there is no
common/config/rush/repo-state.json. The hosted engine rewrote the
subspace locks but gated redirect_rush_repo_state_stale on the one fixed
common path (RUSH_REPO_STATE_REL), so the warning never fired and
`rush install` with preventManualShrinkwrapChanges failed on the hash
check with no hint.

The gate now pairs each rewritten Rush lock with the repo-state.json in
its own directory (the common lock still maps to RUSH_REPO_STATE_REL), so
a subspace rewrite warns on its subspace's file and a common-only rewrite
is not flagged by an unrelated subspace's file. The in-memory host's
selector fetches common/config/subspaces/*/repo-state.json presence-only
as well, so disk and memory runs agree. Docs and CLI_CONTRACT name the
per-subspace file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pnpm 11+ writes the env lockfile (configDependencies /
packageManagerDependencies) as a first YAML document ahead of the project
lock, and records its resolutions as a bare `{integrity}` even under
lockfileIncludeTarballUrl (verified with pnpm 11.27.0 and
`pnpm add --config is-number@7.0.0`). The tier-1 evidence scan walked every
`packages:` section, so that one bare config dependency proved the setting
off and rollback/remove restored a pinned entry without its `tarball:`,
exiting 0 with a lock that was not byte-exact.

The evidence scan now reads only the main document (after the last `---`
marker), through a new shared grammar::main_document helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Hosted mode creates `packages: ['.']` + `trustLockfile: true` for every
v9 root lock with no pnpm-workspace.yaml, and vendored mode creates the
same scaffold for its `overrides:` mirror. On pnpm 9.0.0-10.4.x that file
turns a single-package project into a root-only workspace, where
`pnpm add <pkg>` fails with ERR_PNPM_ADDING_TO_ROOT unless given `-w`
(10.5.0 is the first release that adds normally). Those releases read
neither setting from the file: `trustLockfile` is pnpm >= 11, and
workspace `overrides:` are read from 10.5 on.

Direction taken: a version-gated scaffold, not `ignoreWorkspaceRootCheck`.
When the project has no pnpm-workspace.yaml and every pnpm pin it
carries (at least one) names 9.0-10.4, neither mode creates the file.
The pins are the installed node_modules/.modules.yaml `packageManager`
(YAML on pnpm 9, JSON on 10+) and package.json `packageManager`,
`devEngines.packageManager` and `engines.pnpm`, all read with the
FIFO-safe regular-file readers. A pin naming a later pnpm, a range
reaching past 10.4, an unparseable pin, or no pin keeps the scaffold, so
pnpm >= 11 never loses the setting it needs.

- Hosted: the new redirect_pnpm_trust_lockfile variant names the pins,
  says why nothing was written, and gives the pnpm >= 11 recovery
  (re-run the scan, or `pnpm install --trust-lockfile`). A created
  scaffold's detail now notes the `pnpm add -w` caveat.
- Vendored: package.json `pnpm.overrides` and the lock are wired as
  before, the workspace mirror is skipped; a later vendor on pnpm >= 10.5
  adds it, and revert undoes all three byte-for-byte. The "Commit ..."
  next step names pnpm-workspace.yaml only when the file exists.
- package.json `packageManager` parsing moves to
  utils/package_manager.rs, shared with the yarn migration check.

Tests cover both modes: core unit tests for the pin reader, vendor and
revert (including the upgrade path), in-memory and in-process hosted
runs including heal-on-rerun after an upgrade and rollback removing the
created file, and the real-pnpm e2e legs now expect no file on pnpm 9.
CLI_CONTRACT.md, docs/ecosystems.md and docs/testing/pnpm-compatibility.md
describe the rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Closing (burn-down agent): this draft has no changes. Its only commit is an empty placeholder ("Start pnpm open-issue sweep"), the diff against main is 0 files, and nothing has been pushed in about 2.5 hours with no agent heartbeat. The linked issues stay open and untouched. The branch is kept: reopen this PR, or open a new one, once fixes are pushed.


Generated by Claude Code

End-to-end rollback cases for behaviour only unit tests covered:

- a scoped name resolves against .npmrc `@scope:registry`, not `registry`
  (an off-path CDN tarball stays recorded, a URL conventional under the
  scope registry stays bare; `registry` 404s and the default registry
  advertises the other shape, so reading either changes the lock);
- an unreadable (401) .npmrc mirror falls back to the default registry,
  exits 0 and warns `upstream_registry_fallback`, as CLI_CONTRACT.md says;
- a pnpm 11 project naming its mirror in pnpm-workspace.yaml `registry:`.

Also folds the remove_json copy of rollback_json_with_origin into one
hosted_unwind_json runner that both wrap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The heal-on-rerun step in pnpm_trust only probed the repo-root
pnpm-lock.yaml. A Rush common/subspace lock that an earlier run (any
pre-fix release) already redirected splices nothing on a re-scan, so
pnpm_lock_texts stayed empty and no redirect_pnpm_trust_lockfile warning
was emitted at all. A user re-running the scan to get the corrected Rush
guidance saw "nothing to rewrite" and no pointer to
pnpm_config_trust_lockfile=true or usePnpmFrozenLockfileForRushInstall.

Each unspliced Rush lock now goes through the same pnpm_heal_root gate
(v9+ lock carrying a granted hosted artifact URL). A lock that passes
joins the touched set and counts as Rush, so a Rush-only re-run still
selects the Rush detail. Nothing persists the Rush remedy, so the full
text is re-issued (pnpm_rerun_only stays false). No file is written, and
legacy locks keep no warning, as on the root heal path.

Adds a core unit test and a CLI two-run integration test. Updates
CLI_CONTRACT.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The #633 workspace-member test copied run_rollback into a closure to add
--preserve-state / a path target, and the #435 global-install tests
copied run_apply into run_apply_global_prefix for --global-prefix. That
left three bare binary spawns that a later hermetic-command migration of
run_apply/run_rollback would have to find separately.

Add run_apply_with / run_rollback_with taking extra args, make the plain
helpers delegate to them, and drop both copies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
rustfmt on the lines the #919 / #902 follow-ups added, and
`major.is_none_or(..)` for clippy's nonminimal_bool.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ref (#830)

When a vendored package and its own dependency are both vendored (debug
-> ms), vendoring the child rewrites the `ms` ref inside the parent's
rekeyed snapshot (v9) or packages block (lockfile 5.4/6.0), and records
that ref against the parent's block key at that moment.

Two halves of the revert surgery broke on that interplay:

- revert_block / revert_package_block accepted the parent's live block
  (its key is still ours) and spliced the whole pre-vendor original back,
  dropping the child's live `file:` ref. `remove <parent>` then left
  `debug -> ms: 2.1.2` while ms stayed keyed `ms@file:`, a lock every
  pnpm major rejects with ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY.
- revert_snapshot_ref / revert_pkg_dep_ref looked the child's ref up only
  by the recorded parent key. Once the parent was rekeyed (un-vendored, or
  vendored after the child), the record never matched again: a
  `vendor_lock_entry_removed` warning (drift before #689), and in the
  child-first order the child's `file:` ref was left in the parent block.

The restore is now a three-way merge (merge_live_dep_refs): the recorded
original, except a dependencies/optionalDependencies ref whose live value
moved off what this entry wrote keeps the live value. With live == new it
is the original verbatim, so single-entry reverts stay byte-exact. The
ALREADY CONVERGED probes compare modulo those ref values, so a re-run
after a merged restore stays silent. A ref record whose block key is gone
now looks in the block naming the same package under its other key
(registry key <-> vendored `file:` key, matched by tarball leaf in every
dialect's spelling) and restores only a ref that is ours, through one
shared DepRef helper for both dialects.

Every vendor/revert order of the pair now round-trips byte-identically
with no warnings and no kept artifact; `remove <parent>`, `rollback`,
`vendor --revert` and the vendored -> hosted takeover are covered end to
end through the binary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pnpm's `gitBranchLockfile` setting (`gitBranchLockfile: true` in
pnpm-workspace.yaml, or `git-branch-lockfile=true` in .npmrc on pnpm <= 10)
makes pnpm install a git branch from its own `pnpm-lock.<branch>.yaml`
whenever that lock exists, falling back to `pnpm-lock.yaml` only when it
does not. Nothing in the tree knew about the setting: every pnpm path keyed
on the literal `pnpm-lock.yaml`. So on a feature branch with both locks,
`scan --mode hosted` spliced the stale `pnpm-lock.yaml`, confirmed it and
reported `redirected: 1` while fresh installs stayed upstream; with only
the branch lock it warned `redirect_pnpm_no_lockfile` ("run `pnpm
install`", which just rewrites the branch lock). Vendored mode wired the
stale lock plus the manifest/workspace overrides and reported success,
breaking every `--frozen-lockfile` install on the branch with an overrides
mismatch.

Pinning per-branch locks would need the current git branch, pnpm's branch
name sanitising and the merge pattern, so both modes now refuse instead,
gated on the setting being on AND a root branch lock existing (the setting
alone, e.g. on main, still installs from and pins `pnpm-lock.yaml`):

- `utils::pnpm_workspace::git_branch_locks` reads the setting (YAML wins
  over .npmrc, like `sharedWorkspaceLockfile`; env spellings on disk) and
  lists root `pnpm-lock.<branch>.yaml` files over any ProjectView.
- Hosted: `read_candidate_files` leaves the root pnpm locks out and sets
  the (renamed, shared with #492) `CandidateFiles::pnpm_refusal`, so the
  rewrite warns `redirect_pnpm_git_branch_lockfile` instead of pinning or
  the "no lockfile" hint. The in-memory engine fetches root branch-lock
  names presence-only so it refuses the same projects. A vendored->hosted
  takeover of a pnpm-vendored purl is refused with the same code, keeping
  the vendored wiring rather than reverting into neither mode.
- Vendored: `pnpm_lock::read_project` (V9, legacy, and the pre-flight)
  refuses with `vendor_pnpm_git_branch_lockfile` before any write, and the
  npm flavor probe raises it instead of `vendor_lockfile_missing` when the
  branch lock is the only pnpm lock. The hosted->vendored takeover thus
  keeps the hosted pin.

Documented in CLI_CONTRACT.md and docs/ecosystems.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three gaps in the #492 member-lock detection, each of which let a
`sharedWorkspaceLockfile: false` workspace be classified wrong and the
hosted run report success while members install unpatched:

- Booleans: `shared_lockfile_disabled` matched the exact string `false`,
  while pnpm reads pnpm-workspace.yaml with js-yaml, which takes `False` /
  `FALSE` too. The YAML-wins-over-.npmrc reading is now one helper,
  `pnpm_setting` (+ `PnpmSetting::as_bool`, case-insensitive true/false),
  shared with `git_branch_lockfile_setting`, so both settings parse alike.
- Environment: on disk, when neither file sets the key,
  `pnpm_config_shared_workspace_lockfile` / `npm_config_shared_workspace_lockfile`
  now count, the same fallback `gitBranchLockfile` already had (one
  `env_setting` helper for both). The `root_lock_lists_members` guard still
  keeps shared workspaces Shared.
- No `packages:` key: `package_globs` returned an empty list, so the
  workspace looked memberless and only the root lock was read (or pinned
  nothing with success). pnpm <= 8 then finds projects in every directory,
  more than a bounded walk can promise to list, so `package_globs` now
  reports an absent key as `None` and `member_locks` refuses with
  `redirect_pnpm_member_locks_unresolved`.

CLI_CONTRACT.md and docs/ecosystems.md updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…466)

pnpm >= 11 writes pnpm-lock.yaml as two YAML documents when the project
has config dependencies (`pnpm add --config`, pnpm 11 and 12) or pins
pnpm through `packageManager` (pnpm 12): `---`, an env document with its
own `importers:` (configDependencies / packageManagerDependencies),
`packages:` and `snapshots:`, `---`, then the project lock. The v9
vendored planner treated the file as one document, and `section_bounds`
returns the FIRST column-0 header, so every section lookup except
`overrides:` landed in the env document:

- vendor refused `vendor_lock_entry_not_found` for an installed package;
- with a config dependency sharing the key, vendor reported success after
  rewriting the env document, and a fresh frozen install then failed;
- vendor --revert, rollback and the vendored -> hosted takeover found
  every lock record "drifted" yet still removed the package.json /
  pnpm-workspace.yaml overrides, leaving the lock wired to the artifact
  (takeover: redirected 0, ledger dropped, frozen installs broken).

The planner now splits the lock at its I/O boundary
(`formats::pnpm::lines::split_project_document`): the project document
is what the planners read and edit, and everything ahead of it is
written back byte-for-byte, on vendor (the lock memo maps the whole
file's bytes to the project document's lines) and on revert. The split
accepts only pnpm's layout: an env document whose importers carry just
configDependencies / packageManagerDependencies, followed by a project
document that passes the 9.0 version check. Any other multi-document
lock (three or more documents, an end marker, a BOM before a separator,
an unrecognised first document) is refused before any write with the
new `vendor_pnpm_lock_multi_document` code (remedy: --mode hosted), and
a revert over one fails, dry run included, before touching any surface.

When the vendored package is also a config dependency, the env
document's copy (installed under node_modules/.pnpm-config) stays the
registry's; vendor now says so with a `vendor_config_dependency_unpatched`
warning. Measured on pnpm 11.27.0 and 12.8.1: a fresh frozen install
accepts the untouched env document beside the project-document wiring
and lands the vendored bytes in the project copy.

Tests: core unit tests for the splitter and for vendor / in-sync re-run
/ revert on two-document locks (packageManager and shared config
dependency), revert after the lock became two documents, and the
fail-closed refusals; a hermetic CLI takeover test both ways on a
two-document lock; gated real-pnpm legs (pnpm 12.8.1 packageManager,
pnpm 11.27.0 config dependency, pnpm 12.8.1 both) proving the fresh
frozen install and the byte-exact revert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The #556 refusal only listed `pnpm-lock.<branch>.yaml` at the project root
and read `gitBranchLockfile` only from the run directory's own
pnpm-workspace.yaml / .npmrc, so it missed the layout #492 just enabled:

- Under `sharedWorkspaceLockfile: false` pnpm resolves the wanted lock
  name once and looks for it in every lockfileDir, so a member whose deps
  changed on the branch installs from `<member>/pnpm-lock.<branch>.yaml`
  and its `pnpm-lock.yaml` is stale. With no root branch lock (pnpm 7
  writes no root lock at all) `git_branch_locks` returned None and
  `read_pnpm_member_locks` spliced and confirmed the stale member lock:
  `redirected: 1`, exit 0, while `pnpm install --frozen-lockfile` on the
  branch stayed upstream.
- Run from a member, the setting lives in the governing ancestor
  pnpm-workspace.yaml (or the .npmrc beside it), which was never read.

`git_branch_locks` now also lists the branch locks in every member
directory that installs from its own lock (the same member walk as
`member_locks`, factored into `workspace_members`, so a member branch
lock beside a shared root lock stays a stray file), and, on disk, falls
back to the governing ancestor's pnpm-workspace.yaml and its sibling
.npmrc (`governing_workspace_file`, FIFO-safe regular-file reads) before
the environment. The hosted engine checks it before reading member locks,
so every pnpm lock (members' included) is left untouched and the run warns
`redirect_pnpm_git_branch_lockfile`; vendored mode and the vendored->hosted
takeover share the helper and refuse the same projects.

Engine tests cover a member branch lock with no root branch lock (pnpm 8+
YAML with a root-only lock, and pnpm 7 .npmrc with no root lock), the
setting-off control, and runs from a member reading the ancestor YAML and
the ancestor .npmrc. CLI_CONTRACT.md and docs/ecosystems.md updated.

Discovery is unchanged on purpose: it keeps reading member locks (as it
keeps reading the root lock beside a root branch lock) so pins written
before a branch lock appeared stay visible to rollback/remove.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
classify_ws_override and check_workspace_override compared the raw YAML
value text with the vendored version, so a hand-written workspace pin
spelled `left-pad: '1.3.0'`, `left-pad: "1.3.0"` or
`left-pad: 1.3.0  # CVE-...` was refused as vendor_override_conflict,
while the same pin in package.json (and the plain `1.3.0` pnpm writes to
the lock mirror) was taken over. The refusal detail then told the user an
exact pin equal to 1.3.0 is taken over automatically. From a hosted
project the takeover stayed refused (#853 keeps the pin), so the package
could not be vendored at all.

Both checks (and apply's ours-vs-takeover test) now compare the value as
YAML reads it: a trailing ` #` comment (the workspace parser's
quote-aware strip_comment, now pub(crate)) and one layer of quotes
dropped. The raw text is still what the takeover records as the
original, so revert restores the quotes and the comment byte-for-byte.
A quoted or commented range / other version still refuses.

CLI_CONTRACT.md notes the spelling tolerance on the takeover exception.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	crates/socket-patch-cli/CLI_CONTRACT.md
# Conflicts:
#	crates/socket-patch-cli/CLI_CONTRACT.md
#	crates/socket-patch-core/src/hosted/engine.rs
Conflict resolutions keep both sides' behavior:

- scan/vendor_flow.rs: preview_vendor_json takes both the run's
  patch-server origins (the hosted pnpm takeover lock-text prediction,
  #853) and main's caller-resolved takeover refusals (the gem preflight).
  preflight_refused_purls takes GlobalArgs (main) and derives the origins
  itself, folding in both the pnpm and the gem takeover refusals.
- scan/mod.rs, get.rs: callers resolve the gem takeover refusals and pass
  the origins too; get's lock_text_refusals_for keeps main's HostedPin
  list (the gem takeover needs the pins).
- hosted/engine.rs: union of both guidance imports.
- vendor/mod.rs: main's manifest_pins_yarn_classic helper, now reading the
  pin through utils::package_manager::pinned_version as this branch did.
- CLI_CONTRACT.md: word-level merge; the pnpm would_refuse preview text
  and main's gem preflight paragraph both kept.

Main (#909) removed utils::serde::strip_bom in favor of
formats::text::strip_bom; this branch's callers (pnpm version pins,
pnpm workspace globs, npm upstream restore, pnpm multi-document split)
now use main's helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in the per-member pnpm locks under sharedWorkspaceLockfile: false
(#492) and the gitBranchLockfile refusal (#556), reconciled with the
membership check (#1006), the Rush trust heal (#713/#714) and the
root-only scaffold skip (#734) already on this branch.

- One notion of pnpm workspace membership: utils::pnpm_workspace::
  lists_member decides it from the `packages:` globs (pnpm's dot rule via
  workspace_globs::glob_matches_no_dot, negations anywhere, the project
  finder's default ignores node_modules/bower_components/test/tests, and an
  error for brace/class/extglob syntax). governing_workspace_file ("is this
  directory a member") and member_dirs ("which members' locks to pin") both
  answer through it, and both read the globs with package_globs; the
  serde_saphyr reader and member_dirs' own negation and ignore filtering
  are gone. member_dirs uses cargo_workspace::expand_glob only to list
  candidate directories and refuses (Unresolved) on unmodeled syntax
  rather than silently pinning no member. expand_glob now lets a wildcard
  segment that starts with `.` (`packages/.*`) match dot directories, so
  the candidate walk finds everything the matcher accepts. A new test
  asserts the two answers agree on every directory.
- hosted pnpm_trust keeps both heals: the governing locks (root plus member
  locks, never a Rush lock) and the Rush common/subspace locks, with the
  precedence legacy, then rush_only, then manual guidance when no governing
  v9 lock is touched or the config is opted out.
- in_process_redirect_pnpm.rs and CLI_CONTRACT.md keep both sides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- pnpm-workspace.yaml `packages:` reading (#1006): a null value (`~`,
  `null`, `Null`, `NULL`) reads as an absent key, and a value the line
  reader cannot follow (a multi-line flow list) falls back to a full YAML
  parse (`read_package_globs`). `lists_as_member` and the member-lock walk
  both use it, so `packages: ~` is root-only again and a multi-line flow
  list is read instead of failing closed.
- Member-lock walk (#492): a `packages:` glob that hits the 4096-directory
  walk cap is an error (MemberLocks::Unresolved), never a partial list;
  only directories holding package.json / package.yaml / package.json5
  are members, as pnpm's project finder decides.
- In-memory hosted engine (#492): path selection fetches the nearest
  ancestor pnpm-workspace.yaml of a pnpm root without one, and with the
  trust auto-config on a member root whose v9 lock that file lists is
  refused with redirect_pnpm_settings_elsewhere instead of getting a
  nested pnpm-workspace.yaml pnpm ignores.
- get (#853): one `hosted_pins` helper feeds both the fetch-phase gate
  and the scan preview's hosted_claimed_purls.
- #734: a re-scan of a project pinned to pnpm 9.0-10.4 that still carries
  the root-only scaffold an earlier run created warns to delete it.
- #902: a pnpm 11+ env lockfile document counts as pnpm >= 11 evidence
  for lockfileIncludeTarballUrl when no install record or pin exists.
- #853: the legacy pnpm 7/8 preview gap is named in CLI_CONTRACT and
  pinned by a test.
- Clippy: too_many_arguments allow on pnpm_trust, two test nits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The disk engine pins the member locks of a `sharedWorkspaceLockfile: false`
pnpm workspace from its root; the in-memory engine detected each member
lock as a root of its own and, as an interim, refused it with
redirect_pnpm_settings_elsewhere when the trust auto-config was on. It now
gives the disk result:

- roots::pnpm_workspace_members: a pnpm root with no pnpm-workspace.yaml
  of its own that the nearest ancestor pnpm-workspace.yaml lists
  (lists_as_member; unknown or unreadable content counts as listing it) is
  a workspace member. The session demotes its lock into the workspace
  root, which becomes a root even with no lock (pnpm 7); the member stays
  a root only with another lock marker, minus its pnpm lock, like a Cargo
  member. A member named in projectRoots without its workspace root warns
  pnpm_member_lock_ignored.
- read_candidate_files reads member locks for memory views too
  (member_locks already works over MemoryTree), so per-member pinning, the
  root-file trust config, the member branch-lock refusal and the stale
  member lock under a shared lock all follow the disk code path.
- Lock inventory reads the same member locks (inventory_pnpm_member_locks_in),
  beside the root lock or, with no root lock, in place of it, so their
  dependencies are discovered without an installed tree on disk and in
  memory alike.
- Path selection adds the workspace root above candidate members to
  `roots` and fetches nested member locks, branch locks and the manifests
  beside them.
- The interim refusal (refuse_governed_pnpm_members) is gone; CLI_CONTRACT
  and the napi typings say what the engine does now.

Parity tests (hosted_memory_parity.rs) pin per-member locks, the trust
config, the branch-lock refusal and a stale member lock under a shared lock
identically, straight and through path selection; each fails before this
change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review of 828c1bc: the in-memory engine demoted a member's pnpm-lock.yaml
into its workspace root on path evidence alone, so a lock could end up
pinned by no one.

- The engine now demotes a candidate member (roots::pnpm_member_candidates)
  only when the workspace root's own files confirm it reads that lock
  (utils::pnpm_workspace::root_accounts_for_member_lock): the root
  pnpm-workspace.yaml is readable, its `packages:` globs are modeled and
  list the member, the member has a package manifest, and the root either
  pins the lock (member_locks lists it) or installs from a root lock
  beside which it is stale. Any other candidate (unreadable file,
  unmodeled glob, no manifest, unresolved member list, shared default
  with no root lock) keeps its lock as a root of its own; the restored
  refuse_governed_pnpm_members refuses it under the trust auto-config,
  as before #492, instead of dropping it silently.
- socket.yml: a member is demoted only into a workspace root the path
  policy admits, so an includePaths that leaves the root out keeps the
  member a root (and pinned) instead of scanning it nowhere.
- --ecosystems: no pnpm candidates unless npm is allowed, in the engine
  and in path selection, so a cargo-only run gains no lockless root.
- projectRoots from path selection: the session drops a confirmed member
  named beside its workspace root (unless it holds another lock) and a
  lockless workspace root selection added that pins no member, so a run
  over selection's roots equals the detected run.
- Path selection fetches nested pnpm locks and branch locks only in
  directories holding a package manifest (pnpm never installs from any
  other); the remaining over-fetch is the documented Cargo tradeoff.

Rejected: a member ignored by socket.yml is still rewritten through its
workspace root's member locks. That is the disk run from the root's
behavior, and policy judges project roots, not the member locks a root
reads (as with Cargo member manifests).

Tests: the parity helper now asserts the same project roots and no
project error on the straight and the selection run. New tests cover a
member with another lock, unconfirmed members (unmodeled glob, no
manifest, shared with no root lock, unreadable root file; pinned with the
trust auto-config off, refused with it on), the ecosystem filter, a
socket.yml includePaths, nested workspaces and a workspace file listing
nothing; all fail on 828c1bc. The roots.rs unit test now checks the
per-directory scoping of has_other_root_marker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The pnpm/hosted and pnpm/rescan benches recorded a pnpm 9.15.0 install,
so with #734 the scan rightly skips the root-only pnpm-workspace.yaml
scaffold, and the bench's expected rewrittenFiles no longer matched.
Pinning pnpm 11 keeps the bench measuring the trustLockfile write.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…old warning (#902, #734)

#902: when the hosted pnpm rollback/remove restore has no evidence of the
pnpm that wrote the lock (no unpinned registry entry, no
node_modules/.modules.yaml record, no package.json packageManager pin), it
keeps pnpm 10's reading. Whenever that reading turns
lockfileIncludeTarballUrl on from `.npmrc` alone, which pnpm >= 11 ignores,
and the restore actually writes a derivable `tarball:` back because of it,
it now warns `upstream_pnpm_tarball_setting_guessed` (once per lock, naming
the entries, the `.npmrc` followed, and the remedies: set packageManager or
reinstall and re-run, or move the setting into pnpm-workspace.yaml).

#734: an unpinned project with no install record keeps the root-only
trust scaffold. Its warning now also says a package.json packageManager pin
naming pnpm 9.0-10.4 avoids the file, and CLI_CONTRACT.md states the
residual for both the hosted scaffold and the vendored override mirror.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…902, #734)

Review follow-ups on the upstream_pnpm_tarball_setting_guessed warning:

- The tier-3 guess now warns whenever pnpm 9 (.npmrc only) or pnpm >= 11
  (pnpm-workspace.yaml only), which also write a 9.0 lock, would read
  lockfileIncludeTarballUrl differently from pnpm 10's reading the
  restore follows. That adds the workspace-only and workspace-false /
  .npmrc-true cases, which changed the result without a warning.
- The remedy no longer says to move the setting into pnpm-workspace.yaml,
  which would turn it off on pnpm 9. It now says to pin the pnpm, or to
  give both files the same value. The detail names the setting it
  followed and the pnpm that disagrees. It says "no unpinned registry
  entry that shows the setting", since tier 3 is also reached with
  non-derivable entries. Its present tense fits --dry-run, and the
  restore-from-version-control step is scoped to rollback/remove.
- Rush locks (common/config/rush or a subspace, with rush.json at the
  Rush root) take the pnpm major from rush.json `pnpmVersion`. Rush
  installs in common/temp, so the install record and package.json pin
  never sit beside the lock, and every Rush lock with the .npmrc setting
  used to warn. The remaining guess names the rush.json remedy.
- A test now covers the promise that a non-derivable URL never warns.
  It goes red if `&& derived` is dropped.
- #734 scaffold warning: the packageManager pin remedy now says every
  other pnpm pin must name 9.0-10.4 too.

CLI_CONTRACT.md is updated to match.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko Mikola Lysenko (mikolalysenko) changed the title Fix open pnpm issues (#1006, #919, #902, #854, #853, #830, #778, #734, #714, #713, #633, #556, #492, #466, #435) Fix 15 open pnpm issues across hosted, vendored and agent modes Oct 7, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent-mode apply in a pnpm workspace reports each member-linked package twice, inflating the --json skipped count with duplicate already_patched events Hosted scan with pnpm gitBranchLockfile pins the stale pnpm-lock.yaml and reports success, while pnpm installs unpatched bytes from pnpm-lock.<branch>.yaml Hosted scan on a pnpm workspace with sharedWorkspaceLockfile: false ignores the per-package pnpm-lock.yaml files and reports success while redirecting nothing Vendored pnpm 12 with packageManager set: the two-document pnpm-lock.yaml makes vendor refuse, and vendor --revert, rollback and the hosted takeover half-revert the project and break frozen installs Global agent mode on pnpm 12 (and 11 without the global virtual store) patches only one of the per-install copies of a package, reports success, and VEX attests not_affected

1 participant