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 and npm workspace packages now appear as individual build targets (e.g. `yarn@./:my-package`, `npm@./: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`.
- Project edit: Fix 500 error when running `fossa project edit --policy` on existing projects ([#1688](https://github.com/fossas/fossa-cli/pull/1688))

## 3.17.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
7 changes: 6 additions & 1 deletion docs/references/strategies/languages/nodejs/pnpm.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,12 @@ CLI will infer the package name and version using `/${dependencyName}/${dependen
```

* Peer dependencies will be included in the analysis (they are treated like any other dependency).
* Pnpm workspaces are supported.
* Pnpm workspaces are supported. Each workspace package (including the root)
is exposed as an individual build target (e.g. `pnpm@./:my-app`,
`pnpm@./:lib-utils`). When a subset of targets is selected, 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 in the analysis.
* Development dependencies (`dev: true`) are ignored by default from analysis. To include them in the analysis, execute CLI with `--include-unused` flag e.g. `fossa analyze --include-unused`.
* Optional dependencies are included in the analysis by default. They can be ignored in FOSSA UI.
* `fossa-cli` supports lockFileVersion: 4.x, 5.x, 6.x, 7.x, 8.x, and 9.x.
Expand Down
84 changes: 75 additions & 9 deletions src/Strategy/Node.hs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ module Strategy.Node (
getDeps,
findWorkspaceBuildTargets,
extractDepListsForTargets,
resolveImporterPaths,
resolveBunWorkspacePaths,
) where

import Algebra.Graph.AdjacencyMap qualified as AM
Expand Down Expand Up @@ -39,7 +41,7 @@ import Data.Maybe (catMaybes, isJust, mapMaybe)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Set.NonEmpty qualified as NonEmptySet
import Data.String.Conversion (decodeUtf8, toString)
import Data.String.Conversion (decodeUtf8, toString, toText)
import Data.Tagged (applyTag)
import Data.Text (Text)
import Data.Text qualified as Text
Expand Down Expand Up @@ -73,6 +75,7 @@ import Path (
Rel,
mkRelFile,
parent,
stripProperPrefix,
toFilePath,
(</>),
)
Expand All @@ -99,6 +102,7 @@ import Strategy.Node.Pnpm.PnpmLock qualified as PnpmLock
import Strategy.Node.Pnpm.Workspace (PnpmWorkspace (workspaceSpecs))
import Strategy.Node.YarnV1.YarnLock qualified as V1
import Strategy.Node.YarnV2.YarnLock qualified as V2
import System.FilePath.Posix qualified as FP
import Types (
BuildTarget (BuildTarget),
DependencyResults (DependencyResults),
Expand Down Expand Up @@ -160,6 +164,8 @@ mkProject project = do
projectBuildTargets' = case project of
Yarn _ _ -> findWorkspaceBuildTargets graph
NPMLock _ _ -> findWorkspaceBuildTargets graph
Pnpm _ _ -> findWorkspaceBuildTargets graph
Bun _ _ -> findWorkspaceBuildTargets graph
_ -> ProjectWithoutTargets
Manifest rootManifest <- fromEitherShow $ findWorkspaceRootManifest graph
pure $
Expand Down Expand Up @@ -204,20 +210,80 @@ getDeps ::
m DependencyResults
getDeps targets (Yarn yarnLockFile graph) = analyzeYarn targets yarnLockFile graph
getDeps targets (NPMLock packageLockFile graph) = analyzeNpmLock targets packageLockFile graph
getDeps _ (Pnpm pnpmLockFile _) = analyzePnpmLock pnpmLockFile
getDeps _ (Bun bunLockFile _) = analyzeBunLock bunLockFile
getDeps targets (Pnpm pnpmLockFile graph) = analyzePnpmLock targets pnpmLockFile graph
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) => Manifest -> m DependencyResults
analyzePnpmLock (Manifest pnpmLockFile) = do
result <- PnpmLock.analyze pnpmLockFile
analyzePnpmLock :: (Has Diagnostics sig m, Has ReadFS sig m, Has Logger sig m) => FoundTargets -> Manifest -> PkgJsonGraph -> m DependencyResults
analyzePnpmLock targets (Manifest pnpmLockFile) graph = do
let selectedImporterPaths = resolveImporterPaths targets graph
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
-- (relative paths from the workspace root like ".", "packages/a").
-- When 'ProjectWithoutTargets', returns Nothing (no filtering).
-- When 'FoundTargets', looks up each target's package name in the
-- PkgJsonGraph to find its manifest path, then computes the relative
-- path from the workspace root.
resolveImporterPaths :: FoundTargets -> PkgJsonGraph -> Maybe (Set Text)
resolveImporterPaths ProjectWithoutTargets _ = Nothing
resolveImporterPaths (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, manifestToImporterPath m)
| (Manifest m, pj) <- Map.toList jsonLookup
, Just name <- [packageName pj]
]

manifestToImporterPath :: Path Abs File -> Text
manifestToImporterPath m =
let manifestDir = parent m
in if manifestDir == rootDir
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
22 changes: 16 additions & 6 deletions src/Strategy/Node/Pnpm/PnpmLock.hs
Original file line number Diff line number Diff line change
Expand Up @@ -297,23 +297,33 @@ instance FromJSON Resolution where
gitRes :: Object -> Parser Resolution
gitRes obj = GitResolve <$> (GitResolution <$> obj .: "repo" <*> obj .: "commit")

analyze :: (Has ReadFS sig m, Has Logger sig m, Has Diagnostics sig m) => Path Abs File -> m (Graphing Dependency)
analyze file = context "Analyzing Npm Lockfile (v3)" $ do
-- | Analyze a pnpm lockfile. When @selectedImporterPaths@ is 'Nothing',
-- all importers are included. When @Just paths@, only importers whose
-- key is in @paths@ are treated as direct dependency sources.
analyze :: (Has ReadFS sig m, Has Logger sig m, Has Diagnostics sig m) => Maybe (Set.Set Text) -> Path Abs File -> m (Graphing Dependency)
analyze selectedImporterPaths file = context "Analyzing Npm Lockfile (v3)" $ do
pnpmLockFile <- context "Parsing pnpm-lock file" $ readContentsYaml file

case lockFileVersion pnpmLockFile of
PnpmLockLt4 raw -> logWarn . pretty $ "pnpm-lock file is using older lockFileVersion: " <> raw <> " of, which is not officially supported!"
_ -> pure ()

context "Building dependency graph" $ pure $ buildGraph pnpmLockFile
context "Building dependency graph" $ pure $ buildGraph selectedImporterPaths 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 $
--
-- When @selectedImporterPaths@ is 'Nothing', all importers are included.
-- When @Just paths@, only importers whose key is in @paths@ are treated as
-- direct dependency sources (used for workspace build target filtering).
buildGraph :: Maybe (Set.Set Text) -> PnpmLockfile -> Graphing Dependency
buildGraph selectedImporterPaths lockFile = withoutLocalPackages . hydrateDepEnvs $
run . withLabeling applyLabels $ do
for_ (toList lockFile.importers) $ \(_, projectImporters) -> do
let filteredImporters = case selectedImporterPaths of
Nothing -> toList lockFile.importers
Just paths -> filter (\(k, _) -> k `Set.member` paths) (toList lockFile.importers)
for_ filteredImporters $ \(_, projectImporters) -> do
for_ (Map.toList $ directDependencies projectImporters) $ \(depName, ProjectMapDepMetadata depVersion) ->
for_ (toResolvedDependency depName depVersion) $ \dep -> do
direct dep
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
54 changes: 53 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)
import Strategy.Node (NodeProject (NPMLock), discover, extractDepListsForTargets, findWorkspaceBuildTargets, getDeps, resolveBunWorkspacePaths, resolveImporterPaths)
import Strategy.Node.PackageJson (
FlatDeps (..),
Manifest (..),
Expand Down Expand Up @@ -57,6 +57,8 @@ spec = do
npmLockAnalysisSpec currDir
workspaceBuildTargetsSpec currDir
extractDepListsForTargetsSpec currDir
resolveImporterPathsSpec currDir
resolveBunWorkspacePathsSpec currDir

discoveredWorkSpaceProj :: Path Abs Dir -> DiscoveredProject NodeProject
discoveredWorkSpaceProj currDir =
Expand Down Expand Up @@ -238,6 +240,56 @@ extractDepListsForTargetsSpec currDir = describe "extractDepListsForTargets" $ d
]
directDeps result `shouldBe` applyTag @Production expectedDirect

resolveImporterPathsSpec :: Path Abs Dir -> Spec
resolveImporterPathsSpec currDir = describe "resolveImporterPaths" $ do
let graph = workspaceGraphWithDeps currDir

it "returns Nothing for ProjectWithoutTargets" $ do
resolveImporterPaths ProjectWithoutTargets graph `shouldBe` Nothing

it "maps root target to \".\"" $ do
let targets =
maybe ProjectWithoutTargets FoundTargets . nonEmpty $
Set.fromList [BuildTarget "workspace-test"]
resolveImporterPaths 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"]
resolveImporterPaths 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"]
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
Loading