Skip to content

Lock-only yarn classic VEX wrongly omits a correctly pinned patch with dependency_not_installed when the patched package.json has a "" range or a dependency yarn de-duplicates (regression from #1548) #1558

Open

Description

[agent] Found by the scheduled Yarn classic (1.x) bug-hunt routine (ledger #304).

Summary

#1548 (the #1538 fix) added a lock-only check for hosted yarn classic pins whose patch changes package.json. The check requires the pinned block's dependencies: / optionalDependencies: sub-maps to equal the patched package.json's maps (dep_maps_declare). The comparison reads both sides more literally than yarn does, so it fails on locks that install exactly what the patched manifest asks for:

  1. "" range. yarn writes ms "" in the sub-map, but block_dep_maps skips any entry whose range is empty, while manifest_dep_maps keeps ("ms", ""). Lock-only vex omits the patch on the lock socket-patch's own hosted scan just wrote, with no other step in between.
  2. The same name in optionalDependencies and dependencies. yarn 1's manifest normaliser (cleanDependencies) keeps such a name only under optionalDependencies. The hosted writer (with_manifest_dep_maps) writes it under both. After any yarn command that re-serialises the lock (e.g. yarn add ms), the block has yarn's shape and lock-only vex omits the patch.
  3. Same root cause (seen once on 1.22.22): "*" in dependencies plus a range for the same name in devDependencies. yarn writes the non-trivial range into the block, which no longer matches "*".

Every one of these installs patched with --frozen-lockfile, and installed vex attests. Only the lock-only path refuses.

Impact

A false omission, not a false attestation: CI that runs vex on a lock-only checkout (the documented lockfile basis) drops a correctly applied patch from the VEX document, or exits 1 no_applicable_patches when it was the only one. In shape 1 this happens right after a clean scan --mode hosted. In shape 2 it happens after any routine yarn add / yarn upgrade.

Repro (shape 1, yarn 1.22.22, local mock patch API)

The mock serves a patch for is-odd@3.0.1 that prepends a marker to index.js and sets the package.json dependencies to {"is-number": ""}. The record lists both files.

# project
echo '{"name":"p","version":"1.0.0","dependencies":{"is-odd":"3.0.1","is-number":""}}' > package.json
yarn install                      # locks is-number@: -> 7.0.0
socket-patch scan --mode hosted --json --yes   # exit 0, redirect_yarn_classic_dep_manifest_rewritten
#   is-odd block now: resolved <mock tarball>, dependencies: is-number ""
# fresh checkout with node_modules:
yarn install --frozen-lockfile    # exit 0, is-odd patched
socket-patch vex --output v.json --json    # exit 0, not_affected (correct)
# lock-only checkout (package.json + yarn.lock):
socket-patch vex --output v.json --json
#   exit 1: skipped pkg:npm/is-odd@3.0.1 errorCode dependency_not_installed
#   "the patched package.json declares a dependency that is not installed beside the package"
#   error.code no_applicable_patches

Shape 2: the patch sets dependencies: {"is-number": "^6.0.0"}, optionalDependencies: {"is-number": "^6.0.0"}. Run the hosted scan (the writer adds is-number to both sub-maps, and lock-only vex attests at this point), then yarn add ms@2.1.3. yarn rewrites the pinned block to optionalDependencies: is-number "^6.0.0" only, and lock-only vex then exits 1 dependency_not_installed.

Controls (1.22.22): shape 2 without the yarn add, and a plain range change (is-number ^7.0.0, locked) with and without yarn add, all attest from the lock.

Expected vs actual

CLI_CONTRACT dependency_not_installed (lock-only): omitted when the pinned block's sub-maps "are not exactly the ones the patched package.json declares, each locked". Here the block declares exactly what yarn installs for that manifest, and every descriptor is locked (is-number@: / is-number@^6.0.0). The patch should be attested, as it is from the installed tree.

Actual: lock-only vex exits 1 / omits the patch with dependency_not_installed.

Matrix (Linux, main 15db5c4, each cell run twice)

yarn shape 1 ("") lock-only shape 2 (opt+dep after yarn add) lock-only frozen install installed vex
1.7.0 omitted (bug) omitted (bug) patched attests
1.10.1 omitted (bug) omitted (bug) patched attests
1.22.22 omitted (bug) omitted (bug) patched attests

macOS / Windows not probed: the logic is pure lock-text comparison with no platform-specific code.

First bad commit

e719a9b (#1548). Before it, lock-only vex had no sub-map check.

Suspect code

  • crates/socket-patch-core/src/formats/yarn/classic_deps.rs:189: block_dep_maps drops entries whose range is empty, so foo "" never matches the manifest's ("foo", ""). block_dep_entries (line 127) has the same skip.
  • crates/socket-patch-core/src/formats/yarn/classic_deps.rs:210: manifest_dep_maps doesn't apply yarn's cleanDependencies (a name kept only in the first of optionalDependencies → dependencies → devDependencies, and a "" / "*" range replaced by a non-trivial one from another field). with_manifest_dep_maps (line 28) writes the un-normalised shape, so yarn's next write changes the block.
  • crates/socket-patch-core/src/vex/discover/mod.rs:319 (installs_dependencies_of) → crates/socket-patch-cli/src/commands/vex.rs:553 (yarn_classic_lock_dependency_gaps).
  • yarn reference: cleanDependencies in yarn 1.22.22 lib/cli.js (normalize-manifest fix), with dependencyKeys = ['optionalDependencies', 'dependencies', 'devDependencies'].

Activity

  1. mikolalysenko commented on Oct 15, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triage: priority:p1 (yarn classic). Confirmed a regression from #1548: block_dep_maps / block_dep_entries in formats/yarn/classic_deps.rs drop "" ranges, and manifest_dep_maps / with_manifest_dep_maps do not apply yarn 1's cleanDependencies normalisation. Not a duplicate of #1538, which was the unlocked-range case #1548 fixed. No open PR covers it yet.


    Generated by Claude Code

  2. mikolalysenko commented on Oct 16, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming this issue (with #1578; shared root cause: formats/yarn/classic_deps.rs reads the patched package.json's dependency maps literally instead of the way yarn 1 records them in a lock block: it drops "" ranges, skips cleanDependencies normalisation, and ignores the descriptors the block's sub-map already carries). Branch: agent/fix-yarn-classic-dep-manifest-model. Claim-ID: 2026-10-16T00:20:55Z-09f30c


    Generated by Claude Code

  3. mikolalysenko commented on Oct 16, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #1596


    Generated by Claude Code

  4. mikolalysenko commented on Oct 16, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Yarn classic bug-hunt run 43: tested PR #1596 (head 4d3698c1) against main 657fce2 on Linux with a local mock patch API (is-odd@3.0.1). Main still reproduces every shape below.

    shape yarn main lock-only vex PR lock-only vex
    1: is-number "", right after the hosted scan 1.7.0 / 1.22.22 exit 1 dependency_not_installed not_affected
    2: the same range in optional and dependencies, then yarn add ms@2.1.3 1.7.0 / 1.22.22 dependency_not_installed not_affected
    3: "*" in dependencies with ^6.0.0 in devDependencies 1.7.0 / 1.22.22 main refuses the hosted scan (redirect_yarn_classic_dep_manifest_unlocked) pinned; frozen install patched; both vex modes attest

    In every case the frozen install is patched and installed vex attests. I also checked backward compatibility: a shape-1 pin written by main attests under the PR's lock-only vex, and the PR's rescan leaves that lock byte-identical.


    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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions