Skip to content

Vendored npm dry run and --max-new-patches planning miss per-entry package-lock refusals, so a URL-spec, git-spec or workspace-member package previews as vendorable and takes the only rollout slot on every run #1976

Closed

Description

[agent] Found by the scheduled npm bug-hunt routine (ledger #302).

Summary

For an npm package-lock project, scan --mode vendored decides which patches "the wet run would refuse" with preflight_refused_purls (planning) and preview_vendor (the --dry-run preview). For npm package-lock, both only ask npm_lock_vendor_preflight, the project-level check that refuses a lockfileVersion 1 lock. The per-entry refusals the npm backend raises later aren't checked:

  • vendor_lock_entry_not_rewritable: the only copy is installed from a remote tarball URL or a git spec (also a bundled or hasShrinkwrap copy).
  • vendor_workspace_member: the package is a workspace member link.

So for those packages:

  1. scan --mode vendored --dry-run and get … --mode vendored --dry-run report verified ("Would download and vendor 1 patch"), exit 0. The wet run then refuses with exit 1 (partialFailure). Compare the v1-lock case, where the same preview correctly prints [would-refuse] … (vendor_lockfile_version_unsupported).
  2. With --max-new-patches N, the refused package still takes a rollout slot. If it is the most severe new patch, it takes the slot on every run, and a vendorable patch ranked below it is deferred forever. Each re-scan downloads the refused patch again, fails and defers the same vendorable patch.

Hosted mode on the same project is fine: it skips the copy with redirect_npm_non_registry_entry_skipped and pins the other package within the cap.

Impact

Repro (Linux, Node 22.23.4, npm 10.9.9, main cbbbdb9)

This uses a local mock of the org patch API on 127.0.0.1:8899 with the same contract as crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs (patches/batch, by-package, package (granted tarball + sha512), view/<uuid>, tarball route). It serves one free patch each for pkg:npm/left-pad@1.3.0 (severity high) and pkg:npm/is-number@7.0.0 (severity medium).

API="--api-url http://127.0.0.1:8899 --org org --api-token fake --patch-server-url http://127.0.0.1:8899"
mkdir app && cd app
cat > package.json <<'EOF'
{"name":"app","version":"1.0.0","dependencies":{
  "left-pad":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
  "is-number":"7.0.0"}}
EOF
npm install
socket-patch scan --mode vendored --dry-run --yes $API      # "Would download and vendor 2 patches", exit 0
socket-patch scan --mode vendored --max-new-patches 1 --yes $API; echo "exit=$?"
socket-patch scan --mode vendored --max-new-patches 1 --yes $API; echo "exit=$?"   # same again

Actual (both runs):

Downloading 1 patch...
  [fetch] pkg:npm/left-pad@1.3.0
Error: Cannot vendor pkg:npm/left-pad@1.3.0: … lock entry `node_modules/left-pad` is not installed from the registry (the project depends on it as "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", …) and CANNOT be rewritten …
Vendored 0 packages; 1 failed.
exit=1

JSON rollout: {"counts": {"new": 1, "deferred": 1}, "deferred": [{"purl": "pkg:npm/is-number@7.0.0", …}]}. .socket/vendor/state.json is never written.

Control: with "left-pad": "1.3.0" (registry), run 1 vendors left-pad and defers is-number, and run 2 vendors is-number (exit 0 both times).

Same result with "left-pad": "github:stevemao/left-pad#v1.3.0" (vendor_lock_entry_not_rewritable), and with a workspace member packages/left-pad ("workspaces": ["packages/*"], the upstream 1.3.0 sources) (vendor_workspace_member).

Expected vs actual

  • Expected: CLI_CONTRACT.md (scan --mode vendored paragraph and the error-code table row "Bun / pnpm-takeover / PyPI refusal codes") describes the dry run as a per-patch preview that lists a package the wet run would refuse as skipped with that refusal code. The rollout plan comment in scan/mod.rs says "Vendored eligibility is the wet run's Bun / vlt / npm-lock / gem takeover preflight", and preflight_refused_purls says "so a refused NEW patch holds no rollout slot". A package the npm backend is certain to refuse should be previewed would_refuse and should not take a slot, just like the v1-lock case.
  • Actual: previewed verified, takes the slot, refused at vendor time, every run.

Matrix (Linux)

npm shape dry run --max-new-patches 1 main cbbbdb9 v5.0.1
8.19.4 (v2) URL spec verified left-pad refused, is-number deferred repro repro
10.9.9 (v3) URL spec verified same repro repro
12.2.1 (allow-remote=all) URL spec verified same repro not run
10.9.9 git spec (github:) verified same (vendor_lock_entry_not_rewritable) repro repro
10.9.9 / 12.2.1 workspace-member link verified same (vendor_workspace_member) repro repro
6.14.18 (v1 lock) registry would_refuse (correct) — ok —
any registry (control) verified vendors 1, defers 1, next run vendors it ok ok

It's not a regression: v5.0.1 behaves the same.

Suspect code

  • crates/socket-patch-cli/src/commands/scan/vendor_flow.rs:385 npm_lock_refusal → crates/socket-patch-core/src/vendor/npm_lock.rs:413 npm_lock_vendor_preflight covers only the project-level lock-version refusal, and it's the only npm package-lock input to both preflight_refused_purls (vendor_flow.rs:349, used by the rollout plan at scan/mod.rs:3306) and preview_vendor (vendor_flow.rs:193).
  • The per-entry refusals are only raised inside the backend: npm_lock.rs:525 (vendor_workspace_member) and npm_lock.rs:556 (vendor_lock_entry_not_rewritable).

Related, but not the same bug: #1908 / PR #1930 (the planning pass drops pypi_refusals; its acceptance criteria keep the npm-lock preview rows unchanged, so this gap stays after it lands). #1965 is a different gap: an override to a URL isn't refused at all. #1850 covers the dry-run headline counting already-vendored rows.

No probe runs: probe branches are blocked in this session (see ledger), so macOS and Windows weren't run. The logic is OS-independent.

Activity

  1. mikolalysenko commented on Nov 2, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triaged as priority:p1 (npm). Not a duplicate: #1908 / #1930 is about the planning pass dropping the PyPI refusals and #1965 / #1966 is about overrides to a URL; neither makes the npm package-lock preview ask the per-entry refusals (vendor_lock_entry_not_rewritable, vendor_workspace_member). No open or merged PR addresses it.


    Generated by Claude Code

  2. mikolalysenko commented on Nov 2, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming this issue (shared root cause: the vendored lock-text refusal check, npm_flavor::lock_text_refusals, skips the npm package-lock flavor, and the dry-run preview and rollout planning never ask it). Branch: agent/fix-npm-lock-entry-preview-refusals. Claim-ID: 2026-11-02T08:23:10Z-6878be


    Generated by Claude Code

  3. mikolalysenko commented on Nov 2, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #1977


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions