From 3aaed3ae529c09714eea97cb7b33f10673d5d8d8 Mon Sep 17 00:00:00 2001 From: Zachary LaVallee Date: Tue, 23 Jun 2026 17:00:26 -0700 Subject: [PATCH] fix(pnpm): merge colliding peer-suffixed v9 snapshot keys In pnpm v9, snapshot keys carry a peer-dependency suffix that is stripped so they can be looked up against the (unsuffixed) packages section. A package installed under multiple peer variants yields several snapshot keys that collapse to the same stripped key; the previous `HashMap.mapKeys` collapse was last-wins and silently dropped all but one variant's dependency list. Edges living only on a dropped variant disappeared, so dev-only transitives lost their dev path and were reported as production. Collapse with `HashMap.fromListWith (<>)` instead, unioning the colliding entries' dependency lists so no edges are lost. v5/v6/v7 paths are unaffected (only the v9 snapshots decoder changes). Adds a v9 regression fixture + spec where a dev dependency is present under two peer-suffixed snapshot keys with distinct children. Refs: ANE-2852 Co-Authored-By: Claude Opus 4.8 --- Changelog.md | 4 ++ src/Strategy/Node/Pnpm/PnpmLock.hs | 15 ++++- test/Pnpm/PnpmLockSpec.hs | 26 +++++++++ .../pnpm-9-peer-collision/pnpm-lock.yaml | 55 +++++++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 test/Pnpm/testdata/pnpm-9-peer-collision/pnpm-lock.yaml diff --git a/Changelog.md b/Changelog.md index 432dfb6a8..e4859a406 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,9 @@ # FOSSA CLI Changelog +## Unreleased + +- pnpm: Fix v9 dev-dependency filtering when peer-suffixed lockfile keys collide ([#1724](https://github.com/fossas/fossa-cli/pull/1724)). + ## 3.17.11 - Conan: Handle list-valued license in make_fossa_deps_conan ([#1719](https://github.com/fossas/fossa-cli/pull/1719)). diff --git a/src/Strategy/Node/Pnpm/PnpmLock.hs b/src/Strategy/Node/Pnpm/PnpmLock.hs index 7a98380da..84d0ef1e2 100644 --- a/src/Strategy/Node/Pnpm/PnpmLock.hs +++ b/src/Strategy/Node/Pnpm/PnpmLock.hs @@ -202,7 +202,20 @@ instance FromJSON PnpmLockFileSnapshots where -- Remove the peer dependency suffix. It's present in the snapshot entry, but it's not present in packages -- section which is where we look the dependency up. - let snapshots' = (HashMap.mapKeys withoutPeerDepSuffix) . toHashMapText $ snapshots + -- + -- A single package can have several peer-suffixed snapshot keys (e.g. + -- @core@1.0.0(peerx@1.0.0)@ and @core@1.0.0(peery@1.0.0)@) that all + -- collapse to the same de-suffixed key. Merge (union) their dependency + -- lists instead of letting one variant overwrite the others, otherwise + -- edges that live only on a dropped variant are lost and their dev/prod + -- environment fails to propagate. (ANE-2852) + let snapshots' = + HashMap.fromListWith + (<>) + . map (\(k, v) -> (withoutPeerDepSuffix k, v)) + . HashMap.toList + . toHashMapText + $ snapshots pure $ PnpmLockFileSnapshots{snapshots = snapshots'} diff --git a/test/Pnpm/PnpmLockSpec.hs b/test/Pnpm/PnpmLockSpec.hs index a22c5dfce..188b09317 100644 --- a/test/Pnpm/PnpmLockSpec.hs +++ b/test/Pnpm/PnpmLockSpec.hs @@ -128,6 +128,7 @@ spec = do let pnpmLockV9LocalDep = currentDir $(mkRelFile "test/Pnpm/testdata/pnpm-9-local-dep/pnpm-lock.yaml") let pnpmLockV9MultiVersion = currentDir $(mkRelFile "test/Pnpm/testdata/pnpm-9-multi-version/pnpm-lock.yaml") let pnpmLockV9Catalogs = currentDir $(mkRelFile "test/Pnpm/testdata/pnpm-9-catalogs/pnpm-lock.yaml") + let pnpmLockV9PeerCollision = currentDir $(mkRelFile "test/Pnpm/testdata/pnpm-9-peer-collision/pnpm-lock.yaml") describe "works with v9 format" $ do checkGraph pnpmLockV9 pnpmLockV9GraphSpec @@ -136,6 +137,7 @@ spec = do describe "local dep env propagation" $ checkGraph pnpmLockV9LocalDep pnpmLockV9LocalDepSpec describe "multi-version env labeling" $ checkGraph pnpmLockV9MultiVersion pnpmLockV9MultiVersionSpec describe "catalogs" $ checkGraph pnpmLockV9Catalogs pnpmLockV9CatalogsSpec + describe "colliding peer-suffixed snapshot keys" $ checkGraph pnpmLockV9PeerCollision pnpmLockV9PeerCollisionSpec pnpmLockGraphSpec :: Graphing Dependency -> Spec pnpmLockGraphSpec graph = do @@ -490,6 +492,30 @@ pnpmLockV9MultiVersionSpec graph = do -- sax@1.4.4 is dev-only (from app-b) expectDep (mkDevDep "sax@1.4.4") graph +-- ANE-2852 regression: a dev-only package (`core`) appears in `snapshots:` +-- under several peer-suffixed keys that all collapse to the same de-suffixed +-- key. The collapse must merge (not overwrite) the colliding dependency lists, +-- otherwise a child reachable only through a dropped variant (`plugin-a` / +-- `plugin-b`) loses its dev path and leaks as production. +pnpmLockV9PeerCollisionSpec :: Graphing Dependency -> Spec +pnpmLockV9PeerCollisionSpec graph = do + let hasEdge :: Dependency -> Dependency -> Expectation + hasEdge = expectEdge graph + + describe "buildGraph with colliding peer-suffixed snapshot keys" $ do + it "should mark the direct dev dependency as dev" $ + expectDirect [mkDevDep "core@1.0.0"] graph + + it "should keep edges from every colliding peer variant" $ do + hasEdge (mkDevDep "core@1.0.0") (mkDevDep "plugin-a@1.0.0") + hasEdge (mkDevDep "core@1.0.0") (mkDevDep "plugin-b@1.0.0") + + it "should propagate dev to children of all merged variants" $ do + -- Without the merge, last-wins drops one variant and its child leaks + -- with an empty environment (reported as production). + expectDep (mkDevDep "plugin-a@1.0.0") graph + expectDep (mkDevDep "plugin-b@1.0.0") graph + pnpmLockV9CatalogsSpec :: Graphing Dependency -> Spec pnpmLockV9CatalogsSpec graph = do let hasEdge :: Dependency -> Dependency -> Expectation diff --git a/test/Pnpm/testdata/pnpm-9-peer-collision/pnpm-lock.yaml b/test/Pnpm/testdata/pnpm-9-peer-collision/pnpm-lock.yaml new file mode 100644 index 000000000..ca563e144 --- /dev/null +++ b/test/Pnpm/testdata/pnpm-9-peer-collision/pnpm-lock.yaml @@ -0,0 +1,55 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +# ANE-2852 regression: a dev-only package (`core`) is installed under multiple +# peer-suffixed snapshot keys. Those keys all collapse to the same de-suffixed +# key (`core@1.0.0`). A last-wins collapse keeps only one variant's dependency +# list, so a transitive child reachable only through a dropped variant loses its +# dev path and leaks as production. Here `plugin-a` lives only on the +# `(peerx@1.0.0)` variant and `plugin-b` only on the `(peery@1.0.0)` variant, so +# last-wins always drops one of them. Merging the colliding entries keeps both +# edges and lets the dev environment propagate to both children. +importers: + .: + devDependencies: + core: + specifier: ^1.0.0 + version: 1.0.0(peerx@1.0.0) + +packages: + core@1.0.0: + resolution: {integrity: sha512-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa==} + + peerx@1.0.0: + resolution: {integrity: sha512-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb==} + + peery@1.0.0: + resolution: {integrity: sha512-cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc==} + + plugin-a@1.0.0: + resolution: {integrity: sha512-dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd==} + + plugin-b@1.0.0: + resolution: {integrity: sha512-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee==} + +snapshots: + core@1.0.0(peerx@1.0.0): + dependencies: + peerx: 1.0.0 + plugin-a: 1.0.0 + + core@1.0.0(peery@1.0.0): + dependencies: + peery: 1.0.0 + plugin-b: 1.0.0 + + peerx@1.0.0: {} + + peery@1.0.0: {} + + plugin-a@1.0.0: {} + + plugin-b@1.0.0: {}