Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
60 changes: 60 additions & 0 deletions docs/references/strategies/languages/nodejs/bun.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Bun

[Bun](https://bun.sh/) is a fast JavaScript runtime and package manager.
Bun uses a `bun.lock` lockfile in JSONC format (JSON with Comments).

Reference: https://bun.sh/docs/install/lockfile

## Project Discovery

Find files named `bun.lock` with a corresponding `package.json` file.

## Analysis

Only `bun.lock` is used for analysis. The lockfile is in JSONC format,
meaning it may contain single-line comments (`//`), block comments (`/* */`),
and trailing commas. These are stripped before parsing.

### Lockfile Structure

The `bun.lock` file has the following top-level structure:

```jsonc
{
"lockfileVersion": 1,
"workspaces": {
"": { "name": "my-project", "dependencies": {...}, "devDependencies": {...} },
"packages/a": { "name": "pkg-a", "dependencies": {...} }
},
"packages": {
"lodash": ["lodash@4.17.21", "", {}, "sha512-..."],
"cross-spawn/which": ["which@2.0.2", "", {...}, "sha512-..."]
}
}
```

### Workspaces

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`.

### Packages

Package keys use a slash-delimited path for nested `node_modules`:

- `"lodash"` — top-level package
- `"cross-spawn/which"` — `which` nested under `cross-spawn`

Package values are variable-length arrays depending on the resolution type:

- **npm:** `["name@version", "registry", {deps}, "integrity"]`
- **file:** `["name@file:path", {deps}]`
- **workspace:** `["name@workspace:path"]`
- **git:** `["name@git+url", {deps}, "hash", "integrity"]`

### Environment Labeling

- Dependencies declared in `devDependencies` of any workspace are labeled as development dependencies.
- Dependencies declared in `dependencies` or `optionalDependencies` of any workspace are labeled as production dependencies.
- Workspace packages themselves (those with `workspace:` resolutions) are excluded from the final dependency graph.
3 changes: 2 additions & 1 deletion docs/references/strategies/languages/nodejs/nodejs.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# NodeJS Analysis

The nodejs buildtool ecosystem consists of three major toolchains: the `npm` cli, `pnpm` and `yarn`.
The nodejs buildtool ecosystem consists of four major toolchains: the `npm` cli, `pnpm`, `yarn`, and `bun`.

| Strategy | Direct Deps | Transitive Deps | Edges | Container Scanning |
| ----------------------------- | ------------------ | ------------------ | ------------------ | ------------------ |
| [yarnlock](yarn.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| [npmlock](npm-lockfile.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| [pnpmlock](pnpm.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| [bunlock](bun.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
| [packagejson](packagejson.md) | :white_check_mark: | :x: | :x: | :white_check_mark: |
4 changes: 4 additions & 0 deletions spectrometer.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ library
Data.String.Conversion
Data.Tagged
Data.Text.Extra
Data.Text.Jsonc
Data.Tracing.Instrument
DepTypes
Diag.Common
Expand Down Expand Up @@ -480,6 +481,7 @@ library
Strategy.Nim
Strategy.Nim.NimbleLock
Strategy.Node
Strategy.Node.Bun.BunLock
Strategy.Node.Errors
Strategy.Node.Npm.PackageLock
Strategy.Node.Npm.PackageLockV3
Expand Down Expand Up @@ -611,6 +613,7 @@ test-suite unit-tests
App.Fossa.VSI.TypesSpec
App.Fossa.VSIDepsSpec
BerkeleyDB.BerkeleyDBSpec
Bun.BunLockSpec
BundlerSpec
Cargo.CargoTomlSpec
Cargo.MetadataSpec
Expand Down Expand Up @@ -644,6 +647,7 @@ test-suite unit-tests
Dart.PubSpecSpec
Data.IndexFileTreeSpec
Data.RpmDbHeaderBlobSpec
Data.Text.JsoncSpec
Discovery.ArchiveSpec
Discovery.FiltersSpec
Discovery.WalkSpec
Expand Down
110 changes: 110 additions & 0 deletions src/Data/Text/Jsonc.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
module Data.Text.Jsonc (
stripJsonc,
) where

import Data.Functor (($>))
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Void (Void)
import Text.Megaparsec (
Parsec,
anySingle,
eof,
lookAhead,
manyTill,
runParser,
satisfy,
takeWhileP,
try,
(<|>),
)
import Text.Megaparsec.Char (char, string)
import Text.Megaparsec.Error (errorBundlePretty)

type Parser = Parsec Void Text

-- | Strip JSONC artifacts (single-line comments, block comments,
-- and trailing commas) from text, producing valid JSON.
--
-- Handles:
--
-- * @\/\/@ single-line comments (to end of line)
-- * @\/\* ... \*\/@ block comments
-- * Trailing commas before @}@ or @]@
--
-- Strings (delimited by @"@) are preserved verbatim, including
-- any comment-like or comma characters they contain.
stripJsonc :: Text -> Either String Text
stripJsonc input = case runParser jsoncParser "jsonc" input of
Left err -> Left (errorBundlePretty err)
Right chunks -> Right (Text.concat chunks)

-- | Parse the entire JSONC input into chunks of valid JSON text.
jsoncParser :: Parser [Text]
jsoncParser = manyTill chunk eof
where
chunk :: Parser Text
chunk =
quotedString
<|> lineComment
<|> blockComment
<|> trailingComma
<|> plainText

-- | Parse a JSON string literal, preserving its contents verbatim.
quotedString :: Parser Text
quotedString = do
_ <- char '"'
contents <- manyTill stringChar (char '"')
pure $ "\"" <> Text.concat contents <> "\""
where
stringChar :: Parser Text
stringChar = escapedChar <|> (Text.singleton <$> anySingle)

escapedChar :: Parser Text
escapedChar = do
_ <- char '\\'
c <- anySingle
pure $ "\\" <> Text.singleton c

-- | Parse a @\/\/@ line comment and discard it.
lineComment :: Parser Text
lineComment = do
_ <- try (string "//")
_ <- takeWhileP Nothing (/= '\n')
_ <- (char '\n' $> ()) <|> eof
pure ""

-- | Parse a @\/\* ... \*\/@ block comment and discard it.
blockComment :: Parser Text
blockComment = do
_ <- try (string "/*")
_ <- manyTill anySingle (string "*/")
pure ""

-- | Parse a trailing comma (comma followed by optional whitespace
-- then @}@ or @]@) and discard only the comma, preserving whitespace.
trailingComma :: Parser Text
trailingComma = do
_ <- try $ do
_ <- char ','
_ <- lookAhead (takeWhileP Nothing isJsonWhitespace *> satisfy isClosingBracket)
pure ()
pure ""
where
isClosingBracket :: Char -> Bool
isClosingBracket c = c == '}' || c == ']'

-- | Parse one or more characters that aren't special
-- (not a quote, slash, or comma).
plainText :: Parser Text
plainText = do
c <- anySingle
if c == '/'
then pure (Text.singleton c)
else do
rest <- takeWhileP Nothing (\x -> x /= '"' && x /= '/' && x /= ',')
pure $ Text.singleton c <> rest

isJsonWhitespace :: Char -> Bool
isJsonWhitespace c = c == ' ' || c == '\t' || c == '\n' || c == '\r'
15 changes: 14 additions & 1 deletion src/Effect/ReadFS.hs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ module Effect.ReadFS (
readContentsParser,
readContentsParserBS,
readContentsJson,
readContentsJsonc,
readContentsToml,
readContentsYaml,
readContentsXML,
Expand Down Expand Up @@ -89,9 +90,10 @@ import Data.Bifunctor (first)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Either.Combinators (mapRight)
import Data.String.Conversion (decodeUtf8, toString, toText)
import Data.String.Conversion (decodeUtf8, encodeUtf8, toString, toText)
import Data.Text (Text)
import Data.Text.Extra (showT)
import Data.Text.Jsonc (stripJsonc)
import Data.Void (Void)
import Data.Yaml (decodeEither', prettyPrintParseException)
import Effect.Logger (renderIt)
Expand Down Expand Up @@ -366,6 +368,17 @@ readContentsJson file = context ("Parsing JSON file '" <> toText (toString file)
Left err -> errSupport (fileParseErrorSupportMsg file) $ fatal $ FileParseError (toString file) (toText err)
Right a -> pure a

-- | Read JSONC (JSON with Comments) from a file.
-- Strips single-line comments, block comments, and trailing commas before parsing as JSON.
readContentsJsonc :: (FromJSON a, Has ReadFS sig m, Has Diagnostics sig m) => Path Abs File -> m a
readContentsJsonc file = context ("Parsing JSONC file '" <> toText (toString file) <> "'") $ do
contents <- readContentsText file
case stripJsonc contents of
Left err -> errSupport (fileParseErrorSupportMsg file) $ fatal $ FileParseError (toString file) (toText err)
Right stripped -> case eitherDecodeStrict (encodeUtf8 stripped) of
Left err -> errSupport (fileParseErrorSupportMsg file) $ fatal $ FileParseError (toString file) (toText err)
Right a -> pure a

readContentsToml :: (Toml.Schema.FromValue a, Has ReadFS sig m, Has Diagnostics sig m) => Path Abs File -> m a
readContentsToml file = context ("Parsing TOML file '" <> toText (toString file) <> "'") $ do
contents <- readContentsText file
Expand Down
25 changes: 19 additions & 6 deletions src/Strategy/Node.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -146,6 +147,7 @@ mkProject project = do
Yarn _ g -> (g, YarnProjectType)
NPMLock _ g -> (g, NpmProjectType)
NPM g -> (g, NpmProjectType)
Bun _ g -> (g, BunProjectType)
Pnpm _ g -> (g, PnpmProjectType)
Manifest rootManifest <- fromEitherShow $ findWorkspaceRootManifest graph
pure $
Expand All @@ -172,13 +174,19 @@ 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]

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
npmLockVersion <- detectNpmLockVersion npmLockFile
Expand Down Expand Up @@ -368,19 +376,23 @@ identifyProjectType graph = do
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
pure $ case (yarnExists, pkgLockExists, pnpmLockExists) of
(True, _, _) -> Yarn (Manifest yarnFilePath) graph
(_, True, _) -> NPMLock (Manifest packageLockPath) graph
(_, _, True) -> Pnpm (Manifest pnpmLockPath) graph
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
| Bun Manifest PkgJsonGraph
| Pnpm Manifest PkgJsonGraph
deriving (Eq, Ord, Show, Generic)

Expand Down Expand Up @@ -413,6 +425,7 @@ pkgGraph :: NodeProject -> PkgJsonGraph
pkgGraph (Yarn _ pjg) = pjg
pkgGraph (NPMLock _ pjg) = pjg
pkgGraph (NPM pjg) = pjg
pkgGraph (Bun _ pjg) = pjg
pkgGraph (Pnpm _ pjg) = pjg

findWorkspaceRootManifest :: PkgJsonGraph -> Either String Manifest
Expand Down
Loading
Loading