From 0a36bca97904c618d2489a0e478fb9ffdc9061f6 Mon Sep 17 00:00:00 2001 From: spatten Date: Fri, 13 Mar 2026 15:58:36 -0700 Subject: [PATCH 01/16] Fix pnpm v9 transitive devDependency classification using hydrateDepEnvs pnpm lockfile v9 removed the `dev: true/false` flag from packages. The previous `isPnpm9Dev` only checked direct deps, missing transitive dev deps entirely. Replace with the `hydrateDepEnvs` pattern (matching Yarn V1/V2/Poetry): all v9 deps start with empty environments, direct deps get labeled from importer sections, then labels propagate via graph reachability. Shared deps correctly get both environments. - Remove `devOverride` param from `toDependency`/`toResolvedDependency` - Remove `isPnpm9Dev`, `isPnpm9ProjectDev`, `isPnpm9ProjectDep` - Add `maybeHydrate`, `labelV9DirectDeps`, `v9ProdDirectNames`, `v9DevDirectNames` - `toEnv` returns `mempty` for v9 (hydration sets envs instead) - Non-v9 behavior unchanged (`maybeHydrate = id`, `toEnv` uses `isDev`) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Strategy/Node/Pnpm/PnpmLock.hs | 95 ++++++++++++++++++------------ 1 file changed, 58 insertions(+), 37 deletions(-) diff --git a/src/Strategy/Node/Pnpm/PnpmLock.hs b/src/Strategy/Node/Pnpm/PnpmLock.hs index ebde629ad..1e9cd8854 100644 --- a/src/Strategy/Node/Pnpm/PnpmLock.hs +++ b/src/Strategy/Node/Pnpm/PnpmLock.hs @@ -28,8 +28,10 @@ import Data.Yaml qualified as Yaml import DepTypes ( DepEnvironment (EnvDevelopment, EnvProduction), DepType (GitType, NodeJSType, URLType, UserType), - Dependency (Dependency, dependencyType), + Dependency (Dependency, dependencyName, dependencyType), VerConstraint (CEq), + hydrateDepEnvs, + insertEnvironment, ) import Effect.Grapher (deep, direct, edge, evalGrapher, run) import Effect.Logger ( @@ -39,6 +41,7 @@ import Effect.Logger ( ) import Effect.ReadFS (ReadFS, readContentsYaml) import Graphing (Graphing, shrink) +import Graphing qualified import Path (Abs, File, Path) -- | Pnpm Lockfile @@ -301,7 +304,7 @@ analyze file = context "Analyzing Npm Lockfile (v3)" $ do context "Building dependency graph" $ pure $ buildGraph pnpmLockFile buildGraph :: PnpmLockfile -> Graphing Dependency -buildGraph lockFile = withoutLocalPackages $ +buildGraph lockFile = maybeHydrate . withoutLocalPackages $ run . evalGrapher $ do for_ (toList lockFile.importers) $ \(_, projectImporters) -> do let allDirectDependencies = @@ -309,7 +312,7 @@ buildGraph lockFile = withoutLocalPackages $ <> toList (directDevDependencies projectImporters) for_ allDirectDependencies $ \(depName, (ProjectMapDepMetadata depVersion)) -> - maybe (pure ()) direct $ toResolvedDependency (isPnpm9Dev depName) depName depVersion + maybe (pure ()) direct $ toResolvedDependency depName depVersion -- Add edges and deep dependencies by iterating over all packages. -- @@ -338,14 +341,14 @@ buildGraph lockFile = withoutLocalPackages $ let (depName, depVersion) = case getPkgNameVersion pkgKey of Nothing -> (pkgKey, Nothing) Just (name, version) -> (name, Just version) - let parentDep = toDependency (isPnpm9Dev depName) depName depVersion pkgMeta + let parentDep = toDependency depName depVersion pkgMeta -- It is ok, if this dependency was already graphed as direct -- @direct 1 <> deep 1 = direct 1@ deep parentDep for_ deepDependencies $ \(deepName, deepVersion) -> do - maybe (pure ()) (edge parentDep) (toResolvedDependency (isPnpm9Dev deepName) deepName deepVersion) + maybe (pure ()) (edge parentDep) (toResolvedDependency deepName deepVersion) where getPkgNameVersion :: Text -> Maybe (Text, Text) getPkgNameVersion = case lockFileVersion lockFile of @@ -418,8 +421,8 @@ buildGraph lockFile = withoutLocalPackages $ -- e.g. -- file:../local-package -- - toResolvedDependency :: Bool -> Text -> Text -> Maybe Dependency - toResolvedDependency devOverride depName depVersion = do + toResolvedDependency :: Text -> Text -> Maybe Dependency + toResolvedDependency depName depVersion = do -- Some versions of the lockfile remove the peer dep suffix. -- Others do not which is why it tries both. let strippedVersion = withoutPeerDepSuffix depVersion @@ -431,25 +434,8 @@ buildGraph lockFile = withoutLocalPackages $ <|> fmap (depVersion,) (Map.lookup (mkPkgKey depName depVersion) (packages lockFile)) case (maybeNonRegistrySrcPackage, maybeRegistrySrcPackage) of (Nothing, Nothing) -> Nothing - (Just nonRegistryPkg, _) -> Just $ toDependency devOverride depName Nothing nonRegistryPkg - (Nothing, Just (version, registryPkg)) -> Just $ toDependency devOverride depName (Just version) registryPkg - - -- PNPM lockfile 9 doesn't store information about dev deps directly on package data. - -- Use a project map to determine if a package should be marked dev. - isPnpm9ProjectDev :: Text -> ProjectMap -> Bool - isPnpm9ProjectDev name ProjectMap{directDevDependencies} = Map.member name directDevDependencies - - isPnpm9ProjectDep :: Text -> ProjectMap -> Bool - isPnpm9ProjectDep name ProjectMap{directDependencies} = Map.member name directDependencies - - -- Returns true if a dependency is either absent or a dev dependency in each workspace. If it is a - -- non-dev dependency in any workspace, then this function returns false. Also returns false if the - -- lockfile is not PNPM v9 - isPnpm9Dev :: Text -> Bool - isPnpm9Dev depName = - (lockFile.lockFileVersion == PnpmLockV9) - && (any (isPnpm9ProjectDev depName) lockFile.importers) - && not (any (isPnpm9ProjectDep depName) lockFile.importers) + (Just nonRegistryPkg, _) -> Just $ toDependency depName Nothing nonRegistryPkg + (Nothing, Just (version, registryPkg)) -> Just $ toDependency depName (Just version) registryPkg -- Makes representative key if the package was -- resolved via registry resolver. @@ -466,17 +452,17 @@ buildGraph lockFile = withoutLocalPackages $ PnpmLockV678 _ -> "/" <> name <> "@" <> version PnpmLockV9 -> name <> "@" <> version - toDependency :: Bool -> Text -> Maybe Text -> PackageData -> Dependency - toDependency devOverride name maybeVersion (PackageData isDev _ (RegistryResolve _) _ _) = - toDep NodeJSType name (withoutPeerDepSuffix . withoutSymConstraint <$> maybeVersion) (isDev || devOverride) - toDependency devOverride _ _ (PackageData isDev _ (GitResolve (GitResolution url rev)) _ _) = - toDep GitType url (Just rev) (isDev || devOverride) - toDependency devOverride _ _ (PackageData isDev _ (TarballResolve (TarballResolution url)) _ _) = - toDep URLType url Nothing (isDev || devOverride) - toDependency devOverride _ _ (PackageData isDev (Just name) (DirectoryResolve _) _ _) = - toDep UserType name Nothing (isDev || devOverride) - toDependency devOverride name _ (PackageData isDev Nothing (DirectoryResolve _) _ _) = - toDep UserType name Nothing (isDev || devOverride) + toDependency :: Text -> Maybe Text -> PackageData -> Dependency + toDependency name maybeVersion (PackageData isDev _ (RegistryResolve _) _ _) = + toDep NodeJSType name (withoutPeerDepSuffix . withoutSymConstraint <$> maybeVersion) isDev + toDependency _ _ (PackageData isDev _ (GitResolve (GitResolution url rev)) _ _) = + toDep GitType url (Just rev) isDev + toDependency _ _ (PackageData isDev _ (TarballResolve (TarballResolution url)) _ _) = + toDep URLType url Nothing isDev + toDependency _ _ (PackageData isDev (Just name) (DirectoryResolve _) _ _) = + toDep UserType name Nothing isDev + toDependency name _ (PackageData isDev Nothing (DirectoryResolve _) _ _) = + toDep UserType name Nothing isDev -- Sometimes package versions include symlinked paths -- of sibling dependencies used for resolution. @@ -490,8 +476,43 @@ buildGraph lockFile = withoutLocalPackages $ toDep depType name version isDev = Dependency depType name (CEq <$> version) mempty (toEnv isDev) mempty toEnv :: Bool -> Set.Set DepEnvironment + toEnv _ | isV9 = mempty toEnv isNotRequired = Set.singleton $ if isNotRequired then EnvDevelopment else EnvProduction + isV9 :: Bool + isV9 = lockFile.lockFileVersion == PnpmLockV9 + + -- For v9: label direct deps, then propagate via hydrateDepEnvs. + -- For non-v9: no-op (environments already set via isDev from PackageData). + maybeHydrate :: Graphing Dependency -> Graphing Dependency + maybeHydrate + | isV9 = hydrateDepEnvs . labelV9DirectDeps + | otherwise = id + + -- Seed environment labels on direct deps based on their importer section. + labelV9DirectDeps :: Graphing Dependency -> Graphing Dependency + labelV9DirectDeps = Graphing.gmap $ \dep -> + let name = dependencyName dep + withProd = if Set.member name v9ProdDirectNames then insertEnvironment EnvProduction else id + withDev = if Set.member name v9DevDirectNames then insertEnvironment EnvDevelopment else id + in withProd . withDev $ dep + + v9ProdDirectNames :: Set.Set Text + v9ProdDirectNames = + Set.fromList + [ depName + | (_, proj) <- toList lockFile.importers + , (depName, _) <- toList (directDependencies proj) + ] + + v9DevDirectNames :: Set.Set Text + v9DevDirectNames = + Set.fromList + [ depName + | (_, proj) <- toList lockFile.importers + , (depName, _) <- toList (directDevDependencies proj) + ] + withoutLocalPackages :: Graphing Dependency -> Graphing Dependency withoutLocalPackages = Graphing.shrink (\dep -> dependencyType dep /= UserType) From 32cc0401da1251f080d77e828b5a2171ab9657f9 Mon Sep 17 00:00:00 2001 From: spatten Date: Fri, 13 Mar 2026 15:58:57 -0700 Subject: [PATCH 02/16] Fix v9 test expectations: transitive dev deps should be EnvDevelopment sax and xmlbuilder are transitive deps of xml2js (a devDependency), so they should be marked as dev deps, not prod deps. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/Pnpm/PnpmLockSpec.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Pnpm/PnpmLockSpec.hs b/test/Pnpm/PnpmLockSpec.hs index aa7516228..ff04ecf79 100644 --- a/test/Pnpm/PnpmLockSpec.hs +++ b/test/Pnpm/PnpmLockSpec.hs @@ -407,5 +407,5 @@ pnpmLockV9GraphSpec graph = do -- ├── sax 1.4.4 -- └── xmlbuilder 11.0.1 hasEdge (mkProdDep "uri-js@4.4.1") (mkProdDep "punycode@2.3.1") - hasEdge (mkDevDep "xml2js@0.6.2") (mkProdDep "sax@1.4.4") - hasEdge (mkDevDep "xml2js@0.6.2") (mkProdDep "xmlbuilder@11.0.1") + hasEdge (mkDevDep "xml2js@0.6.2") (mkDevDep "sax@1.4.4") + hasEdge (mkDevDep "xml2js@0.6.2") (mkDevDep "xmlbuilder@11.0.1") From d487fb9c753a986e95d81b5873015c311f427d63 Mon Sep 17 00:00:00 2001 From: spatten Date: Fri, 13 Mar 2026 15:59:56 -0700 Subject: [PATCH 03/16] Add v9 shared-dependency test: deps reachable from both prod and dev New test fixture where sax is a transitive dep of both uri-js (prod) and xml2js (dev). With hydrateDepEnvs, sax correctly gets both EnvProduction and EnvDevelopment. punycode stays prod-only, xmlbuilder stays dev-only. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/Pnpm/PnpmLockSpec.hs | 38 ++++++++++++++ .../testdata/pnpm-9-shared-dep/pnpm-lock.yaml | 50 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 test/Pnpm/testdata/pnpm-9-shared-dep/pnpm-lock.yaml diff --git a/test/Pnpm/PnpmLockSpec.hs b/test/Pnpm/PnpmLockSpec.hs index ff04ecf79..a3ab4c67b 100644 --- a/test/Pnpm/PnpmLockSpec.hs +++ b/test/Pnpm/PnpmLockSpec.hs @@ -31,6 +31,19 @@ mkProdDep nameAtVersion = mkDep nameAtVersion (Just EnvProduction) mkDevDep :: Text -> Dependency mkDevDep nameAtVersion = mkDep nameAtVersion (Just EnvDevelopment) +mkBothEnvDep :: Text -> Dependency +mkBothEnvDep nameAtVersion = do + let nameAndVersionSplit = Text.splitOn "@" nameAtVersion + name = head nameAndVersionSplit + version = last nameAndVersionSplit + Dependency + NodeJSType + name + (CEq <$> Just version) + mempty + (Set.fromList [EnvProduction, EnvDevelopment]) + mempty + mkDep :: Text -> Maybe DepEnvironment -> Dependency mkDep nameAtVersion env = do let nameAndVersionSplit = Text.splitOn "@" nameAtVersion @@ -110,9 +123,12 @@ spec = do -- With the advent of lockfile v9, pnpm now has its own pnpm-workspace.yaml file. let pnpmLockV9Workspace = currentDir $(mkRelFile "test/Pnpm/testdata/pnpm-9-workspace-project/pnpm-lock.yaml") + let pnpmLockV9SharedDep = currentDir $(mkRelFile "test/Pnpm/testdata/pnpm-9-shared-dep/pnpm-lock.yaml") + describe "works with v9 format" $ do checkGraph pnpmLockV9 pnpmLockV9GraphSpec describe "workspace" $ checkGraph pnpmLockV9Workspace pnpmLockV9GraphSpec + describe "shared deps" $ checkGraph pnpmLockV9SharedDep pnpmLockV9SharedDepSpec pnpmLockGraphSpec :: Graphing Dependency -> Spec pnpmLockGraphSpec graph = do @@ -409,3 +425,25 @@ pnpmLockV9GraphSpec graph = do hasEdge (mkProdDep "uri-js@4.4.1") (mkProdDep "punycode@2.3.1") hasEdge (mkDevDep "xml2js@0.6.2") (mkDevDep "sax@1.4.4") hasEdge (mkDevDep "xml2js@0.6.2") (mkDevDep "xmlbuilder@11.0.1") + +pnpmLockV9SharedDepSpec :: Graphing Dependency -> Spec +pnpmLockV9SharedDepSpec graph = do + let hasEdge :: Dependency -> Dependency -> Expectation + hasEdge = expectEdge graph + + describe "buildGraph with shared deps" $ do + it "should mark direct dependencies of project as direct" $ do + expectDirect + [ mkProdDep "uri-js@4.4.1" + , mkDevDep "xml2js@0.6.2" + ] + graph + + it "should build edges with correct environments" $ do + -- sax is reachable from both prod (uri-js) and dev (xml2js) -- gets both + hasEdge (mkProdDep "uri-js@4.4.1") (mkBothEnvDep "sax@1.4.4") + hasEdge (mkDevDep "xml2js@0.6.2") (mkBothEnvDep "sax@1.4.4") + -- punycode is prod-only (via uri-js) + hasEdge (mkProdDep "uri-js@4.4.1") (mkProdDep "punycode@2.3.1") + -- xmlbuilder is dev-only (via xml2js) + hasEdge (mkDevDep "xml2js@0.6.2") (mkDevDep "xmlbuilder@11.0.1") diff --git a/test/Pnpm/testdata/pnpm-9-shared-dep/pnpm-lock.yaml b/test/Pnpm/testdata/pnpm-9-shared-dep/pnpm-lock.yaml new file mode 100644 index 000000000..4bcd354cb --- /dev/null +++ b/test/Pnpm/testdata/pnpm-9-shared-dep/pnpm-lock.yaml @@ -0,0 +1,50 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + dependencies: + uri-js: + specifier: ^4.4.1 + version: 4.4.1 + devDependencies: + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + +packages: + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + sax@1.4.4: + resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + +snapshots: + punycode@2.3.1: {} + + sax@1.4.4: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + sax: 1.4.4 + + xml2js@0.6.2: + dependencies: + sax: 1.4.4 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} From 2667d926b8ede2c45407d199f2b410983b764abf Mon Sep 17 00:00:00 2001 From: spatten Date: Fri, 13 Mar 2026 16:16:01 -0700 Subject: [PATCH 04/16] Replace list comprehensions with foldMap in v9 direct dep name sets Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Strategy/Node/Pnpm/PnpmLock.hs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/Strategy/Node/Pnpm/PnpmLock.hs b/src/Strategy/Node/Pnpm/PnpmLock.hs index 1e9cd8854..ffb541733 100644 --- a/src/Strategy/Node/Pnpm/PnpmLock.hs +++ b/src/Strategy/Node/Pnpm/PnpmLock.hs @@ -498,20 +498,10 @@ buildGraph lockFile = maybeHydrate . withoutLocalPackages $ in withProd . withDev $ dep v9ProdDirectNames :: Set.Set Text - v9ProdDirectNames = - Set.fromList - [ depName - | (_, proj) <- toList lockFile.importers - , (depName, _) <- toList (directDependencies proj) - ] + v9ProdDirectNames = foldMap (Set.fromList . Map.keys . directDependencies) lockFile.importers v9DevDirectNames :: Set.Set Text - v9DevDirectNames = - Set.fromList - [ depName - | (_, proj) <- toList lockFile.importers - , (depName, _) <- toList (directDevDependencies proj) - ] + v9DevDirectNames = foldMap (Set.fromList . Map.keys . directDevDependencies) lockFile.importers withoutLocalPackages :: Graphing Dependency -> Graphing Dependency withoutLocalPackages = Graphing.shrink (\dep -> dependencyType dep /= UserType) From 1dfedffcb6a9fb5f62846b173ebf41252cc8ba69 Mon Sep 17 00:00:00 2001 From: spatten Date: Fri, 13 Mar 2026 16:52:32 -0700 Subject: [PATCH 05/16] Fix v9 env labeling: hydrate before shrink, use dep identity for seeding - Reorder buildGraph pipeline to run maybeHydrate before withoutLocalPackages, so transitive deps of local (file:) packages inherit environment labels before the local nodes are removed. - Replace name-only matching in labelV9DirectDeps with full dependency identity (type + name + version) so multiple versions of the same package get distinct environments and git/tarball deps match correctly. - Replace match guard in toEnv with an if expression per style guidelines. - Add regression tests for local dep env propagation and multi-version env labeling. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Strategy/Node/Pnpm/PnpmLock.hs | 50 ++++++++++++++----- test/Pnpm/PnpmLockSpec.hs | 40 +++++++++++++++ .../testdata/pnpm-9-local-dep/pnpm-lock.yaml | 37 ++++++++++++++ .../pnpm-9-multi-version/pnpm-lock.yaml | 35 +++++++++++++ 4 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 test/Pnpm/testdata/pnpm-9-local-dep/pnpm-lock.yaml create mode 100644 test/Pnpm/testdata/pnpm-9-multi-version/pnpm-lock.yaml diff --git a/src/Strategy/Node/Pnpm/PnpmLock.hs b/src/Strategy/Node/Pnpm/PnpmLock.hs index ffb541733..3096e53c3 100644 --- a/src/Strategy/Node/Pnpm/PnpmLock.hs +++ b/src/Strategy/Node/Pnpm/PnpmLock.hs @@ -17,7 +17,7 @@ import Data.Foldable (for_) import Data.HashMap.Strict qualified as HashMap import Data.Map (Map, toList) import Data.Map qualified as Map -import Data.Maybe (fromMaybe, listToMaybe) +import Data.Maybe (fromMaybe, listToMaybe, mapMaybe) import Data.Maybe qualified as Maybe import Data.Set qualified as Set import Data.String.Conversion (toString) @@ -28,7 +28,7 @@ import Data.Yaml qualified as Yaml import DepTypes ( DepEnvironment (EnvDevelopment, EnvProduction), DepType (GitType, NodeJSType, URLType, UserType), - Dependency (Dependency, dependencyName, dependencyType), + Dependency (Dependency, dependencyName, dependencyType, dependencyVersion), VerConstraint (CEq), hydrateDepEnvs, insertEnvironment, @@ -304,7 +304,7 @@ analyze file = context "Analyzing Npm Lockfile (v3)" $ do context "Building dependency graph" $ pure $ buildGraph pnpmLockFile buildGraph :: PnpmLockfile -> Graphing Dependency -buildGraph lockFile = maybeHydrate . withoutLocalPackages $ +buildGraph lockFile = withoutLocalPackages . maybeHydrate $ run . evalGrapher $ do for_ (toList lockFile.importers) $ \(_, projectImporters) -> do let allDirectDependencies = @@ -476,8 +476,10 @@ buildGraph lockFile = maybeHydrate . withoutLocalPackages $ toDep depType name version isDev = Dependency depType name (CEq <$> version) mempty (toEnv isDev) mempty toEnv :: Bool -> Set.Set DepEnvironment - toEnv _ | isV9 = mempty - toEnv isNotRequired = Set.singleton $ if isNotRequired then EnvDevelopment else EnvProduction + toEnv isNotRequired = + if isV9 + then mempty + else Set.singleton (if isNotRequired then EnvDevelopment else EnvProduction) isV9 :: Bool isV9 = lockFile.lockFileVersion == PnpmLockV9 @@ -490,18 +492,40 @@ buildGraph lockFile = maybeHydrate . withoutLocalPackages $ | otherwise = id -- Seed environment labels on direct deps based on their importer section. + -- Uses resolved dependency identity (type + name + version) rather than + -- just the package name, so that multiple versions are distinguished and + -- git/tarball deps are matched correctly. labelV9DirectDeps :: Graphing Dependency -> Graphing Dependency labelV9DirectDeps = Graphing.gmap $ \dep -> - let name = dependencyName dep - withProd = if Set.member name v9ProdDirectNames then insertEnvironment EnvProduction else id - withDev = if Set.member name v9DevDirectNames then insertEnvironment EnvDevelopment else id + let ident = depIdentity dep + withProd = if Set.member ident v9ProdDirectDeps then insertEnvironment EnvProduction else id + withDev = if Set.member ident v9DevDirectDeps then insertEnvironment EnvDevelopment else id in withProd . withDev $ dep - v9ProdDirectNames :: Set.Set Text - v9ProdDirectNames = foldMap (Set.fromList . Map.keys . directDependencies) lockFile.importers - - v9DevDirectNames :: Set.Set Text - v9DevDirectNames = foldMap (Set.fromList . Map.keys . directDevDependencies) lockFile.importers + depIdentity :: Dependency -> (DepType, Text, Maybe VerConstraint) + depIdentity dep = (dependencyType dep, dependencyName dep, dependencyVersion dep) + + v9ProdDirectDeps :: Set.Set (DepType, Text, Maybe VerConstraint) + v9ProdDirectDeps = + foldMap + ( \proj -> + Set.fromList $ + mapMaybe + (\(depName, ProjectMapDepMetadata depVersion) -> depIdentity <$> toResolvedDependency depName depVersion) + (Map.toList $ directDependencies proj) + ) + lockFile.importers + + v9DevDirectDeps :: Set.Set (DepType, Text, Maybe VerConstraint) + v9DevDirectDeps = + foldMap + ( \proj -> + Set.fromList $ + mapMaybe + (\(depName, ProjectMapDepMetadata depVersion) -> depIdentity <$> toResolvedDependency depName depVersion) + (Map.toList $ directDevDependencies proj) + ) + lockFile.importers withoutLocalPackages :: Graphing Dependency -> Graphing Dependency withoutLocalPackages = Graphing.shrink (\dep -> dependencyType dep /= UserType) diff --git a/test/Pnpm/PnpmLockSpec.hs b/test/Pnpm/PnpmLockSpec.hs index a3ab4c67b..523a81856 100644 --- a/test/Pnpm/PnpmLockSpec.hs +++ b/test/Pnpm/PnpmLockSpec.hs @@ -16,6 +16,7 @@ import DepTypes ( VerConstraint (CEq), ) import GraphUtil ( + expectDep, expectDirect, expectEdge, ) @@ -124,11 +125,15 @@ spec = do let pnpmLockV9Workspace = currentDir $(mkRelFile "test/Pnpm/testdata/pnpm-9-workspace-project/pnpm-lock.yaml") let pnpmLockV9SharedDep = currentDir $(mkRelFile "test/Pnpm/testdata/pnpm-9-shared-dep/pnpm-lock.yaml") + 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") describe "works with v9 format" $ do checkGraph pnpmLockV9 pnpmLockV9GraphSpec describe "workspace" $ checkGraph pnpmLockV9Workspace pnpmLockV9GraphSpec describe "shared deps" $ checkGraph pnpmLockV9SharedDep pnpmLockV9SharedDepSpec + describe "local dep env propagation" $ checkGraph pnpmLockV9LocalDep pnpmLockV9LocalDepSpec + describe "multi-version env labeling" $ checkGraph pnpmLockV9MultiVersion pnpmLockV9MultiVersionSpec pnpmLockGraphSpec :: Graphing Dependency -> Spec pnpmLockGraphSpec graph = do @@ -447,3 +452,38 @@ pnpmLockV9SharedDepSpec graph = do hasEdge (mkProdDep "uri-js@4.4.1") (mkProdDep "punycode@2.3.1") -- xmlbuilder is dev-only (via xml2js) hasEdge (mkDevDep "xml2js@0.6.2") (mkDevDep "xmlbuilder@11.0.1") + +-- Bug 2 regression: transitive deps of a local (file:) package must inherit +-- the environment from the importer that depends on it, even though the +-- local package node itself is removed from the final graph. +pnpmLockV9LocalDepSpec :: Graphing Dependency -> Spec +pnpmLockV9LocalDepSpec graph = do + let hasEdge :: Dependency -> Dependency -> Expectation + hasEdge = expectEdge graph + + describe "buildGraph with local dep" $ do + it "should not include the local package in the final graph" $ do + -- local-pkg is UserType and should be removed by withoutLocalPackages + expectDirect + [ mkProdDep "express@4.18.2" + ] + graph + + it "should propagate prod environment through local package to transitive deps" $ do + -- express is a transitive dep of local-pkg (prod) — must be prod + expectDep (mkProdDep "express@4.18.2") graph + -- body-parser is a transitive dep of express — must also be prod + expectDep (mkProdDep "body-parser@1.20.1") graph + hasEdge (mkProdDep "express@4.18.2") (mkProdDep "body-parser@1.20.1") + +-- Bug 3 regression: when two workspace packages depend on different versions +-- of the same package (one prod, one dev), each version must receive only its +-- own environment label — not both. +pnpmLockV9MultiVersionSpec :: Graphing Dependency -> Spec +pnpmLockV9MultiVersionSpec graph = do + describe "buildGraph with multi-version deps" $ do + it "should label each version with only its own environment" $ do + -- sax@1.2.1 is prod-only (from app-a) + expectDep (mkProdDep "sax@1.2.1") graph + -- sax@1.4.4 is dev-only (from app-b) + expectDep (mkDevDep "sax@1.4.4") graph diff --git a/test/Pnpm/testdata/pnpm-9-local-dep/pnpm-lock.yaml b/test/Pnpm/testdata/pnpm-9-local-dep/pnpm-lock.yaml new file mode 100644 index 000000000..ce40e7d4b --- /dev/null +++ b/test/Pnpm/testdata/pnpm-9-local-dep/pnpm-lock.yaml @@ -0,0 +1,37 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +# Bug 2 regression: a local (file:) package is a direct production dep. +# Its transitive deps (express, body-parser) must inherit EnvProduction +# even though the local package node is removed from the final graph. +importers: + .: + dependencies: + local-pkg: + specifier: file:packages/local-pkg + version: file:packages/local-pkg + +packages: + file:packages/local-pkg: + resolution: {directory: packages/local-pkg, type: directory} + name: local-pkg + + express@4.18.2: + resolution: {integrity: sha512-xxxxxxxxxx==} + + body-parser@1.20.1: + resolution: {integrity: sha512-xxxxxxxxxx==} + +snapshots: + file:packages/local-pkg: + dependencies: + express: 4.18.2 + + express@4.18.2: + dependencies: + body-parser: 1.20.1 + + body-parser@1.20.1: {} diff --git a/test/Pnpm/testdata/pnpm-9-multi-version/pnpm-lock.yaml b/test/Pnpm/testdata/pnpm-9-multi-version/pnpm-lock.yaml new file mode 100644 index 000000000..e5ca8b7ef --- /dev/null +++ b/test/Pnpm/testdata/pnpm-9-multi-version/pnpm-lock.yaml @@ -0,0 +1,35 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +# Bug 3 regression: two workspace packages depend on different versions of +# the same package — one as prod, one as dev. Each version must receive +# only its own environment label, not both. +importers: + .: {} + + packages/app-a: + dependencies: + sax: + specifier: ^1.2.1 + version: 1.2.1 + + packages/app-b: + devDependencies: + sax: + specifier: ^1.4.4 + version: 1.4.4 + +packages: + sax@1.2.1: + resolution: {integrity: sha512-xxxxxxxxxx==} + + sax@1.4.4: + resolution: {integrity: sha512-yyyyyyyyyy==} + +snapshots: + sax@1.2.1: {} + + sax@1.4.4: {} From c1b1d61c2090450e5aa00a85fd1414ec9015560e Mon Sep 17 00:00:00 2001 From: spatten Date: Fri, 13 Mar 2026 17:02:17 -0700 Subject: [PATCH 06/16] Replace match guards in maybeHydrate with if expression Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Strategy/Node/Pnpm/PnpmLock.hs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Strategy/Node/Pnpm/PnpmLock.hs b/src/Strategy/Node/Pnpm/PnpmLock.hs index 3096e53c3..1cdfc1edf 100644 --- a/src/Strategy/Node/Pnpm/PnpmLock.hs +++ b/src/Strategy/Node/Pnpm/PnpmLock.hs @@ -487,9 +487,10 @@ buildGraph lockFile = withoutLocalPackages . maybeHydrate $ -- For v9: label direct deps, then propagate via hydrateDepEnvs. -- For non-v9: no-op (environments already set via isDev from PackageData). maybeHydrate :: Graphing Dependency -> Graphing Dependency - maybeHydrate - | isV9 = hydrateDepEnvs . labelV9DirectDeps - | otherwise = id + maybeHydrate = + if isV9 + then hydrateDepEnvs . labelV9DirectDeps + else id -- Seed environment labels on direct deps based on their importer section. -- Uses resolved dependency identity (type + name + version) rather than From 1b7874ac94809c68fb5cf0e502c3de453a7ecc6b Mon Sep 17 00:00:00 2001 From: spatten Date: Fri, 13 Mar 2026 17:35:03 -0700 Subject: [PATCH 07/16] Add changelog entry for pnpm v9 dev dep classification fix Co-Authored-By: Claude Opus 4.6 (1M context) --- Changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Changelog.md b/Changelog.md index 3f18ee1b6..1bc5684da 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,9 @@ # FOSSA CLI Changelog +## Unreleased + +- PNPM: Fix pnpm v9 lockfile transitive devDependency classification. Dependencies of devDependencies were incorrectly reported as production dependencies because v9 removed the `dev` flag from the `packages` section. ([#1668](https://github.com/fossas/fossa-cli/pull/1668)) + ## 3.16.3 - Elixir: Use `MIX_ENV=prod` for accurate production dependency resolution, with fallback to `--only prod` for projects lacking `config/prod.exs` ([#1662](https://github.com/fossas/fossa-cli/pull/1662)) From 3a80dfa33f9d3d655e018e8f81615aa47dce50a7 Mon Sep 17 00:00:00 2001 From: spatten Date: Mon, 16 Mar 2026 11:21:49 -0700 Subject: [PATCH 08/16] Remove unnecessary comment on labelV9DirectDeps Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Strategy/Node/Pnpm/PnpmLock.hs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Strategy/Node/Pnpm/PnpmLock.hs b/src/Strategy/Node/Pnpm/PnpmLock.hs index 1cdfc1edf..03caca030 100644 --- a/src/Strategy/Node/Pnpm/PnpmLock.hs +++ b/src/Strategy/Node/Pnpm/PnpmLock.hs @@ -475,6 +475,7 @@ buildGraph lockFile = withoutLocalPackages . maybeHydrate $ toDep :: DepType -> Text -> Maybe Text -> Bool -> Dependency toDep depType name version isDev = Dependency depType name (CEq <$> version) mempty (toEnv isDev) mempty + -- TODO: Add a comment here about why v9 (or greater) get mempty toEnv :: Bool -> Set.Set DepEnvironment toEnv isNotRequired = if isV9 @@ -493,9 +494,6 @@ buildGraph lockFile = withoutLocalPackages . maybeHydrate $ else id -- Seed environment labels on direct deps based on their importer section. - -- Uses resolved dependency identity (type + name + version) rather than - -- just the package name, so that multiple versions are distinguished and - -- git/tarball deps are matched correctly. labelV9DirectDeps :: Graphing Dependency -> Graphing Dependency labelV9DirectDeps = Graphing.gmap $ \dep -> let ident = depIdentity dep From 232cab133e3e45a329bf8cb4a94e4a0e1751d25a Mon Sep 17 00:00:00 2001 From: spatten Date: Mon, 16 Mar 2026 11:22:36 -0700 Subject: [PATCH 09/16] Add comment explaining why toEnv returns mempty for v9 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Strategy/Node/Pnpm/PnpmLock.hs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Strategy/Node/Pnpm/PnpmLock.hs b/src/Strategy/Node/Pnpm/PnpmLock.hs index 03caca030..0840db2ca 100644 --- a/src/Strategy/Node/Pnpm/PnpmLock.hs +++ b/src/Strategy/Node/Pnpm/PnpmLock.hs @@ -475,7 +475,10 @@ buildGraph lockFile = withoutLocalPackages . maybeHydrate $ toDep :: DepType -> Text -> Maybe Text -> Bool -> Dependency toDep depType name version isDev = Dependency depType name (CEq <$> version) mempty (toEnv isDev) mempty - -- TODO: Add a comment here about why v9 (or greater) get mempty + -- For v9, environments start empty and are set later by maybeHydrate, + -- which seeds direct deps from importer sections and propagates through + -- the graph via hydrateDepEnvs. For older versions, isDev from PackageData + -- is reliable and environments are set inline. toEnv :: Bool -> Set.Set DepEnvironment toEnv isNotRequired = if isV9 From 9cd6304efe20ade7edf383e814459799dc5afda2 Mon Sep 17 00:00:00 2001 From: spatten Date: Mon, 16 Mar 2026 11:27:30 -0700 Subject: [PATCH 10/16] Rename maybeHydrate to hydrateV9Envs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Strategy/Node/Pnpm/PnpmLock.hs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Strategy/Node/Pnpm/PnpmLock.hs b/src/Strategy/Node/Pnpm/PnpmLock.hs index 0840db2ca..70806b996 100644 --- a/src/Strategy/Node/Pnpm/PnpmLock.hs +++ b/src/Strategy/Node/Pnpm/PnpmLock.hs @@ -304,7 +304,7 @@ analyze file = context "Analyzing Npm Lockfile (v3)" $ do context "Building dependency graph" $ pure $ buildGraph pnpmLockFile buildGraph :: PnpmLockfile -> Graphing Dependency -buildGraph lockFile = withoutLocalPackages . maybeHydrate $ +buildGraph lockFile = withoutLocalPackages . hydrateV9Envs $ run . evalGrapher $ do for_ (toList lockFile.importers) $ \(_, projectImporters) -> do let allDirectDependencies = @@ -475,7 +475,7 @@ buildGraph lockFile = withoutLocalPackages . maybeHydrate $ toDep :: DepType -> Text -> Maybe Text -> Bool -> Dependency toDep depType name version isDev = Dependency depType name (CEq <$> version) mempty (toEnv isDev) mempty - -- For v9, environments start empty and are set later by maybeHydrate, + -- For v9, environments start empty and are set later by hydrateV9Envs, -- which seeds direct deps from importer sections and propagates through -- the graph via hydrateDepEnvs. For older versions, isDev from PackageData -- is reliable and environments are set inline. @@ -490,8 +490,8 @@ buildGraph lockFile = withoutLocalPackages . maybeHydrate $ -- For v9: label direct deps, then propagate via hydrateDepEnvs. -- For non-v9: no-op (environments already set via isDev from PackageData). - maybeHydrate :: Graphing Dependency -> Graphing Dependency - maybeHydrate = + hydrateV9Envs :: Graphing Dependency -> Graphing Dependency + hydrateV9Envs = if isV9 then hydrateDepEnvs . labelV9DirectDeps else id From f7ca432264f41f3966df1ba08ed343d199c0d3ed Mon Sep 17 00:00:00 2001 From: spatten Date: Mon, 16 Mar 2026 11:34:30 -0700 Subject: [PATCH 11/16] Remove unused unqualified shrink import Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Strategy/Node/Pnpm/PnpmLock.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Strategy/Node/Pnpm/PnpmLock.hs b/src/Strategy/Node/Pnpm/PnpmLock.hs index 70806b996..b728aec72 100644 --- a/src/Strategy/Node/Pnpm/PnpmLock.hs +++ b/src/Strategy/Node/Pnpm/PnpmLock.hs @@ -40,7 +40,7 @@ import Effect.Logger ( pretty, ) import Effect.ReadFS (ReadFS, readContentsYaml) -import Graphing (Graphing, shrink) +import Graphing (Graphing) import Graphing qualified import Path (Abs, File, Path) From c1f963011601644f65eac6c0d1f887b05cd40af7 Mon Sep 17 00:00:00 2001 From: spatten Date: Mon, 16 Mar 2026 11:54:56 -0700 Subject: [PATCH 12/16] update the changelog --- Changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Changelog.md b/Changelog.md index 1bc5684da..0cf5e2035 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,7 +2,7 @@ ## Unreleased -- PNPM: Fix pnpm v9 lockfile transitive devDependency classification. Dependencies of devDependencies were incorrectly reported as production dependencies because v9 removed the `dev` flag from the `packages` section. ([#1668](https://github.com/fossas/fossa-cli/pull/1668)) +- PNPM: Fix pnpm v9 lockfile transitive devDependency classification. Dependencies of devDependencies were incorrectly reported as production dependencies in pnpm v9 projects. ([#1668](https://github.com/fossas/fossa-cli/pull/1668)) ## 3.16.3 From 83cf0f72b8b4274cb605b266c3f75928f8329c64 Mon Sep 17 00:00:00 2001 From: spatten Date: Thu, 19 Mar 2026 15:47:59 -0700 Subject: [PATCH 13/16] use withLabelling instead to make the code clearer --- src/Strategy/Node/Pnpm/PnpmLock.hs | 79 ++++++++++-------------------- 1 file changed, 25 insertions(+), 54 deletions(-) diff --git a/src/Strategy/Node/Pnpm/PnpmLock.hs b/src/Strategy/Node/Pnpm/PnpmLock.hs index b728aec72..c0000610f 100644 --- a/src/Strategy/Node/Pnpm/PnpmLock.hs +++ b/src/Strategy/Node/Pnpm/PnpmLock.hs @@ -10,6 +10,7 @@ where import Control.Applicative ((<|>)) import Control.Effect.Diagnostics (Diagnostics, Has, context) +import Control.Monad (when) import Data.Aeson (FromJSON (..), withObject) import Data.Aeson.Extra (TextLike (..)) import Data.Aeson.KeyMap (toHashMapText) @@ -17,7 +18,7 @@ import Data.Foldable (for_) import Data.HashMap.Strict qualified as HashMap import Data.Map (Map, toList) import Data.Map qualified as Map -import Data.Maybe (fromMaybe, listToMaybe, mapMaybe) +import Data.Maybe (fromMaybe, listToMaybe) import Data.Maybe qualified as Maybe import Data.Set qualified as Set import Data.String.Conversion (toString) @@ -28,12 +29,12 @@ import Data.Yaml qualified as Yaml import DepTypes ( DepEnvironment (EnvDevelopment, EnvProduction), DepType (GitType, NodeJSType, URLType, UserType), - Dependency (Dependency, dependencyName, dependencyType, dependencyVersion), + Dependency (Dependency, dependencyType), VerConstraint (CEq), hydrateDepEnvs, insertEnvironment, ) -import Effect.Grapher (deep, direct, edge, evalGrapher, run) +import Effect.Grapher (deep, direct, edge, label, run, withLabeling) import Effect.Logger ( Logger, logWarn, @@ -44,6 +45,9 @@ import Graphing (Graphing) import Graphing qualified import Path (Abs, File, Path) +newtype PnpmLabel = PnpmEnv DepEnvironment + deriving (Eq, Ord) + -- | Pnpm Lockfile -- -- Pnpm lockfile (v5) (in yaml) has the following shape (irrelevant fields omitted): @@ -304,15 +308,18 @@ analyze file = context "Analyzing Npm Lockfile (v3)" $ do context "Building dependency graph" $ pure $ buildGraph pnpmLockFile buildGraph :: PnpmLockfile -> Graphing Dependency -buildGraph lockFile = withoutLocalPackages . hydrateV9Envs $ - run . evalGrapher $ do +buildGraph lockFile = withoutLocalPackages . hydrateDepEnvs $ + run . withLabeling applyLabels $ do for_ (toList lockFile.importers) $ \(_, projectImporters) -> do - let allDirectDependencies = - toList (directDependencies projectImporters) - <> toList (directDevDependencies projectImporters) + for_ (Map.toList $ directDependencies projectImporters) $ \(depName, ProjectMapDepMetadata depVersion) -> + for_ (toResolvedDependency depName depVersion) $ \dep -> do + direct dep + when isV9 $ label dep (PnpmEnv EnvProduction) - for_ allDirectDependencies $ \(depName, (ProjectMapDepMetadata depVersion)) -> - maybe (pure ()) direct $ toResolvedDependency depName depVersion + for_ (Map.toList $ directDevDependencies projectImporters) $ \(depName, ProjectMapDepMetadata depVersion) -> + for_ (toResolvedDependency depName depVersion) $ \dep -> do + direct dep + when isV9 $ label dep (PnpmEnv EnvDevelopment) -- Add edges and deep dependencies by iterating over all packages. -- @@ -475,10 +482,10 @@ buildGraph lockFile = withoutLocalPackages . hydrateV9Envs $ toDep :: DepType -> Text -> Maybe Text -> Bool -> Dependency toDep depType name version isDev = Dependency depType name (CEq <$> version) mempty (toEnv isDev) mempty - -- For v9, environments start empty and are set later by hydrateV9Envs, - -- which seeds direct deps from importer sections and propagates through - -- the graph via hydrateDepEnvs. For older versions, isDev from PackageData - -- is reliable and environments are set inline. + -- For v9, environments start empty and are set later via labels + -- during graph construction, then propagated by hydrateDepEnvs. + -- For older versions, isDev from PackageData is reliable and + -- environments are set inline. toEnv :: Bool -> Set.Set DepEnvironment toEnv isNotRequired = if isV9 @@ -488,46 +495,10 @@ buildGraph lockFile = withoutLocalPackages . hydrateV9Envs $ isV9 :: Bool isV9 = lockFile.lockFileVersion == PnpmLockV9 - -- For v9: label direct deps, then propagate via hydrateDepEnvs. - -- For non-v9: no-op (environments already set via isDev from PackageData). - hydrateV9Envs :: Graphing Dependency -> Graphing Dependency - hydrateV9Envs = - if isV9 - then hydrateDepEnvs . labelV9DirectDeps - else id - - -- Seed environment labels on direct deps based on their importer section. - labelV9DirectDeps :: Graphing Dependency -> Graphing Dependency - labelV9DirectDeps = Graphing.gmap $ \dep -> - let ident = depIdentity dep - withProd = if Set.member ident v9ProdDirectDeps then insertEnvironment EnvProduction else id - withDev = if Set.member ident v9DevDirectDeps then insertEnvironment EnvDevelopment else id - in withProd . withDev $ dep - - depIdentity :: Dependency -> (DepType, Text, Maybe VerConstraint) - depIdentity dep = (dependencyType dep, dependencyName dep, dependencyVersion dep) - - v9ProdDirectDeps :: Set.Set (DepType, Text, Maybe VerConstraint) - v9ProdDirectDeps = - foldMap - ( \proj -> - Set.fromList $ - mapMaybe - (\(depName, ProjectMapDepMetadata depVersion) -> depIdentity <$> toResolvedDependency depName depVersion) - (Map.toList $ directDependencies proj) - ) - lockFile.importers - - v9DevDirectDeps :: Set.Set (DepType, Text, Maybe VerConstraint) - v9DevDirectDeps = - foldMap - ( \proj -> - Set.fromList $ - mapMaybe - (\(depName, ProjectMapDepMetadata depVersion) -> depIdentity <$> toResolvedDependency depName depVersion) - (Map.toList $ directDevDependencies proj) - ) - lockFile.importers + applyLabels :: Dependency -> Set.Set PnpmLabel -> Dependency + applyLabels dep labels = foldr applyLabel dep labels + where + applyLabel (PnpmEnv env) = insertEnvironment env withoutLocalPackages :: Graphing Dependency -> Graphing Dependency withoutLocalPackages = Graphing.shrink (\dep -> dependencyType dep /= UserType) From e8effea19b644b6614b259d76fb3b2ca563298ab Mon Sep 17 00:00:00 2001 From: spatten Date: Thu, 19 Mar 2026 16:06:05 -0700 Subject: [PATCH 14/16] Fix hlint eta reduce warning in applyLabels Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Strategy/Node/Pnpm/PnpmLock.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Strategy/Node/Pnpm/PnpmLock.hs b/src/Strategy/Node/Pnpm/PnpmLock.hs index c0000610f..627baa1e6 100644 --- a/src/Strategy/Node/Pnpm/PnpmLock.hs +++ b/src/Strategy/Node/Pnpm/PnpmLock.hs @@ -496,7 +496,7 @@ buildGraph lockFile = withoutLocalPackages . hydrateDepEnvs $ isV9 = lockFile.lockFileVersion == PnpmLockV9 applyLabels :: Dependency -> Set.Set PnpmLabel -> Dependency - applyLabels dep labels = foldr applyLabel dep labels + applyLabels = foldr applyLabel where applyLabel (PnpmEnv env) = insertEnvironment env From 44052ae167d2709ca73d68589a6ee0d2d8997db0 Mon Sep 17 00:00:00 2001 From: spatten Date: Thu, 19 Mar 2026 16:40:57 -0700 Subject: [PATCH 15/16] comment explaining what buildGraph does --- src/Strategy/Node/Pnpm/PnpmLock.hs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Strategy/Node/Pnpm/PnpmLock.hs b/src/Strategy/Node/Pnpm/PnpmLock.hs index 627baa1e6..0d43fd184 100644 --- a/src/Strategy/Node/Pnpm/PnpmLock.hs +++ b/src/Strategy/Node/Pnpm/PnpmLock.hs @@ -307,6 +307,9 @@ analyze file = context "Analyzing Npm Lockfile (v3)" $ do context "Building dependency graph" $ pure $ buildGraph pnpmLockFile +-- Build the dependency graph, labeling direct deps with their environment +-- (prod/dev). hydrateDepEnvs then propagates those environments to all +-- transitive successors. buildGraph :: PnpmLockfile -> Graphing Dependency buildGraph lockFile = withoutLocalPackages . hydrateDepEnvs $ run . withLabeling applyLabels $ do From 991390cd44ad27d5038b4efe838febe5b3e96871 Mon Sep 17 00:00:00 2001 From: spatten Date: Fri, 20 Mar 2026 12:10:55 -0700 Subject: [PATCH 16/16] update the changelog --- Changelog.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Changelog.md b/Changelog.md index 0cf5e2035..cbca24ed9 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,9 +1,13 @@ # FOSSA CLI Changelog -## Unreleased +## 3.16.5 - PNPM: Fix pnpm v9 lockfile transitive devDependency classification. Dependencies of devDependencies were incorrectly reported as production dependencies in pnpm v9 projects. ([#1668](https://github.com/fossas/fossa-cli/pull/1668)) +## 3.16.4 + +- Bugfix: revert caching changes as they caused a problem with missing libs on macos ([#1675](https://github.com/fossas/fossa-cli/pull/1675)) + ## 3.16.3 - Elixir: Use `MIX_ENV=prod` for accurate production dependency resolution, with fallback to `--only prod` for projects lacking `config/prod.exs` ([#1662](https://github.com/fossas/fossa-cli/pull/1662))