From 0e5281c1b2839ed3b03b661092e3380741fb9511 Mon Sep 17 00:00:00 2001 From: Zachary LaVallee Date: Tue, 28 Jul 2026 13:49:18 -0700 Subject: [PATCH 1/2] fix(node): hydrate Bun transitive devDependency environments (ANE-2836) The Bun lockfile strategy assigned each package's environment by name-membership in the set of directly-declared devDependencies and never ran graph hydration. A dependency reachable only transitively through a devDependency was labeled EnvProduction and survived dev-dependency filtering. Adopt the label-direct-then-hydrate pattern used by the other Node strategies: label only direct roots with their declared environment, then run hydrateDepEnvs so environments propagate down the edges. A dep reachable only via dev deps becomes EnvDevelopment; a dep also reachable from a prod dep keeps EnvProduction. Co-Authored-By: Claude Opus 4.8 --- Changelog.md | 4 ++++ src/Strategy/Node/Bun/BunLock.hs | 25 ++++++++++----------- test/Bun/BunLockSpec.hs | 27 +++++++++++++++++++++-- test/Bun/testdata/transitive-dev/bun.lock | 21 ++++++++++++++++++ 4 files changed, 62 insertions(+), 15 deletions(-) create mode 100644 test/Bun/testdata/transitive-dev/bun.lock diff --git a/Changelog.md b/Changelog.md index 4ccb1bdb9..9112c8526 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,9 @@ # FOSSA CLI Changelog +## Unreleased + +- nodejs: Bun now labels transitive devDependencies via graph hydration, so they are filtered from scan results. ([#XXXX](https://github.com/fossas/fossa-cli/pull/XXXX)) + ## 3.17.15 - Node: Workspaces declared with a leading `./` (for example `./packages/*`) are now matched, so their members are analyzed and their production dependencies are no longer dropped from the results. ([#1733](https://github.com/fossas/fossa-cli/pull/1733)) diff --git a/src/Strategy/Node/Bun/BunLock.hs b/src/Strategy/Node/Bun/BunLock.hs index 8574c6f14..01c62768c 100644 --- a/src/Strategy/Node/Bun/BunLock.hs +++ b/src/Strategy/Node/Bun/BunLock.hs @@ -38,6 +38,7 @@ import DepTypes ( DepType (GitType, NodeJSType), Dependency (..), VerConstraint (CEq), + hydrateDepEnvs, insertEnvironment, ) import Effect.Grapher (LabeledGrapher, deep, direct, edge, label, run, withLabeling) @@ -198,19 +199,23 @@ analyze file = do -- | Build a dependency graph from a parsed bun lockfile. -- -- Strategy: --- 1. Collect all dev dependency names across all workspaces. --- 2. For each workspace, mark its declared dependencies as direct --- and label them with their environment. --- 3. For each supported package (npm, git), add it as a deep dependency, --- label it with its inferred environment, and create edges to its --- transitive dependencies. +-- 1. For each workspace, mark its declared dependencies as direct +-- and label them with their environment (prod\/optional → +-- 'EnvProduction', dev → 'EnvDevelopment'). +-- 2. For each supported package (npm, git), add it as a deep dependency +-- and create edges to its transitive dependencies. Non-direct nodes +-- carry no environment label of their own. -- Unsupported types (workspace, file, link, root, module) are excluded. +-- 3. Run 'hydrateDepEnvs' so the environments of the direct roots +-- propagate to their transitive successors. A dep reachable only via +-- dev deps becomes 'EnvDevelopment' (so it is filtered), while a dep +-- also reachable from a prod dep keeps 'EnvProduction'. -- -- Uses 'LabeledGrapher' so that vertices are environment-agnostic and -- environments accumulate as labels, avoiding duplicate vertices when -- the same package appears in both prod and dev across workspaces. buildGraph :: BunLockfile -> Graphing Dependency -buildGraph lockfile = run . withLabeling vertexToDependency $ do +buildGraph lockfile = hydrateDepEnvs . run . withLabeling vertexToDependency $ do for_ allWorkspaces $ \workspace -> do markDirectDeps EnvProduction workspace.wsDependencies markDirectDeps EnvDevelopment workspace.wsDevDependencies @@ -218,10 +223,7 @@ buildGraph lockfile = run . withLabeling vertexToDependency $ do for_ (packages lockfile) $ \pkg -> for_ (toVertex pkg) $ \parentVertex -> do - let (name, _) = parseResolution (pkgResolution pkg) - inferredEnv = if Set.member name devDepNames then EnvDevelopment else EnvProduction deep parentVertex - label parentVertex (BunDepEnv inferredEnv) for_ (transitiveDepNames pkg) $ \childName -> case Map.lookup childName (packages lockfile) of Nothing -> pure () @@ -232,9 +234,6 @@ buildGraph lockfile = run . withLabeling vertexToDependency $ do allWorkspaces :: [BunWorkspace] allWorkspaces = Map.elems $ workspaces lockfile - devDepNames :: Set.Set PackageName - devDepNames = Set.fromList $ concatMap (Map.keys . wsDevDependencies) allWorkspaces - markDirectDeps :: (Has (LabeledGrapher BunDepVertex BunDepLabel) sig m) => DepEnvironment -> Map PackageName VersionConstraint -> m () markDirectDeps env deps = for_ (Map.keys deps) $ \depName -> diff --git a/test/Bun/BunLockSpec.hs b/test/Bun/BunLockSpec.hs index ad2372050..823dbb2be 100644 --- a/test/Bun/BunLockSpec.hs +++ b/test/Bun/BunLockSpec.hs @@ -45,6 +45,7 @@ spec = do let bunProjectPath = testdata $(mkRelFile "bun-project/bun.lock") let gitDepsPath = testdata $(mkRelFile "git-deps/bun.lock") let mixedEnvsPath = testdata $(mkRelFile "mixed-envs/bun.lock") + let transitiveDevPath = testdata $(mkRelFile "transitive-dev/bun.lock") parseResolutionSpec jsoncSpec jsoncPath @@ -53,6 +54,7 @@ spec = do bunProjectSpec bunProjectPath gitDepsSpec gitDepsPath mixedEnvsSpec mixedEnvsPath + transitiveDevSpec transitiveDevPath parseResolutionSpec :: Spec parseResolutionSpec = describe "parseResolution" $ do @@ -119,8 +121,10 @@ dependenciesSpec path = expectEdge graph (mkProdDep "express" "4.18.2") (mkProdDep "accepts" "1.3.8") expectEdge graph (mkProdDep "accepts" "1.3.8") (mkProdDep "mime-types" "2.1.35") - it "labels transitive deps of dev deps as production" $ do - expectEdge graph (mkDevDep "typescript" "5.3.3") (mkProdDep "semver" "7.6.0") + it "labels transitive deps of dev deps as development" $ do + -- semver is reachable only via typescript (a dev dependency), so + -- hydration propagates EnvDevelopment down to it (ANE-2836). + expectEdge graph (mkDevDep "typescript" "5.3.3") (mkDevDep "semver" "7.6.0") -- | Workspaces: multiple workspaces, workspace refs, and workspace -- package filtering from the final graph. @@ -230,6 +234,25 @@ mixedEnvsSpec path = let lodashVertices = filter (\d -> dependencyName d == "lodash") (Graphing.vertexList graph) length lodashVertices `shouldBe` 1 +-- | Transitive dev deps: a devDependency pulls in a transitive package that +-- is not declared as a direct dependency anywhere. Graph hydration must +-- propagate the dev environment down to it so it is filtered out, while a +-- transitive dep of a prod dependency stays production. A dep reachable from +-- both a prod and a dev root keeps production (ANE-2836). +transitiveDevSpec :: Path Abs File -> Spec +transitiveDevSpec path = + describe "transitive-dev" $ do + describe "graph" $ do + checkGraph path $ \graph -> do + it "labels a transitive dep of a dev dep as development (ANE-2836)" $ + Graphing.vertexList graph `shouldContainDep` mkDevDep "dev-transitive" "1.0.0" + + it "keeps a transitive dep of a prod dep as production" $ + Graphing.vertexList graph `shouldContainDep` mkProdDep "prod-transitive" "1.0.0" + + it "keeps a dep reachable from both prod and dev roots as production" $ + Graphing.vertexList graph `shouldContainDep` mkBothEnvsDep "shared" "1.0.0" + -- | Parse a bun.lock in IO for graph tests (outside the effect stack). checkGraph :: Path Abs File -> (Graphing Dependency -> Spec) -> Spec checkGraph path graphSpec = do diff --git a/test/Bun/testdata/transitive-dev/bun.lock b/test/Bun/testdata/transitive-dev/bun.lock new file mode 100644 index 000000000..62f33ce6a --- /dev/null +++ b/test/Bun/testdata/transitive-dev/bun.lock @@ -0,0 +1,21 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "transitive-dev-test", + "dependencies": { + "prod-root": "^1.0.0", + }, + "devDependencies": { + "dev-root": "^1.0.0", + }, + }, + }, + "packages": { + "dev-root": ["dev-root@1.0.0", "", { "dependencies": { "dev-transitive": "^1.0.0", "shared": "^1.0.0" } }, "sha512-fake=="], + "dev-transitive": ["dev-transitive@1.0.0", "", {}, "sha512-fake=="], + "prod-root": ["prod-root@1.0.0", "", { "dependencies": { "prod-transitive": "^1.0.0", "shared": "^1.0.0" } }, "sha512-fake=="], + "prod-transitive": ["prod-transitive@1.0.0", "", {}, "sha512-fake=="], + "shared": ["shared@1.0.0", "", {}, "sha512-fake=="], + }, +} From cfe863967e9e05f13230603109ceb3fb48b209b8 Mon Sep 17 00:00:00 2001 From: Zachary LaVallee Date: Tue, 28 Jul 2026 13:51:20 -0700 Subject: [PATCH 2/2] docs: fill in changelog PR link (#1737) Co-Authored-By: Claude Opus 4.8 --- Changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Changelog.md b/Changelog.md index 9112c8526..8b7b24ccb 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,7 +2,7 @@ ## Unreleased -- nodejs: Bun now labels transitive devDependencies via graph hydration, so they are filtered from scan results. ([#XXXX](https://github.com/fossas/fossa-cli/pull/XXXX)) +- nodejs: Bun now labels transitive devDependencies via graph hydration, so they are filtered from scan results. ([#1737](https://github.com/fossas/fossa-cli/pull/1737)) ## 3.17.15