Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docs/references/strategies/languages/nodejs/bun.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
38 changes: 34 additions & 4 deletions src/Strategy/Node.hs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module Strategy.Node (
findWorkspaceBuildTargets,
extractDepListsForTargets,
resolveImporterPaths,
resolveBunWorkspacePaths,
) where

import Algebra.Graph.AdjacencyMap qualified as AM
Expand Down Expand Up @@ -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 $
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 15 additions & 5 deletions src/Strategy/Node/Bun/BunLock.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
--
Expand All @@ -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
Expand All @@ -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

Expand Down
35 changes: 33 additions & 2 deletions test/Bun/BunLockSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ spec = do
jsoncSpec jsoncPath
dependenciesSpec depsPath
workspacesSpec wsPath
workspaceFilterSpec wsPath
bunProjectSpec bunProjectPath
gitDepsSpec gitDepsPath
mixedEnvsSpec mixedEnvsPath
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion test/Node/NodeSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (..),
Expand Down Expand Up @@ -58,6 +58,7 @@ spec = do
workspaceBuildTargetsSpec currDir
extractDepListsForTargetsSpec currDir
resolveImporterPathsSpec currDir
resolveBunWorkspacePathsSpec currDir

discoveredWorkSpaceProj :: Path Abs Dir -> DiscoveredProject NodeProject
discoveredWorkSpaceProj currDir =
Expand Down Expand Up @@ -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 =
Expand Down
Loading