From 0fcd1f49bf60e021f30ae9d205bee3aca8b8777d Mon Sep 17 00:00:00 2001 From: Sara Date: Wed, 4 Feb 2026 15:48:17 -0500 Subject: [PATCH 1/3] Add Bun lockfile tactic implementation Add support for parsing Bun's bun.lock (JSONC format) lockfiles. This includes: - BunProjectType registration in Types.hs - BunLock.hs with JSONC parser that handles trailing commas - Dependency graph builder with workspace support - Dev/production dependency labeling Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- spectrometer.cabal | 2 + src/Strategy/Node/Bun/BunLock.hs | 334 +++++++++++++++++++++++++++++++ src/Types.hs | 2 + 3 files changed, 338 insertions(+) create mode 100644 src/Strategy/Node/Bun/BunLock.hs diff --git a/spectrometer.cabal b/spectrometer.cabal index 6b60b2e70..39c990ef8 100644 --- a/spectrometer.cabal +++ b/spectrometer.cabal @@ -480,6 +480,7 @@ library Strategy.Nim Strategy.Nim.NimbleLock Strategy.Node + Strategy.Node.Bun.BunLock Strategy.Node.Errors Strategy.Node.Npm.PackageLock Strategy.Node.Npm.PackageLockV3 @@ -611,6 +612,7 @@ test-suite unit-tests App.Fossa.VSI.TypesSpec App.Fossa.VSIDepsSpec BerkeleyDB.BerkeleyDBSpec + Bun.BunLockSpec BundlerSpec Cargo.CargoTomlSpec Cargo.MetadataSpec diff --git a/src/Strategy/Node/Bun/BunLock.hs b/src/Strategy/Node/Bun/BunLock.hs new file mode 100644 index 000000000..32b1aff5b --- /dev/null +++ b/src/Strategy/Node/Bun/BunLock.hs @@ -0,0 +1,334 @@ +{-# LANGUAGE OverloadedRecordDot #-} + +module Strategy.Node.Bun.BunLock ( + analyze, + parseBunLock, + buildGraph, + BunLockFile (..), + BunWorkspace (..), + BunPackage (..), +) +where + +import Control.Algebra (run) +import Control.Effect.Diagnostics (Diagnostics, Has, context, fatal) +import Data.Aeson ( + FromJSON (parseJSON), + Result (..), + Value (..), + eitherDecodeStrict, + fromJSON, + withArray, + withObject, + (.!=), + (.:), + (.:?), + ) +import Data.Aeson.KeyMap qualified as KM +import Data.Foldable (for_) +import Data.Map (Map) +import Data.Map qualified as Map +import Data.Set qualified as Set +import Data.String.Conversion (encodeUtf8, toText) +import Data.Text (Text) +import Data.Text qualified as Text +import Data.Vector qualified as V +import DepTypes (DepEnvironment (..), DepType (NodeJSType), Dependency (..), VerConstraint (CEq)) +import Effect.Grapher (deep, direct, edge, evalGrapher) +import Effect.ReadFS (ReadFS, ReadFSErr (FileParseError), readContentsText) +import Graphing (Graphing) +import Graphing qualified +import Path (Abs, File, Path) + +-- | Bun Lockfile structure +-- Bun lockfile (bun.lock) is a JSONC format with the following shape: +-- +-- @ +-- > { +-- > "lockfileVersion": 1, +-- > "workspaces": { +-- > "": { +-- > "name": "my-project", +-- > "dependencies": { +-- > "lodash": "^4.17.21" +-- > }, +-- > "devDependencies": { +-- > "typescript": "^5.0.0" +-- > } +-- > } +-- > }, +-- > "packages": { +-- > "lodash": ["lodash@4.17.21", "", {}, "sha512-xxx"], +-- > "typescript": ["typescript@5.3.3", "", {"bin": {"tsc": "bin/tsc"}}, "sha512-yyy"] +-- > } +-- > } +-- @ +-- +-- In this file: +-- * `lockfileVersion`: Version of the lockfile format +-- * `workspaces`: Map of workspace configurations +-- * Key (e.g. "") refers to workspace path +-- * `name`: Workspace name +-- * `dependencies`: Direct production dependencies +-- * `devDependencies`: Direct development dependencies +-- * `packages`: Map of all resolved packages +-- * Key: Package name (e.g. "lodash") +-- * Value: Array [resolution, registry, info, integrity] +-- - resolution: Package@version string +-- - registry: Registry URL (empty string for npm) +-- - info: Object with optional bin, scripts, etc. +-- - integrity: SHA512 hash +data BunLockFile = BunLockFile + { lockfileVersion :: Int + , workspaces :: Map Text BunWorkspace + , packages :: Map Text BunPackage + } + deriving (Show, Eq) + +data BunWorkspace = BunWorkspace + { name :: Text + , dependencies :: Map Text Text + , devDependencies :: Map Text Text + } + deriving (Show, Eq, Ord) + +data BunPackage = BunPackage + { resolution :: Text + , registry :: Text + , info :: Value + , integrity :: Text + } + deriving (Show, Eq) + +-- | FromJSON instance for BunLockFile +-- Parses the top-level bun.lock structure +instance FromJSON BunLockFile where + parseJSON = withObject "BunLockFile" $ \obj -> + BunLockFile + <$> obj .: "lockfileVersion" + <*> obj .:? "workspaces" .!= mempty + <*> obj .:? "packages" .!= mempty + +-- | FromJSON instance for BunWorkspace +-- Parses workspace configuration +instance FromJSON BunWorkspace where + parseJSON = withObject "BunWorkspace" $ \obj -> + BunWorkspace + <$> obj .:? "name" .!= "" + <*> obj .:? "dependencies" .!= mempty + <*> obj .:? "devDependencies" .!= mempty + +-- | FromJSON instance for BunPackage +-- Parses the array format: [resolution, registry, info, integrity] +instance FromJSON BunPackage where + parseJSON = withArray "BunPackage" $ \arr -> do + let vec = V.toList arr + case vec of + [resVal, regVal, infoVal, integrityVal] -> do + res <- parseJSON resVal + reg <- parseJSON regVal + info' <- parseJSON infoVal + integrity' <- parseJSON integrityVal + pure $ BunPackage res reg info' integrity' + _ -> fail $ "Expected array with 4 elements, got " ++ show (length vec) + +-- | Parse a bun.lock file +-- Bun lockfiles use JSONC format (JSON with comments) +-- This function strips comments before parsing +parseBunLock :: + (Has ReadFS sig m, Has Diagnostics sig m) => + Path Abs File -> + m BunLockFile +parseBunLock file = context ("Parsing bun.lock file '" <> toText (show file) <> "'") $ do + contents <- readContentsText file + let stripped = stripJsoncComments contents + bs = encodeUtf8 stripped + case eitherDecodeStrict bs of + Left err -> fatal $ FileParseError (show file) (toText err) + Right lockFile -> pure lockFile + +-- | Convert JSONC to valid JSON +-- JSONC (JSON with Comments) allows: +-- 1. Single-line comments starting with // +-- 2. Trailing commas before } or ] +-- +-- This function strips both to produce valid JSON +stripJsoncComments :: Text -> Text +stripJsoncComments input = removeTrailingCommas $ Text.unlines $ map processLine $ Text.lines input + where + -- Process a single line: strip comments + processLine :: Text -> Text + processLine line = + let stripped = Text.stripStart line + in if "//" `Text.isPrefixOf` stripped + then "" + else stripInlineComment line + + -- Strip inline comments (// outside of strings) + stripInlineComment :: Text -> Text + stripInlineComment = go False + where + go :: Bool -> Text -> Text + go _ t | Text.null t = t + go inString t = + case Text.uncons t of + Nothing -> t + Just ('"', rest) + | not inString -> "\"" <> go True rest + | otherwise -> "\"" <> go False rest + Just ('\\', rest) + | inString -> + -- Escaped char in string, take next char too + case Text.uncons rest of + Just (c, rest') -> "\\" <> Text.singleton c <> go True rest' + Nothing -> "\\" + | otherwise -> "\\" <> go inString rest + Just ('/', rest) + | not inString -> + case Text.uncons rest of + Just ('/', _) -> "" -- Comment found, strip rest of line + _ -> "/" <> go inString rest + | otherwise -> "/" <> go inString rest + Just (c, rest) -> Text.singleton c <> go inString rest + + -- Remove trailing commas before } or ] + -- Pattern: comma followed by optional whitespace then } or ] + removeTrailingCommas :: Text -> Text + removeTrailingCommas = go False + where + go :: Bool -> Text -> Text + go _ t | Text.null t = t + go inString t = + case Text.uncons t of + Nothing -> t + Just ('"', rest) + | not inString -> "\"" <> go True rest + | otherwise -> "\"" <> go False rest + Just ('\\', rest) + | inString -> + case Text.uncons rest of + Just (c, rest') -> "\\" <> Text.singleton c <> go True rest' + Nothing -> "\\" + | otherwise -> "\\" <> go inString rest + Just (',', rest) + | not inString -> + -- Check if this comma is followed by whitespace then } or ] + let afterWs = Text.dropWhile (`elem` [' ', '\t', '\n', '\r']) rest + in case Text.uncons afterWs of + Just ('}', _) -> go False rest -- Skip the comma + Just (']', _) -> go False rest -- Skip the comma + _ -> "," <> go False rest -- Keep the comma + | otherwise -> "," <> go inString rest + Just (c, rest) -> Text.singleton c <> go inString rest + +-- | Build a dependency graph from a parsed bun lockfile +-- +-- The graph building process: +-- 1. Iterate over all workspaces to mark direct dependencies +-- 2. For each workspace dependency, look up the resolved package and mark as direct +-- 3. Dev dependencies are marked with EnvDevelopment, production with EnvProduction +-- 4. Iterate over all packages to add deep dependencies and edges +-- 5. Extract transitive dependencies from package info and create edges +buildGraph :: BunLockFile -> Graphing Dependency +buildGraph lockFile = run . evalGrapher $ do + -- Collect all dev dependency names from all workspaces + let devDepNames = Set.fromList $ concatMap (Map.keys . devDependencies) (Map.elems lockFile.workspaces) + + -- Process all workspaces for direct dependencies + for_ (Map.elems lockFile.workspaces) $ \workspace -> do + -- Production dependencies + for_ (Map.keys workspace.dependencies) $ \depName -> do + case Map.lookup depName lockFile.packages of + Nothing -> pure () + Just pkg -> direct $ packageToDep pkg False + + -- Dev dependencies + for_ (Map.keys workspace.devDependencies) $ \depName -> do + case Map.lookup depName lockFile.packages of + Nothing -> pure () + Just pkg -> direct $ packageToDep pkg True + + -- Process all packages for deep dependencies and edges + for_ (Map.toList lockFile.packages) $ \(_, pkg) -> do + let isDev = isDevDep devDepNames pkg + parentDep = packageToDep pkg isDev + + -- Add as deep dependency + deep parentDep + + -- Extract dependencies from info and create edges + let pkgDeps = extractDependencies pkg.info + for_ (Map.keys pkgDeps) $ \childName -> do + case Map.lookup childName lockFile.packages of + Nothing -> pure () + Just childPkg -> do + let childDep = packageToDep childPkg (isDevDep devDepNames childPkg) + edge parentDep childDep + where + -- Check if a package is a dev dependency based on the collected dev dep names + isDevDep :: Set.Set Text -> BunPackage -> Bool + isDevDep devNames pkg = + let (name, _) = parseResolution pkg.resolution + in Set.member name devNames + +-- | Convert a BunPackage to a Dependency +packageToDep :: BunPackage -> Bool -> Dependency +packageToDep pkg isDev = + let (name, version) = parseResolution pkg.resolution + env = if isDev then EnvDevelopment else EnvProduction + in Dependency + { dependencyType = NodeJSType + , dependencyName = name + , dependencyVersion = Just (CEq version) + , dependencyLocations = mempty + , dependencyEnvironments = Set.singleton env + , dependencyTags = mempty + } + +-- | Parse resolution string "name@version" or "@scope/name@version" +-- +-- >>> parseResolution "lodash@4.17.21" +-- ("lodash", "4.17.21") +-- +-- >>> parseResolution "@angular/core@16.0.0" +-- ("@angular/core", "16.0.0") +parseResolution :: Text -> (Text, Text) +parseResolution res + | "@" `Text.isPrefixOf` res = + -- Scoped package: @scope/name@version + let withoutAt = Text.drop 1 res + (scopeAndName, rest) = Text.breakOn "@" withoutAt + in ("@" <> scopeAndName, Text.drop 1 rest) + | otherwise = + -- Regular package: name@version + let (name, rest) = Text.breakOn "@" res + in (name, Text.drop 1 rest) + +-- | Extract dependencies map from package info Value +-- The info object may contain a "dependencies" key with a map of dep name to version spec +extractDependencies :: Value -> Map Text Text +extractDependencies (Object obj) = + case KM.lookup "dependencies" obj of + Just depsVal -> + case fromJSON depsVal of + Success deps -> deps + Error _ -> mempty + Nothing -> mempty +extractDependencies _ = mempty + +-- | Filter out workspace packages from the dependency graph +-- Workspace packages are internal packages in a monorepo and should not be included +-- in the final dependency graph +filterWorkspaces :: BunLockFile -> Graphing Dependency -> Graphing Dependency +filterWorkspaces lockFile = + let workspaceNames = Set.fromList $ map name (Map.elems lockFile.workspaces) + in Graphing.shrink (\dep -> not (Set.member (dependencyName dep) workspaceNames)) + +-- | Analyze a bun.lock file and produce a dependency graph +analyze :: + (Has ReadFS sig m, Has Diagnostics sig m) => + Path Abs File -> + m (Graphing Dependency) +analyze file = do + lockfile <- parseBunLock file + pure $ filterWorkspaces lockfile $ buildGraph lockfile diff --git a/src/Types.hs b/src/Types.hs index 416face72..69c82e029 100644 --- a/src/Types.hs +++ b/src/Types.hs @@ -67,6 +67,7 @@ data DiscoveredProjectType = AlpineDatabaseProjectType | BerkeleyDBProjectType | BinaryDepsProjectType + | BunProjectType | BundlerProjectType | CabalProjectType | CargoProjectType @@ -119,6 +120,7 @@ projectTypeToText = \case AlpineDatabaseProjectType -> "apkdb" BerkeleyDBProjectType -> "berkeleydb" BinaryDepsProjectType -> "binary-deps" + BunProjectType -> "bun" BundlerProjectType -> "bundler" CabalProjectType -> "cabal" CargoProjectType -> "cargo" From 5e0fdeed9fda0d8170801d7516c09d59d766159f Mon Sep 17 00:00:00 2001 From: Sara Date: Wed, 4 Feb 2026 15:48:25 -0500 Subject: [PATCH 2/3] Add Bun lockfile tactic tests Comprehensive test suite for Bun lockfile parsing and graph building: - Parsing tests for simple, workspace, and devdeps lockfiles - JSONC trailing comma support tests - Graph building tests for direct/transitive dependencies - Environment labeling tests (dev vs production) - Workspace package filtering tests Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- test/Bun/BunLockSpec.hs | 311 +++++++++++++++++++++ test/Bun/testdata/devdeps-bun.lock | 20 ++ test/Bun/testdata/simple-bun.lock | 14 + test/Bun/testdata/trailing-commas-bun.lock | 20 ++ test/Bun/testdata/transitive-bun.lock | 21 ++ test/Bun/testdata/workspace-bun.lock | 28 ++ 6 files changed, 414 insertions(+) create mode 100644 test/Bun/BunLockSpec.hs create mode 100644 test/Bun/testdata/devdeps-bun.lock create mode 100644 test/Bun/testdata/simple-bun.lock create mode 100644 test/Bun/testdata/trailing-commas-bun.lock create mode 100644 test/Bun/testdata/transitive-bun.lock create mode 100644 test/Bun/testdata/workspace-bun.lock diff --git a/test/Bun/BunLockSpec.hs b/test/Bun/BunLockSpec.hs new file mode 100644 index 000000000..f63a35d3b --- /dev/null +++ b/test/Bun/BunLockSpec.hs @@ -0,0 +1,311 @@ +{-# LANGUAGE TemplateHaskell #-} + +module Bun.BunLockSpec ( + spec, +) where + +import Data.Aeson (eitherDecodeStrict) +import Data.Map.Strict qualified as Map +import Data.Set qualified as Set +import Data.String.Conversion qualified +import Data.Text (Text) +import Data.Text qualified as Text +import DepTypes ( + DepEnvironment (EnvDevelopment, EnvProduction), + DepType (NodeJSType), + Dependency (..), + VerConstraint (CEq), + ) +import GraphUtil ( + expectDirect, + expectEdge, + ) +import Graphing (Graphing) +import Graphing qualified +import Path (Abs, File, Path, fromAbsFile, mkRelFile, ()) +import Path.IO (getCurrentDir) +import Strategy.Node.Bun.BunLock ( + BunLockFile (..), + BunPackage (..), + BunWorkspace (..), + buildGraph, + parseBunLock, + ) +import Test.Effect (it', shouldBe') +import Test.Hspec (Expectation, Spec, describe, expectationFailure, it, runIO, shouldBe) + +spec :: Spec +spec = do + currentDir <- runIO getCurrentDir + let simpleBunLockPath = currentDir $(mkRelFile "test/Bun/testdata/simple-bun.lock") + let workspaceBunLockPath = currentDir $(mkRelFile "test/Bun/testdata/workspace-bun.lock") + let devdepsBunLockPath = currentDir $(mkRelFile "test/Bun/testdata/devdeps-bun.lock") + let trailingCommasBunLockPath = currentDir $(mkRelFile "test/Bun/testdata/trailing-commas-bun.lock") + + describe "bun.lock simple" $ do + it' "should parse simple bun.lock" $ do + lockFile <- parseBunLock simpleBunLockPath + lockfileVersion lockFile `shouldBe'` 1 + + -- Check workspaces + let ws = workspaces lockFile + Map.size ws `shouldBe'` 1 + let rootWs = ws Map.! "" + name rootWs `shouldBe'` "simple-project" + dependencies rootWs `shouldBe'` Map.fromList [("lodash", "^4.17.21")] + devDependencies rootWs `shouldBe'` mempty + + -- Check packages + let pkgs = packages lockFile + Map.size pkgs `shouldBe'` 1 + let lodashPkg = pkgs Map.! "lodash" + resolution lodashPkg `shouldBe'` "lodash@4.17.21" + + describe "bun.lock with workspaces" $ do + it' "should parse workspace bun.lock" $ do + lockFile <- parseBunLock workspaceBunLockPath + lockfileVersion lockFile `shouldBe'` 1 + + -- Check workspaces + let ws = workspaces lockFile + Map.size ws `shouldBe'` 3 + + -- Root workspace + let rootWs = ws Map.! "" + name rootWs `shouldBe'` "root-workspace" + dependencies rootWs `shouldBe'` Map.fromList [("express", "^4.18.0")] + + -- API workspace + let apiWs = ws Map.! "packages/api" + name apiWs `shouldBe'` "api-package" + dependencies apiWs `shouldBe'` Map.fromList [("axios", "^1.4.0")] + + -- Web workspace + let webWs = ws Map.! "packages/web" + name webWs `shouldBe'` "web-package" + dependencies webWs `shouldBe'` Map.fromList [("react", "^18.2.0")] + + -- Check packages + let pkgs = packages lockFile + Map.size pkgs `shouldBe'` 3 + resolution (pkgs Map.! "express") `shouldBe'` "express@4.18.2" + resolution (pkgs Map.! "axios") `shouldBe'` "axios@1.4.0" + resolution (pkgs Map.! "react") `shouldBe'` "react@18.2.0" + + describe "bun.lock with dev dependencies" $ do + it' "should parse devdeps bun.lock" $ do + lockFile <- parseBunLock devdepsBunLockPath + lockfileVersion lockFile `shouldBe'` 1 + + -- Check workspaces + let ws = workspaces lockFile + Map.size ws `shouldBe'` 1 + let rootWs = ws Map.! "" + name rootWs `shouldBe'` "devdeps-project" + dependencies rootWs `shouldBe'` Map.fromList [("lodash", "^4.17.21")] + devDependencies rootWs `shouldBe'` Map.fromList [("typescript", "^5.0.0"), ("jest", "^29.5.0")] + + -- Check packages + let pkgs = packages lockFile + Map.size pkgs `shouldBe'` 3 + resolution (pkgs Map.! "lodash") `shouldBe'` "lodash@4.17.21" + resolution (pkgs Map.! "typescript") `shouldBe'` "typescript@5.3.3" + resolution (pkgs Map.! "jest") `shouldBe'` "jest@29.5.0" + + describe "bun.lock with trailing commas (JSONC)" $ do + it' "should parse bun.lock with trailing commas" $ do + lockFile <- parseBunLock trailingCommasBunLockPath + lockfileVersion lockFile `shouldBe'` 1 + + -- Check workspaces + let ws = workspaces lockFile + Map.size ws `shouldBe'` 1 + let rootWs = ws Map.! "" + name rootWs `shouldBe'` "test-project" + Map.size (dependencies rootWs) `shouldBe'` 2 + Map.size (devDependencies rootWs) `shouldBe'` 1 + + -- Check packages + let pkgs = packages lockFile + Map.size pkgs `shouldBe'` 3 + + -- Graph building tests + let transitiveBunLockPath = currentDir $(mkRelFile "test/Bun/testdata/transitive-bun.lock") + + -- Use checkGraph pattern like PnpmLockSpec + checkGraph simpleBunLockPath simpleGraphSpec + checkGraph workspaceBunLockPath workspaceGraphSpec + checkGraph devdepsBunLockPath devdepsGraphSpec + checkGraph transitiveBunLockPath transitiveGraphSpec + +-- | Load a bun.lock file and run graph specs on it +checkGraph :: Path Abs File -> (Graphing Dependency -> Spec) -> Spec +checkGraph pathToFixture buildGraphSpec = do + eitherLockFile <- runIO $ parseBunLockIO pathToFixture + case eitherLockFile of + Left err -> + describe "bun.lock" $ + it "should parse lockfile" (expectationFailure err) + Right lockFile -> buildGraphSpec (buildGraph lockFile) + +-- | Parse a bun.lock file using IO (for tests) +parseBunLockIO :: Path Abs File -> IO (Either String BunLockFile) +parseBunLockIO path = do + contents <- readFile (fromAbsFile path) + let stripped = stripJsoncComments (Data.String.Conversion.toText contents) + bs = Data.String.Conversion.encodeUtf8 stripped + pure $ eitherDecodeStrict bs + +-- | Convert JSONC to valid JSON (copy from BunLock.hs for testing) +-- JSONC allows: // comments and trailing commas +stripJsoncComments :: Text -> Text +stripJsoncComments input = removeTrailingCommas $ Text.unlines $ map processLine $ Text.lines input + where + processLine :: Text -> Text + processLine line = + let stripped = Text.stripStart line + in if "//" `Text.isPrefixOf` stripped + then "" + else stripInlineComment line + + stripInlineComment :: Text -> Text + stripInlineComment = go False + where + go :: Bool -> Text -> Text + go _ t | Text.null t = t + go inString t = + case Text.uncons t of + Nothing -> t + Just ('"', rest) + | not inString -> "\"" <> go True rest + | otherwise -> "\"" <> go False rest + Just ('\\', rest) + | inString -> + case Text.uncons rest of + Just (c, rest') -> "\\" <> Text.singleton c <> go True rest' + Nothing -> "\\" + | otherwise -> "\\" <> go inString rest + Just ('/', rest) + | not inString -> + case Text.uncons rest of + Just ('/', _) -> "" + _ -> "/" <> go inString rest + | otherwise -> "/" <> go inString rest + Just (c, rest) -> Text.singleton c <> go inString rest + + removeTrailingCommas :: Text -> Text + removeTrailingCommas = go False + where + go :: Bool -> Text -> Text + go _ t | Text.null t = t + go inString t = + case Text.uncons t of + Nothing -> t + Just ('"', rest) + | not inString -> "\"" <> go True rest + | otherwise -> "\"" <> go False rest + Just ('\\', rest) + | inString -> + case Text.uncons rest of + Just (c, rest') -> "\\" <> Text.singleton c <> go True rest' + Nothing -> "\\" + | otherwise -> "\\" <> go inString rest + Just (',', rest) + | not inString -> + let afterWs = Text.dropWhile (`elem` [' ', '\t', '\n', '\r']) rest + in case Text.uncons afterWs of + Just ('}', _) -> go False rest + Just (']', _) -> go False rest + _ -> "," <> go False rest + | otherwise -> "," <> go inString rest + Just (c, rest) -> Text.singleton c <> go inString rest + +simpleGraphSpec :: Graphing Dependency -> Spec +simpleGraphSpec graph = do + describe "simple bun.lock graph" $ do + it "marks direct dependencies from workspaces" $ do + expectDirect [mkProdDep "lodash@4.17.21"] graph + +workspaceGraphSpec :: Graphing Dependency -> Spec +workspaceGraphSpec graph = do + describe "workspace bun.lock graph" $ do + it "marks direct dependencies from all workspaces" $ do + expectDirect + [ mkProdDep "express@4.18.2" + , mkProdDep "axios@1.4.0" + , mkProdDep "react@18.2.0" + ] + graph + + it "excludes workspace packages from graph nodes" $ do + -- Workspace packages should not appear in the graph + -- The workspace names are: "root-workspace", "api-package", "web-package" + -- These should be filtered out from the final graph + let vertices = Graphing.vertexList graph + vertexNames = map dependencyName vertices + vertexNames `shouldBe` ["express", "axios", "react"] + +devdepsGraphSpec :: Graphing Dependency -> Spec +devdepsGraphSpec graph = do + describe "devdeps bun.lock graph" $ do + it "labels dev dependencies with EnvDevelopment" $ do + expectDirect + [ mkProdDep "lodash@4.17.21" + , mkDevDep "typescript@5.3.3" + , mkDevDep "jest@29.5.0" + ] + graph + +transitiveGraphSpec :: Graphing Dependency -> Spec +transitiveGraphSpec graph = do + let hasEdge :: Dependency -> Dependency -> Expectation + hasEdge = expectEdge graph + + describe "transitive bun.lock graph" $ do + it "marks direct dependencies" $ do + expectDirect + [ mkProdDep "express@4.18.2" + , mkDevDep "typescript@5.3.3" + ] + graph + + it "creates edges for transitive dependencies" $ do + -- express -> accepts, body-parser + hasEdge (mkProdDep "express@4.18.2") (mkProdDep "accepts@1.3.8") + hasEdge (mkProdDep "express@4.18.2") (mkProdDep "body-parser@1.20.1") + + -- accepts -> mime-types + hasEdge (mkProdDep "accepts@1.3.8") (mkProdDep "mime-types@2.1.35") + +-- | Helper to create a production dependency +mkProdDep :: Text -> Dependency +mkProdDep nameAtVersion = mkDep nameAtVersion (Just EnvProduction) + +-- | Helper to create a dev dependency +mkDevDep :: Text -> Dependency +mkDevDep nameAtVersion = mkDep nameAtVersion (Just EnvDevelopment) + +-- | Helper to create a dependency from "name@version" string +mkDep :: Text -> Maybe DepEnvironment -> Dependency +mkDep nameAtVersion env = do + let (name, version) = parseNameVersion nameAtVersion + Dependency + NodeJSType + name + (CEq <$> Just version) + mempty + (maybe mempty Set.singleton env) + mempty + +-- | Parse "name@version" or "@scope/name@version" into (name, version) +parseNameVersion :: Text -> (Text, Text) +parseNameVersion t + | "@" `Text.isPrefixOf` t = + -- Scoped package: @scope/name@version + let withoutAt = Text.drop 1 t + (scopeAndName, rest) = Text.breakOn "@" withoutAt + in ("@" <> scopeAndName, Text.drop 1 rest) + | otherwise = + -- Regular package: name@version + let (name, rest) = Text.breakOn "@" t + in (name, Text.drop 1 rest) diff --git a/test/Bun/testdata/devdeps-bun.lock b/test/Bun/testdata/devdeps-bun.lock new file mode 100644 index 000000000..59f0b6c4a --- /dev/null +++ b/test/Bun/testdata/devdeps-bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "devdeps-project", + "dependencies": { + "lodash": "^4.17.21" + }, + "devDependencies": { + "typescript": "^5.0.0", + "jest": "^29.5.0" + } + } + }, + "packages": { + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZfKzewUda7lEbFF7sBMudS3aMUvSCbiNgcvJ5NiynzQ=="], + "typescript": ["typescript@5.3.3", "", {"bin": {"tsc": "bin/tsc", "tsserver": "bin/tsserver"}}, "sha512-P5QFLNDH3NKVQWQ7mT8rUI9HDarfua0VSKIDQ+jsQBzSsnVjcccgNeiWGe74XsbqRLuxV5mS1ct6nGkLDTwsQ=="], + "jest": ["jest@29.5.0", "", {"bin": {"jest": "bin/jest.js"}}, "sha512-juAQQXNETCNmngM5KSwtSB56LmCQWfp4+kHA9ZScOH+FmWkQRAoNS59BY59PEnF4CJIkCMXWol1nKlstrDXSQ=="] + } +} diff --git a/test/Bun/testdata/simple-bun.lock b/test/Bun/testdata/simple-bun.lock new file mode 100644 index 000000000..ccc0804d0 --- /dev/null +++ b/test/Bun/testdata/simple-bun.lock @@ -0,0 +1,14 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "simple-project", + "dependencies": { + "lodash": "^4.17.21" + } + } + }, + "packages": { + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZfKzewUda7lEbFF7sBMudS3aMUvSCbiNgcvJ5NiynzQ=="] + } +} diff --git a/test/Bun/testdata/trailing-commas-bun.lock b/test/Bun/testdata/trailing-commas-bun.lock new file mode 100644 index 000000000..9b8159ed3 --- /dev/null +++ b/test/Bun/testdata/trailing-commas-bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "test-project", + "dependencies": { + "chalk": "^5.6.2", + "lodash": "^4.17.23", + }, + "devDependencies": { + "@types/bun": "latest", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.8", "", {}, "sha512-xxx"], + "chalk": ["chalk@5.6.2", "", {}, "sha512-yyy"], + "lodash": ["lodash@4.17.23", "", {}, "sha512-zzz"], + }, +} diff --git a/test/Bun/testdata/transitive-bun.lock b/test/Bun/testdata/transitive-bun.lock new file mode 100644 index 000000000..8b58d9248 --- /dev/null +++ b/test/Bun/testdata/transitive-bun.lock @@ -0,0 +1,21 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "transitive-project", + "dependencies": { + "express": "^4.18.0" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + } + }, + "packages": { + "express": ["express@4.18.2", "", {"dependencies": {"accepts": "~1.3.8", "body-parser": "1.20.1"}}, "sha512-express-hash"], + "accepts": ["accepts@1.3.8", "", {"dependencies": {"mime-types": "~2.1.34"}}, "sha512-accepts-hash"], + "body-parser": ["body-parser@1.20.1", "", {}, "sha512-body-parser-hash"], + "mime-types": ["mime-types@2.1.35", "", {}, "sha512-mime-types-hash"], + "typescript": ["typescript@5.3.3", "", {"bin": {"tsc": "bin/tsc"}}, "sha512-typescript-hash"] + } +} diff --git a/test/Bun/testdata/workspace-bun.lock b/test/Bun/testdata/workspace-bun.lock new file mode 100644 index 000000000..620e40507 --- /dev/null +++ b/test/Bun/testdata/workspace-bun.lock @@ -0,0 +1,28 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "root-workspace", + "dependencies": { + "express": "^4.18.0" + } + }, + "packages/api": { + "name": "api-package", + "dependencies": { + "axios": "^1.4.0" + } + }, + "packages/web": { + "name": "web-package", + "dependencies": { + "react": "^18.2.0" + } + } + }, + "packages": { + "express": ["express@4.18.2", "", {}, "sha512-QqZuS6+K8SJHUJWe7J8XN7f760sSQcIQjrW+yLsd+fFQe+jCIIvCgSznTkeh8x+CPaXWUvOfyWlubLjSoO+IA=="], + "axios": ["axios@1.4.0", "", {}, "sha512-S4XCWMEmzvo64T9GfvQSutL57B+PkWV50oDyW73+5ddQ2cTzrxFuSMzAg+eNmyT7stBeJ7i7ZhiGW+O7zFHzA=="], + "react": ["react@18.2.0", "", {}, "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSJWhcoWNYG5sFZ5Vux0MmQqsrPsek0rXsadNtLrX3QJZWLEn3eNQ=="] + } +} From 74888f6baaad2e8c7e414e67b87a72650ce1c513 Mon Sep 17 00:00:00 2001 From: Sara Date: Wed, 4 Feb 2026 15:48:33 -0500 Subject: [PATCH 3/3] Integrate Bun tactic into Node.js strategy Wire up Bun lockfile support in the Node.js strategy: - Add Bun constructor to NodeProject ADT - Add bun.lock detection in identifyProjectType - Add Bun dispatch in getDeps - Add BunProjectType to discovery filter Detection order: Yarn -> NPM -> Pnpm -> Bun -> fallback NPM Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/Strategy/Node.hs | 55 +++++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/src/Strategy/Node.hs b/src/Strategy/Node.hs index 38b59fb43..7d38fbe89 100644 --- a/src/Strategy/Node.hs +++ b/src/Strategy/Node.hs @@ -73,6 +73,7 @@ import Path ( toFilePath, (), ) +import Strategy.Node.Bun.BunLock qualified as BunLock import Strategy.Node.Errors (CyclicPackageJson (CyclicPackageJson), MissingNodeLockFile (..), fossaNodeDocUrl, npmLockFileDocUrl, yarnLockfileDocUrl, yarnV2LockfileDocUrl) import Strategy.Node.Npm.PackageLock qualified as PackageLock import Strategy.Node.Npm.PackageLockV3 qualified as PackageLockV3 @@ -98,7 +99,7 @@ import Strategy.Node.YarnV2.YarnLock qualified as V2 import Types ( DependencyResults (DependencyResults), DiscoveredProject (..), - DiscoveredProjectType (NpmProjectType, PnpmProjectType, YarnProjectType), + DiscoveredProjectType (BunProjectType, NpmProjectType, PnpmProjectType, YarnProjectType), FoundTargets (ProjectWithoutTargets), GraphBreadth (Complete, Partial), License (License), @@ -118,7 +119,7 @@ discover :: ) => Path Abs Dir -> m [DiscoveredProject NodeProject] -discover dir = withMultiToolFilter [YarnProjectType, NpmProjectType, PnpmProjectType] $ +discover dir = withMultiToolFilter [YarnProjectType, NpmProjectType, PnpmProjectType, BunProjectType] $ context "NodeJS" $ do manifestList <- context "Finding nodejs/pnpm projects" $ collectManifests dir manifestMap <- context "Reading manifest files" $ (Map.fromList . catMaybes) <$> traverse loadPackage manifestList @@ -147,6 +148,7 @@ mkProject project = do NPMLock _ g -> (g, NpmProjectType) NPM g -> (g, NpmProjectType) Pnpm _ g -> (g, PnpmProjectType) + Bun _ g -> (g, BunProjectType) Manifest rootManifest <- fromEitherShow $ findWorkspaceRootManifest graph pure $ DiscoveredProject @@ -172,12 +174,18 @@ getDeps :: getDeps (Yarn yarnLockFile graph) = analyzeYarn yarnLockFile graph getDeps (NPMLock packageLockFile graph) = analyzeNpmLock packageLockFile graph getDeps (Pnpm pnpmLockFile _) = analyzePnpmLock pnpmLockFile +getDeps (Bun bunLockFile _) = analyzeBunLock bunLockFile 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 - pure $ DependencyResults result Complete [pnpmLockFile] + result <- PnpmLock.analyze 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 + pure $ DependencyResults result Complete [bunLockFile] analyzeNpmLock :: (Has Diagnostics sig m, Has ReadFS sig m) => Manifest -> PkgJsonGraph -> m DependencyResults analyzeNpmLock (Manifest npmLockFile) graph = do @@ -364,25 +372,29 @@ identifyProjectType :: PkgJsonGraph -> m NodeProject identifyProjectType graph = do - Manifest manifest <- fromEitherShow $ findWorkspaceRootManifest graph - let yarnFilePath = parent manifest Path. $(mkRelFile "yarn.lock") - packageLockPath = parent manifest Path. $(mkRelFile "package-lock.json") - pnpmLockPath = parent manifest Path. $(mkRelFile "pnpm-lock.yaml") - yarnExists <- doesFileExist yarnFilePath - pkgLockExists <- doesFileExist packageLockPath - pnpmLockExists <- doesFileExist pnpmLockPath - pure $ case (yarnExists, pkgLockExists, pnpmLockExists) of - (True, _, _) -> Yarn (Manifest yarnFilePath) graph - (_, True, _) -> NPMLock (Manifest packageLockPath) graph - (_, _, True) -> Pnpm (Manifest pnpmLockPath) graph - _ -> NPM graph + Manifest manifest <- fromEitherShow $ findWorkspaceRootManifest graph + let yarnFilePath = parent manifest Path. $(mkRelFile "yarn.lock") + packageLockPath = parent manifest Path. $(mkRelFile "package-lock.json") + pnpmLockPath = parent manifest Path. $(mkRelFile "pnpm-lock.yaml") + bunLockPath = parent manifest Path. $(mkRelFile "bun.lock") + yarnExists <- doesFileExist yarnFilePath + pkgLockExists <- doesFileExist packageLockPath + pnpmLockExists <- doesFileExist pnpmLockPath + bunLockExists <- doesFileExist bunLockPath + pure $ case (yarnExists, pkgLockExists, pnpmLockExists, bunLockExists) of + (True, _, _, _) -> Yarn (Manifest yarnFilePath) graph + (_, True, _, _) -> NPMLock (Manifest packageLockPath) graph + (_, _, True, _) -> Pnpm (Manifest pnpmLockPath) graph + (_, _, _, True) -> Bun (Manifest bunLockPath) graph + _ -> NPM graph data NodeProject - = Yarn Manifest PkgJsonGraph - | NPMLock Manifest PkgJsonGraph - | NPM PkgJsonGraph - | Pnpm Manifest PkgJsonGraph - deriving (Eq, Ord, Show, Generic) + = Yarn Manifest PkgJsonGraph + | NPMLock Manifest PkgJsonGraph + | NPM PkgJsonGraph + | Pnpm Manifest PkgJsonGraph + | Bun Manifest PkgJsonGraph + deriving (Eq, Ord, Show, Generic) instance LicenseAnalyzeProject NodeProject where licenseAnalyzeProject = pure . analyzeLicenses . pkgGraph @@ -414,6 +426,7 @@ pkgGraph (Yarn _ pjg) = pjg pkgGraph (NPMLock _ pjg) = pjg pkgGraph (NPM pjg) = pjg pkgGraph (Pnpm _ pjg) = pjg +pkgGraph (Bun _ pjg) = pjg findWorkspaceRootManifest :: PkgJsonGraph -> Either String Manifest findWorkspaceRootManifest PkgJsonGraph{jsonGraph} =