From b61b9d98657779aa5a4eb77e0964dfea389f1f9b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:25:59 +0000 Subject: [PATCH 1/5] Start fix for #1074 Assisted-by: Claude Code:claude-opus-5-5 From 94d7fbb667b684ae520a7244d244bdcd0f9d6f7f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:35:58 +0000 Subject: [PATCH 2/5] Refuse hosted pnpm pins when lockfile is off With pnpm's `lockfile` setting turned off (`lockfile: false` in pnpm-workspace.yaml for pnpm 11+, `lockfile=false` in .npmrc for pnpm 9/10), pnpm never reads pnpm-lock.yaml and installs the upstream package from the registry. Hosted scans still pinned the committed lock, added `trustLockfile: true` and reported success for a patch no install applies. Hosted mode now leaves every pnpm lock untouched for such a project and warns `redirect_pnpm_lockfile_disabled`, pointing at `--mode vendored` (pnpm applies its overrides without a lock). The setting is read the way the gitBranchLockfile refusal already reads its own (project files, governing workspace, environment), through a shared lookup. Refs #1074 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/hosted/engine.rs | 138 +++++++++++++++++- .../src/utils/pnpm_workspace.rs | 67 +++++++-- 2 files changed, 189 insertions(+), 16 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 88a7fdecd..0b9f98009 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -718,7 +718,9 @@ pub async fn read_candidate_files( } } } else if candidates.iter().any(|c| c.dep.ecosystem == "npm") { - if let Some(branch) = crate::utils::pnpm_workspace::git_branch_locks(view).await { + if let Some(source) = crate::utils::pnpm_workspace::lockfile_disabled(view).await { + refuse_disabled_lockfile(&mut out, &source); + } else if let Some(branch) = crate::utils::pnpm_workspace::git_branch_locks(view).await { refuse_git_branch_locks(&mut out, &branch); } else { read_pnpm_member_locks(view, unreadable, &mut out).await; @@ -851,6 +853,40 @@ fn refuse_git_branch_locks( out.pnpm_refusal = Some(git_branch_lock_refusal(branch)); } +/// pnpm's `lockfile` setting is off (#1074): pnpm neither reads nor +/// writes its lock, so a pin there never installs. Every root pnpm lock is +/// left out (member locks are never read) and the run refuses every pnpm +/// pin (see [`CandidateFiles::pnpm_refusal`]), as for a branch lock. +fn refuse_disabled_lockfile(out: &mut CandidateFiles, source: &str) { + const ROOT_LOCKS: [&str; 2] = ["pnpm-lock.yaml", "shrinkwrap.yaml"]; + for lock in ROOT_LOCKS { + out.files.remove(lock); + } + out.symlinked_reads + .retain(|r| !ROOT_LOCKS.contains(&r.as_str())); + out.unreadable_reads + .retain(|r| !ROOT_LOCKS.contains(&r.as_str())); + out.pnpm_refusal = Some(disabled_lockfile_refusal(source)); +} + +/// The [`PNPM_LOCKFILE_DISABLED`] warning for a `lockfile` setting turned +/// off at `source` (see [`crate::utils::pnpm_workspace::lockfile_disabled`]). +pub fn disabled_lockfile_refusal(source: &str) -> RewriteWarning { + RewriteWarning { + code: PNPM_LOCKFILE_DISABLED.into(), + detail: format!( + "{}; no pnpm lock was rewritten — use `--mode vendored` (pnpm applies its \ + `overrides` without a lock), or remove the setting, run `pnpm install` and \ + re-run", + crate::utils::pnpm_workspace::describe_lockfile_disabled(source) + ), + } +} + +/// Refusal code for a project whose pnpm `lockfile` setting is off, so +/// pnpm never reads the lock a hosted pin goes into (#1074). +pub const PNPM_LOCKFILE_DISABLED: &str = "redirect_pnpm_lockfile_disabled"; + /// The [`PNPM_GIT_BRANCH_LOCKFILE`] warning for `branch`: the hosted /// rewrite's, and the vendored→hosted takeover's (which keeps the vendored /// wiring rather than revert it into a lock pnpm does not install from). @@ -5536,6 +5572,106 @@ snapshots: assert!(!done.rewrite.files.contains_key(BRANCH_LOCK)); } } + + fn assert_disabled_lockfile_refused(read: &CandidateFiles, done: &Rewritten, source: &str) { + assert!(!read.files.contains_key("pnpm-lock.yaml")); + // Neither the lock nor a `trustLockfile` key is written. + assert!(done.rewrite.files.is_empty(), "{:?}", done.rewrite.files); + assert!(done.confirmed.is_empty(), "{:?}", done.confirmed); + let codes = warning_codes(done); + assert!(codes.contains(&PNPM_LOCKFILE_DISABLED), "{codes:?}"); + assert!( + !codes.contains(&"redirect_pnpm_no_lockfile") + && !codes.contains(&"redirect_npm_no_lockfile") + && !codes.contains(&"redirect_pnpm_trust_lockfile"), + "{codes:?}" + ); + let detail = &done + .rewrite + .warnings + .iter() + .find(|w| w.code == PNPM_LOCKFILE_DISABLED) + .unwrap() + .detail; + assert!( + detail.contains(source) && detail.contains("--mode vendored"), + "{detail}" + ); + } + + /// pnpm's `lockfile` setting off (#1074): pnpm never reads the + /// committed lock and installs the upstream package, so pinning it + /// would confirm (and VEX attest) a patch no install applies. pnpm 11+ + /// read the setting from `pnpm-workspace.yaml`, pnpm 9/10 from + /// `.npmrc`. + #[tokio::test] + async fn a_disabled_lockfile_is_never_pinned() { + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), "lockfile: false\n", true); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + let (read, done) = member_rewrite(tmp.path()).await; + assert_disabled_lockfile_refused(&read, &done, "`lockfile: false` in pnpm-workspace.yaml"); + + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), "packages:\n - '.'\n", true); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + std::fs::remove_file(tmp.path().join("pnpm-workspace.yaml")).unwrap(); + write_rel(tmp.path(), ".npmrc", "lockfile=false\n"); + let (read, done) = member_rewrite(tmp.path()).await; + assert_disabled_lockfile_refused(&read, &done, "`lockfile=false` in .npmrc"); + + // The in-memory engine refuses the same project. + let mut project = MemoryProject::new(); + project.insert_text("package.json", r#"{"name":"app"}"#); + project.insert_text("pnpm-workspace.yaml", "lockfile: false\n"); + project.insert_text("pnpm-lock.yaml", v9_member_lock(true)); + let (read, done) = view_rewrite(&ProjectView::Memory(&project)).await; + assert_disabled_lockfile_refused(&read, &done, "`lockfile: false` in pnpm-workspace.yaml"); + } + + /// With the lock off, pnpm reads no member's own lock either + /// (`sharedWorkspaceLockfile: false`, #492): none is pinned. + #[tokio::test] + async fn a_disabled_lockfile_pins_no_member_lock() { + let tmp = tempfile::tempdir().unwrap(); + write_member_workspace( + tmp.path(), + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\nlockfile: false\n", + ); + let (read, done) = member_rewrite(tmp.path()).await; + assert!(read.pnpm_member_lock_keys.is_empty()); + assert!(!read.files.keys().any(|k| k.ends_with("pnpm-lock.yaml"))); + assert_disabled_lockfile_refused(&read, &done, "`lockfile: false` in pnpm-workspace.yaml"); + } + + /// The setting on (or unset) keeps the lock pinned as usual, and a + /// YAML value pnpm's reader takes for no boolean leaves the default. + #[tokio::test] + async fn an_enabled_lockfile_is_pinned() { + for (workspace, npmrc) in [ + ("lockfile: true\n", None), + ("packages:\n - '.'\n", Some("lockfile=true\n")), + ("lockfile: maybe\n", None), + // The YAML wins over `.npmrc` (see `pnpm_setting`). + ("lockfile: true\n", Some("lockfile=false\n")), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), workspace, true); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + if let Some(npmrc) = npmrc { + write_rel(tmp.path(), ".npmrc", npmrc); + } + let (_, done) = member_rewrite(tmp.path()).await; + let text = done + .rewrite + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("{workspace}: {:?}", done.rewrite.warnings)); + assert!(text.contains(MEMBER_URL), "{text}"); + assert_eq!(done.confirmed.len(), 1, "{workspace}"); + assert!(!warning_codes(&done).contains(&PNPM_LOCKFILE_DISABLED)); + } + } } /// The one-pass multi-needle confirmation probe answers exactly what the diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 2eb220410..58934c399 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -543,32 +543,69 @@ pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option if locks.is_empty() && members_unlisted.is_none() { return None; } + let setting = + git_branch_on(project_setting(view, "gitBranchLockfile", "git-branch-lockfile").await)?; + Some(GitBranchLocks { + setting, + locks, + members_unlisted, + }) +} + +/// A pnpm setting as the project at `view` sees it: its own +/// `pnpm-workspace.yaml` / `.npmrc` (read through the view's FIFO-safe +/// reader, see [`pnpm_setting`]); on disk, for a project with no +/// `pnpm-workspace.yaml` of its own (a workspace member), the governing +/// ancestor's file and the `.npmrc` beside it ([`governing_workspace_file`]); +/// then, on disk, the `pnpm_config_` / `npm_config_` environment +/// spellings. +async fn project_setting( + view: &ProjectView<'_>, + yaml_key: &str, + npmrc_key: &str, +) -> Option { let workspace = view.read_text(PNPM_WORKSPACE).await.ok(); let npmrc = view.read_text(".npmrc").await.ok(); - const KEYS: (&str, &str) = ("gitBranchLockfile", "git-branch-lockfile"); - let mut setting = pnpm_setting(workspace.as_deref(), npmrc.as_deref(), KEYS.0, KEYS.1); + let mut setting = pnpm_setting(workspace.as_deref(), npmrc.as_deref(), yaml_key, npmrc_key); if setting.is_none() { if let Some(file) = governing_file(view) { let workspace = read_regular_to_string(&file).await.ok(); let npmrc = read_regular_to_string(&file.with_file_name(".npmrc")) .await .ok(); - setting = - pnpm_setting(workspace.as_deref(), npmrc.as_deref(), KEYS.0, KEYS.1).map(|found| { - PnpmSetting { - source: format!("{} (at {})", found.source, file.display()), - ..found - } + setting = pnpm_setting(workspace.as_deref(), npmrc.as_deref(), yaml_key, npmrc_key) + .map(|found| PnpmSetting { + source: format!("{} (at {})", found.source, file.display()), + ..found }); } } - let setting = - git_branch_on(setting.or_else(|| view_setting(view, None, None, KEYS.0, KEYS.1)))?; - Some(GitBranchLocks { - setting, - locks, - members_unlisted, - }) + setting.or_else(|| view_setting(view, None, None, yaml_key, npmrc_key)) +} + +/// Where pnpm's `lockfile` setting is turned off (#1074), as a refusal +/// names it (e.g. `` `lockfile: false` in pnpm-workspace.yaml ``); `None` +/// when it is on (the default). pnpm 11 and newer read the setting from +/// `pnpm-workspace.yaml`, pnpm 10 and older from `.npmrc` (`lockfile=false`), +/// both read through [`project_setting`]. With it off pnpm neither reads +/// nor writes `pnpm-lock.yaml` (or a member's own lock): a plain +/// `pnpm install` re-resolves every dependency from the registry, so a +/// pin in a committed lock is never what installs. A value the setting's +/// reader does not take as a boolean leaves the default. +pub async fn lockfile_disabled(view: &ProjectView<'_>) -> Option { + project_setting(view, "lockfile", "lockfile") + .await + .filter(|setting| setting.as_bool() == Some(false)) + .map(|setting| setting.source) +} + +/// What pnpm does with its `lockfile` setting off, for the refusals and +/// diagnostics that name it ([`lockfile_disabled`]'s `source`). +pub fn describe_lockfile_disabled(source: &str) -> String { + format!( + "{source} makes pnpm neither read nor write {PNPM_LOCK}, so `pnpm install` \ + resolves every dependency from the registry whatever the lock pins" + ) } /// The `pnpm-lock..yaml` files in root-relative `dir` (`""` for From f3679782fc85c5de5711c210268a7772f2136b13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:52:26 +0000 Subject: [PATCH 3/5] Withhold lock-only VEX when pnpm lockfile is off With pnpm's `lockfile` setting off, pnpm never reads pnpm-lock.yaml, yet lock-only `vex` (no node_modules: the usual CI/SBOM run) attested a hosted pin there as not_affected while pnpm installed the upstream package. The hosted references of the root and member pnpm locks now keep no lockfile basis in that case, so only an installed tree whose copies verify can attest them, and one patched_ref_unattributable per lock names them. Vendored references are unchanged: pnpm applies the overrides that wire them without a lock. Rush locks are not affected. Adds a real-pnpm 11 e2e leg covering both the hosted refusal and the lock-only VEX, and documents the setting in the CLI contract. Fixes #1074 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../tests/e2e_redirect_pnpm_build.rs | 89 +++++++++++++++ .../socket-patch-core/src/vex/discover/npm.rs | 104 ++++++++++++++++-- docs/ecosystems.md | 6 + 4 files changed, 195 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index d5f8cecf0..16732a5f2 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -165,6 +165,8 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **pnpm per-member locks (#492)**: a workspace whose root `pnpm-workspace.yaml` sets `sharedWorkspaceLockfile: false` (or, when the YAML leaves the key unset, whose root `.npmrc` sets `shared-workspace-lockfile=false`; on disk, when neither file sets it, `pnpm_config_shared_workspace_lockfile` / `npm_config_shared_workspace_lockfile`; booleans in any letter case, as pnpm's YAML reader takes them) installs each member from the member's own `pnpm-lock.yaml`. Run from the workspace root, hosted mode reads every member lock the `packages:` globs name (quoted `!` negations applied; `node_modules`, `bower_components`, `test` and `tests` trees and symlinked directories skipped) beside the root lock, which may be absent (pnpm 7): each is rewritten like the root lock, a 9.0 member lock engages the `trustLockfile: true` auto-config in the ROOT `pnpm-workspace.yaml` (heal-on-rerun included), and discovery (`list`, `vex`, `rollback`, `remove`) reads the same member locks. A root lock whose `importers:` lists a member is a shared lock, so member locks beside it are stale and never read or attested. When the members' locks cannot be listed or read (no `packages:` key, whose pnpm <= 8 default finds projects in every directory; a `packages:` value that is not a plain list of globs; no member lock and no root lock, or a member lock that exists but is not readable text), every pnpm lock (the root's included) is left untouched and the run warns `redirect_pnpm_member_locks_unresolved` instead of `redirect_pnpm_no_lockfile`; no pnpm pin is confirmed. The lockfile inventory reads the same member locks, so their dependencies are discovered without an installed tree. The in-memory engine gives the same result as a disk run from the workspace root: a directory whose `pnpm-lock.yaml` has no `pnpm-workspace.yaml` beside it is a workspace member, never a root of its own, when (with `npm` among the `ecosystems`) the nearest ancestor `pnpm-workspace.yaml` is readable and its `packages:` globs (read as above, no braces, classes or extglobs) list it, it holds a `package.json` / `package.yaml` / `package.json5`, the workspace root installs it from its own lock (`sharedWorkspaceLockfile: false` with the member list resolved) or from a root lock beside which its lock is a stale leftover, and socket.yml admits the workspace root. Its lock is then demoted into that workspace root, which becomes a root even with no lock of its own (pnpm 7), the way a Cargo member's lock is demoted; the member stays a root only when it holds another lock marker, without its pnpm lock. The workspace root then pins (or refuses) its member locks as above, trusts them in its own file, refuses a member's branch lock (#556) and leaves a member lock beside a shared root lock unread. A directory that fails any of those tests keeps its lock as a root of its own (pinned like any root, or refused with `redirect_pnpm_settings_elsewhere` while the trust auto-config is on and the ancestor file may list it). Path selection adds the workspace root above every candidate to `roots`, fetches it, and fetches the member locks, branch locks and manifests below it in every directory holding a package manifest; given those `roots` as `projectRoots`, the session drops a confirmed member named beside its workspace root (unless it holds another lock) and a lockless workspace root that pins no member, so it runs the roots it would detect. A confirmed member named in `projectRoots` without its workspace root is not pinned: its lock is dropped and the run-level `pnpm_member_lock_ignored` warning names the workspace root to scan. +**pnpm with its lockfile off (#1074)**: with pnpm's `lockfile` setting off (a top-level `lockfile: false` in the root `pnpm-workspace.yaml`, as pnpm 11 and newer read it; when the YAML leaves the key unset, `lockfile=false` in the root `.npmrc`, as pnpm 10 and older read it; on disk also `npm_config_lockfile` / `pnpm_config_lockfile` set to `false`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm neither reads nor writes `pnpm-lock.yaml` (nor a member's own lock) and resolves every dependency from the registry, so a hosted pin in a committed lock never installs. Hosted mode leaves every pnpm lock and `pnpm-workspace.yaml` untouched (no `trustLockfile` key is added), confirms no npm pin from them and warns `redirect_pnpm_lockfile_disabled` (in place of `redirect_pnpm_no_lockfile`); the detail names the setting, and the remedy is `--mode vendored` (pnpm applies the `overrides` it wires without a lock) or removing the setting, running `pnpm install` and re-running. The check comes before the per-branch lock check below. Manifest-less `vex` keeps the hosted references of such a lock but withholds their lockfile basis (see the pnpm row of the discovery table). Rush locks are not affected: Rush runs pnpm with its own settings. Vendored mode is unchanged. + **pnpm per-branch locks (#556)**: with `gitBranchLockfile` on (a top-level `gitBranchLockfile: true` in the root `pnpm-workspace.yaml`; when the YAML leaves the key unset, `git-branch-lockfile=true` in the root `.npmrc`, as pnpm 10 and older read it; on disk also `npm_config_git_branch_lockfile` / `pnpm_config_git_branch_lockfile` set to `true`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm installs a git branch from its own `pnpm-lock..yaml` whenever that lock exists, in every directory it installs a lock from: the root, and under `sharedWorkspaceLockfile: false` each member's (#492). When the setting is on AND at least one such `pnpm-lock..yaml` exists (at the root, or in a member that installs from its own lock), hosted mode leaves every root pnpm lock (`pnpm-lock.yaml`, `shrinkwrap.yaml`) and `pnpm-workspace.yaml` untouched (every member lock too), confirms no npm pin from them and warns `redirect_pnpm_git_branch_lockfile` (in place of `redirect_pnpm_no_lockfile` when the branch lock is the only one); the detail names the setting and the branch lock(s), and the remedy is to turn `gitBranchLockfile` off and fold the branch locks into `pnpm-lock.yaml` with `pnpm install --merge-git-branch-lockfiles`, then re-run. A vendored→hosted takeover of a pnpm-vendored purl is refused with the same code (the vendored wiring and ledger stay byte-for-byte). Vendored mode refuses the same projects with `vendor_pnpm_git_branch_lockfile`, before any write and in the pre-flight. The setting with no branch lock (main, or a branch whose dependencies never changed) installs from `pnpm-lock.yaml`, which both modes wire as usual; a stray branch lock with the setting off is ignored. The in-memory engine fetches root `pnpm-lock.*.yaml` names presence-only and refuses the same way. `redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers. @@ -417,7 +419,7 @@ Discovery is read-only, never touches the network, and never fails the run: a ma | Ecosystem | Files read | Hosted reference | Vendored reference | Hosted pin (`integrity_required`) | |---|---|---|---|---| | npm | `package-lock.json` and `npm-shrinkwrap.json` (both when both exist) | `resolved` on the patch host (`packages` in v2/v3; `dependencies` only in v1; `link` / `inBundle` / `bundled` entries skipped, and so is any entry npm installs from a git, URL or `file:` spec or from a dependency's own `npm-shrinkwrap.json` (beneath a `hasShrinkwrap: true` package, which npm 7–11 install from that shrinkwrap; `redirect_npm_shrinkwrapped_instance_skipped` / `vendor_shrinkwrapped_instance_skipped`), together with every ref for the same `name@version`) | `resolved: file:.socket/vendor/npm//-.tgz` | `integrity`, required | -| pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required | +| pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required. With pnpm's `lockfile` setting off (#1074, outside Rush) pnpm reads no lock: every hosted reference in the root and member locks keeps no pin, and one `patched_ref_unattributable` per lock names them; vendored references are unaffected. | | yarn | `yarn.lock` (classic and berry) | classic `resolved`; berry entry keyed and resolved `@` + root `package.json` `resolutions` `"@npm:": ""` (older releases: `resolution: …::__archiveUrl=`) | classic `resolved "file:./.socket/vendor/npm/…#"`; berry `file:` entry **plus** a root `package.json` `resolutions` mapping onto the same artifact (without it the entry is orphaned: diagnosed, no ref) | classic `integrity` / `#sha1`, berry `checksum`, required | | bun | `bun.lock`; `bun.lockb` only when there is no `bun.lock` (bun reads exactly one) | URL tuple / binary remote-tarball resolution; version from the URL leaf. A user URL or `file:` tarball tuple / record whose `-.tgz` leaf names the same `name@version` is installed from its own spec, so every ref for that `name@version` is dropped | `.socket/vendor/npm//-.tgz` tuple / local-tarball resolution | `sha512-…`, required. A 2-tuple that Bun < 1.3.10 re-saved without its digest is still a reference, but it attests only from an installed tree. | | vlt | `vlt-lock.json` (lockfileVersion absent, `0` or `1`; a BOM-prefixed, unparseable, non-object or other-version lock is not read: diagnosed, no ref). `vlt.json` (read only for its `modifiers`) and `node_modules/.vlt-lock.json` are never wiring. | a registry node (any segment) whose slot [3] is a `/patch/npm/…` URL on the patch host with the leaf `-.tgz` of its DepID's `name@version`, whose embedded `/` path (when present) is that `name@version`, and slot [1] == name; version from the DepID | a `file` node `.socket/vendor/npm//-/node_modules/` (or a user-installed `-.tgz`) with slot [1] == name; version from the path. A same-`name@version` registry node, or a diagnosed Socket-shaped one, beside it is diagnosed, no ref. | slot [2] `sha512-…`, required (a hosted node without one is no reference). A same-`name@version` node on another registry, or a diagnosed Socket-shaped one, keeps the reference but withholds the lockfile basis: only an installed tree whose every store copy verifies attests. So does a lock some vlt release discards, the conditions of `redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored` and `redirect_vlt_scalar_registry_ignored` (which in-run `--vex` withholds too): every hosted reference in it keeps no pin, and one `patched_ref_unattributable` names them. | diff --git a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs index c6cd5f28a..83d70e82f 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs @@ -1205,6 +1205,95 @@ async fn pnpm11_zero_touch_frozen_install_lands_patched_bytes_via_auto_trust_con assert_fixture_manifestless_vex(&fx, &fresh, "pnpm11 zero-touch"); } +/// #1074: with pnpm's `lockfile` setting off, pnpm neither reads nor +/// writes `pnpm-lock.yaml` and installs the upstream package from the +/// registry, so a hosted pin in the committed lock patches nothing. Over a +/// lock the real pnpm 11 scan pinned, once `lockfile: false` lands in +/// `pnpm-workspace.yaml`: +/// +/// * a re-run of `scan --mode hosted` refuses with +/// `redirect_pnpm_lockfile_disabled` and leaves the lock and the +/// workspace file byte-for-byte; +/// * lock-only `vex` (no node_modules, no manifest, no ledgers: the CI/SBOM +/// case) no longer attests the patch from the lock pin. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm11_disabled_lockfile_is_neither_pinned_nor_attested() { + let Some(fx) = redirect_scanned_pnpm_project( + PNPM_TERTIARY, + "pnpm11-lockfile-off", + false, + false, + HostedDriver::Scan, + ) + .await + else { + return; + }; + let ws_path = fx.proj.join("pnpm-workspace.yaml"); + let mut workspace = std::fs::read_to_string(&ws_path).unwrap_or_default(); + if !workspace.is_empty() && !workspace.ends_with('\n') { + workspace.push('\n'); + } + workspace.push_str("lockfile: false\n"); + std::fs::write(&ws_path, &workspace).unwrap(); + let lock_path = fx.proj.join(&fx.lock_name); + let lock_wired = std::fs::read_to_string(&lock_path).unwrap(); + assert_ne!( + lock_wired, fx.lock_before, + "the fixture scan pinned the lock" + ); + + let (code, stdout, stderr) = run_hosted(HostedDriver::Scan, &fx.proj, &fx._server.uri(), &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!(env["summary"]["applied"], 0, "{env}"); + let codes = warning_codes(&env); + assert!( + codes.contains(&"redirect_pnpm_lockfile_disabled".to_string()) + && !codes.contains(&"redirect_pnpm_trust_lockfile".to_string()), + "{codes:?}\n{env}" + ); + assert_eq!(std::fs::read_to_string(&lock_path).unwrap(), lock_wired); + assert_eq!(std::fs::read_to_string(&ws_path).unwrap(), workspace); + + // A lock-only checkout: package.json, the pinned lock and the workspace + // file pnpm reads the setting from, nothing installed. + let fresh = fx.tmp.path().join("fresh-lockfile-off"); + std::fs::create_dir_all(&fresh).unwrap(); + for file in ["package.json", fx.lock_name.as_str(), "pnpm-workspace.yaml"] { + std::fs::copy(fx.proj.join(file), fresh.join(file)).unwrap(); + } + let patch_server = fx._server.uri(); + off_runtime(|| { + let api = hosted_patch_api(PURL, &fx.patched); + let out = run_vex( + &socket_bin(), + &fresh, + &VexRun { + patch_server_url: Some(patch_server.clone()), + ..VexRun::online(&api) + }, + ); + assert_ne!(out.code, Some(0), "lock-only vex must not attest: {out}"); + assert_absent(out.doc.as_ref(), PURL); + + // Control: the same checkout with the setting removed attests. + std::fs::remove_file(fresh.join("pnpm-workspace.yaml")).unwrap(); + let out = run_vex( + &socket_bin(), + &fresh, + &VexRun { + patch_server_url: Some(patch_server.clone()), + ..VexRun::online(&api) + }, + ); + assert_eq!(out.code, Some(0), "control: {out}"); + assert_attested(out.doc(), PURL, UUID, Marker::Redirected, HOSTED_VULNS); + }); +} + /// `--no-trust-lockfile-config` control: pins the opt-out (the scan writes /// no pnpm-workspace.yaml — asserted inside the fixture helper) AND the old /// behavior it restores. Without the trust config the PLAIN frozen install diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index f044bb889..f8de92eec 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -673,21 +673,31 @@ fn entry_ref( /// a file with neither `lockfileVersion:` nor `shrinkwrapVersion:` is not a /// pnpm lock ([`DIAG_LOCKFILE_UNPARSEABLE`]). async fn extract_pnpm(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { + // pnpm's `lockfile` setting off (#1074): pnpm reads neither the root + // lock nor a member's, so their hosted pins keep no lockfile basis. + // Rush runs pnpm with a workspace and `.npmrc` of its own. + let rush = ctx.exists("rush.json").await; + let disabled = if rush { + None + } else { + crate::utils::pnpm_workspace::lockfile_disabled(&ctx.view).await + }; + let disabled = disabled.as_deref(); let root_lock = ctx.exists(PNPM_LOCK).await; if root_lock { - extract_pnpm_lock(ctx, PNPM_LOCK, out).await; + extract_pnpm_lock(ctx, PNPM_LOCK, disabled, out).await; // pnpm never reads the debris shrinkwrap: the patches it still names // are recognized as NOT wired (module docs, rule 11), so a ledger // record its stale Socket url alone keeps "mentioned" is dead. ctx.recognize_ignored(PNPM_SHRINKWRAP_LEGACY).await; } else { - extract_pnpm_lock(ctx, PNPM_SHRINKWRAP_LEGACY, out).await; + extract_pnpm_lock(ctx, PNPM_SHRINKWRAP_LEGACY, disabled, out).await; } - if ctx.exists("rush.json").await { + if rush { // The common lock, then each subspace's, sorted for deterministic // diagnostics (stat / list only — the reads below stay on the ctx). for rel in rush_lock_rels(ctx.view).await { - extract_pnpm_lock(ctx, &rel, out).await; + extract_pnpm_lock(ctx, &rel, None, out).await; } } else if let crate::utils::pnpm_workspace::MemberLocks::PerMember(rels) = crate::utils::pnpm_workspace::member_locks(&ctx.view).await @@ -698,13 +708,22 @@ async fn extract_pnpm(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { // member importers) is a stale leftover pnpm never reads, and is // not among `rels`. for rel in rels { - extract_pnpm_lock(ctx, &rel, out).await; + extract_pnpm_lock(ctx, &rel, disabled, out).await; } } } /// One pnpm lock at root-relative `file` (see [`extract_pnpm`]). -async fn extract_pnpm_lock(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discovery) { +/// `disabled` names where pnpm's `lockfile` setting is turned off: its +/// hosted refs then keep no lock pin, and one [`DIAG_REF_UNATTRIBUTABLE`] +/// names them. Vendored refs are untouched: pnpm applies the `overrides` +/// that wire them without a lock. +async fn extract_pnpm_lock( + ctx: &DiscoverCtx<'_>, + file: &str, + disabled: Option<&str>, + out: &mut Discovery, +) { let Some(text) = ctx.read_text(file, out).await else { return; }; @@ -719,12 +738,27 @@ async fn extract_pnpm_lock(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discover } let mut copies: Vec = Vec::new(); let mut bundles: Vec<(&str, Bundled<'_>)> = Vec::new(); + let mut withheld: BTreeSet = BTreeSet::new(); for package in lock.packages() { - pnpm_entry_ref(ctx, file, package, &mut copies, out); + let withhold = disabled.map(|_| &mut withheld); + pnpm_entry_ref(ctx, file, package, &mut copies, withhold, out); bundles.extend(entry_bundled(&package.entry).map(|b| (package.key, b))); } record_pnpm_file_copies(ctx, file, copies, out).await; record_pnpm_bundled_copies(file, bundles, out); + if let (Some(source), false) = (disabled, withheld.is_empty()) { + out.diag( + DIAG_REF_UNATTRIBUTABLE, + file, + format!( + "{file}: {}, so its hosted pins of {} are not attested from the lock alone, \ + only from an installed tree whose every copy verifies; \ + `socket-patch scan --mode vendored` wires them without a lock", + crate::utils::pnpm_workspace::describe_lockfile_disabled(source), + withheld.into_iter().collect::>().join(", ") + ), + ); + } } /// Record the bundled copies a pnpm lock installs ([`UnwiredCopy`]). @@ -887,6 +921,7 @@ fn pnpm_entry_ref( file: &str, package: &PnpmPackage<'_>, copies: &mut Vec, + withheld: Option<&mut BTreeSet>, out: &mut Discovery, ) { let key = package.key; @@ -938,6 +973,14 @@ fn pnpm_entry_ref( ); return; }; + // A lock pnpm does not read pins nothing (see `extract_pnpm_lock`). + let integrity = match withheld { + Some(withheld) => { + withheld.insert(purl.clone()); + None + } + None => integrity, + }; out.push(PatchedRef::hosted( purl, uuid, @@ -3066,6 +3109,53 @@ mod tests { ); } + /// pnpm's `lockfile` setting off (#1074): pnpm neither reads nor writes + /// its lock and installs every dependency from the registry, so a + /// hosted pin in a committed lock keeps no lockfile basis (only an + /// installed tree attests it), and one diagnostic names the pins. Both + /// spellings count (pnpm 11+ `pnpm-workspace.yaml`, pnpm 9/10 `.npmrc`), + /// and so do the member locks of an unshared workspace. + #[tokio::test] + async fn pnpm_disabled_lockfile_withholds_the_lockfile_basis() { + let lock = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n ms:\n specifier: 1.3.0\n version: 1.3.0\n\npackages:\n\n ms@1.3.0:\n resolution: {integrity: sha512-UP==}\n"; + let wired = hosted_rewrite("pnpm-lock.yaml", lock, &[pnpm_override("ms", UUID_B, None)]); + let withheld = |out: &Discovery, source: &str| { + assert_refs(out, &[("pkg:npm/ms@1.3.0", UUID_B, WiringMode::Hosted)]); + assert!(!out.refs[0].lockfile_basis_ok()); + let diag = out + .diagnostics + .iter() + .find(|d| d.code == DIAG_REF_UNATTRIBUTABLE) + .unwrap_or_else(|| panic!("{:#?}", out.diagnostics)); + assert!( + diag.detail.contains("pkg:npm/ms@1.3.0") && diag.detail.contains(source), + "{}", + diag.detail + ); + }; + + let p = Project::new(); + p.write("pnpm-lock.yaml", &wired); + let out = run(&p).await; + assert!(out.refs[0].lockfile_basis_ok(), "control: the lock is read"); + p.write("pnpm-workspace.yaml", "lockfile: false\n"); + withheld(&run(&p).await, "`lockfile: false` in pnpm-workspace.yaml"); + + let p = Project::new(); + p.write("pnpm-lock.yaml", &wired); + p.write(".npmrc", "lockfile=false\n"); + withheld(&run(&p).await, "`lockfile=false` in .npmrc"); + + let p = Project::new(); + p.write( + "pnpm-workspace.yaml", + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\nlockfile: false\n", + ); + p.write("packages/a/package.json", "{}"); + p.write("packages/a/pnpm-lock.yaml", &wired); + withheld(&run(&p).await, "`lockfile: false` in pnpm-workspace.yaml"); + } + /// `shrinkwrap.yaml` (pnpm <= 2) is read only when there is no /// `pnpm-lock.yaml`: beside one it is migration debris, and a stale /// Socket url left in it must not attest a reverted project. diff --git a/docs/ecosystems.md b/docs/ecosystems.md index b5a82cb3e..443c5d099 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -178,6 +178,12 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: `vendor_pnpm_git_branch_lockfile`. Turn the setting off and run `pnpm install --merge-git-branch-lockfiles`, then re-run. With no branch lock, `pnpm-lock.yaml` is the lock pnpm installs from and is pinned as usual. + With the `lockfile` setting off (`lockfile: false` in `pnpm-workspace.yaml` + on pnpm 11 and newer, `lockfile=false` in `.npmrc` on pnpm 10 and older), + pnpm never reads `pnpm-lock.yaml` and installs from the registry, so hosted + mode refuses the pnpm pins with `redirect_pnpm_lockfile_disabled` and + lock-only `vex` attests no hosted pin from that lock. Use `--mode vendored`, + which pnpm applies through `overrides` without a lock. For 9.0 root or member locks, the CLI configures `trustLockfile: true` in the root `pnpm-workspace.yaml` unless opted out with `--no-trust-lockfile-config` or explicitly disabled by the project. pnpm >=11 needs this for hosted URLs. From e7e7bb2786cdfd4c73fa1aa47402ac3eb7df4e01 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:07:07 +0000 Subject: [PATCH 4/5] Read pnpm lockfile setting per pnpm major Review follow-ups for the #1074 refusal: - Read `lockfile` from the file the project's pnpm major reads (YAML on pnpm 11+, .npmrc on pnpm 10 and older), from the install record, the packageManager pin or the lock grammar; with no major known, either file turning it off counts. A pnpm 9 project with a stray YAML `lockfile: true` and `.npmrc` `lockfile=false` is now refused. - Read only the project's own files through the view, so the re-scan read cache is no longer disabled by an ancestor walk on every run, and keep the environment out of `vex`. - Refuse only in a pnpm project, so an npm project's `.npmrc` keeps npm's hints, and name `--mode vendored` only when a pnpm lock exists for it to wire. - Share the root-lock withholding with the gitBranchLockfile refusal. Refs #1074 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-core/src/hosted/engine.rs | 182 ++++++++++++++---- .../src/patch/redirect/upstream/mod.rs | 2 +- .../src/patch/redirect/upstream/npm.rs | 6 +- .../src/utils/pnpm_workspace.rs | 122 ++++++++---- .../socket-patch-core/src/vex/discover/npm.rs | 2 +- 6 files changed, 236 insertions(+), 80 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 16732a5f2..4a7c31c63 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -165,7 +165,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **pnpm per-member locks (#492)**: a workspace whose root `pnpm-workspace.yaml` sets `sharedWorkspaceLockfile: false` (or, when the YAML leaves the key unset, whose root `.npmrc` sets `shared-workspace-lockfile=false`; on disk, when neither file sets it, `pnpm_config_shared_workspace_lockfile` / `npm_config_shared_workspace_lockfile`; booleans in any letter case, as pnpm's YAML reader takes them) installs each member from the member's own `pnpm-lock.yaml`. Run from the workspace root, hosted mode reads every member lock the `packages:` globs name (quoted `!` negations applied; `node_modules`, `bower_components`, `test` and `tests` trees and symlinked directories skipped) beside the root lock, which may be absent (pnpm 7): each is rewritten like the root lock, a 9.0 member lock engages the `trustLockfile: true` auto-config in the ROOT `pnpm-workspace.yaml` (heal-on-rerun included), and discovery (`list`, `vex`, `rollback`, `remove`) reads the same member locks. A root lock whose `importers:` lists a member is a shared lock, so member locks beside it are stale and never read or attested. When the members' locks cannot be listed or read (no `packages:` key, whose pnpm <= 8 default finds projects in every directory; a `packages:` value that is not a plain list of globs; no member lock and no root lock, or a member lock that exists but is not readable text), every pnpm lock (the root's included) is left untouched and the run warns `redirect_pnpm_member_locks_unresolved` instead of `redirect_pnpm_no_lockfile`; no pnpm pin is confirmed. The lockfile inventory reads the same member locks, so their dependencies are discovered without an installed tree. The in-memory engine gives the same result as a disk run from the workspace root: a directory whose `pnpm-lock.yaml` has no `pnpm-workspace.yaml` beside it is a workspace member, never a root of its own, when (with `npm` among the `ecosystems`) the nearest ancestor `pnpm-workspace.yaml` is readable and its `packages:` globs (read as above, no braces, classes or extglobs) list it, it holds a `package.json` / `package.yaml` / `package.json5`, the workspace root installs it from its own lock (`sharedWorkspaceLockfile: false` with the member list resolved) or from a root lock beside which its lock is a stale leftover, and socket.yml admits the workspace root. Its lock is then demoted into that workspace root, which becomes a root even with no lock of its own (pnpm 7), the way a Cargo member's lock is demoted; the member stays a root only when it holds another lock marker, without its pnpm lock. The workspace root then pins (or refuses) its member locks as above, trusts them in its own file, refuses a member's branch lock (#556) and leaves a member lock beside a shared root lock unread. A directory that fails any of those tests keeps its lock as a root of its own (pinned like any root, or refused with `redirect_pnpm_settings_elsewhere` while the trust auto-config is on and the ancestor file may list it). Path selection adds the workspace root above every candidate to `roots`, fetches it, and fetches the member locks, branch locks and manifests below it in every directory holding a package manifest; given those `roots` as `projectRoots`, the session drops a confirmed member named beside its workspace root (unless it holds another lock) and a lockless workspace root that pins no member, so it runs the roots it would detect. A confirmed member named in `projectRoots` without its workspace root is not pinned: its lock is dropped and the run-level `pnpm_member_lock_ignored` warning names the workspace root to scan. -**pnpm with its lockfile off (#1074)**: with pnpm's `lockfile` setting off (a top-level `lockfile: false` in the root `pnpm-workspace.yaml`, as pnpm 11 and newer read it; when the YAML leaves the key unset, `lockfile=false` in the root `.npmrc`, as pnpm 10 and older read it; on disk also `npm_config_lockfile` / `pnpm_config_lockfile` set to `false`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm neither reads nor writes `pnpm-lock.yaml` (nor a member's own lock) and resolves every dependency from the registry, so a hosted pin in a committed lock never installs. Hosted mode leaves every pnpm lock and `pnpm-workspace.yaml` untouched (no `trustLockfile` key is added), confirms no npm pin from them and warns `redirect_pnpm_lockfile_disabled` (in place of `redirect_pnpm_no_lockfile`); the detail names the setting, and the remedy is `--mode vendored` (pnpm applies the `overrides` it wires without a lock) or removing the setting, running `pnpm install` and re-running. The check comes before the per-branch lock check below. Manifest-less `vex` keeps the hosted references of such a lock but withholds their lockfile basis (see the pnpm row of the discovery table). Rush locks are not affected: Rush runs pnpm with its own settings. Vendored mode is unchanged. +**pnpm with its lockfile off (#1074)**: pnpm's `lockfile` setting off makes pnpm neither read nor write `pnpm-lock.yaml` (nor a member's own lock): it resolves every dependency from the registry, so a hosted pin in a committed lock never installs. Each pnpm major reads the setting from one file: pnpm 11 and newer a top-level `lockfile: false` in the project's `pnpm-workspace.yaml`, pnpm 10 and older `lockfile=false` in its `.npmrc`. The major is the one `node_modules/.modules.yaml` records, else package.json's `packageManager` pin, else what the root lock's grammar implies (a pre-9 lock or `shrinkwrap.yaml`: pnpm 8 or older; an env lockfile document: pnpm 11 or newer); when none says, either file turning it off counts. Hosted mode also reads `npm_config_lockfile` / `pnpm_config_lockfile` from its environment. Only the project's own files are read (a workspace member with no settings file of its own does not inherit its root's). In a pnpm project (a root pnpm lock or a `pnpm-workspace.yaml`), hosted mode then leaves every pnpm lock and `pnpm-workspace.yaml` untouched (no `trustLockfile` key is added), confirms no npm pin from them and warns `redirect_pnpm_lockfile_disabled` (in place of `redirect_pnpm_no_lockfile`), exiting 0 like the per-branch lock refusal below; the detail names the setting, and the remedy is `--mode vendored` when a pnpm lock exists (pnpm applies the `overrides` it wires without a lock), else removing the setting and running `pnpm install`. The check comes before the per-branch lock check. Manifest-less `vex` keeps the hosted references of such a lock but withholds their lockfile basis (see the pnpm row of the discovery table). Rush locks are not affected: Rush runs pnpm with its own settings. Vendored mode is unchanged. **pnpm per-branch locks (#556)**: with `gitBranchLockfile` on (a top-level `gitBranchLockfile: true` in the root `pnpm-workspace.yaml`; when the YAML leaves the key unset, `git-branch-lockfile=true` in the root `.npmrc`, as pnpm 10 and older read it; on disk also `npm_config_git_branch_lockfile` / `pnpm_config_git_branch_lockfile` set to `true`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm installs a git branch from its own `pnpm-lock..yaml` whenever that lock exists, in every directory it installs a lock from: the root, and under `sharedWorkspaceLockfile: false` each member's (#492). When the setting is on AND at least one such `pnpm-lock..yaml` exists (at the root, or in a member that installs from its own lock), hosted mode leaves every root pnpm lock (`pnpm-lock.yaml`, `shrinkwrap.yaml`) and `pnpm-workspace.yaml` untouched (every member lock too), confirms no npm pin from them and warns `redirect_pnpm_git_branch_lockfile` (in place of `redirect_pnpm_no_lockfile` when the branch lock is the only one); the detail names the setting and the branch lock(s), and the remedy is to turn `gitBranchLockfile` off and fold the branch locks into `pnpm-lock.yaml` with `pnpm install --merge-git-branch-lockfiles`, then re-run. A vendored→hosted takeover of a pnpm-vendored purl is refused with the same code (the vendored wiring and ledger stay byte-for-byte). Vendored mode refuses the same projects with `vendor_pnpm_git_branch_lockfile`, before any write and in the pre-flight. The setting with no branch lock (main, or a branch whose dependencies never changed) installs from `pnpm-lock.yaml`, which both modes wire as usual; a stray branch lock with the setting off is ignored. The in-memory engine fetches root `pnpm-lock.*.yaml` names presence-only and refuses the same way. diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 0b9f98009..5f38cbd55 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -369,8 +369,9 @@ pub struct CandidateFiles { pub gem_refusal: Option, /// Set when the root pnpm lock pins nothing pnpm installs: the /// workspace keeps one lock per member but the members cannot be - /// listed (#492), or `gitBranchLockfile` installs the branch from a - /// `pnpm-lock..yaml` (#556). The root lock was left out of + /// listed (#492), `gitBranchLockfile` installs the branch from a + /// `pnpm-lock..yaml` (#556), or pnpm's `lockfile` setting is + /// off so pnpm reads no lock at all (#1074). The root lock was left out of /// `files`, and the rewrite reports this instead of a redirect or a /// "no lockfile" hint. pub pnpm_refusal: Option, @@ -718,8 +719,21 @@ pub async fn read_candidate_files( } } } else if candidates.iter().any(|c| c.dep.ecosystem == "npm") { - if let Some(source) = crate::utils::pnpm_workspace::lockfile_disabled(view).await { - refuse_disabled_lockfile(&mut out, &source); + // Only a pnpm project (a root pnpm lock, or a pnpm-workspace.yaml) + // is refused for pnpm's `lockfile` setting: an npm, yarn or bun + // project keeps its own hints. + let pnpm_lock = ROOT_PNPM_LOCKS.iter().any(|lock| { + out.files.contains_key(*lock) + || out.symlinked_reads.iter().any(|r| r == lock) + || out.unreadable_reads.iter().any(|r| r == lock) + }); + let pnpm_project = pnpm_lock || view.read_text(PNPM_WORKSPACE_REL).await.is_ok(); + let disabled = match pnpm_project { + true => crate::utils::pnpm_workspace::lockfile_disabled(view, true).await, + false => None, + }; + if let Some(source) = disabled { + withhold_root_pnpm_locks(&mut out, disabled_lockfile_refusal(&source, pnpm_lock)); } else if let Some(branch) = crate::utils::pnpm_workspace::git_branch_locks(view).await { refuse_git_branch_locks(&mut out, &branch); } else { @@ -836,48 +850,50 @@ fn refuse_pnpm_members(out: &mut CandidateFiles, why: String) { /// `gitBranchLockfile` with a branch lock present (#556): pnpm installs /// the branch from that lock, which hosted mode cannot pin, so the root /// lock (stale on the branch) is left out and the run refuses every pnpm -/// pin (see [`CandidateFiles::pnpm_refusal`]). In memory the root lock is -/// dropped from the link and unreadable lists too: it is not written. +/// pin (see [`CandidateFiles::pnpm_refusal`]). fn refuse_git_branch_locks( out: &mut CandidateFiles, branch: &crate::utils::pnpm_workspace::GitBranchLocks, ) { - const ROOT_LOCKS: [&str; 2] = ["pnpm-lock.yaml", "shrinkwrap.yaml"]; - for lock in ROOT_LOCKS { - out.files.remove(lock); - } - out.symlinked_reads - .retain(|r| !ROOT_LOCKS.contains(&r.as_str())); - out.unreadable_reads - .retain(|r| !ROOT_LOCKS.contains(&r.as_str())); - out.pnpm_refusal = Some(git_branch_lock_refusal(branch)); + withhold_root_pnpm_locks(out, git_branch_lock_refusal(branch)); } -/// pnpm's `lockfile` setting is off (#1074): pnpm neither reads nor -/// writes its lock, so a pin there never installs. Every root pnpm lock is -/// left out (member locks are never read) and the run refuses every pnpm -/// pin (see [`CandidateFiles::pnpm_refusal`]), as for a branch lock. -fn refuse_disabled_lockfile(out: &mut CandidateFiles, source: &str) { - const ROOT_LOCKS: [&str; 2] = ["pnpm-lock.yaml", "shrinkwrap.yaml"]; - for lock in ROOT_LOCKS { +/// The root pnpm locks (see [`withhold_root_pnpm_locks`]). +const ROOT_PNPM_LOCKS: [&str; 2] = ["pnpm-lock.yaml", "shrinkwrap.yaml"]; + +/// Leave every root pnpm lock out of the run and refuse every pnpm pin +/// with `refusal` (see [`CandidateFiles::pnpm_refusal`]): a branch lock +/// (#556) or pnpm's `lockfile` setting off (#1074, where pnpm reads no +/// lock at all; member locks are never read then). In memory the root +/// lock is dropped from the link and unreadable lists too: it is not +/// written. +fn withhold_root_pnpm_locks(out: &mut CandidateFiles, refusal: RewriteWarning) { + for lock in ROOT_PNPM_LOCKS { out.files.remove(lock); } out.symlinked_reads - .retain(|r| !ROOT_LOCKS.contains(&r.as_str())); + .retain(|r| !ROOT_PNPM_LOCKS.contains(&r.as_str())); out.unreadable_reads - .retain(|r| !ROOT_LOCKS.contains(&r.as_str())); - out.pnpm_refusal = Some(disabled_lockfile_refusal(source)); + .retain(|r| !ROOT_PNPM_LOCKS.contains(&r.as_str())); + out.pnpm_refusal = Some(refusal); } /// The [`PNPM_LOCKFILE_DISABLED`] warning for a `lockfile` setting turned /// off at `source` (see [`crate::utils::pnpm_workspace::lockfile_disabled`]). -pub fn disabled_lockfile_refusal(source: &str) -> RewriteWarning { +/// Vendored mode is named only when a pnpm lock exists: its pnpm backend +/// wires the lock it finds (pnpm then applies the `overrides` without +/// reading it) and refuses a project with none. +fn disabled_lockfile_refusal(source: &str, pnpm_lock: bool) -> RewriteWarning { + let remedy = if pnpm_lock { + "use `--mode vendored` (pnpm applies the `overrides` it wires without a lock), \ + or remove the setting, run `pnpm install` and re-run" + } else { + "remove the setting, run `pnpm install` so pnpm writes pnpm-lock.yaml, and re-run" + }; RewriteWarning { code: PNPM_LOCKFILE_DISABLED.into(), detail: format!( - "{}; no pnpm lock was rewritten — use `--mode vendored` (pnpm applies its \ - `overrides` without a lock), or remove the setting, run `pnpm install` and \ - re-run", + "{}; no pnpm lock was rewritten — {remedy}", crate::utils::pnpm_workspace::describe_lockfile_disabled(source) ), } @@ -5646,14 +5662,26 @@ snapshots: /// The setting on (or unset) keeps the lock pinned as usual, and a /// YAML value pnpm's reader takes for no boolean leaves the default. + /// With a known pnpm major only the file that major reads counts: + /// pnpm 11+ ignores `.npmrc`'s `lockfile`, pnpm 10 and older the YAML + /// key. #[tokio::test] async fn an_enabled_lockfile_is_pinned() { - for (workspace, npmrc) in [ - ("lockfile: true\n", None), - ("packages:\n - '.'\n", Some("lockfile=true\n")), - ("lockfile: maybe\n", None), - // The YAML wins over `.npmrc` (see `pnpm_setting`). - ("lockfile: true\n", Some("lockfile=false\n")), + for (workspace, npmrc, pm) in [ + ("lockfile: true\n", None, None), + ("packages:\n - '.'\n", Some("lockfile=true\n"), None), + ("lockfile: maybe\n", None, None), + ( + "lockfile: true\n", + Some("lockfile=false\n"), + Some("pnpm@11.2.0"), + ), + ("lockfile: false\n", None, Some("pnpm@10.34.6")), + ( + "lockfile: false\n", + Some("lockfile=true\n"), + Some("pnpm@9.15.9"), + ), ] { let tmp = tempfile::tempdir().unwrap(); write_branch_lock_project(tmp.path(), workspace, true); @@ -5661,17 +5689,99 @@ snapshots: if let Some(npmrc) = npmrc { write_rel(tmp.path(), ".npmrc", npmrc); } + if let Some(pm) = pm { + write_pinned_manifest(tmp.path(), pm); + } let (_, done) = member_rewrite(tmp.path()).await; let text = done .rewrite .files .get("pnpm-lock.yaml") - .unwrap_or_else(|| panic!("{workspace}: {:?}", done.rewrite.warnings)); + .unwrap_or_else(|| panic!("{workspace} {pm:?}: {:?}", done.rewrite.warnings)); assert!(text.contains(MEMBER_URL), "{text}"); assert_eq!(done.confirmed.len(), 1, "{workspace}"); assert!(!warning_codes(&done).contains(&PNPM_LOCKFILE_DISABLED)); } } + + fn write_pinned_manifest(root: &std::path::Path, pm: &str) { + write_rel( + root, + "package.json", + &format!( + r#"{{"name":"app","packageManager":"{pm}","dependencies":{{"is-number":"7.0.0"}}}}"# + ), + ); + } + + /// The file the pinned major reads decides (#1074): pnpm 9 ignores a + /// YAML `lockfile: true` and reads `.npmrc`'s `lockfile=false`; with no + /// major known, either file turning it off refuses. + #[tokio::test] + async fn a_disabled_lockfile_is_read_where_the_pnpm_major_reads_it() { + for (workspace, npmrc, pm, source) in [ + ( + "lockfile: true\n", + "lockfile=false\n", + Some("pnpm@9.15.9"), + "`lockfile=false` in .npmrc", + ), + ( + "lockfile: true\n", + "lockfile=false\n", + None, + "`lockfile=false` in .npmrc", + ), + ( + "lockfile: false\n", + "lockfile=true\n", + Some("pnpm@12.10.1"), + "`lockfile: false` in pnpm-workspace.yaml", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), workspace, true); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + write_rel(tmp.path(), ".npmrc", npmrc); + if let Some(pm) = pm { + write_pinned_manifest(tmp.path(), pm); + } + let (read, done) = member_rewrite(tmp.path()).await; + assert_disabled_lockfile_refused(&read, &done, source); + } + } + + /// With no pnpm lock the remedy cannot be vendored mode (its pnpm + /// backend needs a lock to wire), and an npm project's `.npmrc` + /// `lockfile=false` is no pnpm setting at all: it keeps npm's hints. + #[tokio::test] + async fn a_disabled_lockfile_without_a_pnpm_lock() { + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), "lockfile: false\n", false); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + let (_, done) = member_rewrite(tmp.path()).await; + let detail = &done + .rewrite + .warnings + .iter() + .find(|w| w.code == PNPM_LOCKFILE_DISABLED) + .unwrap_or_else(|| panic!("{:?}", done.rewrite.warnings)) + .detail; + assert!( + !detail.contains("--mode vendored") && detail.contains("pnpm install"), + "{detail}" + ); + + let mut project = MemoryProject::new(); + project.insert_text("package.json", r#"{"name":"app"}"#); + project.insert_text(".npmrc", "lockfile=false\n"); + let (_, done) = view_rewrite(&ProjectView::Memory(&project)).await; + assert!( + !warning_codes(&done).contains(&PNPM_LOCKFILE_DISABLED), + "{:?}", + done.rewrite.warnings + ); + } } /// The one-pass multi-needle confirmation probe answers exactly what the diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs index 15ee3aa7f..d60f68ea6 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -34,7 +34,7 @@ mod gem; mod golang; mod gradle; mod maven; -mod npm; +pub(crate) mod npm; mod nuget; mod pypi; mod pypi_locks; diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 688aa9947..f35f87e2c 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -1202,7 +1202,7 @@ impl PnpmTarballPolicy { /// (only pnpm <= 8 writes those), else 11 for a lock carrying pnpm 11+'s /// env lockfile document ahead of the project lock (only pnpm >= 11 /// writes one, so its `.npmrc` pnpm settings are ignored). -fn pnpm_settings_major(text: &str, pm_major: Option) -> Option { +pub(crate) fn pnpm_settings_major(text: &str, pm_major: Option) -> Option { use crate::formats::pnpm::grammar::{is_pnpm_lock_text, main_document}; use crate::formats::pnpm::lock_version_major; @@ -1479,7 +1479,7 @@ fn pnpm_spec_major(spec: &str) -> Option { /// The pnpm major an install record names: `node_modules/.modules.yaml`'s /// `packageManager` (JSON on pnpm 10+, a top-level YAML scalar before). -fn modules_yaml_pnpm_major(text: &str) -> Option { +pub(crate) fn modules_yaml_pnpm_major(text: &str) -> Option { let text = crate::formats::text::strip_bom(text); if let Ok(value) = serde_json::from_str::(text) { return value @@ -1493,7 +1493,7 @@ fn modules_yaml_pnpm_major(text: &str) -> Option { } /// The pnpm major package.json's corepack `packageManager` pins. -fn package_json_pnpm_major(text: &str) -> Option { +pub(crate) fn package_json_pnpm_major(text: &str) -> Option { serde_json::from_str::(crate::formats::text::strip_bom(text)) .ok()? .get("packageManager")? diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 58934c399..9b30fd647 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -543,60 +543,106 @@ pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option if locks.is_empty() && members_unlisted.is_none() { return None; } - let setting = - git_branch_on(project_setting(view, "gitBranchLockfile", "git-branch-lockfile").await)?; - Some(GitBranchLocks { - setting, - locks, - members_unlisted, - }) -} - -/// A pnpm setting as the project at `view` sees it: its own -/// `pnpm-workspace.yaml` / `.npmrc` (read through the view's FIFO-safe -/// reader, see [`pnpm_setting`]); on disk, for a project with no -/// `pnpm-workspace.yaml` of its own (a workspace member), the governing -/// ancestor's file and the `.npmrc` beside it ([`governing_workspace_file`]); -/// then, on disk, the `pnpm_config_` / `npm_config_` environment -/// spellings. -async fn project_setting( - view: &ProjectView<'_>, - yaml_key: &str, - npmrc_key: &str, -) -> Option { let workspace = view.read_text(PNPM_WORKSPACE).await.ok(); let npmrc = view.read_text(".npmrc").await.ok(); - let mut setting = pnpm_setting(workspace.as_deref(), npmrc.as_deref(), yaml_key, npmrc_key); + const KEYS: (&str, &str) = ("gitBranchLockfile", "git-branch-lockfile"); + let mut setting = pnpm_setting(workspace.as_deref(), npmrc.as_deref(), KEYS.0, KEYS.1); if setting.is_none() { if let Some(file) = governing_file(view) { let workspace = read_regular_to_string(&file).await.ok(); let npmrc = read_regular_to_string(&file.with_file_name(".npmrc")) .await .ok(); - setting = pnpm_setting(workspace.as_deref(), npmrc.as_deref(), yaml_key, npmrc_key) - .map(|found| PnpmSetting { - source: format!("{} (at {})", found.source, file.display()), - ..found + setting = + pnpm_setting(workspace.as_deref(), npmrc.as_deref(), KEYS.0, KEYS.1).map(|found| { + PnpmSetting { + source: format!("{} (at {})", found.source, file.display()), + ..found + } }); } } - setting.or_else(|| view_setting(view, None, None, yaml_key, npmrc_key)) + let setting = + git_branch_on(setting.or_else(|| view_setting(view, None, None, KEYS.0, KEYS.1)))?; + Some(GitBranchLocks { + setting, + locks, + members_unlisted, + }) } /// Where pnpm's `lockfile` setting is turned off (#1074), as a refusal /// names it (e.g. `` `lockfile: false` in pnpm-workspace.yaml ``); `None` -/// when it is on (the default). pnpm 11 and newer read the setting from -/// `pnpm-workspace.yaml`, pnpm 10 and older from `.npmrc` (`lockfile=false`), -/// both read through [`project_setting`]. With it off pnpm neither reads -/// nor writes `pnpm-lock.yaml` (or a member's own lock): a plain -/// `pnpm install` re-resolves every dependency from the registry, so a -/// pin in a committed lock is never what installs. A value the setting's -/// reader does not take as a boolean leaves the default. -pub async fn lockfile_disabled(view: &ProjectView<'_>) -> Option { - project_setting(view, "lockfile", "lockfile") +/// when it is on (the default). With it off pnpm neither reads nor writes +/// `pnpm-lock.yaml` (or a member's own lock): `pnpm install` resolves +/// every dependency from the registry, so a pin in a committed lock is +/// never what installs. +/// +/// Each pnpm major reads the setting from one place: pnpm 11 and newer +/// from `pnpm-workspace.yaml` (`lockfile: false`), pnpm 10 and older from +/// `.npmrc` (`lockfile=false`; pnpm 10 ignores the YAML key). The major is +/// the one that installed the project (`node_modules/.modules.yaml`), else +/// the one package.json pins, else what the root lock's grammar implies +/// (`pnpm_settings_major`). When none says, either source turning the +/// setting off counts: withholding a pin is the safe side. With `env`, on +/// disk, `pnpm_config_lockfile` / `npm_config_lockfile` count too (the +/// hosted engine runs in the install's environment; `vex` need not). +/// +/// Only the project's own files are read, through the view's +/// fingerprinted reader, so a re-scan read cache stays valid; a workspace +/// member with no settings file of its own is not related to its root's. +pub async fn lockfile_disabled(view: &ProjectView<'_>, env: bool) -> Option { + const KEY: &str = "lockfile"; + let off = |setting: Option| { + setting + .filter(|setting| setting.as_bool() == Some(false)) + .map(|setting| setting.source) + }; + let workspace = view.read_text(PNPM_WORKSPACE).await.ok(); + let npmrc = view.read_text(".npmrc").await.ok(); + let yaml = off(pnpm_setting(workspace.as_deref(), None, KEY, KEY)); + let rc = off(pnpm_setting(None, npmrc.as_deref(), KEY, KEY)); + let env = match view { + ProjectView::Disk(_) | ProjectView::Snapshot(_) if env => { + off(env_setting(KEY, |var| std::env::var(var).ok())) + } + _ => None, + }; + if yaml.is_none() && rc.is_none() { + return env; + } + let major = pnpm_major(view).await; + match major { + Some(major) if major >= 11 => yaml, + Some(_) => rc, + None => yaml.or(rc), + } + .or(env) +} + +/// The pnpm major whose settings reading applies to the project at `view` +/// (see [`lockfile_disabled`]). +async fn pnpm_major(view: &ProjectView<'_>) -> Option { + use crate::patch::redirect::upstream::npm::{ + modules_yaml_pnpm_major, package_json_pnpm_major, pnpm_settings_major, + }; + let installed = view + .read_text("node_modules/.modules.yaml") .await - .filter(|setting| setting.as_bool() == Some(false)) - .map(|setting| setting.source) + .ok() + .as_deref() + .and_then(modules_yaml_pnpm_major); + let pinned = match installed { + Some(major) => Some(major), + None => view + .read_text("package.json") + .await + .ok() + .as_deref() + .and_then(package_json_pnpm_major), + }; + let lock = view.read_text(PNPM_LOCK).await.ok().unwrap_or_default(); + pnpm_settings_major(&lock, pinned) } /// What pnpm does with its `lockfile` setting off, for the refusals and diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index f8de92eec..c3ba67b22 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -680,7 +680,7 @@ async fn extract_pnpm(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let disabled = if rush { None } else { - crate::utils::pnpm_workspace::lockfile_disabled(&ctx.view).await + crate::utils::pnpm_workspace::lockfile_disabled(&ctx.view, false).await }; let disabled = disabled.as_deref(); let root_lock = ctx.exists(PNPM_LOCK).await; From 0363b4243d1fde43ab01cc14784bd5aedb6f08f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:01:57 +0000 Subject: [PATCH 5/5] Treat package-lock=false as pnpm lockfile off Every pnpm (7 through 11) reads `lockfile` only when it is a boolean and otherwise falls back to npm's `package-lock` to decide whether it uses its lock. A pnpm 9/10 project with the common npm-era `package-lock=false` in .npmrc therefore never reads pnpm-lock.yaml, yet hosted mode still pinned the lock and lock-only vex attested it. The hosted refusal and the VEX withholding now take `package-lock` (`packageLock` in pnpm-workspace.yaml) into account when `lockfile` is unset; an explicit `lockfile=true` still wins. Refs #1074 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-core/src/hosted/engine.rs | 40 +++++++++++++++++++ .../src/utils/pnpm_workspace.rs | 27 ++++++++----- .../socket-patch-core/src/vex/discover/npm.rs | 6 +++ docs/ecosystems.md | 3 +- 5 files changed, 66 insertions(+), 12 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 4a7c31c63..bfe6eb4c4 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -165,7 +165,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **pnpm per-member locks (#492)**: a workspace whose root `pnpm-workspace.yaml` sets `sharedWorkspaceLockfile: false` (or, when the YAML leaves the key unset, whose root `.npmrc` sets `shared-workspace-lockfile=false`; on disk, when neither file sets it, `pnpm_config_shared_workspace_lockfile` / `npm_config_shared_workspace_lockfile`; booleans in any letter case, as pnpm's YAML reader takes them) installs each member from the member's own `pnpm-lock.yaml`. Run from the workspace root, hosted mode reads every member lock the `packages:` globs name (quoted `!` negations applied; `node_modules`, `bower_components`, `test` and `tests` trees and symlinked directories skipped) beside the root lock, which may be absent (pnpm 7): each is rewritten like the root lock, a 9.0 member lock engages the `trustLockfile: true` auto-config in the ROOT `pnpm-workspace.yaml` (heal-on-rerun included), and discovery (`list`, `vex`, `rollback`, `remove`) reads the same member locks. A root lock whose `importers:` lists a member is a shared lock, so member locks beside it are stale and never read or attested. When the members' locks cannot be listed or read (no `packages:` key, whose pnpm <= 8 default finds projects in every directory; a `packages:` value that is not a plain list of globs; no member lock and no root lock, or a member lock that exists but is not readable text), every pnpm lock (the root's included) is left untouched and the run warns `redirect_pnpm_member_locks_unresolved` instead of `redirect_pnpm_no_lockfile`; no pnpm pin is confirmed. The lockfile inventory reads the same member locks, so their dependencies are discovered without an installed tree. The in-memory engine gives the same result as a disk run from the workspace root: a directory whose `pnpm-lock.yaml` has no `pnpm-workspace.yaml` beside it is a workspace member, never a root of its own, when (with `npm` among the `ecosystems`) the nearest ancestor `pnpm-workspace.yaml` is readable and its `packages:` globs (read as above, no braces, classes or extglobs) list it, it holds a `package.json` / `package.yaml` / `package.json5`, the workspace root installs it from its own lock (`sharedWorkspaceLockfile: false` with the member list resolved) or from a root lock beside which its lock is a stale leftover, and socket.yml admits the workspace root. Its lock is then demoted into that workspace root, which becomes a root even with no lock of its own (pnpm 7), the way a Cargo member's lock is demoted; the member stays a root only when it holds another lock marker, without its pnpm lock. The workspace root then pins (or refuses) its member locks as above, trusts them in its own file, refuses a member's branch lock (#556) and leaves a member lock beside a shared root lock unread. A directory that fails any of those tests keeps its lock as a root of its own (pinned like any root, or refused with `redirect_pnpm_settings_elsewhere` while the trust auto-config is on and the ancestor file may list it). Path selection adds the workspace root above every candidate to `roots`, fetches it, and fetches the member locks, branch locks and manifests below it in every directory holding a package manifest; given those `roots` as `projectRoots`, the session drops a confirmed member named beside its workspace root (unless it holds another lock) and a lockless workspace root that pins no member, so it runs the roots it would detect. A confirmed member named in `projectRoots` without its workspace root is not pinned: its lock is dropped and the run-level `pnpm_member_lock_ignored` warning names the workspace root to scan. -**pnpm with its lockfile off (#1074)**: pnpm's `lockfile` setting off makes pnpm neither read nor write `pnpm-lock.yaml` (nor a member's own lock): it resolves every dependency from the registry, so a hosted pin in a committed lock never installs. Each pnpm major reads the setting from one file: pnpm 11 and newer a top-level `lockfile: false` in the project's `pnpm-workspace.yaml`, pnpm 10 and older `lockfile=false` in its `.npmrc`. The major is the one `node_modules/.modules.yaml` records, else package.json's `packageManager` pin, else what the root lock's grammar implies (a pre-9 lock or `shrinkwrap.yaml`: pnpm 8 or older; an env lockfile document: pnpm 11 or newer); when none says, either file turning it off counts. Hosted mode also reads `npm_config_lockfile` / `pnpm_config_lockfile` from its environment. Only the project's own files are read (a workspace member with no settings file of its own does not inherit its root's). In a pnpm project (a root pnpm lock or a `pnpm-workspace.yaml`), hosted mode then leaves every pnpm lock and `pnpm-workspace.yaml` untouched (no `trustLockfile` key is added), confirms no npm pin from them and warns `redirect_pnpm_lockfile_disabled` (in place of `redirect_pnpm_no_lockfile`), exiting 0 like the per-branch lock refusal below; the detail names the setting, and the remedy is `--mode vendored` when a pnpm lock exists (pnpm applies the `overrides` it wires without a lock), else removing the setting and running `pnpm install`. The check comes before the per-branch lock check. Manifest-less `vex` keeps the hosted references of such a lock but withholds their lockfile basis (see the pnpm row of the discovery table). Rush locks are not affected: Rush runs pnpm with its own settings. Vendored mode is unchanged. +**pnpm with its lockfile off (#1074)**: pnpm's `lockfile` setting off makes pnpm neither read nor write `pnpm-lock.yaml` (nor a member's own lock): it resolves every dependency from the registry, so a hosted pin in a committed lock never installs. Each pnpm major reads the setting from one file: pnpm 11 and newer a top-level `lockfile: false` in the project's `pnpm-workspace.yaml`, pnpm 10 and older `lockfile=false` in its `.npmrc`; when `lockfile` is unset (or not a boolean) pnpm falls back to npm's `package-lock` (`packageLock: false` / `package-lock=false`), which counts the same way. The major is the one `node_modules/.modules.yaml` records, else package.json's `packageManager` pin, else what the root lock's grammar implies (a pre-9 lock or `shrinkwrap.yaml`: pnpm 8 or older; an env lockfile document: pnpm 11 or newer); when none says, either file turning it off counts. Hosted mode also reads `npm_config_lockfile` / `pnpm_config_lockfile` from its environment. Only the project's own files are read (a workspace member with no settings file of its own does not inherit its root's). In a pnpm project (a root pnpm lock or a `pnpm-workspace.yaml`), hosted mode then leaves every pnpm lock and `pnpm-workspace.yaml` untouched (no `trustLockfile` key is added), confirms no npm pin from them and warns `redirect_pnpm_lockfile_disabled` (in place of `redirect_pnpm_no_lockfile`), exiting 0 like the per-branch lock refusal below; the detail names the setting, and the remedy is `--mode vendored` when a pnpm lock exists (pnpm applies the `overrides` it wires without a lock), else removing the setting and running `pnpm install`. The check comes before the per-branch lock check. Manifest-less `vex` keeps the hosted references of such a lock but withholds their lockfile basis (see the pnpm row of the discovery table). Rush locks are not affected: Rush runs pnpm with its own settings. Vendored mode is unchanged. **pnpm per-branch locks (#556)**: with `gitBranchLockfile` on (a top-level `gitBranchLockfile: true` in the root `pnpm-workspace.yaml`; when the YAML leaves the key unset, `git-branch-lockfile=true` in the root `.npmrc`, as pnpm 10 and older read it; on disk also `npm_config_git_branch_lockfile` / `pnpm_config_git_branch_lockfile` set to `true`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm installs a git branch from its own `pnpm-lock..yaml` whenever that lock exists, in every directory it installs a lock from: the root, and under `sharedWorkspaceLockfile: false` each member's (#492). When the setting is on AND at least one such `pnpm-lock..yaml` exists (at the root, or in a member that installs from its own lock), hosted mode leaves every root pnpm lock (`pnpm-lock.yaml`, `shrinkwrap.yaml`) and `pnpm-workspace.yaml` untouched (every member lock too), confirms no npm pin from them and warns `redirect_pnpm_git_branch_lockfile` (in place of `redirect_pnpm_no_lockfile` when the branch lock is the only one); the detail names the setting and the branch lock(s), and the remedy is to turn `gitBranchLockfile` off and fold the branch locks into `pnpm-lock.yaml` with `pnpm install --merge-git-branch-lockfiles`, then re-run. A vendored→hosted takeover of a pnpm-vendored purl is refused with the same code (the vendored wiring and ledger stay byte-for-byte). Vendored mode refuses the same projects with `vendor_pnpm_git_branch_lockfile`, before any write and in the pre-flight. The setting with no branch lock (main, or a branch whose dependencies never changed) installs from `pnpm-lock.yaml`, which both modes wire as usual; a stray branch lock with the setting off is ignored. The in-memory engine fetches root `pnpm-lock.*.yaml` names presence-only and refuses the same way. diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 5f38cbd55..7c38b972a 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -5751,6 +5751,46 @@ snapshots: } } + /// pnpm reads `lockfile` only when it is a boolean, else falls back to + /// npm's `package-lock` (`useLockfile` in every pnpm 7–11): the common + /// npm-era `package-lock=false` turns pnpm's lock off too, unless + /// `lockfile=true` says otherwise. + #[tokio::test] + async fn package_lock_false_disables_the_lockfile() { + for (npmrc, refused) in [ + ("package-lock=false\n", true), + ("lockfile=true\npackage-lock=false\n", false), + ("package-lock=true\n", false), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), "packages:\n - '.'\n", true); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + write_rel(tmp.path(), ".npmrc", npmrc); + write_pinned_manifest(tmp.path(), "pnpm@10.33.0"); + let (read, done) = member_rewrite(tmp.path()).await; + if refused { + assert_disabled_lockfile_refused(&read, &done, "`package-lock=false` in .npmrc"); + } else { + assert!( + done.rewrite.files.contains_key("pnpm-lock.yaml"), + "{npmrc}: {:?}", + done.rewrite.warnings + ); + } + } + // pnpm 11+ reads the YAML spelling. + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), "packageLock: false\n", true); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + write_pinned_manifest(tmp.path(), "pnpm@11.28.5"); + let (read, done) = member_rewrite(tmp.path()).await; + assert_disabled_lockfile_refused( + &read, + &done, + "`packageLock: false` in pnpm-workspace.yaml", + ); + } + /// With no pnpm lock the remedy cannot be vendored mode (its pnpm /// backend needs a lock to wire), and an npm project's `.npmrc` /// `lockfile=false` is no pnpm setting at all: it keeps npm's hints. diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 9b30fd647..135b64962 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -571,7 +571,8 @@ pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option }) } -/// Where pnpm's `lockfile` setting is turned off (#1074), as a refusal +/// Where pnpm's `lockfile` setting (or the `package-lock` it falls back +/// to, see [`lockfile_off`]) is turned off (#1074), as a refusal /// names it (e.g. `` `lockfile: false` in pnpm-workspace.yaml ``); `None` /// when it is on (the default). With it off pnpm neither reads nor writes /// `pnpm-lock.yaml` (or a member's own lock): `pnpm install` resolves @@ -592,19 +593,13 @@ pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option /// fingerprinted reader, so a re-scan read cache stays valid; a workspace /// member with no settings file of its own is not related to its root's. pub async fn lockfile_disabled(view: &ProjectView<'_>, env: bool) -> Option { - const KEY: &str = "lockfile"; - let off = |setting: Option| { - setting - .filter(|setting| setting.as_bool() == Some(false)) - .map(|setting| setting.source) - }; let workspace = view.read_text(PNPM_WORKSPACE).await.ok(); let npmrc = view.read_text(".npmrc").await.ok(); - let yaml = off(pnpm_setting(workspace.as_deref(), None, KEY, KEY)); - let rc = off(pnpm_setting(None, npmrc.as_deref(), KEY, KEY)); + let yaml = lockfile_off(|key, _| pnpm_setting(workspace.as_deref(), None, key, key)); + let rc = lockfile_off(|_, key| pnpm_setting(None, npmrc.as_deref(), key, key)); let env = match view { ProjectView::Disk(_) | ProjectView::Snapshot(_) if env => { - off(env_setting(KEY, |var| std::env::var(var).ok())) + lockfile_off(|_, key| env_setting(key, |var| std::env::var(var).ok())) } _ => None, }; @@ -620,6 +615,18 @@ pub async fn lockfile_disabled(view: &ProjectView<'_>, env: bool) -> Option Option) -> Option { + let off = |found: PnpmSetting| (found.as_bool() == Some(false)).then_some(found.source); + match setting("lockfile", "lockfile") { + Some(found) if found.as_bool().is_some() => off(found), + _ => setting("packageLock", "package-lock").and_then(off), + } +} + /// The pnpm major whose settings reading applies to the project at `view` /// (see [`lockfile_disabled`]). async fn pnpm_major(view: &ProjectView<'_>) -> Option { diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index c3ba67b22..7698ca4b0 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -3146,6 +3146,12 @@ mod tests { p.write(".npmrc", "lockfile=false\n"); withheld(&run(&p).await, "`lockfile=false` in .npmrc"); + // npm's `package-lock=false`, which pnpm falls back to. + let p = Project::new(); + p.write("pnpm-lock.yaml", &wired); + p.write(".npmrc", "package-lock=false\n"); + withheld(&run(&p).await, "`package-lock=false` in .npmrc"); + let p = Project::new(); p.write( "pnpm-workspace.yaml", diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 443c5d099..948177bc7 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -179,7 +179,8 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: `pnpm install --merge-git-branch-lockfiles`, then re-run. With no branch lock, `pnpm-lock.yaml` is the lock pnpm installs from and is pinned as usual. With the `lockfile` setting off (`lockfile: false` in `pnpm-workspace.yaml` - on pnpm 11 and newer, `lockfile=false` in `.npmrc` on pnpm 10 and older), + on pnpm 11 and newer, `lockfile=false` in `.npmrc` on pnpm 10 and older, + or npm's `package-lock=false` that pnpm falls back to), pnpm never reads `pnpm-lock.yaml` and installs from the registry, so hosted mode refuses the pnpm pins with `redirect_pnpm_lockfile_disabled` and lock-only `vex` attests no hosted pin from that lock. Use `--mode vendored`,