diff --git a/Changelog.md b/Changelog.md index cca29346b..2241a802d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,7 +2,7 @@ ## Unreleased -- Node.js: Yarn, npm, and pnpm workspace packages now appear as individual build targets (e.g. `yarn@./:my-package`, `npm@./:my-package`, `pnpm@./:my-package`), enabling per-package dependency scoping via `.fossa.yml`. +- Node.js: Yarn, npm, pnpm, and bun workspace packages now appear as individual build targets (e.g. `yarn@./:my-package`, `npm@./:my-package`, `pnpm@./:my-package`, `bun@./:my-package`), enabling per-package dependency scoping via `.fossa.yml`. ## 3.16.0 diff --git a/docs/references/strategies/languages/nodejs/bun.md b/docs/references/strategies/languages/nodejs/bun.md index ca6a4571b..76962eea1 100644 --- a/docs/references/strategies/languages/nodejs/bun.md +++ b/docs/references/strategies/languages/nodejs/bun.md @@ -39,6 +39,12 @@ Workspace entries are keyed by their relative path from the root, with `""` representing the root workspace. Each workspace declares its own `dependencies`, `devDependencies`, and `optionalDependencies`. +Each workspace package (including the root) is exposed as an individual build +target (e.g. `bun@./:my-app`, `bun@./:lib-utils`). When a subset of targets +is selected via `.fossa.yml`, only those packages' dependencies are included +in the analysis. When no filtering is applied, all targets are selected and +all dependencies from every workspace package are included. + ### Packages Package keys use a slash-delimited path for nested `node_modules`: diff --git a/src/Strategy/Node.hs b/src/Strategy/Node.hs index 5d4f64d23..8acf8f49a 100644 --- a/src/Strategy/Node.hs +++ b/src/Strategy/Node.hs @@ -10,6 +10,7 @@ module Strategy.Node ( findWorkspaceBuildTargets, extractDepListsForTargets, resolveImporterPaths, + resolveBunWorkspacePaths, ) where import Algebra.Graph.AdjacencyMap qualified as AM @@ -164,6 +165,7 @@ mkProject project = do Yarn _ _ -> findWorkspaceBuildTargets graph NPMLock _ _ -> findWorkspaceBuildTargets graph Pnpm _ _ -> findWorkspaceBuildTargets graph + Bun _ _ -> findWorkspaceBuildTargets graph _ -> ProjectWithoutTargets Manifest rootManifest <- fromEitherShow $ findWorkspaceRootManifest graph pure $ @@ -209,7 +211,7 @@ getDeps :: getDeps targets (Yarn yarnLockFile graph) = analyzeYarn targets yarnLockFile graph getDeps targets (NPMLock packageLockFile graph) = analyzeNpmLock targets packageLockFile graph getDeps targets (Pnpm pnpmLockFile graph) = analyzePnpmLock targets pnpmLockFile graph -getDeps _ (Bun bunLockFile _) = analyzeBunLock bunLockFile +getDeps targets (Bun bunLockFile graph) = analyzeBunLock targets bunLockFile graph getDeps _ (NPM graph) = analyzeNpm graph analyzePnpmLock :: (Has Diagnostics sig m, Has ReadFS sig m, Has Logger sig m) => FoundTargets -> Manifest -> PkgJsonGraph -> m DependencyResults @@ -218,9 +220,10 @@ analyzePnpmLock targets (Manifest pnpmLockFile) graph = do result <- PnpmLock.analyze selectedImporterPaths pnpmLockFile pure $ DependencyResults result Complete [pnpmLockFile] -analyzeBunLock :: (Has Diagnostics sig m, Has ReadFS sig m) => Manifest -> m DependencyResults -analyzeBunLock (Manifest bunLockFile) = do - result <- BunLock.analyze bunLockFile +analyzeBunLock :: (Has Diagnostics sig m, Has ReadFS sig m) => FoundTargets -> Manifest -> PkgJsonGraph -> m DependencyResults +analyzeBunLock targets (Manifest bunLockFile) graph = do + let selectedWorkspacePaths = resolveBunWorkspacePaths targets graph + result <- BunLock.analyze selectedWorkspacePaths bunLockFile pure $ DependencyResults result Complete [bunLockFile] -- | Map selected build targets (package names) to pnpm importer paths @@ -254,6 +257,33 @@ resolveImporterPaths (FoundTargets targets) graph@PkgJsonGraph{..} = then "." else maybe "." (toText . FP.dropTrailingPathSeparator . toFilePath) (stripProperPrefix rootDir manifestDir) +-- | Like 'resolveImporterPaths' but for bun lockfiles, which use @""@ +-- for the root workspace instead of @"."@. +resolveBunWorkspacePaths :: FoundTargets -> PkgJsonGraph -> Maybe (Set Text) +resolveBunWorkspacePaths ProjectWithoutTargets _ = Nothing +resolveBunWorkspacePaths (FoundTargets targets) graph@PkgJsonGraph{..} = + case findWorkspaceRootManifest graph of + Left _ -> Nothing + Right (Manifest rootManifest) -> + Just $ Set.fromList [p | (name, p) <- namePathPairs, name `Set.member` targetNames] + where + rootDir = parent rootManifest + targetNames = Set.map unBuildTarget (NonEmptySet.toSet targets) + + namePathPairs :: [(Text, Text)] + namePathPairs = + [ (name, manifestToWorkspacePath m) + | (Manifest m, pj) <- Map.toList jsonLookup + , Just name <- [packageName pj] + ] + + manifestToWorkspacePath :: Path Abs File -> Text + manifestToWorkspacePath m = + let manifestDir = parent m + in if manifestDir == rootDir + then "" + else maybe "" (toText . FP.dropTrailingPathSeparator . toFilePath) (stripProperPrefix rootDir manifestDir) + analyzeNpmLock :: (Has Diagnostics sig m, Has ReadFS sig m) => FoundTargets -> Manifest -> PkgJsonGraph -> m DependencyResults analyzeNpmLock targets (Manifest npmLockFile) graph = do npmLockVersion <- detectNpmLockVersion npmLockFile diff --git a/src/Strategy/Node/Bun/BunLock.hs b/src/Strategy/Node/Bun/BunLock.hs index 8574c6f14..6079daa5a 100644 --- a/src/Strategy/Node/Bun/BunLock.hs +++ b/src/Strategy/Node/Bun/BunLock.hs @@ -29,6 +29,7 @@ import Data.Aeson ( import Data.Foldable (for_) import Data.Map (Map) import Data.Map qualified as Map +import Data.Set (Set) import Data.Set qualified as Set import Data.Text (Text) import Data.Text qualified as Text @@ -187,13 +188,17 @@ parseResolution res in (name, Text.drop 1 rest) -- | Analyze a bun.lock file and produce a dependency graph. +-- When @selectedWorkspacePaths@ is 'Nothing', all workspaces are included. +-- When @Just paths@, only workspaces whose key is in @paths@ contribute +-- direct dependencies. analyze :: (Has ReadFS sig m, Has Diagnostics sig m) => + Maybe (Set Text) -> Path Abs File -> m (Graphing Dependency) -analyze file = do +analyze selectedWorkspacePaths file = do lockfile <- context "Parsing bun.lock" $ readContentsJsonc file - context "Building dependency graph" $ pure $ buildGraph lockfile + context "Building dependency graph" $ pure $ buildGraph selectedWorkspacePaths lockfile -- | Build a dependency graph from a parsed bun lockfile. -- @@ -209,9 +214,9 @@ analyze file = do -- 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 - for_ allWorkspaces $ \workspace -> do +buildGraph :: Maybe (Set Text) -> BunLockfile -> Graphing Dependency +buildGraph selectedWorkspacePaths lockfile = run . withLabeling vertexToDependency $ do + for_ filteredWorkspaces $ \workspace -> do markDirectDeps EnvProduction workspace.wsDependencies markDirectDeps EnvDevelopment workspace.wsDevDependencies markDirectDeps EnvProduction workspace.wsOptionalDependencies @@ -232,6 +237,11 @@ buildGraph lockfile = run . withLabeling vertexToDependency $ do allWorkspaces :: [BunWorkspace] allWorkspaces = Map.elems $ workspaces lockfile + filteredWorkspaces :: [BunWorkspace] + filteredWorkspaces = case selectedWorkspacePaths of + Nothing -> allWorkspaces + Just paths -> Map.elems $ Map.filterWithKey (\k _ -> k `Set.member` paths) (workspaces lockfile) + devDepNames :: Set.Set PackageName devDepNames = Set.fromList $ concatMap (Map.keys . wsDevDependencies) allWorkspaces diff --git a/test/Bun/BunLockSpec.hs b/test/Bun/BunLockSpec.hs index ad2372050..ae176cad8 100644 --- a/test/Bun/BunLockSpec.hs +++ b/test/Bun/BunLockSpec.hs @@ -50,6 +50,7 @@ spec = do jsoncSpec jsoncPath dependenciesSpec depsPath workspacesSpec wsPath + workspaceFilterSpec wsPath bunProjectSpec bunProjectPath gitDepsSpec gitDepsPath mixedEnvsSpec mixedEnvsPath @@ -152,6 +153,33 @@ workspacesSpec path = names `shouldNotContain` "@types/app" names `shouldNotContain` "utils" +-- | Workspace target filtering: verify that filtering by workspace path +-- scopes which workspaces contribute direct dependencies. +workspaceFilterSpec :: Path Abs File -> Spec +workspaceFilterSpec path = + describe "workspace target filtering" $ do + describe "filtered to root only" $ + checkGraphWithFilter (Just (Set.singleton "")) path $ \graph -> do + let directDeps = Graphing.directList graph + + it "should include root dev deps as direct" $ + directDeps `shouldContainDep` mkDevDep "typescript" "5.3.3" + + it "should not include sub-workspace deps as direct" $ do + let directNames = map dependencyName directDeps + directNames `shouldNotContain` "lodash" + + describe "filtered to sub-workspace only" $ + checkGraphWithFilter (Just (Set.singleton "packages/app")) path $ \graph -> do + let directDeps = Graphing.directList graph + + it "should include sub-workspace deps as direct" $ + directDeps `shouldContainDep` mkProdDep "lodash" "4.17.21" + + it "should not include root deps as direct" $ do + let directNames = map dependencyName directDeps + directNames `shouldNotContain` "typescript" + -- | Bun project: a real-world bun.lock from the bun project itself. -- Covers scoped packages, git refs, nested package keys, optional/peer deps -- in packages, and large dependency counts. @@ -232,13 +260,16 @@ mixedEnvsSpec path = -- | 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 +checkGraph = checkGraphWithFilter Nothing + +checkGraphWithFilter :: Maybe (Set.Set Text) -> Path Abs File -> (Graphing Dependency -> Spec) -> Spec +checkGraphWithFilter wsFilter path graphSpec = do result <- runIO $ parseBunLockIO path case result of Left err -> describe (fromAbsFile path) $ it "should parse" (expectationFailure err) - Right lockfile -> graphSpec (buildGraph lockfile) + Right lockfile -> graphSpec (buildGraph wsFilter lockfile) parseBunLockIO :: Path Abs File -> IO (Either String BunLockfile) parseBunLockIO path = do diff --git a/test/Node/NodeSpec.hs b/test/Node/NodeSpec.hs index 87e7b234c..21ce9d550 100644 --- a/test/Node/NodeSpec.hs +++ b/test/Node/NodeSpec.hs @@ -13,7 +13,7 @@ import Data.Tagged (applyTag) import Graphing qualified import Path (Abs, Dir, Path, mkRelDir, mkRelFile, ()) import Path.IO (getCurrentDir) -import Strategy.Node (NodeProject (NPMLock), discover, extractDepListsForTargets, findWorkspaceBuildTargets, getDeps, resolveImporterPaths) +import Strategy.Node (NodeProject (NPMLock), discover, extractDepListsForTargets, findWorkspaceBuildTargets, getDeps, resolveBunWorkspacePaths, resolveImporterPaths) import Strategy.Node.PackageJson ( FlatDeps (..), Manifest (..), @@ -58,6 +58,7 @@ spec = do workspaceBuildTargetsSpec currDir extractDepListsForTargetsSpec currDir resolveImporterPathsSpec currDir + resolveBunWorkspacePathsSpec currDir discoveredWorkSpaceProj :: Path Abs Dir -> DiscoveredProject NodeProject discoveredWorkSpaceProj currDir = @@ -264,6 +265,31 @@ resolveImporterPathsSpec currDir = describe "resolveImporterPaths" $ do Set.fromList [BuildTarget "workspace-test", BuildTarget "pkg-a", BuildTarget "pkg-b"] resolveImporterPaths targets graph `shouldBe` Just (Set.fromList [".", "pkg-a", "nested/pkg-b"]) +resolveBunWorkspacePathsSpec :: Path Abs Dir -> Spec +resolveBunWorkspacePathsSpec currDir = describe "resolveBunWorkspacePaths" $ do + let graph = workspaceGraphWithDeps currDir + + it "returns Nothing for ProjectWithoutTargets" $ do + resolveBunWorkspacePaths ProjectWithoutTargets graph `shouldBe` Nothing + + it "maps root target to empty string" $ do + let targets = + maybe ProjectWithoutTargets FoundTargets . nonEmpty $ + Set.fromList [BuildTarget "workspace-test"] + resolveBunWorkspacePaths targets graph `shouldBe` Just (Set.singleton "") + + it "maps workspace member targets to relative paths" $ do + let targets = + maybe ProjectWithoutTargets FoundTargets . nonEmpty $ + Set.fromList [BuildTarget "pkg-a", BuildTarget "pkg-b"] + resolveBunWorkspacePaths targets graph `shouldBe` Just (Set.fromList ["pkg-a", "nested/pkg-b"]) + + it "maps all targets including root" $ do + let targets = + maybe ProjectWithoutTargets FoundTargets . nonEmpty $ + Set.fromList [BuildTarget "workspace-test", BuildTarget "pkg-a", BuildTarget "pkg-b"] + resolveBunWorkspacePaths targets graph `shouldBe` Just (Set.fromList ["", "pkg-a", "nested/pkg-b"]) + -- | A workspace graph with actual dependencies for testing extractDepListsForTargets. workspaceGraphWithDeps :: Path Abs Dir -> PkgJsonGraph workspaceGraphWithDeps currDir =