diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4f8067ea..cce660612 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1397,7 +1397,7 @@ jobs: # The composer capstones shell out to a real composer; `composer:` # pins the release line (1, 2.2 LTS, 2) so the composer.lock grammar # the edits assert stays stable across runners. - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' tools: composer:${{ matrix.composer }} diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 84dc7475c..8f4d68288 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1295,7 +1295,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). | | `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | | `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | -| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`); when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | +| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`); when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; a directory whose only locks are `package-lock.json` / `npm-shrinkwrap.json` is refused the same way when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json`, because npm never reads a lock inside a workspace member (#1094; vendored refuses it with `vendor_lockfile_missing`)) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | | `redirect_pnpm_settings_elsewhere` | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from the nearest ancestor `pnpm-workspace.yaml`, which pnpm reads alone (a member's own file is ignored). When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. Disk runs only. | | `eject_refused` | top-level `errorCode` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. | | `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. | diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index fd0135dfc..865ae4969 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -1781,3 +1781,88 @@ fn assert_refused_workspace_lock_elsewhere( "{case}: nothing written in the member" ); } + +/// #1094: an npm workspace member holding a stray `package-lock.json` / +/// `npm-shrinkwrap.json` of its own (one npm never reads; members install +/// from the root lock) used to skip the #884 refusal. `scan` and `get` +/// pinned the ignored member lock and exited 0. They now refuse, name the +/// root lock and the ignored member lock, and leave both untouched. +#[tokio::test] +#[serial] +async fn hosted_scan_from_npm_member_with_stray_lock_refuses() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + for (root_lock, member_lock) in [ + ("package-lock.json", "package-lock.json"), + ("package-lock.json", "npm-shrinkwrap.json"), + ("npm-shrinkwrap.json", "package-lock.json"), + ] { + let tmp = tempfile::tempdir().unwrap(); + let member = write_package_json_workspace(tmp.path(), root_lock, false); + let stray = member.join(member_lock); + let stray_text = serde_json::json!({ + "name": "a", "version": "1.0.0", "lockfileVersion": 3, + "packages": { + "": { "name": "a", "version": "1.0.0", "dependencies": { NAME: VERSION } }, + format!("node_modules/{NAME}"): { + "version": VERSION, + "resolved": format!("https://registry.npmjs.org/{NAME}/-/{NAME}-{VERSION}.tgz"), + "integrity": "sha512-orig==" + } + } + }) + .to_string(); + std::fs::write(&stray, &stray_text).unwrap(); + let lock = tmp.path().join(root_lock); + let before = std::fs::read_to_string(&lock).unwrap(); + let case = format!("root {root_lock}, member {member_lock}"); + + for args in [ + vec!["scan", "--mode", "hosted"], + vec!["get", UUID, "--mode", "hosted"], + ] { + let out = scrubbed_cli() + .args(&args) + .args([ + "--json", + "--yes", + "--cwd", + member.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + let doc: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "{case} {args:?}: output is not JSON ({e}):\n{}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + }); + let case = format!("{case} {args:?}"); + assert_refused_workspace_lock_elsewhere( + &case, + out.status.code(), + &doc, + &lock, + &before, + &member, + ); + let message = doc["error"].as_str().unwrap_or_default(); + assert!(message.contains(member_lock), "{case}: {message}"); + assert_eq!( + std::fs::read_to_string(&stray).unwrap(), + stray_text, + "{case}: the stray member lock is untouched" + ); + assert!(!member.join(".npmrc").exists(), "{case}"); + } + } +} diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index cf3f0520c..2700665ad 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -86,7 +86,7 @@ pub async fn refusal( } if candidates.iter().any(|c| c.dep.ecosystem == "npm") { let workspace = if has_own_npm_family_lock(root) { - None + npm_member_stray_lock_refusal(root).await } else { nearer_root( package_json_workspace_refusal(root).await, @@ -272,6 +272,96 @@ const WORKSPACE_ROOT_FAMILIES: [NpmLockFamily; 3] = /// governing root with the refusal, so [`refusal`] can weigh it against /// the pnpm check (the nearer root wins; a tie goes to pnpm's message). async fn package_json_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { + let (ancestor, locks) = package_json_workspace_root(root).await?; + let refusal = Refusal { + code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), + message: format!( + "{} is a workspace member with no lockfile of its own: the workspace \ + root {} lists it under \"workspaces\" and installs it from {}, which a \ + hosted run here cannot see; run socket-patch from {} (the workspace \ + root); nothing was written", + root.display(), + ancestor.display(), + join_paths(&ancestor, &locks), + ancestor.display() + ), + }; + Some((ancestor, refusal)) +} + +/// #1094: the project directory holds only npm locks (`package-lock.json`, +/// `npm-shrinkwrap.json`) and is a member of a `package.json` workspace +/// whose root holds an npm lock. npm installs every workspace member from +/// the root's lock and never reads a lock inside the member (a stray one, +/// typically left behind when the package moved into the monorepo), so a +/// run here would pin or vendor a lock npm ignores and report success. +/// +/// A member that also holds a lock its own manager reads (pnpm, yarn, Bun, +/// vlt, or a Rush repo) keeps the own-lock shortcut: pnpm and vlt ignore +/// `package.json` workspaces, and yarn berry treats a nested `yarn.lock` +/// as a separate project. A root with no npm lock is left alone too. +/// +/// Returns the workspace root with a one-line detail naming both locks, +/// `None` otherwise. +pub(crate) async fn npm_member_stray_lock(root: &Path) -> Option<(PathBuf, String)> { + let npm_locks = NpmLockFamily::Npm.files(); + let own: Vec<&str> = npm_locks + .iter() + .copied() + .filter(|name| root.join(name).exists()) + .collect(); + let other_own = npm_lock_files() + .filter(|name| !npm_locks.contains(name)) + .chain(EXTRA_OWN_LOCKS) + .any(|name| root.join(name).exists()) + || root.join("rush.json").exists(); + if own.is_empty() || other_own { + return None; + } + let (ancestor, locks) = package_json_workspace_root(root).await?; + let root_npm_locks: Vec<&str> = locks + .into_iter() + .filter(|name| npm_locks.contains(name)) + .collect(); + if root_npm_locks.is_empty() { + return None; + } + let detail = format!( + "{} is a member of the npm workspace rooted at {}: npm installs it from {} and \ + ignores its own {}, so a lock rewritten here would never be installed", + root.display(), + ancestor.display(), + join_paths(&ancestor, &root_npm_locks), + join_paths(root, &own) + ); + Some((ancestor, detail)) +} + +/// The hosted refusal for [`npm_member_stray_lock`]. +async fn npm_member_stray_lock_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { + let (ancestor, detail) = npm_member_stray_lock(root).await?; + let refusal = Refusal { + code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), + message: format!( + "{detail}; run socket-patch from {} (the workspace root); nothing was \ + written", + ancestor.display() + ), + }; + Some((ancestor, refusal)) +} + +fn join_paths(dir: &Path, names: &[&str]) -> String { + names + .iter() + .map(|name| dir.join(name).display().to_string()) + .collect::>() + .join(", ") +} + +/// The governing `package.json` workspace root of a member and the +/// workspace locks it holds (see [`package_json_workspace_refusal`]). +async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'static str>)> { let canonical = tokio::fs::canonicalize(root) .await .unwrap_or_else(|_| root.to_path_buf()); @@ -293,7 +383,7 @@ async fn package_json_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal if !workspaces_include(&patterns, &rel) { continue; } - let mut locks: Vec<&str> = WORKSPACE_ROOT_FAMILIES + let mut locks: Vec<&'static str> = WORKSPACE_ROOT_FAMILIES .iter() .flat_map(|family| family.files().iter().copied()) .filter(|name| ancestor.join(name).is_file()) @@ -313,24 +403,7 @@ async fn package_json_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal member = ancestor; continue; } - let refusal = Refusal { - code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), - message: format!( - "{} is a workspace member with no lockfile of its own: the workspace \ - root {} lists it under \"workspaces\" and installs it from {}, which a \ - hosted run here cannot see; run socket-patch from {} (the workspace \ - root); nothing was written", - root.display(), - ancestor.display(), - locks - .iter() - .map(|name| ancestor.join(name).display().to_string()) - .collect::>() - .join(", "), - ancestor.display() - ), - }; - return Some((ancestor.to_path_buf(), refusal)); + return Some((ancestor.to_path_buf(), locks)); } None } @@ -1078,7 +1151,8 @@ mod tests { code(&tmp.path().join("packages/excluded"), "npm").await, None ); - // A member with its own lock. + // A member with its own npm lock, under a root whose yarn.lock npm + // does not read (the npm-root case is #1094's test). write(tmp.path(), "packages/a/package-lock.json", "{}"); assert_eq!(code(&member, "npm").await, None); @@ -1090,6 +1164,75 @@ mod tests { assert_eq!(code(&tmp.path().join("sub"), "npm").await, None); } + /// #1094: npm installs every workspace member from the root's lock and + /// never reads a `package-lock.json` / `npm-shrinkwrap.json` inside the + /// member, so a stray member npm lock does not make the member its own + /// lock root when the workspace root holds an npm lock. Other own locks + /// (pnpm, yarn, Bun, vlt), and a root with no npm lock, keep the + /// member's own-lock shortcut. + #[tokio::test] + async fn npm_member_with_stray_npm_lock_is_refused() { + for (root_lock, member_lock) in [ + ("package-lock.json", "package-lock.json"), + ("package-lock.json", "npm-shrinkwrap.json"), + ("npm-shrinkwrap.json", "package-lock.json"), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"name":"root","private":true,"workspaces":["packages/*"]}"#, + ); + write(tmp.path(), root_lock, "{}"); + write(tmp.path(), "packages/a/package.json", "{}"); + write(tmp.path(), &format!("packages/a/{member_lock}"), "{}"); + let member = tmp.path().join("packages/a"); + let refused = refusal(&ProjectView::Disk(&member), &[candidate("npm")], true) + .await + .unwrap_or_else(|| panic!("{root_lock}/{member_lock}: member must be refused")); + assert_eq!(refused.code, WORKSPACE_LOCKFILE_ELSEWHERE); + assert!( + refused.message.contains(root_lock) + && refused.message.contains(member_lock) + && refused.message.contains("ignores") + && refused.message.contains("nothing was written") + // The only convergent remedy: the directory stays a + // listed member whatever lock it holds (Bugbot on #1095). + && !refused.message.contains("delete"), + "{}", + refused.message + ); + assert_eq!(code(&member, "pypi").await, None); + assert_eq!(code(tmp.path(), "npm").await, None); + } + + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"private":true,"workspaces":["packages/*"]}"#, + ); + write(tmp.path(), "packages/a/package.json", "{}"); + write(tmp.path(), "packages/a/package-lock.json", "{}"); + let member = tmp.path().join("packages/a"); + // No npm lock at the root (lockless, or another manager's lock). + assert_eq!(code(&member, "npm").await, None); + write(tmp.path(), "yarn.lock", ""); + assert_eq!(code(&member, "npm").await, None); + // A member that also holds a lock its manager does read keeps the + // shortcut (yarn berry treats a nested yarn.lock as its own project). + write(tmp.path(), "package-lock.json", "{}"); + assert_eq!( + code(&member, "npm").await.as_deref(), + Some(WORKSPACE_LOCKFILE_ELSEWHERE) + ); + for own in ["yarn.lock", "bun.lock", "vlt-lock.json"] { + write(tmp.path(), &format!("packages/a/{own}"), ""); + assert_eq!(code(&member, "npm").await, None, "{own}"); + std::fs::remove_file(member.join(own)).unwrap(); + } + } + /// The nearest ancestor that lists the member is its root, past an /// intermediate `package.json` that does not. #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 5bd94ed76..480a91782 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -397,6 +397,26 @@ async fn detect_vendorable_npm_flavor_with( )) } +/// #1094: a package-lock project that is a member of an npm workspace +/// holds a lock npm never reads (members install from the workspace +/// root's lock), so vendoring into it would wire nothing. Refused as a +/// member without that lock is (`vendor_lockfile_missing`). Shared by +/// [`vendor_npm_any`] and the hosted→vendored takeover preflight +/// ([`super::npm_lock::npm_lock_vendor_preflight`]), which must refuse +/// before the takeover restores the hosted pin. +pub(crate) async fn npm_member_stray_lock_refusal( + project_root: &Path, +) -> Option<(&'static str, String)> { + let (root, detail) = crate::hosted::governing_root::npm_member_stray_lock(project_root).await?; + Some(( + "vendor_lockfile_missing", + format!( + "{detail}; vendor from {} (the workspace root)", + root.display() + ), + )) +} + /// Vendor one npm package through whichever lockfile-flavor backend serves /// this project (package-lock / yarn classic / yarn berry node-modules / /// pnpm / pnpm legacy / bun / vlt). Probe refusals (PnP, unsupported lock @@ -419,6 +439,11 @@ pub async fn vendor_npm_any<'a>( Ok(found) => found, Err((code, detail)) => return VendorOutcome::Refused { code, detail }, }; + if flavor == NpmLockFlavor::PackageLock { + if let Some((code, detail)) = npm_member_stray_lock_refusal(project_root).await { + return VendorOutcome::Refused { code, detail }; + } + } if let Some(detail) = flavor_change_refusal(project_root, purl, flavor).await { return VendorOutcome::Refused { code: "vendor_flavor_changed", @@ -1737,6 +1762,57 @@ mod tests { ))); } + /// #1094: a workspace member's own package-lock.json is a lock npm never + /// reads (members install from the workspace root's lock), so vendoring + /// into it would wire nothing. The member is refused as it is without + /// the stray lock, and nothing is written. + #[tokio::test] + async fn npm_member_with_stray_lock_is_refused() { + let (tmp, record) = npm_project().await; + let ws = tempfile::tempdir().unwrap(); + let member = ws.path().join("packages/a"); + tokio::fs::create_dir_all(member.parent().unwrap()) + .await + .unwrap(); + tokio::fs::rename(tmp.path(), &member).await.unwrap(); + touch( + ws.path(), + "package.json", + r#"{"name":"root","private":true,"workspaces":["packages/*"]}"#, + ) + .await; + touch(ws.path(), "package-lock.json", "{}").await; + let lock_before = tokio::fs::read(member.join("package-lock.json")) + .await + .unwrap(); + + // The hosted→vendored takeover preflight raises the same refusal + // first, so a leftover hosted pin is never restored only to be + // refused (Bugbot on #1095). + let preflight = crate::vendor::npm_lock_vendor_preflight(&member) + .await + .expect("the takeover preflight refuses the member"); + + let outcome = vendor_any(&member, &record).await; + let VendorOutcome::Refused { code, detail } = outcome else { + panic!("expected Refused, got {outcome:?}"); + }; + assert_eq!(code, "vendor_lockfile_missing"); + assert!( + detail.contains("workspace") && detail.contains("ignores"), + "{detail}" + ); + assert!(!detail.contains("delete"), "{detail}"); + assert_eq!(preflight, (code, detail)); + assert!(!member.join(".socket/vendor").exists()); + assert_eq!( + tokio::fs::read(member.join("package-lock.json")) + .await + .unwrap(), + lock_before + ); + } + /// A yarn.lock ROUTES to the yarn-classic backend. With a header-only /// lock that has no matching block, the backend's own `vendor_lock_entry_not_found` /// proves the dispatch reached it — and nothing is written. diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 7f14ddf8b..cf3ff48a8 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -417,8 +417,10 @@ pub async fn vendor_npm<'a>( } /// The project-level refusal [`vendor_npm`]'s step 2 raises whatever the -/// purl: the primary lock (`npm-shrinkwrap.json`, else `package-lock.json`) -/// is not parseable JSON or not a v2/v3 lock. `None` unless the project's +/// purl: the project is an npm workspace member whose own lock npm never +/// reads (#1094, [`super::npm_flavor::npm_member_stray_lock_refusal`]), or +/// the primary lock (`npm-shrinkwrap.json`, else `package-lock.json`) is +/// not parseable JSON or not a v2/v3 lock. `None` unless the project's /// npm flavor is package-lock (the probe `vendor_npm_any` routes on) and /// that lock fails the gate; a missing or unreadable lock is left to the /// backend's own refusal. @@ -437,6 +439,9 @@ pub async fn npm_lock_vendor_preflight(project_root: &Path) -> Option<(&'static ) { return None; } + if let Some(refusal) = super::npm_flavor::npm_member_stray_lock_refusal(project_root).await { + return Some(refusal); + } let (lock_name, lock_bytes, _) = select_lockfile(project_root).await.ok()??; let gate = match LOCK_MEMO.parse(&lock_bytes, || parse_json_manifest(&lock_bytes)) { Ok(lock) => lock_version_gate(&lock, &lock_name).err(),