diff --git a/test/fixtures/cargo/Cargo.lock b/test/fixtures/cargo/Cargo.lock
new file mode 100644
index 0000000..27ce744
--- /dev/null
+++ b/test/fixtures/cargo/Cargo.lock
@@ -0,0 +1,25 @@
+[[package]]
+name = "fixture-cargo"
+version = "0.1.0"
+dependencies = [
+ "serde 1.0.0",
+ "tokio 1.37.0"
+]
+
+[[package]]
+name = "serde"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+
+[[package]]
+name = "tokio"
+version = "1.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "bytes 1.6.0"
+]
+
+[[package]]
+name = "bytes"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/test/fixtures/cargo/Cargo.toml b/test/fixtures/cargo/Cargo.toml
new file mode 100644
index 0000000..f29e154
--- /dev/null
+++ b/test/fixtures/cargo/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "fixture-cargo"
+version = "0.1.0"
+
+[dependencies]
+serde = "1.0.0"
+
+[dev-dependencies]
+tokio = { version = "1.37.0", features = ["full"] }
diff --git a/test/fixtures/composer/composer.json b/test/fixtures/composer/composer.json
new file mode 100644
index 0000000..9c39117
--- /dev/null
+++ b/test/fixtures/composer/composer.json
@@ -0,0 +1,8 @@
+{
+ "require": {
+ "laravel/framework": "^11.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0"
+ }
+}
diff --git a/test/fixtures/composer/composer.lock b/test/fixtures/composer/composer.lock
new file mode 100644
index 0000000..0603532
--- /dev/null
+++ b/test/fixtures/composer/composer.lock
@@ -0,0 +1,28 @@
+{
+ "packages": [
+ {
+ "name": "laravel/framework",
+ "version": "v11.0.0",
+ "require": {
+ "symfony/http-foundation": "^7.0",
+ "php": "^8.2"
+ }
+ },
+ {
+ "name": "symfony/http-foundation",
+ "version": "v7.0.0",
+ "require": {
+ "php": "^8.2"
+ }
+ }
+ ],
+ "packages-dev": [
+ {
+ "name": "phpunit/phpunit",
+ "version": "11.0.0",
+ "require": {
+ "php": "^8.2"
+ }
+ }
+ ]
+}
diff --git a/test/fixtures/dart/pubspec.lock b/test/fixtures/dart/pubspec.lock
new file mode 100644
index 0000000..1384cb4
--- /dev/null
+++ b/test/fixtures/dart/pubspec.lock
@@ -0,0 +1,15 @@
+packages:
+ http:
+ dependency: "direct main"
+ description:
+ name: http
+ source: hosted
+ version: "1.2.1"
+ test:
+ dependency: "direct dev"
+ description:
+ name: test
+ source: hosted
+ version: "1.25.2"
+sdks:
+ dart: ">=3.0.0 <4.0.0"
diff --git a/test/fixtures/dart/pubspec.yaml b/test/fixtures/dart/pubspec.yaml
new file mode 100644
index 0000000..e7d0b60
--- /dev/null
+++ b/test/fixtures/dart/pubspec.yaml
@@ -0,0 +1,7 @@
+name: fixture_dart
+
+dependencies:
+ http: ^1.2.1
+
+dev_dependencies:
+ test: ^1.25.2
diff --git a/test/fixtures/docker/Dockerfile b/test/fixtures/docker/Dockerfile
new file mode 100644
index 0000000..1ee1611
--- /dev/null
+++ b/test/fixtures/docker/Dockerfile
@@ -0,0 +1,5 @@
+ARG BASE_IMAGE=python:3.11-slim
+FROM --platform=linux/amd64 $BASE_IMAGE AS base
+FROM base AS test
+FROM scratch AS export
+FROM alpine:3.19
diff --git a/test/fixtures/docker/docker-compose.yml b/test/fixtures/docker/docker-compose.yml
new file mode 100644
index 0000000..d44c448
--- /dev/null
+++ b/test/fixtures/docker/docker-compose.yml
@@ -0,0 +1,7 @@
+services:
+ api:
+ image: redis:7.2
+ worker:
+ build: .
+ db:
+ image: postgres:16
diff --git a/test/fixtures/go/go.mod b/test/fixtures/go/go.mod
new file mode 100644
index 0000000..8877575
--- /dev/null
+++ b/test/fixtures/go/go.mod
@@ -0,0 +1,8 @@
+module example.com/fixture
+
+go 1.21
+
+require (
+ github.com/gin-gonic/gin v1.9.1
+ github.com/stretchr/testify v1.8.4 // indirect
+)
diff --git a/test/fixtures/gradle/build.gradle b/test/fixtures/gradle/build.gradle
new file mode 100644
index 0000000..1c95cc4
--- /dev/null
+++ b/test/fixtures/gradle/build.gradle
@@ -0,0 +1,8 @@
+plugins {
+ id "java"
+}
+
+dependencies {
+ implementation "org.springframework:spring-core:6.1.0"
+ testImplementation "junit:junit:4.13.2"
+}
diff --git a/test/fixtures/gradle/gradle.lockfile b/test/fixtures/gradle/gradle.lockfile
new file mode 100644
index 0000000..458d7fb
--- /dev/null
+++ b/test/fixtures/gradle/gradle.lockfile
@@ -0,0 +1,2 @@
+org.springframework:spring-core:6.1.0=compileClasspath
+junit:junit:4.13.2=testCompileClasspath
diff --git a/test/fixtures/helm/Chart.lock b/test/fixtures/helm/Chart.lock
new file mode 100644
index 0000000..3f291a3
--- /dev/null
+++ b/test/fixtures/helm/Chart.lock
@@ -0,0 +1,5 @@
+dependencies:
+ - name: redis
+ version: 19.6.0
+digest: sha256:fixture
+generated: "2026-04-05T00:00:00Z"
diff --git a/test/fixtures/helm/Chart.yaml b/test/fixtures/helm/Chart.yaml
new file mode 100644
index 0000000..2c94405
--- /dev/null
+++ b/test/fixtures/helm/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: fixture-chart
+version: 0.1.0
+dependencies:
+ - name: redis
+ version: 19.6.0
diff --git a/test/fixtures/hex/mix.exs b/test/fixtures/hex/mix.exs
new file mode 100644
index 0000000..61c6617
--- /dev/null
+++ b/test/fixtures/hex/mix.exs
@@ -0,0 +1,16 @@
+defmodule FixtureHex.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :fixture_hex,
+ version: "0.1.0"
+ ]
+ end
+
+ defp deps do
+ [
+ {:jason, "~> 1.4"}
+ ]
+ end
+end
diff --git a/test/fixtures/hex/mix.lock b/test/fixtures/hex/mix.lock
new file mode 100644
index 0000000..4847e99
--- /dev/null
+++ b/test/fixtures/hex/mix.lock
@@ -0,0 +1,3 @@
+%{
+ "jason": {:hex, :jason, "1.4.1", "checksum", [:mix], [], "hexpm", "checksum"}
+}
diff --git a/test/fixtures/maven/dependency-tree.txt b/test/fixtures/maven/dependency-tree.txt
new file mode 100644
index 0000000..51150ba
--- /dev/null
+++ b/test/fixtures/maven/dependency-tree.txt
@@ -0,0 +1,3 @@
+[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:3.2.0:compile
+[INFO] | \- org.springframework:spring-core:jar:6.1.0:compile
+[INFO] \- junit:junit:jar:4.13.2:test
diff --git a/test/fixtures/maven/pom.xml b/test/fixtures/maven/pom.xml
new file mode 100644
index 0000000..1146e69
--- /dev/null
+++ b/test/fixtures/maven/pom.xml
@@ -0,0 +1,14 @@
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+ 3.2.0
+
+
+ junit
+ junit
+ test
+
+
+
diff --git a/test/fixtures/npm/package-lock.json b/test/fixtures/npm/package-lock.json
new file mode 100644
index 0000000..5b9e20e
--- /dev/null
+++ b/test/fixtures/npm/package-lock.json
@@ -0,0 +1,29 @@
+{
+ "name": "fixture-app",
+ "lockfileVersion": 3,
+ "packages": {
+ "": {
+ "name": "fixture-app",
+ "version": "1.0.0",
+ "dependencies": {
+ "express": "^4.18.2",
+ "@scope/pkg": "^1.0.0"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.18.2",
+ "dependencies": {
+ "accepts": "~1.3.8"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8"
+ },
+ "node_modules/@scope/pkg": {
+ "version": "1.0.0"
+ },
+ "node_modules/another/node_modules/accepts": {
+ "version": "1.3.8"
+ }
+ }
+}
diff --git a/test/fixtures/npm/package.json b/test/fixtures/npm/package.json
new file mode 100644
index 0000000..71c3c31
--- /dev/null
+++ b/test/fixtures/npm/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "fixture-app",
+ "version": "1.0.0",
+ "dependencies": {
+ "express": "^4.18.2",
+ "@scope/pkg": "^1.0.0"
+ }
+}
diff --git a/test/fixtures/npm/pnpm-lock.yaml b/test/fixtures/npm/pnpm-lock.yaml
new file mode 100644
index 0000000..6888293
--- /dev/null
+++ b/test/fixtures/npm/pnpm-lock.yaml
@@ -0,0 +1,20 @@
+lockfileVersion: "9.0"
+importers:
+ .:
+ dependencies:
+ express:
+ specifier: ^4.18.2
+ version: 4.18.2
+ "@scope/pkg":
+ specifier: ^1.0.0
+ version: 1.0.0
+packages:
+ express@4.18.2:
+ dependencies:
+ accepts: 1.3.8
+ accepts@1.3.8:
+ resolution:
+ integrity: sha512-abc
+ "@scope/pkg@1.0.0":
+ resolution:
+ integrity: sha512-def
diff --git a/test/fixtures/npm/yarn.lock b/test/fixtures/npm/yarn.lock
new file mode 100644
index 0000000..69e5716
--- /dev/null
+++ b/test/fixtures/npm/yarn.lock
@@ -0,0 +1,10 @@
+express@^4.18.2:
+ version "4.18.2"
+ dependencies:
+ accepts "~1.3.8"
+
+accepts@~1.3.8:
+ version "1.3.8"
+
+"@scope/pkg@^1.0.0":
+ version "1.0.0"
diff --git a/test/fixtures/nuget/Fixture.csproj b/test/fixtures/nuget/Fixture.csproj
new file mode 100644
index 0000000..cbf45f6
--- /dev/null
+++ b/test/fixtures/nuget/Fixture.csproj
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/test/fixtures/nuget/packages.lock.json b/test/fixtures/nuget/packages.lock.json
new file mode 100644
index 0000000..550fcac
--- /dev/null
+++ b/test/fixtures/nuget/packages.lock.json
@@ -0,0 +1,18 @@
+{
+ "version": 1,
+ "dependencies": {
+ ".NETCoreApp,Version=v8.0": {
+ "Newtonsoft.Json": {
+ "type": "Direct",
+ "resolved": "13.0.3",
+ "dependencies": {
+ "System.Runtime.CompilerServices.Unsafe": "6.0.0"
+ }
+ },
+ "System.Runtime.CompilerServices.Unsafe": {
+ "type": "Transitive",
+ "resolved": "6.0.0"
+ }
+ }
+ }
+}
diff --git a/test/fixtures/python/Pipfile.lock b/test/fixtures/python/Pipfile.lock
new file mode 100644
index 0000000..5687d4c
--- /dev/null
+++ b/test/fixtures/python/Pipfile.lock
@@ -0,0 +1,12 @@
+{
+ "default": {
+ "flask": {
+ "version": "==2.3.0"
+ }
+ },
+ "develop": {
+ "pytest": {
+ "version": "==8.2.0"
+ }
+ }
+}
diff --git a/test/fixtures/python/poetry.lock b/test/fixtures/python/poetry.lock
new file mode 100644
index 0000000..d2d422d
--- /dev/null
+++ b/test/fixtures/python/poetry.lock
@@ -0,0 +1,14 @@
+[[package]]
+name = "flask"
+version = "2.3.0"
+
+[package.dependencies]
+click = ">=8.0"
+
+[[package]]
+name = "requests"
+version = "2.28.0"
+
+[[package]]
+name = "click"
+version = "8.1.7"
diff --git a/test/fixtures/python/pyproject.toml b/test/fixtures/python/pyproject.toml
new file mode 100644
index 0000000..5f49c62
--- /dev/null
+++ b/test/fixtures/python/pyproject.toml
@@ -0,0 +1,15 @@
+[tool.poetry]
+name = "fixture-python"
+version = "0.1.0"
+
+[tool.poetry.dependencies]
+python = "^3.11"
+# Web
+flask = "^2.3.0"
+requests = { version = "^2.28.0", optional = true } # client
+
+[project]
+name = "fixture-python"
+dependencies = [
+ "fastapi==0.111.0", # API
+]
diff --git a/test/fixtures/python/requirements.txt b/test/fixtures/python/requirements.txt
new file mode 100644
index 0000000..56868ab
--- /dev/null
+++ b/test/fixtures/python/requirements.txt
@@ -0,0 +1,2 @@
+flask==2.3.0
+requests>=2.28.0
diff --git a/test/fixtures/python/uv.lock b/test/fixtures/python/uv.lock
new file mode 100644
index 0000000..f003f62
--- /dev/null
+++ b/test/fixtures/python/uv.lock
@@ -0,0 +1,18 @@
+[[package]]
+name = "fixture-python"
+version = "0.1.0"
+source = { editable = "." }
+dependencies = [{ name = "fastapi" }]
+
+[[package]]
+name = "fastapi"
+version = "0.111.0"
+dependencies = ["starlette", "pydantic"]
+
+[[package]]
+name = "starlette"
+version = "0.37.2"
+
+[[package]]
+name = "pydantic"
+version = "2.7.0"
diff --git a/test/fixtures/ruby/Gemfile b/test/fixtures/ruby/Gemfile
new file mode 100644
index 0000000..cc4b5e5
--- /dev/null
+++ b/test/fixtures/ruby/Gemfile
@@ -0,0 +1,3 @@
+source "https://rubygems.org"
+
+gem "rails", "~> 7.1.3"
diff --git a/test/fixtures/ruby/Gemfile.lock b/test/fixtures/ruby/Gemfile.lock
new file mode 100644
index 0000000..035bc10
--- /dev/null
+++ b/test/fixtures/ruby/Gemfile.lock
@@ -0,0 +1,9 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ rails (7.1.3)
+ actionpack (= 7.1.3)
+ actionpack (7.1.3)
+
+DEPENDENCIES
+ rails (~> 7.1.3)
diff --git a/test/fixtures/swift/Package.resolved b/test/fixtures/swift/Package.resolved
new file mode 100644
index 0000000..4294e8a
--- /dev/null
+++ b/test/fixtures/swift/Package.resolved
@@ -0,0 +1,14 @@
+{
+ "pins": [
+ {
+ "identity": "alamofire",
+ "kind": "remoteSourceControl",
+ "location": "https://github.com/Alamofire/Alamofire.git",
+ "state": {
+ "revision": "abcdef1234567890",
+ "version": "5.8.0"
+ }
+ }
+ ],
+ "version": 2
+}
diff --git a/test/fixtures/swift/Package.swift b/test/fixtures/swift/Package.swift
new file mode 100644
index 0000000..206e9ed
--- /dev/null
+++ b/test/fixtures/swift/Package.swift
@@ -0,0 +1,9 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+ name: "FixtureSwift",
+ dependencies: [
+ .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0")
+ ]
+)
diff --git a/test/helpers/fixtureWorkspace.js b/test/helpers/fixtureWorkspace.js
new file mode 100644
index 0000000..c074d2d
--- /dev/null
+++ b/test/helpers/fixtureWorkspace.js
@@ -0,0 +1,46 @@
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+
+async function makeTempWorkspace(prefix = "cloudsmith-lockfile-") {
+ return fs.promises.mkdtemp(path.join(os.tmpdir(), prefix));
+}
+
+async function copyFixtureDir(fixtureName, targetDir) {
+ const sourceDir = path.join(__dirname, "..", "fixtures", fixtureName);
+ await copyDirectory(sourceDir, targetDir);
+}
+
+async function copyDirectory(sourceDir, targetDir) {
+ await fs.promises.mkdir(targetDir, { recursive: true });
+ const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const sourcePath = path.join(sourceDir, entry.name);
+ const targetPath = path.join(targetDir, entry.name);
+
+ if (entry.isDirectory()) {
+ await copyDirectory(sourcePath, targetPath);
+ continue;
+ }
+
+ await fs.promises.copyFile(sourcePath, targetPath);
+ }
+}
+
+async function writeTextFile(targetPath, content) {
+ await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
+ await fs.promises.writeFile(targetPath, content, "utf8");
+}
+
+async function removeDirectory(targetDir) {
+ await fs.promises.rm(targetDir, { recursive: true, force: true });
+}
+
+module.exports = {
+ copyDirectory,
+ copyFixtureDir,
+ makeTempWorkspace,
+ removeDirectory,
+ writeTextFile,
+};
diff --git a/test/lockfileParsers/cargoParser.test.js b/test/lockfileParsers/cargoParser.test.js
new file mode 100644
index 0000000..c046811
--- /dev/null
+++ b/test/lockfileParsers/cargoParser.test.js
@@ -0,0 +1,119 @@
+const assert = require("assert");
+const path = require("path");
+const cargoParser = require("../../util/lockfileParsers/cargoParser");
+const {
+ makeTempWorkspace,
+ removeDirectory,
+ writeTextFile,
+} = require("../helpers/fixtureWorkspace");
+
+suite("cargoParser Test Suite", () => {
+ const fixtureDir = path.join(__dirname, "..", "fixtures", "cargo");
+ const tempDirs = [];
+
+ async function createWorkspace() {
+ const workspace = await makeTempWorkspace("cloudsmith-cargo-parser-");
+ tempDirs.push(workspace);
+ return workspace;
+ }
+
+ suiteTeardown(async () => {
+ await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir)));
+ });
+
+ test("resolves Cargo.lock uniquely, skips the root package, and marks direct dependencies from Cargo.toml", async () => {
+ const tree = await cargoParser.resolve({
+ lockfilePath: path.join(fixtureDir, "Cargo.lock"),
+ manifestPath: path.join(fixtureDir, "Cargo.toml"),
+ });
+
+ assert.strictEqual(tree.sourceFile, "Cargo.lock");
+ assert.strictEqual(tree.dependencies.length, 3);
+ assert.strictEqual(tree.dependencies.some((dependency) => dependency.name === "fixture-cargo"), false);
+
+ const serde = tree.dependencies.find((dependency) => dependency.name === "serde");
+ const tokio = tree.dependencies.find((dependency) => dependency.name === "tokio");
+ const bytes = tree.dependencies.find((dependency) => dependency.name === "bytes");
+
+ assert.ok(serde);
+ assert.ok(tokio);
+ assert.ok(bytes);
+ assert.strictEqual(serde.isDirect, true);
+ assert.strictEqual(tokio.isDirect, true);
+ assert.strictEqual(bytes.isDirect, false);
+ assert.deepStrictEqual(bytes.parentChain, ["tokio"]);
+ });
+
+ test("detect returns no matches when Cargo files are missing", async () => {
+ const workspace = await createWorkspace();
+
+ const matches = await cargoParser.detect(workspace);
+
+ assert.deepStrictEqual(matches, []);
+ assert.strictEqual(await cargoParser.canResolve(workspace), false);
+ });
+
+ test("throws for malformed Cargo.lock files", async () => {
+ const workspace = await createWorkspace();
+ const lockfilePath = path.join(workspace, "Cargo.lock");
+ const manifestPath = path.join(workspace, "Cargo.toml");
+ await writeTextFile(lockfilePath, "[[package]]\nname = \"broken\"\n");
+ await writeTextFile(manifestPath, "[dependencies]\nserde = \"1.0.0\"\n");
+
+ await assert.rejects(
+ () => cargoParser.resolve({ lockfilePath, manifestPath }),
+ /Malformed Cargo\.lock: no package entries found/
+ );
+ });
+
+ test("deduplicates large Cargo graphs down to unique packages", async () => {
+ const workspace = await createWorkspace();
+ const lockfilePath = path.join(workspace, "Cargo.lock");
+ const manifestPath = path.join(workspace, "Cargo.toml");
+ const packageCount = 300;
+ const registrySource = "registry+https://github.com/rust-lang/crates.io-index";
+
+ const manifestLines = [
+ "[package]",
+ 'name = "fixture-cargo"',
+ 'version = "0.1.0"',
+ "",
+ "[dependencies]",
+ 'crate-000 = "1.0.0"',
+ ];
+
+ const lockEntries = [];
+ for (let index = 0; index < packageCount; index += 1) {
+ const currentName = `crate-${String(index).padStart(3, "0")}`;
+ const nextName = index + 1 < packageCount
+ ? `crate-${String(index + 1).padStart(3, "0")}`
+ : null;
+ lockEntries.push(
+ [
+ "[[package]]",
+ `name = "${currentName}"`,
+ 'version = "1.0.0"',
+ `source = "${registrySource}"`,
+ nextName
+ ? `dependencies = ["${nextName} 1.0.0"]`
+ : "",
+ "",
+ ].filter(Boolean).join("\n")
+ );
+ }
+
+ await writeTextFile(manifestPath, manifestLines.join("\n"));
+ await writeTextFile(lockfilePath, lockEntries.join("\n"));
+
+ const tree = await cargoParser.resolve({
+ lockfilePath,
+ manifestPath,
+ });
+
+ assert.strictEqual(tree.dependencies.length, packageCount);
+ assert.strictEqual(
+ new Set(tree.dependencies.map((dependency) => `${dependency.name}@${dependency.version}`)).size,
+ packageCount
+ );
+ });
+});
diff --git a/test/lockfileParsers/dockerParser.test.js b/test/lockfileParsers/dockerParser.test.js
new file mode 100644
index 0000000..ec6d670
--- /dev/null
+++ b/test/lockfileParsers/dockerParser.test.js
@@ -0,0 +1,78 @@
+const assert = require("assert");
+const path = require("path");
+const dockerParser = require("../../util/lockfileParsers/dockerParser");
+const {
+ makeTempWorkspace,
+ removeDirectory,
+ writeTextFile,
+} = require("../helpers/fixtureWorkspace");
+
+suite("dockerParser Test Suite", () => {
+ const fixtureDir = path.join(__dirname, "..", "fixtures", "docker");
+ const tempDirs = [];
+
+ async function createWorkspace() {
+ const workspace = await makeTempWorkspace("cloudsmith-docker-parser-");
+ tempDirs.push(workspace);
+ return workspace;
+ }
+
+ suiteTeardown(async () => {
+ await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir)));
+ });
+
+ test("parses Dockerfile FROM instructions and skips scratch and stage references", async () => {
+ const tree = await dockerParser.resolve({
+ lockfilePath: path.join(fixtureDir, "Dockerfile"),
+ });
+
+ assert.strictEqual(tree.sourceFile, "Dockerfile");
+ assert.deepStrictEqual(
+ tree.dependencies.map((dependency) => `${dependency.name}:${dependency.version}`),
+ ["python:3.11-slim", "alpine:3.19"]
+ );
+ });
+
+ test("parses docker-compose images and skips build-only services", async () => {
+ const tree = await dockerParser.resolve({
+ lockfilePath: path.join(fixtureDir, "docker-compose.yml"),
+ });
+
+ assert.strictEqual(tree.sourceFile, "docker-compose.yml");
+ assert.deepStrictEqual(
+ tree.dependencies.map((dependency) => `${dependency.name}:${dependency.version}`),
+ ["redis:7.2", "postgres:16"]
+ );
+ });
+
+ test("detect returns no matches when Docker manifests are missing", async () => {
+ const workspace = await createWorkspace();
+
+ const matches = await dockerParser.detect(workspace);
+
+ assert.deepStrictEqual(matches, []);
+ assert.strictEqual(await dockerParser.canResolve(workspace), false);
+ });
+
+ test("detect returns no matches for invalid workspace roots", async () => {
+ const workspace = await createWorkspace();
+ const matches = await dockerParser.detect(path.join(workspace, "missing-workspace"));
+
+ assert.deepStrictEqual(matches, []);
+ });
+
+ test("ignores malformed FROM lines that do not resolve to image references", async () => {
+ const workspace = await createWorkspace();
+ const lockfilePath = path.join(workspace, "Dockerfile");
+ await writeTextFile(lockfilePath, [
+ "ARG BASE_IMAGE",
+ "FROM $BASE_IMAGE",
+ "FROM scratch",
+ "",
+ ].join("\n"));
+
+ const tree = await dockerParser.resolve({ lockfilePath });
+
+ assert.strictEqual(tree.dependencies.length, 0);
+ });
+});
diff --git a/test/lockfileParsers/mavenParser.test.js b/test/lockfileParsers/mavenParser.test.js
new file mode 100644
index 0000000..a95cdba
--- /dev/null
+++ b/test/lockfileParsers/mavenParser.test.js
@@ -0,0 +1,81 @@
+const assert = require("assert");
+const path = require("path");
+const mavenParser = require("../../util/lockfileParsers/mavenParser");
+const {
+ makeTempWorkspace,
+ removeDirectory,
+ writeTextFile,
+} = require("../helpers/fixtureWorkspace");
+
+suite("mavenParser Test Suite", () => {
+ const fixtureDir = path.join(__dirname, "..", "fixtures", "maven");
+ const tempDirs = [];
+
+ async function createWorkspace() {
+ const workspace = await makeTempWorkspace("cloudsmith-maven-parser-");
+ tempDirs.push(workspace);
+ return workspace;
+ }
+
+ suiteTeardown(async () => {
+ await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir)));
+ });
+
+ test("hydrates direct dependencies from pom.xml and transitives from dependency-tree.txt", async () => {
+ const tree = await mavenParser.resolve({
+ lockfilePath: path.join(fixtureDir, "dependency-tree.txt"),
+ manifestPath: path.join(fixtureDir, "pom.xml"),
+ });
+
+ assert.strictEqual(tree.sourceFile, "pom.xml");
+ assert.strictEqual(tree.dependencies.length, 3);
+
+ const starter = tree.dependencies.find((dependency) => (
+ dependency.name === "org.springframework.boot:spring-boot-starter-web"
+ ));
+ const springCore = tree.dependencies.find((dependency) => dependency.name === "org.springframework:spring-core");
+ const junit = tree.dependencies.find((dependency) => dependency.name === "junit:junit");
+
+ assert.ok(starter);
+ assert.ok(springCore);
+ assert.ok(junit);
+ assert.strictEqual(starter.isDirect, true);
+ assert.strictEqual(springCore.isDirect, false);
+ assert.strictEqual(junit.version, "4.13.2");
+ assert.strictEqual(junit.isDevelopmentDependency, true);
+ });
+
+ test("detect returns no matches when pom.xml is missing", async () => {
+ const workspace = await createWorkspace();
+
+ const matches = await mavenParser.detect(workspace);
+
+ assert.deepStrictEqual(matches, []);
+ assert.strictEqual(await mavenParser.canResolve(workspace), false);
+ });
+
+ test("ignores malformed dependency tree lines and still returns manifest dependencies", async () => {
+ const workspace = await createWorkspace();
+ const manifestPath = path.join(workspace, "pom.xml");
+ const lockfilePath = path.join(workspace, "dependency-tree.txt");
+ await writeTextFile(manifestPath, [
+ "",
+ " ",
+ " ",
+ " org.springframework.boot",
+ " spring-boot-starter",
+ " 3.2.0",
+ " ",
+ " ",
+ "",
+ "",
+ ].join("\n"));
+ await writeTextFile(lockfilePath, "this is not a Maven dependency tree\n");
+
+ const tree = await mavenParser.resolve({ lockfilePath, manifestPath });
+
+ assert.strictEqual(tree.dependencies.length, 1);
+ assert.strictEqual(tree.dependencies[0].name, "org.springframework.boot:spring-boot-starter");
+ assert.strictEqual(tree.dependencies[0].isDirect, true);
+ });
+});
diff --git a/test/lockfileParsers/npmParser.test.js b/test/lockfileParsers/npmParser.test.js
new file mode 100644
index 0000000..1a5f0ca
--- /dev/null
+++ b/test/lockfileParsers/npmParser.test.js
@@ -0,0 +1,258 @@
+const assert = require("assert");
+const path = require("path");
+const npmParser = require("../../util/lockfileParsers/npmParser");
+const {
+ makeTempWorkspace,
+ removeDirectory,
+ writeTextFile,
+} = require("../helpers/fixtureWorkspace");
+
+suite("npmParser Test Suite", () => {
+ const fixtureDir = path.join(__dirname, "..", "fixtures", "npm");
+ const tempDirs = [];
+
+ async function createWorkspace() {
+ const workspace = await makeTempWorkspace("cloudsmith-npm-parser-");
+ tempDirs.push(workspace);
+ return workspace;
+ }
+
+ suiteTeardown(async () => {
+ await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir)));
+ });
+
+ test("resolves package-lock.json with deduplication, scoped packages, and root skipping", async () => {
+ const tree = await npmParser.resolve({
+ lockfilePath: path.join(fixtureDir, "package-lock.json"),
+ manifestPath: path.join(fixtureDir, "package.json"),
+ options: { maxDependenciesToScan: 10000 },
+ });
+
+ assert.strictEqual(tree.sourceFile, "package-lock.json");
+ assert.strictEqual(tree.dependencies.length, 3);
+ assert.strictEqual(tree.dependencies.some((dependency) => dependency.name === "fixture-app"), false);
+
+ const express = tree.dependencies.find((dependency) => dependency.name === "express");
+ const accepts = tree.dependencies.find((dependency) => dependency.name === "accepts");
+ const scoped = tree.dependencies.find((dependency) => dependency.name === "@scope/pkg");
+
+ assert.ok(express);
+ assert.ok(accepts);
+ assert.ok(scoped);
+ assert.strictEqual(express.isDirect, true);
+ assert.strictEqual(accepts.isDirect, false);
+ assert.deepStrictEqual(accepts.parentChain, ["express"]);
+ assert.strictEqual(scoped.version, "1.0.0");
+ });
+
+ test("resolves yarn.lock fixtures", async () => {
+ const tree = await npmParser.resolve({
+ lockfilePath: path.join(fixtureDir, "yarn.lock"),
+ manifestPath: path.join(fixtureDir, "package.json"),
+ options: { maxDependenciesToScan: 10000 },
+ });
+
+ assert.strictEqual(tree.sourceFile, "yarn.lock");
+ assert.strictEqual(tree.dependencies.length, 3);
+ assert.ok(tree.dependencies.some((dependency) => dependency.name === "@scope/pkg"));
+ });
+
+ test("preserves multiple resolved versions for the same yarn package", async () => {
+ const workspace = await createWorkspace();
+ const lockfilePath = path.join(workspace, "yarn.lock");
+ const manifestPath = path.join(workspace, "package.json");
+
+ await writeTextFile(manifestPath, JSON.stringify({
+ name: "fixture-app",
+ version: "1.0.0",
+ dependencies: {
+ "package-a": "^1.0.0",
+ "package-b": "^1.0.0",
+ },
+ }, null, 2));
+
+ await writeTextFile(lockfilePath, [
+ "package-a@^1.0.0:",
+ ' version "1.0.0"',
+ " dependencies:",
+ ' left-pad "^1.0.0"',
+ "",
+ "package-b@^1.0.0:",
+ ' version "1.0.0"',
+ " dependencies:",
+ ' left-pad "^2.0.0"',
+ "",
+ "left-pad@^1.0.0:",
+ ' version "1.0.1"',
+ "",
+ "left-pad@^2.0.0:",
+ ' version "2.0.0"',
+ "",
+ ].join("\n"));
+
+ const tree = await npmParser.resolve({
+ lockfilePath,
+ manifestPath,
+ options: { maxDependenciesToScan: 10000 },
+ });
+
+ const packageKeys = tree.dependencies.map((dependency) => `${dependency.name}@${dependency.version}`);
+ const packageA = tree.dependencies.find((dependency) => dependency.name === "package-a");
+ const packageB = tree.dependencies.find((dependency) => dependency.name === "package-b");
+
+ assert.ok(packageA);
+ assert.ok(packageB);
+ assert.strictEqual(packageA.transitives[0].version, "1.0.1");
+ assert.strictEqual(packageB.transitives[0].version, "2.0.0");
+ assert.ok(packageKeys.includes("left-pad@1.0.1"));
+ assert.ok(packageKeys.includes("left-pad@2.0.0"));
+ assert.strictEqual(packageKeys.filter((key) => key.startsWith("left-pad@")).length, 2);
+ });
+
+ test("resolves pnpm-lock.yaml fixtures", async () => {
+ const tree = await npmParser.resolve({
+ lockfilePath: path.join(fixtureDir, "pnpm-lock.yaml"),
+ manifestPath: path.join(fixtureDir, "package.json"),
+ options: { maxDependenciesToScan: 10000 },
+ });
+
+ assert.strictEqual(tree.sourceFile, "pnpm-lock.yaml");
+ assert.strictEqual(tree.dependencies.length, 3);
+ assert.ok(tree.dependencies.some((dependency) => dependency.name === "accepts"));
+ });
+
+ test("detect returns no matches when npm lockfiles are missing", async () => {
+ const workspace = await createWorkspace();
+
+ const matches = await npmParser.detect(workspace);
+
+ assert.deepStrictEqual(matches, []);
+ assert.strictEqual(await npmParser.canResolve(workspace), false);
+ });
+
+ test("resolve ignores manifests outside the provided workspace folder", async () => {
+ const workspace = await createWorkspace();
+ const outsideWorkspace = await createWorkspace();
+ const lockfilePath = path.join(workspace, "package-lock.json");
+ const manifestPath = path.join(outsideWorkspace, "package.json");
+
+ await writeTextFile(lockfilePath, JSON.stringify({
+ name: "fixture-app",
+ lockfileVersion: 3,
+ packages: {
+ "": {},
+ "node_modules/express": {
+ version: "4.18.2",
+ },
+ },
+ }, null, 2));
+ await writeTextFile(manifestPath, JSON.stringify({
+ dependencies: {
+ express: "^4.18.0",
+ },
+ }, null, 2));
+
+ const tree = await npmParser.resolve({
+ workspaceFolder: workspace,
+ lockfilePath,
+ manifestPath,
+ options: { maxDependenciesToScan: 10000 },
+ });
+
+ const express = tree.dependencies.find((dependency) => dependency.name === "express");
+
+ assert.ok(express);
+ assert.strictEqual(
+ express.isDirect,
+ false,
+ "out-of-workspace manifests should not influence direct dependency classification"
+ );
+ });
+
+ test("throws for malformed package-lock.json files", async () => {
+ const workspace = await createWorkspace();
+ const lockfilePath = path.join(workspace, "package-lock.json");
+ const manifestPath = path.join(workspace, "package.json");
+ await writeTextFile(lockfilePath, "{\n \"name\": \"broken\"\n}\n");
+ await writeTextFile(manifestPath, "{\n \"dependencies\": {}\n}\n");
+
+ await assert.rejects(
+ () => npmParser.resolve({
+ lockfilePath,
+ manifestPath,
+ options: { maxDependenciesToScan: 10000 },
+ }),
+ /missing packages object/
+ );
+ });
+
+ test("adds a warning when the unique dependency count exceeds the scan cap", async () => {
+ const tree = await npmParser.resolve({
+ lockfilePath: path.join(fixtureDir, "package-lock.json"),
+ manifestPath: path.join(fixtureDir, "package.json"),
+ options: { maxDependenciesToScan: 2 },
+ });
+
+ assert.strictEqual(tree.warnings.length, 1);
+ assert.match(tree.warnings[0], /Display is capped at 2 dependencies/);
+ });
+
+ test("includes orphaned package-lock entries once even when duplicate package records share a key", async () => {
+ const workspace = await createWorkspace();
+ const lockfilePath = path.join(workspace, "package-lock.json");
+ const manifestPath = path.join(workspace, "package.json");
+
+ await writeTextFile(manifestPath, JSON.stringify({
+ name: "fixture-app",
+ version: "1.0.0",
+ dependencies: {
+ express: "1.0.0",
+ },
+ }, null, 2));
+
+ await writeTextFile(lockfilePath, JSON.stringify({
+ name: "fixture-app",
+ version: "1.0.0",
+ lockfileVersion: 3,
+ packages: {
+ "": {
+ dependencies: {
+ express: "1.0.0",
+ },
+ },
+ "node_modules/express": {
+ version: "1.0.0",
+ dependencies: {
+ accepts: "1.0.0",
+ shared: "1.0.0",
+ },
+ },
+ "node_modules/accepts": {
+ version: "1.0.0",
+ },
+ "node_modules/shared": {
+ version: "1.0.0",
+ },
+ "node_modules/express/node_modules/shared": {
+ version: "1.0.0",
+ },
+ "node_modules/orphan": {
+ version: "2.0.0",
+ },
+ },
+ }, null, 2));
+
+ const tree = await npmParser.resolve({
+ lockfilePath,
+ manifestPath,
+ options: { maxDependenciesToScan: 10000 },
+ });
+
+ const packageKeys = tree.dependencies.map((dependency) => `${dependency.name}@${dependency.version}`);
+
+ assert.strictEqual(packageKeys.filter((key) => key === "shared@1.0.0").length, 1);
+ assert.strictEqual(packageKeys.filter((key) => key === "orphan@2.0.0").length, 1);
+ assert.ok(packageKeys.includes("express@1.0.0"));
+ assert.ok(packageKeys.includes("accepts@1.0.0"));
+ });
+});
diff --git a/test/lockfileParsers/nugetParser.test.js b/test/lockfileParsers/nugetParser.test.js
new file mode 100644
index 0000000..68454ca
--- /dev/null
+++ b/test/lockfileParsers/nugetParser.test.js
@@ -0,0 +1,28 @@
+const assert = require("assert");
+const path = require("path");
+const nugetParser = require("../../util/lockfileParsers/nugetParser");
+const {
+ makeTempWorkspace,
+ removeDirectory,
+} = require("../helpers/fixtureWorkspace");
+
+suite("nugetParser Test Suite", () => {
+ const tempDirs = [];
+
+ async function createWorkspace() {
+ const workspace = await makeTempWorkspace("cloudsmith-nuget-parser-");
+ tempDirs.push(workspace);
+ return workspace;
+ }
+
+ suiteTeardown(async () => {
+ await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir)));
+ });
+
+ test("detect returns no matches for invalid workspace roots", async () => {
+ const workspace = await createWorkspace();
+ const matches = await nugetParser.detect(path.join(workspace, "missing-workspace"));
+
+ assert.deepStrictEqual(matches, []);
+ });
+});
diff --git a/test/lockfileParsers/pythonParser.test.js b/test/lockfileParsers/pythonParser.test.js
new file mode 100644
index 0000000..f040ea1
--- /dev/null
+++ b/test/lockfileParsers/pythonParser.test.js
@@ -0,0 +1,109 @@
+const assert = require("assert");
+const path = require("path");
+const pythonParser = require("../../util/lockfileParsers/pythonParser");
+const {
+ makeTempWorkspace,
+ removeDirectory,
+ writeTextFile,
+} = require("../helpers/fixtureWorkspace");
+
+suite("pythonParser Test Suite", () => {
+ const fixtureDir = path.join(__dirname, "..", "fixtures", "python");
+ const tempDirs = [];
+
+ async function createWorkspace() {
+ const workspace = await makeTempWorkspace("cloudsmith-python-parser-");
+ tempDirs.push(workspace);
+ return workspace;
+ }
+
+ suiteTeardown(async () => {
+ await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir)));
+ });
+
+ test("resolves poetry.lock and keeps all package entries while marking directs from pyproject.toml", async () => {
+ const tree = await pythonParser.resolve({
+ lockfilePath: path.join(fixtureDir, "poetry.lock"),
+ manifestPath: path.join(fixtureDir, "pyproject.toml"),
+ });
+
+ assert.strictEqual(tree.sourceFile, "poetry.lock");
+ assert.strictEqual(tree.dependencies.length, 3);
+
+ const flask = tree.dependencies.find((dependency) => dependency.name === "flask");
+ const requests = tree.dependencies.find((dependency) => dependency.name === "requests");
+ const click = tree.dependencies.find((dependency) => dependency.name === "click");
+
+ assert.ok(flask);
+ assert.ok(requests);
+ assert.ok(click);
+ assert.strictEqual(flask.isDirect, true);
+ assert.strictEqual(requests.isDirect, true);
+ assert.strictEqual(click.isDirect, false);
+ assert.deepStrictEqual(click.parentChain, ["flask"]);
+ });
+
+ test("skips the editable uv root package and resolves its transitive dependencies", async () => {
+ const tree = await pythonParser.resolve({
+ lockfilePath: path.join(fixtureDir, "uv.lock"),
+ manifestPath: path.join(fixtureDir, "pyproject.toml"),
+ });
+
+ assert.strictEqual(tree.sourceFile, "uv.lock");
+ assert.strictEqual(tree.dependencies.some((dependency) => dependency.name === "fixture-python"), false);
+ assert.strictEqual(tree.dependencies.length, 3);
+ assert.ok(tree.dependencies.some((dependency) => dependency.name === "fastapi" && dependency.isDirect));
+ assert.ok(tree.dependencies.some((dependency) => dependency.name === "starlette" && !dependency.isDirect));
+ assert.ok(tree.dependencies.some((dependency) => dependency.name === "pydantic" && !dependency.isDirect));
+ });
+
+ test("warns when only requirements.txt is available", async () => {
+ const tree = await pythonParser.resolve({
+ lockfilePath: path.join(fixtureDir, "requirements.txt"),
+ });
+
+ assert.strictEqual(tree.sourceFile, "requirements.txt");
+ assert.strictEqual(tree.dependencies.length, 2);
+ assert.strictEqual(tree.dependencies.every((dependency) => dependency.isDirect), true);
+ assert.strictEqual(tree.warnings.length, 1);
+ assert.match(tree.warnings[0], /requirements\.txt does not encode transitive dependencies/i);
+ });
+
+ test("detect returns no matches when Python dependency files are missing", async () => {
+ const workspace = await createWorkspace();
+
+ const matches = await pythonParser.detect(workspace);
+
+ assert.deepStrictEqual(matches, []);
+ assert.strictEqual(await pythonParser.canResolve(workspace), false);
+ });
+
+ test("resolve rejects lockfiles outside the provided workspace folder", async () => {
+ const workspace = await createWorkspace();
+ const outsideWorkspace = await createWorkspace();
+ const lockfilePath = path.join(outsideWorkspace, "requirements.txt");
+
+ await writeTextFile(lockfilePath, "requests==2.31.0\n");
+
+ await assert.rejects(
+ () => pythonParser.resolve({
+ workspaceFolder: workspace,
+ lockfilePath,
+ }),
+ /Refusing to read files outside the workspace folder/
+ );
+ });
+
+ test("throws for malformed poetry.lock files", async () => {
+ const workspace = await createWorkspace();
+ const lockfilePath = path.join(workspace, "poetry.lock");
+ const manifestPath = path.join(workspace, "pyproject.toml");
+ await writeTextFile(lockfilePath, "[metadata]\nlock-version = \"2.0\"\n");
+ await writeTextFile(manifestPath, "[tool.poetry.dependencies]\nflask = \"^2.3.0\"\n");
+
+ await assert.rejects(
+ () => pythonParser.resolve({ lockfilePath, manifestPath }),
+ /no package entries found/
+ );
+ });
+});
diff --git a/util/lockfileParsers/cargoParser.js b/util/lockfileParsers/cargoParser.js
new file mode 100644
index 0000000..06199ff
--- /dev/null
+++ b/util/lockfileParsers/cargoParser.js
@@ -0,0 +1,283 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const {
+ buildTree,
+ createDependency,
+ deduplicateDeps,
+ flattenDependencies,
+ getSourceFileName,
+ getWorkspacePath,
+ parseKeyValueLine,
+ pathExists,
+ readUtf8,
+} = require("./shared");
+const { parseCargoTomlManifest } = require("./manifestHelpers");
+
+const cargoParser = {
+ name: "cargoParser",
+ ecosystem: "cargo",
+
+ async canResolve(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ return (await pathExists(path.join(rootPath, "Cargo.lock"), workspaceFolder))
+ || (await pathExists(path.join(rootPath, "Cargo.toml"), workspaceFolder));
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ const lockfilePath = await pathExists(path.join(rootPath, "Cargo.lock"), workspaceFolder)
+ ? path.join(rootPath, "Cargo.lock")
+ : null;
+ const manifestPath = await pathExists(path.join(rootPath, "Cargo.toml"), workspaceFolder)
+ ? path.join(rootPath, "Cargo.toml")
+ : null;
+ if (!lockfilePath && !manifestPath) {
+ return [];
+ }
+ return [{
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath,
+ manifestPath,
+ sourceFile: getSourceFileName(lockfilePath || manifestPath),
+ }];
+ },
+
+ async resolve({ lockfilePath, manifestPath, workspaceFolder }) {
+ const manifestDependencies = manifestPath && await pathExists(manifestPath, workspaceFolder)
+ ? parseCargoTomlManifest(await readUtf8(manifestPath, workspaceFolder))
+ : [];
+ const sourceFile = getSourceFileName(lockfilePath || manifestPath);
+
+ if (!lockfilePath) {
+ return buildTree("cargo", sourceFile, manifestDependencies.map((dependency) => createDependency({
+ name: dependency.name,
+ version: dependency.version,
+ ecosystem: "cargo",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: dependency.isDevelopmentDependency,
+ })));
+ }
+
+ const records = parseCargoLock(await readUtf8(lockfilePath, workspaceFolder));
+ if (records.length === 0) {
+ throw new Error("Malformed Cargo.lock: no package entries found");
+ }
+ const directNames = new Set(manifestDependencies.map((dependency) => dependency.name.toLowerCase()));
+ const recordsByName = new Map();
+ const incomingCounts = new Map();
+
+ for (const record of records) {
+ if (!recordsByName.has(record.name.toLowerCase())) {
+ recordsByName.set(record.name.toLowerCase(), []);
+ }
+ recordsByName.get(record.name.toLowerCase()).push(record);
+ for (const dependency of record.dependencies) {
+ incomingCounts.set(
+ dependency.name.toLowerCase(),
+ (incomingCounts.get(dependency.name.toLowerCase()) || 0) + 1
+ );
+ }
+ }
+
+ const rootRecords = manifestDependencies.length > 0
+ ? manifestDependencies.map((dependency) => selectCargoRecord(recordsByName, dependency.name, dependency.version)).filter(Boolean)
+ : records.filter((record) => !incomingCounts.get(record.name.toLowerCase()));
+
+ const directRoots = deduplicateDeps(rootRecords.map((record) => buildCargoDependency(
+ record,
+ [],
+ recordsByName,
+ new Set(),
+ sourceFile,
+ directNames
+ )));
+
+ let dependencies = deduplicateDeps(flattenDependencies(directRoots));
+ for (const record of records) {
+ const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`;
+ if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) {
+ continue;
+ }
+ dependencies.push(createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "cargo",
+ isDirect: directNames.has(record.name.toLowerCase()),
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ }));
+ }
+
+ return buildTree("cargo", sourceFile, dependencies);
+ },
+};
+
+function parseCargoLock(content) {
+ const records = [];
+ let current = null;
+ let inDependenciesArray = false;
+
+ const flushCurrent = () => {
+ if (!current || !current.name || !current.version) {
+ current = null;
+ inDependenciesArray = false;
+ return;
+ }
+ const source = String(current.source || "").trim();
+ if (source && !source.startsWith("path+") && !source.startsWith("git+")) {
+ records.push(current);
+ }
+ current = null;
+ inDependenciesArray = false;
+ };
+
+ for (const rawLine of String(content || "").split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith("#")) {
+ continue;
+ }
+ if (line === "[[package]]") {
+ flushCurrent();
+ current = { name: "", version: "", source: "", dependencies: [] };
+ continue;
+ }
+ if (!current) {
+ continue;
+ }
+
+ if (inDependenciesArray) {
+ if (line === "]") {
+ inDependenciesArray = false;
+ continue;
+ }
+ const match = line.trim().replace(/,$/, "").replace(/^"|"$/g, "").match(/^([^ ]+)(?: ([^ ]+))?/);
+ if (match) {
+ current.dependencies.push({
+ name: match[1],
+ version: match[2] ? match[2].replace(/^\(/, "").replace(/\)$/, "") : "",
+ });
+ }
+ continue;
+ }
+
+ if (line.startsWith("name =")) {
+ current.name = parseKeyValueLine(line).value.replace(/^"|"$/g, "");
+ continue;
+ }
+ if (line.startsWith("version =")) {
+ current.version = parseKeyValueLine(line).value.replace(/^"|"$/g, "");
+ continue;
+ }
+ if (line.startsWith("source =")) {
+ current.source = parseKeyValueLine(line).value.replace(/^"|"$/g, "");
+ continue;
+ }
+ if (line.startsWith("dependencies = [")) {
+ inDependenciesArray = true;
+ const inline = line.slice(line.indexOf("[") + 1, line.lastIndexOf("]"));
+ if (inline.trim()) {
+ for (const item of inline.split(",")) {
+ const cleaned = item.trim().replace(/^"|"$/g, "");
+ if (!cleaned) {
+ continue;
+ }
+ const match = cleaned.match(/^([^ ]+)(?: ([^ ]+))?/);
+ if (match) {
+ current.dependencies.push({ name: match[1], version: match[2] || "" });
+ }
+ }
+ inDependenciesArray = false;
+ }
+ }
+ }
+
+ flushCurrent();
+ return deduplicateCargoRecords(records);
+}
+
+function deduplicateCargoRecords(records) {
+ const seen = new Set();
+ const results = [];
+ for (const record of records) {
+ const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`;
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ results.push(record);
+ }
+ return results;
+}
+
+function selectCargoRecord(recordsByName, name, version) {
+ const candidates = recordsByName.get(name.toLowerCase()) || [];
+ if (candidates.length === 0) {
+ return null;
+ }
+ if (version) {
+ const exactMatch = candidates.find((record) => record.version === version);
+ if (exactMatch) {
+ return exactMatch;
+ }
+ }
+ return candidates[0];
+}
+
+function buildCargoDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) {
+ const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`;
+ if (visiting.has(key)) {
+ return createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "cargo",
+ isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()),
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ });
+ }
+
+ const nextVisiting = new Set(visiting);
+ nextVisiting.add(key);
+ const nextParentChain = parentChain.concat(record.name);
+ const transitives = [];
+
+ for (const dependency of record.dependencies) {
+ const childRecord = selectCargoRecord(recordsByName, dependency.name, dependency.version);
+ if (!childRecord) {
+ continue;
+ }
+ transitives.push(buildCargoDependency(
+ childRecord,
+ nextParentChain,
+ recordsByName,
+ nextVisiting,
+ sourceFile,
+ directNames
+ ));
+ }
+
+ return createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "cargo",
+ isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()),
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: deduplicateDeps(transitives),
+ sourceFile,
+ isDevelopmentDependency: false,
+ });
+}
+
+module.exports = cargoParser;
diff --git a/util/lockfileParsers/composerParser.js b/util/lockfileParsers/composerParser.js
new file mode 100644
index 0000000..0f250fc
--- /dev/null
+++ b/util/lockfileParsers/composerParser.js
@@ -0,0 +1,174 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const {
+ buildTree,
+ createDependency,
+ deduplicateDeps,
+ flattenDependencies,
+ getSourceFileName,
+ getWorkspacePath,
+ readJson,
+ pathExists,
+ readUtf8,
+} = require("./shared");
+const { parseComposerManifest } = require("./manifestHelpers");
+
+const composerParser = {
+ name: "composerParser",
+ ecosystem: "composer",
+
+ async canResolve(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ return (await pathExists(path.join(rootPath, "composer.lock"), workspaceFolder))
+ || (await pathExists(path.join(rootPath, "composer.json"), workspaceFolder));
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ const lockfilePath = await pathExists(path.join(rootPath, "composer.lock"), workspaceFolder)
+ ? path.join(rootPath, "composer.lock")
+ : null;
+ const manifestPath = await pathExists(path.join(rootPath, "composer.json"), workspaceFolder)
+ ? path.join(rootPath, "composer.json")
+ : null;
+ if (!lockfilePath && !manifestPath) {
+ return [];
+ }
+ return [{
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath,
+ manifestPath,
+ sourceFile: getSourceFileName(lockfilePath || manifestPath),
+ }];
+ },
+
+ async resolve({ lockfilePath, manifestPath, workspaceFolder }) {
+ const sourceFile = getSourceFileName(lockfilePath || manifestPath);
+ const manifestDependencies = manifestPath && await pathExists(manifestPath, workspaceFolder)
+ ? parseComposerManifest(await readUtf8(manifestPath, workspaceFolder))
+ : [];
+
+ if (!lockfilePath) {
+ return buildTree("composer", sourceFile, manifestDependencies.map((dependency) => createDependency({
+ name: dependency.name,
+ version: dependency.version,
+ ecosystem: "composer",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: dependency.isDevelopmentDependency,
+ })));
+ }
+
+ const root = await readJson(lockfilePath);
+ const records = [];
+
+ for (const record of [...(root.packages || []), ...(root["packages-dev"] || [])]) {
+ if (!record || !record.name) {
+ continue;
+ }
+ records.push({
+ name: record.name,
+ version: record.version || "",
+ dependencies: Object.keys(record.require || {}).filter((name) => name.includes("/") && !name.startsWith("ext-") && !name.startsWith("lib-") && name !== "php"),
+ });
+ }
+
+ const directNames = new Set(manifestDependencies.map((dependency) => dependency.name.toLowerCase()));
+ const recordsByName = new Map(records.map((record) => [record.name.toLowerCase(), record]));
+ const incomingCounts = new Map();
+ for (const record of records) {
+ for (const dependencyName of record.dependencies) {
+ incomingCounts.set(dependencyName.toLowerCase(), (incomingCounts.get(dependencyName.toLowerCase()) || 0) + 1);
+ }
+ }
+
+ const rootRecords = directNames.size > 0
+ ? [...directNames].map((name) => recordsByName.get(name)).filter(Boolean)
+ : records.filter((record) => !incomingCounts.get(record.name.toLowerCase()));
+
+ const directRoots = deduplicateDeps(rootRecords.map((record) => buildComposerDependency(
+ record,
+ [],
+ recordsByName,
+ new Set(),
+ sourceFile,
+ directNames
+ )));
+ let dependencies = deduplicateDeps(flattenDependencies(directRoots));
+
+ for (const record of records) {
+ const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`;
+ if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) {
+ continue;
+ }
+ dependencies.push(createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "composer",
+ isDirect: directNames.has(record.name.toLowerCase()),
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ }));
+ }
+
+ return buildTree("composer", sourceFile, dependencies);
+ },
+};
+
+function buildComposerDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) {
+ const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`;
+ if (visiting.has(key)) {
+ return createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "composer",
+ isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()),
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ });
+ }
+
+ const nextVisiting = new Set(visiting);
+ nextVisiting.add(key);
+ const nextParentChain = parentChain.concat(record.name);
+ const transitives = [];
+
+ for (const dependencyName of record.dependencies) {
+ const childRecord = recordsByName.get(dependencyName.toLowerCase());
+ if (!childRecord) {
+ continue;
+ }
+ transitives.push(buildComposerDependency(
+ childRecord,
+ nextParentChain,
+ recordsByName,
+ nextVisiting,
+ sourceFile,
+ directNames
+ ));
+ }
+
+ return createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "composer",
+ isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()),
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: deduplicateDeps(transitives),
+ sourceFile,
+ isDevelopmentDependency: false,
+ });
+}
+
+module.exports = composerParser;
diff --git a/util/lockfileParsers/dartParser.js b/util/lockfileParsers/dartParser.js
new file mode 100644
index 0000000..7e3689b
--- /dev/null
+++ b/util/lockfileParsers/dartParser.js
@@ -0,0 +1,124 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const {
+ buildTree,
+ countIndent,
+ createDependency,
+ getSourceFileName,
+ getWorkspacePath,
+ pathExists,
+ readUtf8,
+ stripYamlComment,
+} = require("./shared");
+const { parsePubspecManifest } = require("./manifestHelpers");
+
+const dartParser = {
+ name: "dartParser",
+ ecosystem: "dart",
+
+ async canResolve(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ return (await pathExists(path.join(rootPath, "pubspec.lock"), workspaceFolder))
+ || (await pathExists(path.join(rootPath, "pubspec.yaml"), workspaceFolder));
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ const lockfilePath = await pathExists(path.join(rootPath, "pubspec.lock"), workspaceFolder)
+ ? path.join(rootPath, "pubspec.lock")
+ : null;
+ const manifestPath = await pathExists(path.join(rootPath, "pubspec.yaml"), workspaceFolder)
+ ? path.join(rootPath, "pubspec.yaml")
+ : null;
+ if (!lockfilePath && !manifestPath) {
+ return [];
+ }
+ return [{
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath,
+ manifestPath,
+ sourceFile: getSourceFileName(lockfilePath || manifestPath),
+ }];
+ },
+
+ async resolve({ lockfilePath, manifestPath, workspaceFolder }) {
+ const sourceFile = getSourceFileName(lockfilePath || manifestPath);
+ if (!lockfilePath) {
+ return buildTree("dart", sourceFile, parsePubspecManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({
+ name: dependency.name,
+ version: dependency.version,
+ ecosystem: "dart",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: dependency.isDevelopmentDependency,
+ })));
+ }
+
+ const dependencies = [];
+ let inPackages = false;
+ let current = null;
+
+ const flushCurrent = () => {
+ if (!current || !current.name) {
+ current = null;
+ return;
+ }
+ dependencies.push(createDependency({
+ name: current.name,
+ version: current.version,
+ ecosystem: "dart",
+ isDirect: !String(current.dependencyType || "").toLowerCase().includes("transitive"),
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: String(current.dependencyType || "").toLowerCase().includes("dev"),
+ }));
+ current = null;
+ };
+
+ for (const rawLine of String(await readUtf8(lockfilePath, workspaceFolder)).split(/\r?\n/)) {
+ const line = stripYamlComment(rawLine).trim();
+ if (!line) {
+ continue;
+ }
+
+ const indent = countIndent(rawLine);
+ if (indent === 0 && line === "packages:") {
+ inPackages = true;
+ continue;
+ }
+ if (indent === 0 && line.endsWith(":") && line !== "packages:") {
+ inPackages = false;
+ flushCurrent();
+ continue;
+ }
+ if (!inPackages) {
+ continue;
+ }
+ if (indent === 2 && line.endsWith(":")) {
+ flushCurrent();
+ current = { name: line.slice(0, -1), version: "", dependencyType: "" };
+ continue;
+ }
+ if (!current) {
+ continue;
+ }
+ if (indent === 4 && line.startsWith("dependency:")) {
+ current.dependencyType = line.slice("dependency:".length).trim().replace(/^["']|["']$/g, "");
+ }
+ if (indent === 4 && line.startsWith("version:")) {
+ current.version = line.slice("version:".length).trim().replace(/^["']|["']$/g, "");
+ }
+ }
+
+ flushCurrent();
+ return buildTree("dart", sourceFile, dependencies);
+ },
+};
+
+module.exports = dartParser;
diff --git a/util/lockfileParsers/dockerParser.js b/util/lockfileParsers/dockerParser.js
new file mode 100644
index 0000000..7abf4d7
--- /dev/null
+++ b/util/lockfileParsers/dockerParser.js
@@ -0,0 +1,300 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const {
+ buildTree,
+ countIndent,
+ createDependency,
+ getSourceFileName,
+ getWorkspacePath,
+ readUtf8,
+ resolveWorkspaceFilePath,
+ stripYamlComment,
+} = require("./shared");
+
+const dockerParser = {
+ name: "dockerParser",
+ ecosystem: "docker",
+
+ async canResolve(workspaceFolder) {
+ const matches = await this.detect(workspaceFolder);
+ return matches.length > 0;
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ const safeRootPath = await resolveWorkspaceFilePath(rootPath, workspaceFolder);
+ if (!safeRootPath) {
+ return [];
+ }
+ const entries = [];
+ const allFiles = await require("fs").promises.readdir(safeRootPath);
+
+ for (const fileName of allFiles.sort()) {
+ const isDockerfile = fileName === "Dockerfile" || fileName.startsWith("Dockerfile.");
+ const isComposeFile = [
+ "docker-compose.yml",
+ "docker-compose.yaml",
+ "compose.yml",
+ "compose.yaml",
+ ].includes(fileName);
+ if (!isDockerfile && !isComposeFile) {
+ continue;
+ }
+ entries.push({
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath: path.join(safeRootPath, fileName),
+ manifestPath: null,
+ sourceFile: fileName,
+ });
+ }
+
+ return entries;
+ },
+
+ async resolve({ lockfilePath, workspaceFolder }) {
+ const sourceFile = getSourceFileName(lockfilePath);
+ const content = await readUtf8(lockfilePath, workspaceFolder);
+ const dependencies = isComposeFileName(sourceFile)
+ ? parseCompose(content, sourceFile)
+ : parseDockerfile(content, sourceFile);
+ return buildTree("docker", sourceFile, dependencies);
+ },
+};
+
+function parseDockerfile(content, sourceFile) {
+ const dependencies = [];
+ const stageAliases = new Set();
+ const argDefaults = new Map();
+
+ for (const instruction of toLogicalDockerLines(content)) {
+ const cleaned = stripDockerComment(instruction).trim();
+ if (!cleaned) {
+ continue;
+ }
+
+ if (/^ARG\s+/i.test(cleaned)) {
+ const definition = cleaned.replace(/^ARG\s+/i, "");
+ const [name, value] = definition.split("=", 2);
+ if (name && value) {
+ argDefaults.set(name.trim(), resolveDockerArgs(value.trim(), argDefaults));
+ }
+ continue;
+ }
+
+ if (!/^FROM\s+/i.test(cleaned)) {
+ continue;
+ }
+
+ const parsed = parseFromInstruction(cleaned, argDefaults, stageAliases);
+ if (!parsed) {
+ continue;
+ }
+ if (parsed.alias) {
+ stageAliases.add(parsed.alias.toLowerCase());
+ }
+ if (!parsed.isDependency) {
+ continue;
+ }
+
+ dependencies.push(createDependency({
+ name: parsed.name,
+ version: parsed.version,
+ ecosystem: "docker",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ }));
+ }
+
+ return dependencies;
+}
+
+function parseCompose(content, sourceFile) {
+ const dependencies = [];
+ let servicesIndent = null;
+ let currentService = null;
+
+ const flushCurrentService = () => {
+ if (!currentService) {
+ return;
+ }
+ if (!currentService.hasBuild && currentService.image) {
+ const parsed = parseDockerImageReference(currentService.image);
+ if (parsed && parsed.name.toLowerCase() !== "scratch") {
+ dependencies.push(createDependency({
+ name: parsed.name,
+ version: parsed.version,
+ ecosystem: "docker",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ }));
+ }
+ }
+ currentService = null;
+ };
+
+ for (const rawLine of String(content || "").split(/\r?\n/)) {
+ const cleaned = stripYamlComment(rawLine).trim();
+ if (!cleaned) {
+ continue;
+ }
+
+ const indent = countIndent(rawLine);
+ if (cleaned === "services:") {
+ flushCurrentService();
+ servicesIndent = indent;
+ continue;
+ }
+ if (servicesIndent != null && indent <= servicesIndent && cleaned.endsWith(":")) {
+ flushCurrentService();
+ servicesIndent = null;
+ }
+ if (servicesIndent == null || indent <= servicesIndent) {
+ continue;
+ }
+
+ if (indent === servicesIndent + 2 && cleaned.endsWith(":")) {
+ flushCurrentService();
+ currentService = { indent, hasBuild: false, image: "" };
+ continue;
+ }
+
+ if (!currentService || indent <= currentService.indent || cleaned.startsWith("- ")) {
+ continue;
+ }
+
+ if (cleaned.startsWith("build:")) {
+ currentService.hasBuild = true;
+ continue;
+ }
+ if (cleaned.startsWith("image:")) {
+ currentService.image = unquote(cleaned.slice("image:".length).trim());
+ }
+ }
+
+ flushCurrentService();
+ return dependencies;
+}
+
+function toLogicalDockerLines(content) {
+ const lines = [];
+ let current = "";
+ for (const rawLine of String(content || "").split(/\r?\n/)) {
+ const trimmed = rawLine.trimEnd();
+ if (!trimmed) {
+ if (current) {
+ lines.push(current);
+ current = "";
+ }
+ continue;
+ }
+
+ const continues = trimmed.endsWith("\\");
+ const segment = continues ? trimmed.slice(0, -1).trimEnd() : trimmed;
+ current += current ? ` ${segment}` : segment;
+ if (!continues) {
+ lines.push(current);
+ current = "";
+ }
+ }
+ if (current) {
+ lines.push(current);
+ }
+ return lines;
+}
+
+function stripDockerComment(line) {
+ let inSingleQuote = false;
+ let inDoubleQuote = false;
+
+ for (let index = 0; index < line.length; index += 1) {
+ const char = line[index];
+ const previous = index > 0 ? line[index - 1] : "";
+ if (char === "'" && !inDoubleQuote && previous !== "\\") {
+ inSingleQuote = !inSingleQuote;
+ continue;
+ }
+ if (char === "\"" && !inSingleQuote && previous !== "\\") {
+ inDoubleQuote = !inDoubleQuote;
+ continue;
+ }
+ if (char === "#" && !inSingleQuote && !inDoubleQuote) {
+ return line.slice(0, index);
+ }
+ }
+ return line;
+}
+
+function resolveDockerArgs(value, args) {
+ return String(value || "")
+ .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-+?])([^}]*))?}/g, (_match, name, operator, fallback) => {
+ if (args.has(name)) {
+ return args.get(name);
+ }
+ return operator === "-" || operator === ":-" ? fallback : _match;
+ })
+ .replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, name) => (args.has(name) ? args.get(name) : match));
+}
+
+function parseFromInstruction(line, argDefaults, stageAliases) {
+ const parts = line.split(/\s+/).filter(Boolean);
+ let index = 1;
+ while (parts[index] && parts[index].startsWith("--")) {
+ index += 1;
+ }
+ const imageToken = parts[index];
+ if (!imageToken) {
+ return null;
+ }
+ const alias = parts[index + 1] && /^AS$/i.test(parts[index + 1]) ? parts[index + 2] : "";
+ const resolvedImage = resolveDockerArgs(unquote(imageToken), argDefaults).trim();
+ if (!resolvedImage) {
+ return null;
+ }
+ const stageReference = stageAliases.has(resolvedImage.toLowerCase());
+ const parsed = parseDockerImageReference(resolvedImage);
+ if (!parsed) {
+ return null;
+ }
+ return {
+ ...parsed,
+ alias: alias ? unquote(alias) : "",
+ isDependency: !stageReference && parsed.name.toLowerCase() !== "scratch",
+ };
+}
+
+function parseDockerImageReference(reference) {
+ const raw = unquote(reference);
+ if (!raw || raw.includes("$")) {
+ return null;
+ }
+ const withoutDigest = raw.split("@")[0];
+ const digest = raw.includes("@") ? raw.split("@")[1] : "";
+ const lastSlash = withoutDigest.lastIndexOf("/");
+ const lastColon = withoutDigest.lastIndexOf(":");
+ const hasTag = lastColon > lastSlash;
+ const name = hasTag ? withoutDigest.slice(0, lastColon) : withoutDigest;
+ const version = hasTag ? withoutDigest.slice(lastColon + 1) : digest || "latest";
+ if (!name) {
+ return null;
+ }
+ return { name, version };
+}
+
+function unquote(value) {
+ return String(value || "").trim().replace(/^["']|["']$/g, "");
+}
+
+function isComposeFileName(fileName) {
+ return ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"].includes(fileName);
+}
+
+module.exports = dockerParser;
diff --git a/util/lockfileParsers/goParser.js b/util/lockfileParsers/goParser.js
new file mode 100644
index 0000000..2ec141b
--- /dev/null
+++ b/util/lockfileParsers/goParser.js
@@ -0,0 +1,82 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const {
+ buildTree,
+ createDependency,
+ getSourceFileName,
+ getWorkspacePath,
+ pathExists,
+ readUtf8,
+} = require("./shared");
+
+const goParser = {
+ name: "goParser",
+ ecosystem: "go",
+
+ async canResolve(workspaceFolder) {
+ return pathExists(path.join(getWorkspacePath(workspaceFolder), "go.mod"), workspaceFolder);
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ const manifestPath = path.join(rootPath, "go.mod");
+ if (!(await pathExists(manifestPath, workspaceFolder))) {
+ return [];
+ }
+ return [{
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath: manifestPath,
+ manifestPath,
+ sourceFile: "go.mod",
+ }];
+ },
+
+ async resolve({ manifestPath, workspaceFolder }) {
+ const dependencies = [];
+ const sourceFile = getSourceFileName(manifestPath);
+ let inRequireBlock = false;
+
+ for (const rawLine of String(await readUtf8(manifestPath, workspaceFolder)).split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith("//")) {
+ continue;
+ }
+ if (line === "require (") {
+ inRequireBlock = true;
+ continue;
+ }
+ if (line === ")" && inRequireBlock) {
+ inRequireBlock = false;
+ continue;
+ }
+
+ const lineToParse = line.startsWith("require ") ? line.slice("require ".length).trim() : line;
+ if (!inRequireBlock && !line.startsWith("require ")) {
+ continue;
+ }
+
+ const cleaned = lineToParse.split("//")[0].trim();
+ const parts = cleaned.split(/\s+/);
+ if (parts.length < 2) {
+ continue;
+ }
+
+ dependencies.push(createDependency({
+ name: parts[0],
+ version: parts[1].replace(/^v/, ""),
+ ecosystem: "go",
+ isDirect: !rawLine.includes("// indirect"),
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: rawLine.includes("// indirect"),
+ }));
+ }
+
+ return buildTree("go", sourceFile, dependencies);
+ },
+};
+
+module.exports = goParser;
diff --git a/util/lockfileParsers/gradleParser.js b/util/lockfileParsers/gradleParser.js
new file mode 100644
index 0000000..180bba9
--- /dev/null
+++ b/util/lockfileParsers/gradleParser.js
@@ -0,0 +1,124 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const {
+ buildTree,
+ createDependency,
+ deduplicateDeps,
+ getSourceFileName,
+ getWorkspacePath,
+ pathExists,
+ readUtf8,
+} = require("./shared");
+const { parseBuildGradleManifest } = require("./manifestHelpers");
+
+const BUILD_FILES = ["build.gradle", "build.gradle.kts"];
+
+const gradleParser = {
+ name: "gradleParser",
+ ecosystem: "gradle",
+
+ async canResolve(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ for (const buildFile of BUILD_FILES) {
+ if (await pathExists(path.join(rootPath, buildFile), workspaceFolder)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ for (const buildFile of BUILD_FILES) {
+ const manifestPath = path.join(rootPath, buildFile);
+ if (!(await pathExists(manifestPath, workspaceFolder))) {
+ continue;
+ }
+ const lockfilePath = await pathExists(path.join(rootPath, "gradle.lockfile"), workspaceFolder)
+ ? path.join(rootPath, "gradle.lockfile")
+ : null;
+ return [{
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath,
+ manifestPath,
+ sourceFile: buildFile,
+ }];
+ }
+ return [];
+ },
+
+ async resolve({ lockfilePath, manifestPath, workspaceFolder }) {
+ const directDependencies = parseBuildGradleManifest(await readUtf8(manifestPath, workspaceFolder));
+ const sourceFile = getSourceFileName(manifestPath);
+
+ if (!lockfilePath) {
+ return buildTree("gradle", sourceFile, directDependencies.map((dependency) => createDependency({
+ name: dependency.name,
+ version: dependency.version,
+ ecosystem: "gradle",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: dependency.isDevelopmentDependency,
+ })));
+ }
+
+ const lockVersions = parseGradleLockfile(await readUtf8(lockfilePath, workspaceFolder));
+ const dependencies = [];
+
+ for (const directDependency of directDependencies) {
+ const resolvedVersion = lockVersions.get(directDependency.name) || directDependency.version;
+ dependencies.push(createDependency({
+ name: directDependency.name,
+ version: resolvedVersion,
+ ecosystem: "gradle",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: directDependency.isDevelopmentDependency,
+ }));
+ }
+
+ for (const [name, version] of lockVersions.entries()) {
+ dependencies.push(createDependency({
+ name,
+ version,
+ ecosystem: "gradle",
+ isDirect: directDependencies.some((dependency) => dependency.name === name),
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ }));
+ }
+
+ return buildTree("gradle", sourceFile, deduplicateDeps(dependencies));
+ },
+};
+
+function parseGradleLockfile(content) {
+ const versions = new Map();
+
+ for (const rawLine of String(content || "").split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith("#")) {
+ continue;
+ }
+ const entry = line.split("=", 1)[0].trim();
+ const parts = entry.split(":");
+ if (parts.length < 3) {
+ continue;
+ }
+ versions.set(`${parts[0]}:${parts[1]}`, parts[2]);
+ }
+
+ return versions;
+}
+
+module.exports = gradleParser;
diff --git a/util/lockfileParsers/helmParser.js b/util/lockfileParsers/helmParser.js
new file mode 100644
index 0000000..abc5a61
--- /dev/null
+++ b/util/lockfileParsers/helmParser.js
@@ -0,0 +1,62 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const {
+ buildTree,
+ createDependency,
+ getSourceFileName,
+ getWorkspacePath,
+ pathExists,
+ readUtf8,
+} = require("./shared");
+const { parseChartManifest } = require("./manifestHelpers");
+
+const helmParser = {
+ name: "helmParser",
+ ecosystem: "helm",
+
+ async canResolve(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ return (await pathExists(path.join(rootPath, "Chart.lock"), workspaceFolder))
+ || (await pathExists(path.join(rootPath, "Chart.yaml"), workspaceFolder));
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ const lockfilePath = await pathExists(path.join(rootPath, "Chart.lock"), workspaceFolder)
+ ? path.join(rootPath, "Chart.lock")
+ : null;
+ const manifestPath = await pathExists(path.join(rootPath, "Chart.yaml"), workspaceFolder)
+ ? path.join(rootPath, "Chart.yaml")
+ : null;
+ if (!lockfilePath && !manifestPath) {
+ return [];
+ }
+ return [{
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath,
+ manifestPath,
+ sourceFile: getSourceFileName(lockfilePath || manifestPath),
+ }];
+ },
+
+ async resolve({ lockfilePath, manifestPath, workspaceFolder }) {
+ const sourcePath = lockfilePath || manifestPath;
+ const sourceFile = getSourceFileName(sourcePath);
+ const dependencies = parseChartManifest(await readUtf8(sourcePath, workspaceFolder)).map((dependency) => createDependency({
+ name: dependency.name,
+ version: dependency.version,
+ ecosystem: "helm",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ }));
+
+ return buildTree("helm", sourceFile, dependencies);
+ },
+};
+
+module.exports = helmParser;
diff --git a/util/lockfileParsers/hexParser.js b/util/lockfileParsers/hexParser.js
new file mode 100644
index 0000000..a0808b2
--- /dev/null
+++ b/util/lockfileParsers/hexParser.js
@@ -0,0 +1,86 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const {
+ buildTree,
+ createDependency,
+ getSourceFileName,
+ getWorkspacePath,
+ pathExists,
+ readUtf8,
+} = require("./shared");
+const { parseMixExsManifest } = require("./manifestHelpers");
+
+const hexParser = {
+ name: "hexParser",
+ ecosystem: "hex",
+
+ async canResolve(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ return (await pathExists(path.join(rootPath, "mix.lock"), workspaceFolder))
+ || (await pathExists(path.join(rootPath, "mix.exs"), workspaceFolder));
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ const lockfilePath = await pathExists(path.join(rootPath, "mix.lock"), workspaceFolder)
+ ? path.join(rootPath, "mix.lock")
+ : null;
+ const manifestPath = await pathExists(path.join(rootPath, "mix.exs"), workspaceFolder)
+ ? path.join(rootPath, "mix.exs")
+ : null;
+ if (!lockfilePath && !manifestPath) {
+ return [];
+ }
+ return [{
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath,
+ manifestPath,
+ sourceFile: getSourceFileName(lockfilePath || manifestPath),
+ }];
+ },
+
+ async resolve({ lockfilePath, manifestPath, workspaceFolder }) {
+ const sourceFile = getSourceFileName(lockfilePath || manifestPath);
+ if (!lockfilePath) {
+ return buildTree("hex", sourceFile, parseMixExsManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({
+ name: dependency.name,
+ version: dependency.version,
+ ecosystem: "hex",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ })));
+ }
+
+ const directNames = manifestPath && await pathExists(manifestPath, workspaceFolder)
+ ? new Set(parseMixExsManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => dependency.name.toLowerCase()))
+ : new Set();
+ const entryPattern = /"([^"]+)"\s*:\s*\{\s*:hex,\s*(?::"[^"]+"|:[^,]+)\s*,\s*"([^"]+)"/g;
+ const dependencies = [];
+ for (const match of String(await readUtf8(lockfilePath, workspaceFolder)).matchAll(entryPattern)) {
+ dependencies.push(createDependency({
+ name: match[1],
+ version: match[2],
+ ecosystem: "hex",
+ isDirect: directNames.size === 0 || directNames.has(match[1].toLowerCase()),
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ }));
+ }
+
+ if (dependencies.length === 0) {
+ throw new Error("Malformed mix.lock: no Hex package entries found");
+ }
+
+ return buildTree("hex", sourceFile, dependencies);
+ },
+};
+
+module.exports = hexParser;
diff --git a/util/lockfileParsers/manifestHelpers.js b/util/lockfileParsers/manifestHelpers.js
new file mode 100644
index 0000000..3aa59d9
--- /dev/null
+++ b/util/lockfileParsers/manifestHelpers.js
@@ -0,0 +1,650 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const {
+ escapeRegExp,
+ normalizeVersion,
+ parseInlineTomlValue,
+ parseKeyValueLine,
+ parseQuotedArray,
+ stripTomlComment,
+ stripYamlComment,
+} = require("./shared");
+
+function parsePackageJsonManifest(content) {
+ let parsed;
+ try {
+ parsed = JSON.parse(content);
+ } catch {
+ return {
+ dependencies: [],
+ directNames: new Set(),
+ devNames: new Set(),
+ };
+ }
+
+ const dependencies = [];
+ const directNames = new Set();
+ const devNames = new Set();
+
+ const addSection = (sectionName, isDevelopmentDependency) => {
+ const section = parsed[sectionName];
+ if (!section || typeof section !== "object") {
+ return;
+ }
+
+ for (const [name, version] of Object.entries(section)) {
+ dependencies.push({
+ name,
+ version: normalizeVersion(version),
+ isDevelopmentDependency,
+ });
+ if (isDevelopmentDependency) {
+ devNames.add(name);
+ } else {
+ directNames.add(name);
+ }
+ }
+ };
+
+ addSection("dependencies", false);
+ addSection("devDependencies", true);
+ addSection("optionalDependencies", false);
+ addSection("peerDependencies", false);
+
+ return {
+ dependencies,
+ directNames,
+ devNames,
+ };
+}
+
+function parsePyprojectManifest(content) {
+ const lines = String(content || "").split(/\r?\n/);
+ const dependencies = [];
+ const directNames = new Set();
+ const devNames = new Set();
+ let projectName = "";
+ let section = "";
+ let collectingProjectDependencies = false;
+ let projectDependenciesBuffer = "";
+
+ const flushProjectDependencies = () => {
+ if (!projectDependenciesBuffer) {
+ return;
+ }
+ for (const item of parseQuotedArray(projectDependenciesBuffer)) {
+ const parsed = parseRequirementSpec(item);
+ if (!parsed) {
+ continue;
+ }
+ dependencies.push({
+ ...parsed,
+ isDevelopmentDependency: false,
+ });
+ directNames.add(parsed.name);
+ }
+ projectDependenciesBuffer = "";
+ collectingProjectDependencies = false;
+ };
+
+ for (const rawLine of lines) {
+ const withoutComment = stripTomlComment(rawLine);
+ const line = withoutComment.trim();
+ if (!line || line.startsWith("#")) {
+ continue;
+ }
+
+ if (collectingProjectDependencies) {
+ projectDependenciesBuffer += projectDependenciesBuffer ? ` ${line}` : line;
+ if (projectDependenciesBuffer.includes("]")) {
+ flushProjectDependencies();
+ }
+ continue;
+ }
+
+ if (line.startsWith("[") && line.endsWith("]")) {
+ section = line;
+ continue;
+ }
+
+ if (section === "[project]" && line.startsWith("name =")) {
+ projectName = unquote(parseKeyValueLine(line).value);
+ continue;
+ }
+
+ if (section === "[tool.poetry]" && line.startsWith("name =")) {
+ projectName = unquote(parseKeyValueLine(line).value);
+ continue;
+ }
+
+ if (section === "[project]" && line.startsWith("dependencies")) {
+ projectDependenciesBuffer = parseKeyValueLine(line).value;
+ if (projectDependenciesBuffer.includes("]")) {
+ flushProjectDependencies();
+ } else {
+ collectingProjectDependencies = true;
+ }
+ continue;
+ }
+
+ if (
+ section === "[tool.poetry.dependencies]"
+ || section === "[tool.poetry.dev-dependencies]"
+ || /^\[tool\.poetry\.group\.[^.]+\.dependencies]$/.test(section)
+ ) {
+ const parts = parseKeyValueLine(line);
+ if (!parts) {
+ continue;
+ }
+
+ const name = parts.key;
+ if (!name || name.toLowerCase() === "python") {
+ continue;
+ }
+
+ const rawValue = parts.value;
+ const version = rawValue.startsWith("{")
+ ? normalizeVersion(parseInlineTomlValue(rawValue, "version"))
+ : normalizeVersion(unquote(rawValue));
+ const isDevelopmentDependency = section !== "[tool.poetry.dependencies]";
+
+ dependencies.push({
+ name,
+ version: version === "*" ? "" : version,
+ isDevelopmentDependency,
+ });
+
+ if (isDevelopmentDependency) {
+ devNames.add(name);
+ } else {
+ directNames.add(name);
+ }
+ }
+ }
+
+ return {
+ projectName,
+ dependencies,
+ directNames,
+ devNames,
+ };
+}
+
+function parseRequirementSpec(spec) {
+ const rawSpec = String(spec || "").trim().replace(/^["']|["']$/g, "");
+ if (!rawSpec) {
+ return null;
+ }
+
+ const withoutMarker = rawSpec.split(";")[0].trim();
+ const match = withoutMarker.match(/^([A-Za-z0-9_.-]+)(?:\[[^\]]+])?\s*(.*)$/);
+ if (!match) {
+ return null;
+ }
+
+ return {
+ name: match[1],
+ version: normalizeVersion(match[2] || ""),
+ };
+}
+
+function parseCargoTomlManifest(content) {
+ const dependencies = [];
+ let section = "";
+
+ for (const rawLine of String(content || "").split(/\r?\n/)) {
+ const line = stripTomlComment(rawLine).trim();
+ if (!line) {
+ continue;
+ }
+
+ if (line.startsWith("[") && line.endsWith("]")) {
+ section = line;
+ continue;
+ }
+
+ if (![
+ "[dependencies]",
+ "[dev-dependencies]",
+ "[build-dependencies]",
+ "[workspace.dependencies]",
+ ].includes(section)) {
+ continue;
+ }
+
+ const parts = parseKeyValueLine(line);
+ if (!parts) {
+ continue;
+ }
+
+ const declaredName = parts.key;
+ const rawValue = parts.value;
+ const actualName = parseInlineTomlValue(rawValue, "package") || declaredName;
+ const version = rawValue.startsWith("{")
+ ? normalizeVersion(parseInlineTomlValue(rawValue, "version"))
+ : normalizeVersion(unquote(rawValue));
+
+ dependencies.push({
+ name: actualName,
+ version,
+ isDevelopmentDependency: section !== "[dependencies]" && section !== "[workspace.dependencies]",
+ });
+ }
+
+ return dependencies;
+}
+
+function parseGemfileManifest(content) {
+ const dependencies = [];
+ const pattern = /^\s*gem\s+["']([^"']+)["'](?:\s*,\s*["']([^"']+)["'])?/;
+
+ for (const rawLine of String(content || "").split(/\r?\n/)) {
+ const line = stripRubyComment(rawLine).trim();
+ const match = line.match(pattern);
+ if (!match) {
+ continue;
+ }
+ dependencies.push({
+ name: match[1],
+ version: normalizeVersion(match[2] || ""),
+ isDevelopmentDependency: false,
+ });
+ }
+
+ return dependencies;
+}
+
+function parseBuildGradleManifest(content) {
+ const dependencies = [];
+ const lines = String(content || "").split(/\r?\n/);
+ let inDependenciesBlock = false;
+ let braceDepth = 0;
+ let dependencyBlockDepth = 0;
+
+ for (const rawLine of lines) {
+ const line = stripJavaLikeComment(rawLine).trim();
+ if (!line) {
+ braceDepth += countBraces(rawLine);
+ continue;
+ }
+
+ if (!inDependenciesBlock && /^dependencies\s*\{/.test(line)) {
+ inDependenciesBlock = true;
+ dependencyBlockDepth = braceDepth + 1;
+ } else if (!inDependenciesBlock && line === "dependencies") {
+ inDependenciesBlock = true;
+ dependencyBlockDepth = braceDepth + 1;
+ } else if (inDependenciesBlock) {
+ const parsed = parseGradleDependencyLine(line);
+ if (parsed) {
+ dependencies.push(parsed);
+ }
+ }
+
+ braceDepth += countBraces(rawLine);
+ if (inDependenciesBlock && braceDepth < dependencyBlockDepth) {
+ inDependenciesBlock = false;
+ dependencyBlockDepth = 0;
+ }
+ }
+
+ return dedupeManifestDeps(dependencies);
+}
+
+function parseGradleDependencyLine(line) {
+ const match = line.match(/^\s*([A-Za-z][A-Za-z0-9_-]*)\s*\(?\s*["']([^"']+)["']/);
+ if (!match) {
+ return null;
+ }
+
+ const configuration = match[1].toLowerCase();
+ const coordinates = match[2].split(":").filter(Boolean);
+ if (coordinates.length < 2) {
+ return null;
+ }
+
+ return {
+ name: `${coordinates[0]}:${coordinates[1]}`,
+ version: coordinates[2] ? normalizeVersion(coordinates[2]) : "",
+ isDevelopmentDependency: configuration.includes("test"),
+ };
+}
+
+function parseCsprojManifest(content) {
+ const dependencies = [];
+ const inlinePattern = /]*)\/>/gi;
+ const blockPattern = /]*)>([\s\S]*?)<\/PackageReference>/gi;
+
+ const parseAttributes = (attributesText, blockText) => {
+ const includeMatch = attributesText.match(/\b(?:Include|Update)="([^"]+)"/i);
+ if (!includeMatch) {
+ return;
+ }
+
+ let version = "";
+ const attributeVersionMatch = attributesText.match(/\bVersion="([^"]+)"/i);
+ if (attributeVersionMatch) {
+ version = attributeVersionMatch[1];
+ } else if (blockText) {
+ const nestedVersionMatch = blockText.match(/\s*([^<]+)\s*<\/Version>/i);
+ if (nestedVersionMatch) {
+ version = nestedVersionMatch[1];
+ }
+ }
+
+ dependencies.push({
+ name: includeMatch[1].trim(),
+ version: normalizeVersion(version),
+ isDevelopmentDependency: false,
+ });
+ };
+
+ for (const match of content.matchAll(inlinePattern)) {
+ parseAttributes(match[1], "");
+ }
+
+ for (const match of content.matchAll(blockPattern)) {
+ parseAttributes(match[1], match[2]);
+ }
+
+ return dedupeManifestDeps(dependencies);
+}
+
+function parsePubspecManifest(content) {
+ const dependencies = [];
+ let section = "";
+
+ for (const rawLine of String(content || "").split(/\r?\n/)) {
+ const lineWithoutComment = stripYamlComment(rawLine);
+ const line = lineWithoutComment.trim();
+ if (!line) {
+ continue;
+ }
+
+ const indent = rawLine.search(/\S/);
+ if (indent === 0 && line.endsWith(":")) {
+ section = line.slice(0, -1);
+ continue;
+ }
+
+ if (!["dependencies", "dev_dependencies"].includes(section) || indent !== 2) {
+ continue;
+ }
+
+ if (line.startsWith("-")) {
+ continue;
+ }
+
+ const name = line.split(":", 1)[0].trim();
+ const rawValue = line.includes(":") ? line.split(":").slice(1).join(":").trim() : "";
+ const version = rawValue.startsWith("{")
+ ? normalizeVersion(parseYamlInlineValue(rawValue, "version"))
+ : normalizeVersion(unquote(rawValue));
+
+ if (!name) {
+ continue;
+ }
+
+ dependencies.push({
+ name,
+ version,
+ isDevelopmentDependency: section === "dev_dependencies",
+ });
+ }
+
+ return dedupeManifestDeps(dependencies);
+}
+
+function parseComposerManifest(content) {
+ let parsed;
+ try {
+ parsed = JSON.parse(content);
+ } catch {
+ return [];
+ }
+
+ const dependencies = [];
+ for (const [name, version] of Object.entries(parsed.require || {})) {
+ if (!isComposerPackageName(name)) {
+ continue;
+ }
+ dependencies.push({ name, version: normalizeVersion(version), isDevelopmentDependency: false });
+ }
+ for (const [name, version] of Object.entries(parsed["require-dev"] || {})) {
+ if (!isComposerPackageName(name)) {
+ continue;
+ }
+ dependencies.push({ name, version: normalizeVersion(version), isDevelopmentDependency: true });
+ }
+ return dedupeManifestDeps(dependencies);
+}
+
+function parseChartManifest(content) {
+ return parseSimpleYamlDependencyList(content, "dependencies");
+}
+
+function parsePackageSwiftManifest(content) {
+ const dependencies = [];
+ const pattern = /\.package\s*\(([\s\S]*?)\)/g;
+
+ for (const match of content.matchAll(pattern)) {
+ const declaration = match[1];
+ const identityMatch = declaration.match(/\b(?:name|id|identity)\s*:\s*"([^"]+)"/);
+ const urlMatch = declaration.match(/\burl\s*:\s*"([^"]+)"/);
+ const versionMatch = declaration.match(/\b(?:from|exact|branch|revision)\s*:\s*"([^"]+)"/);
+ const name = normalizeSwiftIdentity(identityMatch ? identityMatch[1] : urlMatch ? urlMatch[1] : "");
+ if (!name) {
+ continue;
+ }
+ dependencies.push({
+ name,
+ version: normalizeVersion(versionMatch ? versionMatch[1] : ""),
+ isDevelopmentDependency: false,
+ });
+ }
+
+ return dedupeManifestDeps(dependencies);
+}
+
+function normalizeSwiftIdentity(name) {
+ return String(name || "")
+ .trim()
+ .split("/")
+ .filter(Boolean)
+ .pop()
+ ?.replace(/\.git$/i, "")
+ .toLowerCase() || "";
+}
+
+function parseMixExsManifest(content) {
+ const dependencies = [];
+ const depsBlockMatch = content.match(/defp\s+deps\s+do\s*\[([\s\S]*?)\]\s*end/m);
+ if (!depsBlockMatch) {
+ return [];
+ }
+
+ const pattern = /\{\s*:([A-Za-z0-9_]+)\s*,\s*"([^"]*)"/g;
+ for (const match of depsBlockMatch[1].matchAll(pattern)) {
+ dependencies.push({
+ name: match[1],
+ version: normalizeVersion(match[2]),
+ isDevelopmentDependency: false,
+ });
+ }
+ return dedupeManifestDeps(dependencies);
+}
+
+function parsePomManifest(content) {
+ const dependencies = [];
+ const dependencyBlocks = content.match(/[\s\S]*?<\/dependency>/gi) || [];
+
+ for (const block of dependencyBlocks) {
+ const groupId = matchXmlValue(block, "groupId");
+ const artifactId = matchXmlValue(block, "artifactId");
+ const scope = matchXmlValue(block, "scope");
+ if (!groupId || !artifactId) {
+ continue;
+ }
+
+ let version = matchXmlValue(block, "version");
+ if (version && /\$\{[^}]+}/.test(version)) {
+ version = "";
+ }
+
+ dependencies.push({
+ name: `${groupId}:${artifactId}`,
+ version: normalizeVersion(version),
+ isDevelopmentDependency: scope === "test",
+ });
+ }
+
+ return dedupeManifestDeps(dependencies);
+}
+
+function parseSimpleYamlDependencyList(content, sectionName) {
+ const dependencies = [];
+ let inSection = false;
+ let currentDependency = null;
+
+ const flushCurrent = () => {
+ if (!currentDependency || !currentDependency.name) {
+ currentDependency = null;
+ return;
+ }
+ dependencies.push({
+ name: currentDependency.name,
+ version: normalizeVersion(currentDependency.version),
+ isDevelopmentDependency: false,
+ });
+ currentDependency = null;
+ };
+
+ for (const rawLine of String(content || "").split(/\r?\n/)) {
+ const lineWithoutComment = stripYamlComment(rawLine);
+ const line = lineWithoutComment.trim();
+ if (!line) {
+ continue;
+ }
+
+ const indent = rawLine.search(/\S/);
+ if (indent === 0 && line === `${sectionName}:`) {
+ inSection = true;
+ continue;
+ }
+ if (indent === 0 && line.endsWith(":") && line !== `${sectionName}:`) {
+ inSection = false;
+ flushCurrent();
+ continue;
+ }
+ if (!inSection) {
+ continue;
+ }
+
+ if (indent === 2 && line.startsWith("- ")) {
+ flushCurrent();
+ currentDependency = { name: "", version: "" };
+ const remainder = line.slice(2).trim();
+ if (remainder.startsWith("name:")) {
+ currentDependency.name = remainder.slice("name:".length).trim();
+ }
+ continue;
+ }
+
+ if (!currentDependency) {
+ continue;
+ }
+
+ if (indent >= 4 && line.startsWith("name:")) {
+ currentDependency.name = line.slice("name:".length).trim();
+ }
+ if (indent >= 4 && line.startsWith("version:")) {
+ currentDependency.version = line.slice("version:".length).trim();
+ }
+ }
+
+ flushCurrent();
+ return dedupeManifestDeps(dependencies);
+}
+
+function dedupeManifestDeps(dependencies) {
+ const seen = new Set();
+ const results = [];
+ for (const dependency of dependencies) {
+ const key = `${dependency.name.toLowerCase()}:${dependency.version.toLowerCase()}:${dependency.isDevelopmentDependency}`;
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ results.push(dependency);
+ }
+ return results;
+}
+
+function parseYamlInlineValue(block, key) {
+ const match = String(block || "").match(new RegExp(`${escapeRegExp(key)}\\s*:\\s*([^,}]+)`));
+ return match ? unquote(match[1].trim()) : "";
+}
+
+function unquote(value) {
+ return String(value || "").trim().replace(/^["']|["']$/g, "");
+}
+
+function stripRubyComment(line) {
+ let inSingleQuote = false;
+ let inDoubleQuote = false;
+ for (let index = 0; index < line.length; index += 1) {
+ const char = line[index];
+ const previous = index > 0 ? line[index - 1] : "";
+ if (char === "'" && !inDoubleQuote && previous !== "\\") {
+ inSingleQuote = !inSingleQuote;
+ continue;
+ }
+ if (char === "\"" && !inSingleQuote && previous !== "\\") {
+ inDoubleQuote = !inDoubleQuote;
+ continue;
+ }
+ if (char === "#" && !inSingleQuote && !inDoubleQuote) {
+ return line.slice(0, index);
+ }
+ }
+ return line;
+}
+
+function stripJavaLikeComment(line) {
+ return String(line || "").replace(/\/\/.*$/, "").trimEnd();
+}
+
+function countBraces(line) {
+ const openBraces = (line.match(/\{/g) || []).length;
+ const closeBraces = (line.match(/\}/g) || []).length;
+ return openBraces - closeBraces;
+}
+
+function isComposerPackageName(name) {
+ return typeof name === "string"
+ && name.includes("/")
+ && !name.startsWith("ext-")
+ && !name.startsWith("lib-")
+ && name !== "php";
+}
+
+function matchXmlValue(block, tagName) {
+ const match = String(block || "").match(new RegExp(`<${tagName}>\\s*([^<]+)\\s*${tagName}>`, "i"));
+ return match ? match[1].trim() : "";
+}
+
+module.exports = {
+ normalizeSwiftIdentity,
+ parseBuildGradleManifest,
+ parseCargoTomlManifest,
+ parseChartManifest,
+ parseComposerManifest,
+ parseCsprojManifest,
+ parseGemfileManifest,
+ parseMixExsManifest,
+ parsePackageJsonManifest,
+ parsePackageSwiftManifest,
+ parsePomManifest,
+ parsePubspecManifest,
+ parsePyprojectManifest,
+ parseRequirementSpec,
+};
diff --git a/util/lockfileParsers/mavenParser.js b/util/lockfileParsers/mavenParser.js
new file mode 100644
index 0000000..173a006
--- /dev/null
+++ b/util/lockfileParsers/mavenParser.js
@@ -0,0 +1,181 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const {
+ buildTree,
+ createDependency,
+ deduplicateDeps,
+ flattenDependencies,
+ getSourceFileName,
+ getWorkspacePath,
+ pathExists,
+ readUtf8,
+} = require("./shared");
+const { parsePomManifest } = require("./manifestHelpers");
+
+const TREE_FILE_CANDIDATES = [
+ "dependency-tree.txt",
+ path.join("target", "dependency-tree.txt"),
+ path.join(".mvn", "dependency-tree.txt"),
+];
+
+const mavenParser = {
+ name: "mavenParser",
+ ecosystem: "maven",
+
+ async canResolve(workspaceFolder) {
+ return await pathExists(path.join(getWorkspacePath(workspaceFolder), "pom.xml"), workspaceFolder);
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ const pomPath = path.join(rootPath, "pom.xml");
+ if (!(await pathExists(pomPath, workspaceFolder))) {
+ return [];
+ }
+
+ let lockfilePath = null;
+ for (const candidate of TREE_FILE_CANDIDATES) {
+ const candidatePath = path.join(rootPath, candidate);
+ if (await pathExists(candidatePath, workspaceFolder)) {
+ lockfilePath = candidatePath;
+ break;
+ }
+ }
+
+ return [{
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath,
+ manifestPath: pomPath,
+ sourceFile: "pom.xml",
+ }];
+ },
+
+ async resolve({ lockfilePath, manifestPath, workspaceFolder }) {
+ const directDependencies = parsePomManifest(await readUtf8(manifestPath, workspaceFolder))
+ .map((dependency) => createDependency({
+ name: dependency.name,
+ version: dependency.version,
+ ecosystem: "maven",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile: getSourceFileName(manifestPath),
+ isDevelopmentDependency: dependency.isDevelopmentDependency,
+ }));
+
+ if (!lockfilePath) {
+ return buildTree("maven", getSourceFileName(manifestPath), directDependencies);
+ }
+
+ const treeRoots = parseDependencyTree(await readUtf8(lockfilePath, workspaceFolder));
+ const hydratedDirectDependencies = directDependencies.map((dependency) => {
+ const matchingTreeNode = treeRoots.find((node) => (
+ node.name === dependency.name
+ && (!dependency.version || node.version === dependency.version || !node.version)
+ )) || treeRoots.find((node) => node.name === dependency.name);
+
+ if (!matchingTreeNode) {
+ return dependency;
+ }
+
+ return {
+ ...dependency,
+ version: dependency.version || matchingTreeNode.version,
+ transitives: matchingTreeNode.children.map((child) => toMavenDependency(child, [dependency.name], getSourceFileName(manifestPath))),
+ };
+ });
+
+ let dependencies = deduplicateDeps(flattenDependencies(hydratedDirectDependencies));
+ for (const rootNode of treeRoots) {
+ appendTreeNodeIfMissing(rootNode, dependencies, getSourceFileName(manifestPath));
+ }
+
+ return buildTree("maven", getSourceFileName(manifestPath), dependencies);
+ },
+};
+
+function parseDependencyTree(content) {
+ const roots = [];
+ const stack = [];
+
+ for (const rawLine of String(content || "").split(/\r?\n/)) {
+ const body = rawLine.replace(/^\[INFO\]\s*/, "");
+ if (!body.trim()) {
+ continue;
+ }
+
+ const markerIndex = body.search(/[+\\]-/);
+ if (markerIndex === -1) {
+ continue;
+ }
+
+ const depth = Math.floor(markerIndex / 3);
+ const coordinates = body.slice(markerIndex + 2).trim().replace(/\s+\(\*\)$/, "");
+ const node = parseMavenCoordinate(coordinates);
+ if (!node) {
+ continue;
+ }
+
+ while (stack.length > depth) {
+ stack.pop();
+ }
+
+ if (stack.length === 0) {
+ roots.push(node);
+ } else {
+ stack[stack.length - 1].children.push(node);
+ }
+ stack.push(node);
+ }
+
+ return roots;
+}
+
+function parseMavenCoordinate(coordinates) {
+ const withoutScopeHint = coordinates.split(" -> ")[0].trim();
+ const parts = withoutScopeHint.split(":");
+ if (parts.length < 4) {
+ return null;
+ }
+
+ const resolvedVersion = coordinates.includes(" -> ")
+ ? coordinates.split(" -> ")[1].trim().split(" ")[0]
+ : parts[3];
+
+ return {
+ name: `${parts[0]}:${parts[1]}`,
+ version: resolvedVersion || "",
+ children: [],
+ };
+}
+
+function toMavenDependency(node, parentChain, sourceFile) {
+ return createDependency({
+ name: node.name,
+ version: node.version,
+ ecosystem: "maven",
+ isDirect: parentChain.length === 0,
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: deduplicateDeps(node.children.map((child) => toMavenDependency(child, parentChain.concat(node.name), sourceFile))),
+ sourceFile,
+ isDevelopmentDependency: false,
+ });
+}
+
+function appendTreeNodeIfMissing(node, dependencies, sourceFile) {
+ const key = `${node.name.toLowerCase()}@${node.version.toLowerCase()}`;
+ const exists = dependencies.some((dependency) => (
+ `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key
+ ));
+ if (!exists) {
+ dependencies.push(toMavenDependency(node, [], sourceFile));
+ }
+ for (const child of node.children) {
+ appendTreeNodeIfMissing(child, dependencies, sourceFile);
+ }
+}
+
+module.exports = mavenParser;
diff --git a/util/lockfileParsers/npmParser.js b/util/lockfileParsers/npmParser.js
new file mode 100644
index 0000000..8df4970
--- /dev/null
+++ b/util/lockfileParsers/npmParser.js
@@ -0,0 +1,836 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const {
+ buildTree,
+ createDependency,
+ deduplicateDeps,
+ flattenDependencies,
+ getSourceFileName,
+ getWorkspacePath,
+ normalizeVersion,
+ readJson,
+ pathExists,
+ readUtf8,
+ statSafe,
+ stripYamlComment,
+} = require("./shared");
+const { parsePackageJsonManifest } = require("./manifestHelpers");
+
+const LOCKFILE_NAMES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
+
+const npmParser = {
+ name: "npmParser",
+ ecosystem: "npm",
+
+ async canResolve(workspaceFolder) {
+ const matches = await this.detect(workspaceFolder);
+ return matches.length > 0;
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ for (const fileName of LOCKFILE_NAMES) {
+ const lockfilePath = path.join(rootPath, fileName);
+ if (await pathExists(lockfilePath, workspaceFolder)) {
+ const manifestPath = await pathExists(path.join(rootPath, "package.json"), workspaceFolder)
+ ? path.join(rootPath, "package.json")
+ : null;
+ return [{
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath,
+ manifestPath,
+ sourceFile: fileName,
+ }];
+ }
+ }
+ return [];
+ },
+
+ async resolve({ lockfilePath, manifestPath, workspaceFolder, options = {} }) {
+ const sourceFile = getSourceFileName(lockfilePath);
+ const manifest = manifestPath && await pathExists(manifestPath, workspaceFolder)
+ ? parsePackageJsonManifest(await readUtf8(manifestPath, workspaceFolder))
+ : { dependencies: [], directNames: new Set(), devNames: new Set() };
+
+ if (sourceFile === "package-lock.json") {
+ return parsePackageLock(lockfilePath, manifest, workspaceFolder, options);
+ }
+
+ if (sourceFile === "yarn.lock") {
+ return parseYarnLock(lockfilePath, manifest, workspaceFolder, options);
+ }
+
+ if (sourceFile === "pnpm-lock.yaml") {
+ return parsePnpmLock(lockfilePath, manifest, workspaceFolder, options);
+ }
+
+ throw new Error(`Unsupported npm lockfile: ${sourceFile}`);
+ },
+};
+
+async function parsePackageLock(lockfilePath, manifest, workspaceFolder, options) {
+ const warnings = [];
+ const stats = await statSafe(lockfilePath, workspaceFolder);
+ if (stats && stats.size > 50 * 1024 * 1024) {
+ warnings.push("Large package-lock.json detected. Parsing may take longer than usual.");
+ }
+
+ const root = await readJson(lockfilePath, workspaceFolder);
+ const packages = root && typeof root === "object" && root.packages && typeof root.packages === "object"
+ ? root.packages
+ : null;
+
+ if (!packages) {
+ throw new Error("Malformed package-lock.json: missing packages object");
+ }
+
+ const rootEntry = packages[""] || {};
+ const rootDependencyMap = {
+ ...(rootEntry.dependencies || {}),
+ ...(rootEntry.optionalDependencies || {}),
+ ...(rootEntry.devDependencies || {}),
+ };
+
+ const mergedManifestVersionHints = new Map();
+ for (const dependency of manifest.dependencies) {
+ mergedManifestVersionHints.set(dependency.name, dependency.version);
+ }
+ for (const [name, version] of Object.entries(rootDependencyMap)) {
+ if (!mergedManifestVersionHints.has(name)) {
+ mergedManifestVersionHints.set(name, normalizeVersion(version));
+ }
+ }
+
+ const uniqueEntries = new Map();
+ const nameIndex = new Map();
+ const nameIndexKeys = new Map();
+
+ for (const [packagePath, packageInfo] of Object.entries(packages)) {
+ if (packagePath === "" || !packageInfo || typeof packageInfo !== "object") {
+ continue;
+ }
+
+ const name = extractPackageLockName(packagePath);
+ const version = String(packageInfo.version || "").trim();
+ if (!name || !version) {
+ continue;
+ }
+
+ const key = `${name.toLowerCase()}@${version.toLowerCase()}`;
+ const existing = uniqueEntries.get(key);
+ const dependencies = {
+ ...(packageInfo.dependencies || {}),
+ ...(packageInfo.optionalDependencies || {}),
+ };
+ const merged = existing || {
+ key,
+ name,
+ version,
+ dependencies: {},
+ };
+ Object.assign(merged.dependencies, dependencies);
+ uniqueEntries.set(key, merged);
+
+ const existingByName = nameIndex.get(name) || [];
+ const existingKeys = nameIndexKeys.get(name) || new Set();
+ if (!existingKeys.has(key)) {
+ existingKeys.add(key);
+ existingByName.push(merged);
+ nameIndex.set(name, existingByName);
+ nameIndexKeys.set(name, existingKeys);
+ }
+ }
+
+ const directNames = manifest.directNames.size > 0 || manifest.devNames.size > 0
+ ? new Set([...manifest.directNames, ...manifest.devNames])
+ : new Set(Object.keys(rootDependencyMap));
+
+ const directRoots = [];
+ const seenDirectKeys = new Set();
+
+ for (const directName of directNames) {
+ const entry = selectEntryByName(nameIndex, directName, mergedManifestVersionHints.get(directName));
+ const dependency = buildNpmDependency(entry, directName, [], nameIndex, new Set(), {
+ sourceFile: getSourceFileName(lockfilePath),
+ directNames,
+ devNames: manifest.devNames,
+ });
+ const key = `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}`;
+ if (!seenDirectKeys.has(key)) {
+ seenDirectKeys.add(key);
+ directRoots.push(dependency);
+ }
+ }
+
+ let dependencies = deduplicateDeps(flattenDependencies(directRoots));
+ const addedKeys = new Set();
+ collectDependencyKeys(dependencies, addedKeys);
+ for (const entry of uniqueEntries.values()) {
+ const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`;
+ if (addedKeys.has(key)) {
+ continue;
+ }
+ addedKeys.add(key);
+ dependencies.push(createDependency({
+ name: entry.name,
+ version: entry.version,
+ ecosystem: "npm",
+ isDirect: false,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile: getSourceFileName(lockfilePath),
+ isDevelopmentDependency: manifest.devNames.has(entry.name),
+ }));
+ }
+
+ if (options.maxDependenciesToScan && dependencies.length > options.maxDependenciesToScan) {
+ warnings.push(
+ `Large npm dependency tree (${dependencies.length} unique packages). ` +
+ `Display is capped at ${options.maxDependenciesToScan} dependencies.`
+ );
+ }
+
+ return buildTree("npm", getSourceFileName(lockfilePath), dependencies, warnings);
+}
+
+function collectDependencyKeys(dependencies, addedKeys) {
+ for (const dependency of Array.isArray(dependencies) ? dependencies : []) {
+ addedKeys.add(`${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}`);
+ if (Array.isArray(dependency.transitives) && dependency.transitives.length > 0) {
+ collectDependencyKeys(dependency.transitives, addedKeys);
+ }
+ }
+}
+
+function buildNpmDependency(entry, fallbackName, parentChain, nameIndex, visiting, context) {
+ if (!entry) {
+ return createDependency({
+ name: fallbackName,
+ version: "",
+ ecosystem: "npm",
+ isDirect: parentChain.length === 0,
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: [],
+ sourceFile: context.sourceFile,
+ isDevelopmentDependency: context.devNames.has(fallbackName),
+ });
+ }
+
+ if (visiting.has(entry.key)) {
+ return createDependency({
+ name: entry.name,
+ version: entry.version,
+ ecosystem: "npm",
+ isDirect: parentChain.length === 0,
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: [],
+ sourceFile: context.sourceFile,
+ isDevelopmentDependency: context.devNames.has(entry.name),
+ });
+ }
+
+ const nextVisiting = new Set(visiting);
+ nextVisiting.add(entry.key);
+ const nextParentChain = parentChain.concat(entry.name);
+ const transitives = [];
+
+ for (const [dependencyName, versionHint] of Object.entries(entry.dependencies || {})) {
+ const childEntry = selectEntryByName(nameIndex, dependencyName, normalizeVersion(versionHint));
+ if (childEntry && nextVisiting.has(childEntry.key)) {
+ continue;
+ }
+ transitives.push(buildNpmDependency(
+ childEntry,
+ dependencyName,
+ nextParentChain,
+ nameIndex,
+ nextVisiting,
+ context
+ ));
+ }
+
+ return createDependency({
+ name: entry.name,
+ version: entry.version,
+ ecosystem: "npm",
+ isDirect: parentChain.length === 0 || context.directNames.has(entry.name),
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: deduplicateDeps(transitives),
+ sourceFile: context.sourceFile,
+ isDevelopmentDependency: context.devNames.has(entry.name),
+ });
+}
+
+function selectEntryByName(nameIndex, dependencyName, versionHint) {
+ const entries = nameIndex.get(dependencyName) || [];
+ if (entries.length === 0) {
+ return null;
+ }
+
+ const normalizedHint = normalizeVersion(versionHint);
+ if (normalizedHint) {
+ const exactMatch = entries.find((entry) => entry.version === normalizedHint);
+ if (exactMatch) {
+ return exactMatch;
+ }
+ }
+
+ return entries[0];
+}
+
+function extractPackageLockName(packagePath) {
+ const marker = "node_modules/";
+ const lastMarkerIndex = packagePath.lastIndexOf(marker);
+ const relativePath = lastMarkerIndex === -1
+ ? packagePath
+ : packagePath.slice(lastMarkerIndex + marker.length);
+ const segments = relativePath.split("/").filter(Boolean);
+ if (segments.length === 0) {
+ return "";
+ }
+ if (segments[0].startsWith("@") && segments.length >= 2) {
+ return `${segments[0]}/${segments[1]}`;
+ }
+ return segments[0];
+}
+
+async function parseYarnLock(lockfilePath, manifest, workspaceFolder, options) {
+ const content = await readUtf8(lockfilePath, workspaceFolder);
+ const parsed = parseYarnEntries(content);
+ if (parsed.entries.size === 0) {
+ throw new Error("Malformed yarn.lock: no package entries found");
+ }
+
+ const sourceFile = getSourceFileName(lockfilePath);
+ const manifestVersionHints = new Map();
+ for (const dependency of manifest.dependencies) {
+ manifestVersionHints.set(dependency.name, dependency.version);
+ }
+ const directNames = manifest.directNames.size > 0 || manifest.devNames.size > 0
+ ? new Set([...manifest.directNames, ...manifest.devNames])
+ : new Set([...parsed.entries.values()].map((entry) => entry.name));
+
+ const directRoots = [];
+ for (const directName of directNames) {
+ const entry = selectYarnEntry(parsed, directName, manifestVersionHints.get(directName));
+ directRoots.push(buildYarnDependency(
+ entry,
+ directName,
+ [],
+ parsed,
+ new Set(),
+ sourceFile,
+ manifest.devNames
+ ));
+ }
+
+ let dependencies = deduplicateDeps(flattenDependencies(directRoots));
+ for (const entry of parsed.entries.values()) {
+ const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`;
+ if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) {
+ continue;
+ }
+ dependencies.push(createDependency({
+ name: entry.name,
+ version: entry.version,
+ ecosystem: "npm",
+ isDirect: false,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: manifest.devNames.has(entry.name),
+ }));
+ }
+
+ const warnings = [];
+ if (options.maxDependenciesToScan && dependencies.length > options.maxDependenciesToScan) {
+ warnings.push(
+ `Large npm dependency tree (${dependencies.length} unique packages). ` +
+ `Display is capped at ${options.maxDependenciesToScan} dependencies.`
+ );
+ }
+
+ return buildTree("npm", sourceFile, dependencies, warnings);
+}
+
+function buildYarnDependency(entry, fallbackName, parentChain, parsedEntries, visiting, sourceFile, devNames) {
+ if (!entry) {
+ return createDependency({
+ name: fallbackName,
+ version: "",
+ ecosystem: "npm",
+ isDirect: parentChain.length === 0,
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: devNames.has(fallbackName),
+ });
+ }
+
+ const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`;
+ if (visiting.has(key)) {
+ return createDependency({
+ name: entry.name,
+ version: entry.version,
+ ecosystem: "npm",
+ isDirect: parentChain.length === 0,
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: devNames.has(entry.name),
+ });
+ }
+
+ const nextVisiting = new Set(visiting);
+ nextVisiting.add(key);
+ const nextParentChain = parentChain.concat(entry.name);
+ const transitives = [];
+
+ for (const dependencyName of Object.keys(entry.dependencies || {})) {
+ const versionHint = entry.dependencies[dependencyName];
+ transitives.push(buildYarnDependency(
+ selectYarnEntry(parsedEntries, dependencyName, versionHint),
+ dependencyName,
+ nextParentChain,
+ parsedEntries,
+ nextVisiting,
+ sourceFile,
+ devNames
+ ));
+ }
+
+ return createDependency({
+ name: entry.name,
+ version: entry.version,
+ ecosystem: "npm",
+ isDirect: parentChain.length === 0,
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: deduplicateDeps(transitives),
+ sourceFile,
+ isDevelopmentDependency: devNames.has(entry.name),
+ });
+}
+
+function parseYarnEntries(content) {
+ const entries = new Map();
+ const entriesByName = new Map();
+ const selectorIndex = new Map();
+ const lines = String(content || "").split(/\r?\n/);
+ let currentEntry = null;
+ let inDependencies = false;
+
+ const flushCurrent = () => {
+ if (currentEntry && currentEntry.name && currentEntry.version) {
+ const key = `${currentEntry.name.toLowerCase()}@${currentEntry.version.toLowerCase()}`;
+ const existing = entries.get(key);
+ if (existing) {
+ Object.assign(existing.dependencies, currentEntry.dependencies);
+ for (const selector of currentEntry.selectors || []) {
+ if (!existing.selectors.includes(selector)) {
+ existing.selectors.push(selector);
+ }
+ selectorIndex.set(selector, key);
+ }
+ } else {
+ currentEntry.key = key;
+ entries.set(key, currentEntry);
+ if (!entriesByName.has(currentEntry.name)) {
+ entriesByName.set(currentEntry.name, []);
+ }
+ entriesByName.get(currentEntry.name).push(currentEntry);
+ for (const selector of currentEntry.selectors || []) {
+ selectorIndex.set(selector, key);
+ }
+ }
+ }
+ currentEntry = null;
+ inDependencies = false;
+ };
+
+ for (const rawLine of lines) {
+ const line = rawLine.replace(/\r$/, "");
+ const trimmed = line.trim();
+ if (!trimmed) {
+ flushCurrent();
+ continue;
+ }
+ if (trimmed === "__metadata:") {
+ flushCurrent();
+ continue;
+ }
+
+ if (!line.startsWith(" ")) {
+ flushCurrent();
+ const header = trimmed.replace(/:$/, "");
+ const selectors = header.split(",").map((selector) => selector.trim().replace(/^["']|["']$/g, ""));
+ const primarySelector = selectors[0] || "";
+ const name = parseYarnSelectorName(primarySelector);
+ if (!name) {
+ continue;
+ }
+ currentEntry = {
+ name,
+ version: "",
+ dependencies: {},
+ selectors,
+ };
+ continue;
+ }
+
+ if (!currentEntry) {
+ continue;
+ }
+
+ if (trimmed === "dependencies:") {
+ inDependencies = true;
+ continue;
+ }
+
+ const versionMatch = trimmed.match(/^version\s+"([^"]+)"/);
+ if (versionMatch) {
+ currentEntry.version = versionMatch[1];
+ inDependencies = false;
+ continue;
+ }
+
+ if (inDependencies) {
+ const dependencyMatch = trimmed.match(/^("?[^"\s]+"?)\s+"([^"]+)"/);
+ if (!dependencyMatch) {
+ continue;
+ }
+ const dependencyName = dependencyMatch[1].replace(/^["']|["']$/g, "");
+ currentEntry.dependencies[dependencyName] = dependencyMatch[2];
+ }
+ }
+
+ flushCurrent();
+ return {
+ entries,
+ entriesByName,
+ selectorIndex,
+ };
+}
+
+function selectYarnEntry(parsedEntries, dependencyName, versionHint) {
+ if (!parsedEntries || !dependencyName) {
+ return null;
+ }
+
+ const normalizedName = String(dependencyName || "").trim();
+ if (!normalizedName) {
+ return null;
+ }
+
+ const normalizedHint = String(versionHint || "").trim().replace(/^["']|["']$/g, "");
+ if (normalizedHint) {
+ const exactSelectorKey = `${normalizedName}@${normalizedHint}`;
+ const selectedKey = parsedEntries.selectorIndex.get(exactSelectorKey);
+ if (selectedKey && parsedEntries.entries.has(selectedKey)) {
+ return parsedEntries.entries.get(selectedKey);
+ }
+ }
+
+ const candidates = parsedEntries.entriesByName.get(normalizedName) || [];
+ if (candidates.length === 0) {
+ return null;
+ }
+
+ if (normalizedHint) {
+ const exactVersionMatch = candidates.find((entry) => entry.version === normalizedHint);
+ if (exactVersionMatch) {
+ return exactVersionMatch;
+ }
+ }
+
+ return candidates[0];
+}
+
+function parseYarnSelectorName(selector) {
+ const normalizedSelector = selector.trim().replace(/^["']|["']$/g, "");
+ if (!normalizedSelector) {
+ return "";
+ }
+
+ if (normalizedSelector.startsWith("@")) {
+ const secondAt = normalizedSelector.indexOf("@", 1);
+ return secondAt === -1 ? normalizedSelector : normalizedSelector.slice(0, secondAt);
+ }
+
+ const atIndex = normalizedSelector.indexOf("@");
+ return atIndex === -1 ? normalizedSelector : normalizedSelector.slice(0, atIndex);
+}
+
+async function parsePnpmLock(lockfilePath, manifest, workspaceFolder, options) {
+ const parsed = parsePnpmEntries(await readUtf8(lockfilePath, workspaceFolder));
+ if (parsed.packageEntries.size === 0) {
+ throw new Error("Malformed pnpm-lock.yaml: no package entries found");
+ }
+
+ const sourceFile = getSourceFileName(lockfilePath);
+ const directNames = manifest.directNames.size > 0 || manifest.devNames.size > 0
+ ? new Set([...manifest.directNames, ...manifest.devNames])
+ : new Set(parsed.directVersions.keys());
+ const directRoots = [];
+
+ for (const directName of directNames) {
+ const entry = selectPnpmEntry(parsed.packageEntries, directName, parsed.directVersions.get(directName));
+ directRoots.push(buildPnpmDependency(
+ entry,
+ directName,
+ [],
+ parsed.packageEntries,
+ new Set(),
+ sourceFile,
+ manifest.devNames
+ ));
+ }
+
+ let dependencies = deduplicateDeps(flattenDependencies(directRoots));
+ for (const entry of parsed.packageEntries.values()) {
+ const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`;
+ if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) {
+ continue;
+ }
+ dependencies.push(createDependency({
+ name: entry.name,
+ version: entry.version,
+ ecosystem: "npm",
+ isDirect: false,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: manifest.devNames.has(entry.name),
+ }));
+ }
+
+ const warnings = [];
+ if (options.maxDependenciesToScan && dependencies.length > options.maxDependenciesToScan) {
+ warnings.push(
+ `Large npm dependency tree (${dependencies.length} unique packages). ` +
+ `Display is capped at ${options.maxDependenciesToScan} dependencies.`
+ );
+ }
+
+ return buildTree("npm", sourceFile, dependencies, warnings);
+}
+
+function buildPnpmDependency(entry, fallbackName, parentChain, packageEntries, visiting, sourceFile, devNames) {
+ if (!entry) {
+ return createDependency({
+ name: fallbackName,
+ version: "",
+ ecosystem: "npm",
+ isDirect: parentChain.length === 0,
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: devNames.has(fallbackName),
+ });
+ }
+
+ const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`;
+ if (visiting.has(key)) {
+ return createDependency({
+ name: entry.name,
+ version: entry.version,
+ ecosystem: "npm",
+ isDirect: parentChain.length === 0,
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: devNames.has(entry.name),
+ });
+ }
+
+ const nextVisiting = new Set(visiting);
+ nextVisiting.add(key);
+ const nextParentChain = parentChain.concat(entry.name);
+ const transitives = [];
+
+ for (const [dependencyName, versionHint] of Object.entries(entry.dependencies || {})) {
+ transitives.push(buildPnpmDependency(
+ selectPnpmEntry(packageEntries, dependencyName, versionHint),
+ dependencyName,
+ nextParentChain,
+ packageEntries,
+ nextVisiting,
+ sourceFile,
+ devNames
+ ));
+ }
+
+ return createDependency({
+ name: entry.name,
+ version: entry.version,
+ ecosystem: "npm",
+ isDirect: parentChain.length === 0,
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: deduplicateDeps(transitives),
+ sourceFile,
+ isDevelopmentDependency: devNames.has(entry.name),
+ });
+}
+
+function selectPnpmEntry(packageEntries, dependencyName, versionHint) {
+ const normalizedHint = normalizeVersion(versionHint).split("(")[0].trim();
+ const entries = [...packageEntries.values()].filter((entry) => entry.name === dependencyName);
+ if (entries.length === 0) {
+ return null;
+ }
+ if (normalizedHint) {
+ const exactMatch = entries.find((entry) => entry.version === normalizedHint);
+ if (exactMatch) {
+ return exactMatch;
+ }
+ }
+ return entries[0];
+}
+
+function parsePnpmEntries(content) {
+ const packageEntries = new Map();
+ const directVersions = new Map();
+ const lines = String(content || "").split(/\r?\n/);
+ let section = "";
+ let currentPackage = null;
+ let currentPackageSubsection = "";
+ let inImporter = false;
+ let importerSection = "";
+ let currentImporterPackage = "";
+
+ const flushPackage = () => {
+ if (!currentPackage || !currentPackage.name || !currentPackage.version) {
+ currentPackage = null;
+ currentPackageSubsection = "";
+ return;
+ }
+ packageEntries.set(`${currentPackage.name.toLowerCase()}@${currentPackage.version.toLowerCase()}`, currentPackage);
+ currentPackage = null;
+ currentPackageSubsection = "";
+ };
+
+ for (const rawLine of lines) {
+ const lineWithoutComment = stripYamlComment(rawLine);
+ const line = lineWithoutComment.trimEnd();
+ const trimmed = line.trim();
+ if (!trimmed) {
+ continue;
+ }
+
+ const indent = rawLine.search(/\S/);
+ if (indent === 0 && trimmed === "importers:") {
+ flushPackage();
+ section = "importers";
+ inImporter = false;
+ continue;
+ }
+ if (indent === 0 && trimmed === "packages:") {
+ flushPackage();
+ section = "packages";
+ continue;
+ }
+ if (indent === 0 && trimmed.endsWith(":") && !["importers:", "packages:"].includes(trimmed)) {
+ flushPackage();
+ section = "";
+ continue;
+ }
+
+ if (section === "importers") {
+ if (indent === 2 && trimmed.endsWith(":")) {
+ inImporter = true;
+ importerSection = "";
+ currentImporterPackage = "";
+ continue;
+ }
+ if (!inImporter) {
+ continue;
+ }
+ if (indent === 4 && trimmed.endsWith(":")) {
+ importerSection = trimmed.slice(0, -1);
+ currentImporterPackage = "";
+ continue;
+ }
+ if (!["dependencies", "devDependencies", "optionalDependencies"].includes(importerSection)) {
+ continue;
+ }
+ if (indent === 6 && trimmed.endsWith(":")) {
+ currentImporterPackage = trimmed.slice(0, -1).replace(/^["']|["']$/g, "");
+ continue;
+ }
+ if (indent === 8 && trimmed.startsWith("version:") && currentImporterPackage) {
+ directVersions.set(
+ currentImporterPackage,
+ normalizeVersion(trimmed.slice("version:".length).trim()).split("(")[0].trim()
+ );
+ }
+ continue;
+ }
+
+ if (section === "packages") {
+ if (indent === 2 && trimmed.endsWith(":")) {
+ flushPackage();
+ const parsedKey = parsePnpmPackageKey(trimmed.slice(0, -1));
+ if (!parsedKey) {
+ continue;
+ }
+ currentPackage = {
+ ...parsedKey,
+ dependencies: {},
+ };
+ continue;
+ }
+ if (!currentPackage) {
+ continue;
+ }
+ if (indent === 4 && trimmed.endsWith(":")) {
+ currentPackageSubsection = trimmed.slice(0, -1);
+ continue;
+ }
+ if (!["dependencies", "optionalDependencies"].includes(currentPackageSubsection)) {
+ continue;
+ }
+ if (indent === 6 && trimmed.includes(":")) {
+ const parts = trimmed.split(":", 2);
+ currentPackage.dependencies[parts[0].trim()] = normalizeVersion(parts[1].trim()).split("(")[0].trim();
+ }
+ }
+ }
+
+ flushPackage();
+ return {
+ packageEntries,
+ directVersions,
+ };
+}
+
+function parsePnpmPackageKey(rawKey) {
+ const cleaned = rawKey.replace(/^\/+/, "").trim().replace(/^["']|["']$/g, "");
+ if (!cleaned) {
+ return null;
+ }
+
+ const withoutPeerSuffix = cleaned.split("(")[0];
+ const atIndex = withoutPeerSuffix.lastIndexOf("@");
+ if (atIndex <= 0) {
+ return null;
+ }
+
+ return {
+ name: withoutPeerSuffix.slice(0, atIndex),
+ version: withoutPeerSuffix.slice(atIndex + 1),
+ };
+}
+
+module.exports = npmParser;
diff --git a/util/lockfileParsers/nugetParser.js b/util/lockfileParsers/nugetParser.js
new file mode 100644
index 0000000..95f8c7c
--- /dev/null
+++ b/util/lockfileParsers/nugetParser.js
@@ -0,0 +1,190 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const fs = require("fs");
+const path = require("path");
+const {
+ buildTree,
+ createDependency,
+ deduplicateDeps,
+ flattenDependencies,
+ getSourceFileName,
+ getWorkspacePath,
+ pathExists,
+ readJson,
+ readUtf8,
+ resolveWorkspaceFilePath,
+} = require("./shared");
+const { parseCsprojManifest } = require("./manifestHelpers");
+
+const nugetParser = {
+ name: "nugetParser",
+ ecosystem: "nuget",
+
+ async canResolve(workspaceFolder) {
+ const matches = await this.detect(workspaceFolder);
+ return matches.length > 0;
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ const safeRootPath = await resolveWorkspaceFilePath(rootPath, workspaceFolder);
+ if (!safeRootPath) {
+ return [];
+ }
+ const entries = await fs.promises.readdir(safeRootPath);
+ const csprojPath = entries.find((entry) => entry.toLowerCase().endsWith(".csproj"));
+ const lockfilePath = await pathExists(path.join(safeRootPath, "packages.lock.json"), workspaceFolder)
+ ? path.join(safeRootPath, "packages.lock.json")
+ : null;
+ const manifestPath = csprojPath ? path.join(safeRootPath, csprojPath) : null;
+
+ if (!lockfilePath && !manifestPath) {
+ return [];
+ }
+
+ return [{
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath,
+ manifestPath,
+ sourceFile: getSourceFileName(lockfilePath || manifestPath),
+ }];
+ },
+
+ async resolve({ lockfilePath, manifestPath, workspaceFolder }) {
+ const sourceFile = getSourceFileName(lockfilePath || manifestPath);
+ const manifestDependencies = manifestPath && await pathExists(manifestPath, workspaceFolder)
+ ? parseCsprojManifest(await readUtf8(manifestPath, workspaceFolder))
+ : [];
+ const directNames = new Set(manifestDependencies.map((dependency) => dependency.name.toLowerCase()));
+
+ if (!lockfilePath) {
+ return buildTree("nuget", sourceFile, manifestDependencies.map((dependency) => createDependency({
+ name: dependency.name,
+ version: dependency.version,
+ ecosystem: "nuget",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: dependency.isDevelopmentDependency,
+ })));
+ }
+
+ const root = await readJson(lockfilePath, workspaceFolder);
+ const dependencyRoot = root && root.dependencies && typeof root.dependencies === "object"
+ ? root.dependencies
+ : null;
+ if (!dependencyRoot) {
+ throw new Error("Malformed packages.lock.json: missing dependencies object");
+ }
+
+ const recordsByName = new Map();
+ for (const frameworkDependencies of Object.values(dependencyRoot)) {
+ if (!frameworkDependencies || typeof frameworkDependencies !== "object") {
+ continue;
+ }
+ for (const [name, details] of Object.entries(frameworkDependencies)) {
+ const dependencies = details && details.dependencies && typeof details.dependencies === "object"
+ ? Object.keys(details.dependencies)
+ : [];
+ const existing = recordsByName.get(name.toLowerCase());
+ recordsByName.set(name.toLowerCase(), {
+ name,
+ version: details.resolved || "",
+ dependencies: deduplicateStringValues([...(existing ? existing.dependencies : []), ...dependencies]),
+ isDirect: Boolean(existing && existing.isDirect) || String(details.type || "").toLowerCase() === "direct",
+ });
+ }
+ }
+
+ const rootRecords = manifestDependencies.length > 0
+ ? manifestDependencies.map((dependency) => recordsByName.get(dependency.name.toLowerCase())).filter(Boolean)
+ : [...recordsByName.values()].filter((record) => record.isDirect);
+
+ const directRoots = deduplicateDeps(rootRecords.map((record) => buildNugetDependency(
+ record,
+ [],
+ recordsByName,
+ new Set(),
+ sourceFile,
+ directNames
+ )));
+ let dependencies = deduplicateDeps(flattenDependencies(directRoots));
+
+ for (const record of recordsByName.values()) {
+ const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`;
+ if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) {
+ continue;
+ }
+ dependencies.push(createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "nuget",
+ isDirect: directNames.has(record.name.toLowerCase()) || record.isDirect,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ }));
+ }
+
+ return buildTree("nuget", sourceFile, dependencies);
+ },
+};
+
+function buildNugetDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) {
+ const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`;
+ if (visiting.has(key)) {
+ return createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "nuget",
+ isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()) || record.isDirect,
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ });
+ }
+
+ const nextVisiting = new Set(visiting);
+ nextVisiting.add(key);
+ const nextParentChain = parentChain.concat(record.name);
+ const transitives = [];
+
+ for (const dependencyName of record.dependencies) {
+ const childRecord = recordsByName.get(dependencyName.toLowerCase());
+ if (!childRecord) {
+ continue;
+ }
+ transitives.push(buildNugetDependency(
+ childRecord,
+ nextParentChain,
+ recordsByName,
+ nextVisiting,
+ sourceFile,
+ directNames
+ ));
+ }
+
+ return createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "nuget",
+ isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()) || record.isDirect,
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: deduplicateDeps(transitives),
+ sourceFile,
+ isDevelopmentDependency: false,
+ });
+}
+
+function deduplicateStringValues(values) {
+ return [...new Set(values.filter(Boolean))];
+}
+
+module.exports = nugetParser;
diff --git a/util/lockfileParsers/pythonParser.js b/util/lockfileParsers/pythonParser.js
new file mode 100644
index 0000000..80decf9
--- /dev/null
+++ b/util/lockfileParsers/pythonParser.js
@@ -0,0 +1,383 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const {
+ buildTree,
+ createDependency,
+ deduplicateDeps,
+ flattenDependencies,
+ getSourceFileName,
+ getWorkspacePath,
+ normalizeVersion,
+ parseInlineTomlValue,
+ parseKeyValueLine,
+ readJson,
+ parseQuotedArray,
+ pathExists,
+ readUtf8,
+ stripTomlComment,
+} = require("./shared");
+const {
+ parsePyprojectManifest,
+ parseRequirementSpec,
+} = require("./manifestHelpers");
+const { normalizePackageName } = require("../packageNameNormalizer");
+
+const SOURCE_PRIORITY = ["uv.lock", "poetry.lock", "Pipfile.lock", "requirements.txt"];
+
+const pythonParser = {
+ name: "pythonParser",
+ ecosystem: "python",
+
+ async canResolve(workspaceFolder) {
+ const matches = await this.detect(workspaceFolder);
+ return matches.length > 0;
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ for (const fileName of SOURCE_PRIORITY) {
+ const lockfilePath = path.join(rootPath, fileName);
+ if (await pathExists(lockfilePath, workspaceFolder)) {
+ const pyprojectPath = path.join(rootPath, "pyproject.toml");
+ return [{
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath,
+ manifestPath: await pathExists(pyprojectPath, workspaceFolder) ? pyprojectPath : null,
+ sourceFile: fileName,
+ }];
+ }
+ }
+ return [];
+ },
+
+ async resolve({ lockfilePath, manifestPath, workspaceFolder }) {
+ const sourceFile = getSourceFileName(lockfilePath);
+ const pyproject = manifestPath && await pathExists(manifestPath, workspaceFolder)
+ ? parsePyprojectManifest(await readUtf8(manifestPath, workspaceFolder))
+ : { projectName: "", dependencies: [], directNames: new Set(), devNames: new Set() };
+
+ if (sourceFile === "requirements.txt") {
+ return parseRequirements(lockfilePath, workspaceFolder);
+ }
+ if (sourceFile === "Pipfile.lock") {
+ return parsePipfile(lockfilePath, workspaceFolder);
+ }
+ if (sourceFile === "poetry.lock" || sourceFile === "uv.lock") {
+ return parseTomlLock(lockfilePath, pyproject, workspaceFolder, sourceFile === "uv.lock");
+ }
+
+ throw new Error(`Unsupported Python dependency source: ${sourceFile}`);
+ },
+};
+
+async function parseRequirements(lockfilePath, workspaceFolder) {
+ const dependencies = [];
+
+ for (const rawLine of String(await readUtf8(lockfilePath, workspaceFolder)).split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith("#") || line.startsWith("-")) {
+ continue;
+ }
+ const parsed = parseRequirementSpec(line);
+ if (!parsed) {
+ throw new Error(`Malformed requirements.txt entry: ${line}`);
+ }
+ dependencies.push(createDependency({
+ name: parsed.name,
+ version: parsed.version,
+ ecosystem: "python",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile: getSourceFileName(lockfilePath),
+ isDevelopmentDependency: false,
+ }));
+ }
+
+ return buildTree("python", getSourceFileName(lockfilePath), dependencies, [
+ "requirements.txt does not encode transitive dependencies. Showing direct requirements only.",
+ ]);
+}
+
+async function parsePipfile(lockfilePath, workspaceFolder) {
+ const root = await readJson(lockfilePath, workspaceFolder);
+ const dependencies = [];
+
+ for (const [name, details] of Object.entries(root.default || {})) {
+ dependencies.push(createDependency({
+ name,
+ version: normalizeVersion(details && details.version),
+ ecosystem: "python",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile: getSourceFileName(lockfilePath),
+ isDevelopmentDependency: false,
+ }));
+ }
+
+ for (const [name, details] of Object.entries(root.develop || {})) {
+ dependencies.push(createDependency({
+ name,
+ version: normalizeVersion(details && details.version),
+ ecosystem: "python",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile: getSourceFileName(lockfilePath),
+ isDevelopmentDependency: true,
+ }));
+ }
+
+ return buildTree("python", getSourceFileName(lockfilePath), deduplicateDeps(dependencies));
+}
+
+async function parseTomlLock(lockfilePath, pyproject, workspaceFolder, skipEditableRoot) {
+ const records = parsePythonPackageRecords(
+ await readUtf8(lockfilePath, workspaceFolder),
+ skipEditableRoot
+ );
+ if (records.length === 0) {
+ throw new Error(`Malformed ${getSourceFileName(lockfilePath)}: no package entries found`);
+ }
+
+ const sourceFile = getSourceFileName(lockfilePath);
+ const normalizedDirectNames = pyproject.directNames.size > 0 || pyproject.devNames.size > 0
+ ? new Set(
+ [...pyproject.directNames, ...pyproject.devNames]
+ .map((name) => normalizePackageName(name, "python"))
+ )
+ : new Set(records.filter((record) => record.isRootDependency).map((record) => record.normalizedName));
+
+ const recordsByName = new Map();
+ const incomingCounts = new Map();
+ for (const record of records) {
+ if (!recordsByName.has(record.normalizedName)) {
+ recordsByName.set(record.normalizedName, []);
+ }
+ recordsByName.get(record.normalizedName).push(record);
+ for (const dependencyName of record.dependencies) {
+ const normalizedDependencyName = normalizePackageName(dependencyName, "python");
+ incomingCounts.set(
+ normalizedDependencyName,
+ (incomingCounts.get(normalizedDependencyName) || 0) + 1
+ );
+ }
+ }
+
+ const rootRecords = normalizedDirectNames.size > 0
+ ? [...normalizedDirectNames].map((name) => (recordsByName.get(name) || [])[0]).filter(Boolean)
+ : records.filter((record) => !incomingCounts.get(record.normalizedName));
+
+ const directRoots = deduplicateDeps(rootRecords.map((record) => buildPythonDependency(
+ record,
+ [],
+ recordsByName,
+ new Set(),
+ sourceFile,
+ new Set([...pyproject.devNames].map((name) => normalizePackageName(name, "python")))
+ )));
+
+ let dependencies = deduplicateDeps(flattenDependencies(directRoots));
+ for (const record of records) {
+ const key = `${record.normalizedName}@${record.version.toLowerCase()}`;
+ if (dependencies.some((dependency) => (
+ `${normalizePackageName(dependency.name, "python")}@${dependency.version.toLowerCase()}` === key
+ ))) {
+ continue;
+ }
+
+ dependencies.push(createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "python",
+ isDirect: normalizedDirectNames.has(record.normalizedName),
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ }));
+ }
+
+ return buildTree("python", sourceFile, dependencies);
+}
+
+function buildPythonDependency(record, parentChain, recordsByName, visiting, sourceFile, normalizedDevNames) {
+ const key = `${record.normalizedName}@${record.version.toLowerCase()}`;
+ if (visiting.has(key)) {
+ return createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "python",
+ isDirect: parentChain.length === 0,
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: normalizedDevNames.has(record.normalizedName),
+ });
+ }
+
+ const nextVisiting = new Set(visiting);
+ nextVisiting.add(key);
+ const nextParentChain = parentChain.concat(record.name);
+ const transitives = [];
+
+ for (const dependencyName of record.dependencies) {
+ const normalizedDependencyName = normalizePackageName(dependencyName, "python");
+ const childRecord = (recordsByName.get(normalizedDependencyName) || [])[0];
+ if (!childRecord) {
+ continue;
+ }
+ transitives.push(buildPythonDependency(
+ childRecord,
+ nextParentChain,
+ recordsByName,
+ nextVisiting,
+ sourceFile,
+ normalizedDevNames
+ ));
+ }
+
+ return createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "python",
+ isDirect: parentChain.length === 0,
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: deduplicateDeps(transitives),
+ sourceFile,
+ isDevelopmentDependency: normalizedDevNames.has(record.normalizedName),
+ });
+}
+
+function parsePythonPackageRecords(content, skipEditableRoot) {
+ const records = [];
+ let current = null;
+ let section = "";
+ let metadataDirectNames = [];
+
+ const flushCurrent = () => {
+ if (!current || !current.name || !current.version) {
+ current = null;
+ return;
+ }
+ const isEditableRoot = skipEditableRoot && current.sourceEditable === ".";
+ if (!isEditableRoot) {
+ records.push({
+ ...current,
+ normalizedName: normalizePackageName(current.name, "python"),
+ isRootDependency: metadataDirectNames.includes(normalizePackageName(current.name, "python")),
+ });
+ }
+ current = null;
+ };
+
+ for (const rawLine of String(content || "").split(/\r?\n/)) {
+ const line = stripTomlComment(rawLine).trim();
+ if (!line || line.startsWith("#")) {
+ continue;
+ }
+
+ if (line === "[[package]]") {
+ flushCurrent();
+ current = {
+ name: "",
+ version: "",
+ dependencies: [],
+ sourceEditable: "",
+ };
+ section = "package";
+ continue;
+ }
+
+ if (line === "[package.dependencies]") {
+ section = "package.dependencies";
+ continue;
+ }
+
+ if (line === "[metadata]") {
+ flushCurrent();
+ section = "metadata";
+ continue;
+ }
+
+ if (line.startsWith("[") && line.endsWith("]")) {
+ section = "";
+ continue;
+ }
+
+ if (section === "package" && current) {
+ if (line.startsWith("name =")) {
+ current.name = parseKeyValueLine(line).value.replace(/^["']|["']$/g, "");
+ continue;
+ }
+ if (line.startsWith("version =")) {
+ current.version = parseKeyValueLine(line).value.replace(/^["']|["']$/g, "");
+ continue;
+ }
+ if (line.startsWith("source =")) {
+ current.sourceEditable = parseInlineTomlValue(parseKeyValueLine(line).value, "editable");
+ continue;
+ }
+ if (line.startsWith("dependencies =")) {
+ const value = parseKeyValueLine(line).value;
+ if (value.startsWith("[")) {
+ current.dependencies.push(...parsePythonDependencyArray(value));
+ } else if (value.startsWith("{")) {
+ current.dependencies.push(...parsePythonDependencyInlineObjects(value));
+ }
+ }
+ continue;
+ }
+
+ if (section === "package.dependencies" && current) {
+ const parts = parseKeyValueLine(line);
+ if (parts && parts.key) {
+ current.dependencies.push(parts.key.replace(/^["']|["']$/g, ""));
+ }
+ continue;
+ }
+
+ if (section === "metadata") {
+ if (line.startsWith("direct-dependencies =") || line.startsWith("root-dependencies =")) {
+ metadataDirectNames = parseQuotedArray(parseKeyValueLine(line).value)
+ .map((name) => normalizePackageName(name, "python"));
+ }
+ }
+ }
+
+ flushCurrent();
+ return records;
+}
+
+function parsePythonDependencyArray(value) {
+ const names = [];
+ for (const item of parseQuotedArray(value)) {
+ const parsed = parseRequirementSpec(item);
+ if (parsed) {
+ names.push(parsed.name);
+ }
+ }
+ if (names.length > 0) {
+ return names;
+ }
+ return parsePythonDependencyInlineObjects(value);
+}
+
+function parsePythonDependencyInlineObjects(value) {
+ const names = [];
+ const pattern = /name\s*=\s*"([^"]+)"/g;
+ for (const match of String(value || "").matchAll(pattern)) {
+ names.push(match[1]);
+ }
+ return names;
+}
+
+module.exports = pythonParser;
diff --git a/util/lockfileParsers/rubyParser.js b/util/lockfileParsers/rubyParser.js
new file mode 100644
index 0000000..c673653
--- /dev/null
+++ b/util/lockfileParsers/rubyParser.js
@@ -0,0 +1,217 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const {
+ buildTree,
+ countIndent,
+ createDependency,
+ deduplicateDeps,
+ flattenDependencies,
+ getSourceFileName,
+ getWorkspacePath,
+ pathExists,
+ readUtf8,
+} = require("./shared");
+const { parseGemfileManifest } = require("./manifestHelpers");
+
+const rubyParser = {
+ name: "rubyParser",
+ ecosystem: "ruby",
+
+ async canResolve(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ return (await pathExists(path.join(rootPath, "Gemfile.lock"), workspaceFolder))
+ || (await pathExists(path.join(rootPath, "Gemfile"), workspaceFolder));
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ const lockfilePath = await pathExists(path.join(rootPath, "Gemfile.lock"), workspaceFolder)
+ ? path.join(rootPath, "Gemfile.lock")
+ : null;
+ const manifestPath = await pathExists(path.join(rootPath, "Gemfile"), workspaceFolder)
+ ? path.join(rootPath, "Gemfile")
+ : null;
+ if (!lockfilePath && !manifestPath) {
+ return [];
+ }
+ return [{
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath,
+ manifestPath,
+ sourceFile: getSourceFileName(lockfilePath || manifestPath),
+ }];
+ },
+
+ async resolve({ lockfilePath, manifestPath, workspaceFolder }) {
+ const sourceFile = getSourceFileName(lockfilePath || manifestPath);
+ if (!lockfilePath) {
+ const dependencies = parseGemfileManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({
+ name: dependency.name,
+ version: dependency.version,
+ ecosystem: "ruby",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: dependency.isDevelopmentDependency,
+ }));
+ return buildTree("ruby", sourceFile, dependencies);
+ }
+
+ const directNames = manifestPath && await pathExists(manifestPath, workspaceFolder)
+ ? new Set(parseGemfileManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => dependency.name.toLowerCase()))
+ : null;
+ const records = parseGemfileLock(await readUtf8(lockfilePath, workspaceFolder));
+ const recordsByName = new Map();
+ const incomingCounts = new Map();
+
+ for (const record of records) {
+ recordsByName.set(record.name.toLowerCase(), record);
+ for (const dependencyName of record.dependencies) {
+ incomingCounts.set(dependencyName.toLowerCase(), (incomingCounts.get(dependencyName.toLowerCase()) || 0) + 1);
+ }
+ }
+
+ const rootRecords = directNames && directNames.size > 0
+ ? [...directNames].map((name) => recordsByName.get(name)).filter(Boolean)
+ : records.filter((record) => !incomingCounts.get(record.name.toLowerCase()));
+
+ const directRoots = deduplicateDeps(rootRecords.map((record) => buildRubyDependency(
+ record,
+ [],
+ recordsByName,
+ new Set(),
+ sourceFile,
+ directNames || new Set()
+ )));
+
+ let dependencies = deduplicateDeps(flattenDependencies(directRoots));
+ for (const record of records) {
+ const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`;
+ if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) {
+ continue;
+ }
+ dependencies.push(createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "ruby",
+ isDirect: directNames ? directNames.has(record.name.toLowerCase()) : false,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ }));
+ }
+
+ return buildTree("ruby", sourceFile, dependencies);
+ },
+};
+
+function parseGemfileLock(content) {
+ const records = [];
+ let section = "";
+ let inSpecs = false;
+ let current = null;
+
+ const flushCurrent = () => {
+ if (current && current.name && current.version) {
+ records.push(current);
+ }
+ current = null;
+ };
+
+ for (const rawLine of String(content || "").split(/\r?\n/)) {
+ const trimmed = rawLine.trimEnd();
+ if (!trimmed) {
+ continue;
+ }
+ const indent = countIndent(rawLine);
+ const line = trimmed.trim();
+
+ if (indent === 0 && /^[A-Z][A-Z0-9_ ]+$/.test(line)) {
+ flushCurrent();
+ section = line;
+ inSpecs = false;
+ continue;
+ }
+ if (section === "GEM" && indent === 2 && line === "specs:") {
+ inSpecs = true;
+ continue;
+ }
+ if (!inSpecs) {
+ continue;
+ }
+ if (indent === 4) {
+ flushCurrent();
+ const match = line.match(/^([^\s(]+) \(([^)]+)\)/);
+ if (!match) {
+ continue;
+ }
+ current = { name: match[1], version: match[2], dependencies: [] };
+ continue;
+ }
+ if (indent >= 6 && current) {
+ const dependencyName = line.split(" ", 1)[0].split("(", 1)[0].replace(/!$/, "").trim();
+ if (dependencyName) {
+ current.dependencies.push(dependencyName);
+ }
+ }
+ }
+
+ flushCurrent();
+ return records;
+}
+
+function buildRubyDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) {
+ const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`;
+ if (visiting.has(key)) {
+ return createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "ruby",
+ isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()),
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ });
+ }
+
+ const nextVisiting = new Set(visiting);
+ nextVisiting.add(key);
+ const nextParentChain = parentChain.concat(record.name);
+ const transitives = [];
+
+ for (const dependencyName of record.dependencies) {
+ const childRecord = recordsByName.get(dependencyName.toLowerCase());
+ if (!childRecord) {
+ continue;
+ }
+ transitives.push(buildRubyDependency(
+ childRecord,
+ nextParentChain,
+ recordsByName,
+ nextVisiting,
+ sourceFile,
+ directNames
+ ));
+ }
+
+ return createDependency({
+ name: record.name,
+ version: record.version,
+ ecosystem: "ruby",
+ isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()),
+ parent: parentChain[parentChain.length - 1] || null,
+ parentChain,
+ transitives: deduplicateDeps(transitives),
+ sourceFile,
+ isDevelopmentDependency: false,
+ });
+}
+
+module.exports = rubyParser;
diff --git a/util/lockfileParsers/shared.js b/util/lockfileParsers/shared.js
new file mode 100644
index 0000000..f4be6a1
--- /dev/null
+++ b/util/lockfileParsers/shared.js
@@ -0,0 +1,412 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const fs = require("fs");
+const path = require("path");
+
+const LARGE_FILE_THRESHOLD_BYTES = 50 * 1024 * 1024;
+const WORKSPACE_PATH_ERROR = "Refusing to read files outside the workspace folder.";
+
+function getWorkspacePath(workspaceFolder) {
+ if (!workspaceFolder) {
+ return "";
+ }
+
+ if (typeof workspaceFolder === "string") {
+ return workspaceFolder;
+ }
+
+ if (workspaceFolder.uri && workspaceFolder.uri.fsPath) {
+ return workspaceFolder.uri.fsPath;
+ }
+
+ return String(workspaceFolder);
+}
+
+async function pathExists(targetPath, workspaceFolder) {
+ const safePath = await resolveWorkspaceFilePath(targetPath, workspaceFolder);
+ if (!safePath) {
+ return false;
+ }
+
+ try {
+ await fs.promises.access(safePath, fs.constants.R_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function getCandidateWorkspaceRoot(targetPath, workspaceFolder) {
+ const workspacePath = getWorkspacePath(workspaceFolder);
+ if (workspacePath) {
+ return workspacePath;
+ }
+
+ const rawTargetPath = String(targetPath || "").trim();
+ if (!rawTargetPath) {
+ return "";
+ }
+
+ return path.dirname(path.resolve(rawTargetPath));
+}
+
+async function resolveWorkspaceRoot(targetPath, workspaceFolder) {
+ const candidateRoot = getCandidateWorkspaceRoot(targetPath, workspaceFolder);
+ if (!candidateRoot) {
+ return "";
+ }
+
+ try {
+ return await fs.promises.realpath(candidateRoot);
+ } catch {
+ return path.resolve(candidateRoot);
+ }
+}
+
+function isWithinWorkspace(workspaceRoot, targetPath) {
+ if (!workspaceRoot || !targetPath) {
+ return false;
+ }
+
+ const relativePath = path.relative(workspaceRoot, targetPath);
+ return relativePath === ""
+ || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
+}
+
+async function resolveWorkspaceFilePath(targetPath, workspaceFolder) {
+ const rawTargetPath = String(targetPath || "").trim();
+ if (!rawTargetPath) {
+ return null;
+ }
+
+ const resolvedTargetPath = path.resolve(rawTargetPath);
+ const workspaceRoot = await resolveWorkspaceRoot(resolvedTargetPath, workspaceFolder);
+ if (!workspaceRoot) {
+ return null;
+ }
+
+ let realTargetPath;
+ try {
+ realTargetPath = await fs.promises.realpath(resolvedTargetPath);
+ } catch {
+ return null;
+ }
+
+ return isWithinWorkspace(workspaceRoot, realTargetPath)
+ ? realTargetPath
+ : null;
+}
+
+async function readUtf8(targetPath, workspaceFolder) {
+ const safePath = await resolveWorkspaceFilePath(targetPath, workspaceFolder);
+ if (!safePath) {
+ throw new Error(WORKSPACE_PATH_ERROR);
+ }
+
+ return fs.promises.readFile(safePath, "utf8");
+}
+
+async function readJson(targetPath, workspaceFolder) {
+ return JSON.parse(await readUtf8(targetPath, workspaceFolder));
+}
+
+async function statSafe(targetPath, workspaceFolder) {
+ const safePath = await resolveWorkspaceFilePath(targetPath, workspaceFolder);
+ if (!safePath) {
+ return null;
+ }
+
+ try {
+ return await fs.promises.stat(safePath);
+ } catch {
+ return null;
+ }
+}
+
+function getSourceFileName(targetPath) {
+ return path.basename(targetPath || "");
+}
+
+function normalizeVersion(version) {
+ if (version == null) {
+ return "";
+ }
+
+ return String(version)
+ .trim()
+ .replace(/^["']|["']$/g, "")
+ .replace(/^[~^<>=! ]+/, "")
+ .trim();
+}
+
+function createDependency({
+ name,
+ version,
+ ecosystem,
+ isDirect,
+ parent,
+ parentChain,
+ transitives,
+ sourceFile,
+ isDevelopmentDependency,
+}) {
+ return {
+ name: String(name || "").trim(),
+ version: String(version || "").trim(),
+ ecosystem: String(ecosystem || "").trim(),
+ isDirect: Boolean(isDirect),
+ parent: parent || null,
+ parentChain: Array.isArray(parentChain) ? parentChain.slice() : [],
+ transitives: Array.isArray(transitives) ? transitives.slice() : [],
+ cloudsmithStatus: null,
+ cloudsmithPackage: null,
+ sourceFile: sourceFile || null,
+ isDevelopmentDependency: Boolean(isDevelopmentDependency),
+ };
+}
+
+function flattenDependencies(dependencies) {
+ const flattened = [];
+
+ for (const dependency of Array.isArray(dependencies) ? dependencies : []) {
+ flattened.push(dependency);
+ if (Array.isArray(dependency.transitives) && dependency.transitives.length > 0) {
+ flattened.push(...flattenDependencies(dependency.transitives));
+ }
+ }
+
+ return flattened;
+}
+
+function dependencyKey(dependency) {
+ return [
+ String(dependency.ecosystem || "").trim().toLowerCase(),
+ String(dependency.name || "").trim().toLowerCase(),
+ String(dependency.version || "").trim().toLowerCase(),
+ ].join(":");
+}
+
+function deduplicateDeps(dependencies) {
+ const unique = [];
+ const seen = new Map();
+
+ for (const dependency of Array.isArray(dependencies) ? dependencies : []) {
+ const key = dependencyKey(dependency);
+ const existing = seen.get(key);
+ if (!existing) {
+ seen.set(key, dependency);
+ unique.push(dependency);
+ continue;
+ }
+
+ if (!existing.isDirect && dependency.isDirect) {
+ const index = unique.indexOf(existing);
+ if (index !== -1) {
+ unique[index] = dependency;
+ }
+ seen.set(key, dependency);
+ continue;
+ }
+
+ if (
+ Array.isArray(existing.parentChain) &&
+ existing.parentChain.length === 0 &&
+ Array.isArray(dependency.parentChain) &&
+ dependency.parentChain.length > 0
+ ) {
+ const merged = {
+ ...existing,
+ parent: dependency.parent,
+ parentChain: dependency.parentChain.slice(),
+ };
+ const index = unique.indexOf(existing);
+ if (index !== -1) {
+ unique[index] = merged;
+ }
+ seen.set(key, merged);
+ }
+ }
+
+ return unique;
+}
+
+function buildTree(ecosystem, sourceFile, dependencies, warnings) {
+ return {
+ ecosystem,
+ sourceFile,
+ dependencies: deduplicateDeps(dependencies),
+ warnings: Array.isArray(warnings) ? warnings.slice() : [],
+ };
+}
+
+function stripTomlComment(line) {
+ if (typeof line !== "string" || !line.includes("#")) {
+ return line || "";
+ }
+
+ let inSingleQuote = false;
+ let inDoubleQuote = false;
+
+ for (let index = 0; index < line.length; index += 1) {
+ const char = line[index];
+ const previous = index > 0 ? line[index - 1] : "";
+ if (char === "'" && !inDoubleQuote && previous !== "\\") {
+ inSingleQuote = !inSingleQuote;
+ continue;
+ }
+ if (char === "\"" && !inSingleQuote && previous !== "\\") {
+ inDoubleQuote = !inDoubleQuote;
+ continue;
+ }
+ if (char === "#" && !inSingleQuote && !inDoubleQuote) {
+ return line.slice(0, index);
+ }
+ }
+
+ return line;
+}
+
+function stripYamlComment(line) {
+ if (typeof line !== "string" || !line.includes("#")) {
+ return line || "";
+ }
+
+ let inSingleQuote = false;
+ let inDoubleQuote = false;
+
+ for (let index = 0; index < line.length; index += 1) {
+ const char = line[index];
+ const previous = index > 0 ? line[index - 1] : "";
+ if (char === "'" && !inDoubleQuote && previous !== "\\") {
+ inSingleQuote = !inSingleQuote;
+ continue;
+ }
+ if (char === "\"" && !inSingleQuote && previous !== "\\") {
+ inDoubleQuote = !inDoubleQuote;
+ continue;
+ }
+ if (char === "#" && !inSingleQuote && !inDoubleQuote) {
+ return line.slice(0, index);
+ }
+ }
+
+ return line;
+}
+
+function countIndent(line) {
+ if (typeof line !== "string") {
+ return 0;
+ }
+
+ const firstNonWhitespace = line.search(/\S/);
+ return firstNonWhitespace === -1 ? line.length : firstNonWhitespace;
+}
+
+function parseQuotedArray(rawValue) {
+ const value = String(rawValue || "").trim();
+ if (!value.startsWith("[") || !value.endsWith("]")) {
+ return [];
+ }
+
+ const results = [];
+ let current = "";
+ let inSingleQuote = false;
+ let inDoubleQuote = false;
+
+ for (let index = 1; index < value.length - 1; index += 1) {
+ const char = value[index];
+ const previous = index > 0 ? value[index - 1] : "";
+
+ if (char === "'" && !inDoubleQuote && previous !== "\\") {
+ inSingleQuote = !inSingleQuote;
+ current += char;
+ continue;
+ }
+ if (char === "\"" && !inSingleQuote && previous !== "\\") {
+ inDoubleQuote = !inDoubleQuote;
+ current += char;
+ continue;
+ }
+
+ if (char === "," && !inSingleQuote && !inDoubleQuote) {
+ const cleaned = current.trim().replace(/^["']|["']$/g, "");
+ if (cleaned) {
+ results.push(cleaned);
+ }
+ current = "";
+ continue;
+ }
+
+ current += char;
+ }
+
+ const cleaned = current.trim().replace(/^["']|["']$/g, "");
+ if (cleaned) {
+ results.push(cleaned);
+ }
+
+ return results;
+}
+
+function parseInlineTomlValue(block, key) {
+ if (typeof block !== "string" || !block.includes("{")) {
+ return "";
+ }
+
+ const expression = new RegExp(`${escapeRegExp(key)}\\s*=\\s*(\"([^\"]*)\"|'([^']*)'|([^,}]+))`);
+ const match = block.match(expression);
+ if (!match) {
+ return "";
+ }
+
+ return (match[2] || match[3] || match[4] || "").trim();
+}
+
+function escapeRegExp(value) {
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function firstDefined(...values) {
+ for (const value of values) {
+ if (value != null && value !== "") {
+ return value;
+ }
+ }
+ return "";
+}
+
+function parseKeyValueLine(line) {
+ if (typeof line !== "string" || !line.includes("=")) {
+ return null;
+ }
+
+ const separatorIndex = line.indexOf("=");
+ return {
+ key: line.slice(0, separatorIndex).trim(),
+ value: line.slice(separatorIndex + 1).trim(),
+ };
+}
+
+module.exports = {
+ LARGE_FILE_THRESHOLD_BYTES,
+ buildTree,
+ countIndent,
+ createDependency,
+ deduplicateDeps,
+ dependencyKey,
+ escapeRegExp,
+ firstDefined,
+ flattenDependencies,
+ getSourceFileName,
+ getWorkspacePath,
+ normalizeVersion,
+ parseInlineTomlValue,
+ readJson,
+ parseKeyValueLine,
+ parseQuotedArray,
+ pathExists,
+ readUtf8,
+ resolveWorkspaceFilePath,
+ statSafe,
+ stripTomlComment,
+ stripYamlComment,
+};
diff --git a/util/lockfileParsers/swiftParser.js b/util/lockfileParsers/swiftParser.js
new file mode 100644
index 0000000..0e21f88
--- /dev/null
+++ b/util/lockfileParsers/swiftParser.js
@@ -0,0 +1,89 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const {
+ buildTree,
+ createDependency,
+ getSourceFileName,
+ getWorkspacePath,
+ readJson,
+ pathExists,
+ readUtf8,
+} = require("./shared");
+const { normalizeSwiftIdentity, parsePackageSwiftManifest } = require("./manifestHelpers");
+
+const swiftParser = {
+ name: "swiftParser",
+ ecosystem: "swift",
+
+ async canResolve(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ return (await pathExists(path.join(rootPath, "Package.resolved"), workspaceFolder))
+ || (await pathExists(path.join(rootPath, "Package.swift"), workspaceFolder));
+ },
+
+ async detect(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ const lockfilePath = await pathExists(path.join(rootPath, "Package.resolved"), workspaceFolder)
+ ? path.join(rootPath, "Package.resolved")
+ : null;
+ const manifestPath = await pathExists(path.join(rootPath, "Package.swift"), workspaceFolder)
+ ? path.join(rootPath, "Package.swift")
+ : null;
+ if (!lockfilePath && !manifestPath) {
+ return [];
+ }
+ return [{
+ resolverName: this.name,
+ ecosystem: this.ecosystem,
+ lockfilePath,
+ manifestPath,
+ sourceFile: getSourceFileName(lockfilePath || manifestPath),
+ }];
+ },
+
+ async resolve({ lockfilePath, manifestPath, workspaceFolder }) {
+ const sourceFile = getSourceFileName(lockfilePath || manifestPath);
+ if (!lockfilePath) {
+ return buildTree("swift", sourceFile, parsePackageSwiftManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({
+ name: dependency.name,
+ version: dependency.version,
+ ecosystem: "swift",
+ isDirect: true,
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ })));
+ }
+
+ const manifestDirectNames = manifestPath && await pathExists(manifestPath, workspaceFolder)
+ ? new Set(parsePackageSwiftManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => dependency.name))
+ : new Set();
+ const root = await readJson(lockfilePath);
+ const pins = Array.isArray(root.pins)
+ ? root.pins
+ : (root.object && Array.isArray(root.object.pins) ? root.object.pins : []);
+ if (pins.length === 0) {
+ throw new Error("Malformed Package.resolved: missing pins array");
+ }
+
+ return buildTree("swift", sourceFile, pins.map((pin) => {
+ const state = pin.state || {};
+ const identity = normalizeSwiftIdentity(pin.identity || pin.package || pin.location || "");
+ return createDependency({
+ name: identity,
+ version: state.version || state.revision || state.branch || "",
+ ecosystem: "swift",
+ isDirect: manifestDirectNames.size === 0 || manifestDirectNames.has(identity),
+ parent: null,
+ parentChain: [],
+ transitives: [],
+ sourceFile,
+ isDevelopmentDependency: false,
+ });
+ }));
+ },
+};
+
+module.exports = swiftParser;
diff --git a/util/lockfileResolver.js b/util/lockfileResolver.js
new file mode 100644
index 0000000..844a51e
--- /dev/null
+++ b/util/lockfileResolver.js
@@ -0,0 +1,140 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const path = require("path");
+const npmParser = require("./lockfileParsers/npmParser");
+const pythonParser = require("./lockfileParsers/pythonParser");
+const mavenParser = require("./lockfileParsers/mavenParser");
+const gradleParser = require("./lockfileParsers/gradleParser");
+const goParser = require("./lockfileParsers/goParser");
+const cargoParser = require("./lockfileParsers/cargoParser");
+const rubyParser = require("./lockfileParsers/rubyParser");
+const dockerParser = require("./lockfileParsers/dockerParser");
+const nugetParser = require("./lockfileParsers/nugetParser");
+const dartParser = require("./lockfileParsers/dartParser");
+const composerParser = require("./lockfileParsers/composerParser");
+const helmParser = require("./lockfileParsers/helmParser");
+const swiftParser = require("./lockfileParsers/swiftParser");
+const hexParser = require("./lockfileParsers/hexParser");
+const {
+ getWorkspacePath,
+ resolveWorkspaceFilePath,
+} = require("./lockfileParsers/shared");
+
+const REGISTERED_RESOLVERS = [
+ npmParser,
+ pythonParser,
+ mavenParser,
+ gradleParser,
+ goParser,
+ cargoParser,
+ rubyParser,
+ dockerParser,
+ nugetParser,
+ dartParser,
+ composerParser,
+ helmParser,
+ swiftParser,
+ hexParser,
+];
+
+class LockfileResolver {
+ static getResolvers() {
+ return REGISTERED_RESOLVERS.slice();
+ }
+
+ static async detectResolvers(workspaceFolder) {
+ const rootPath = getWorkspacePath(workspaceFolder);
+ const matches = [];
+
+ for (const resolver of REGISTERED_RESOLVERS) {
+ if (!resolver || typeof resolver.canResolve !== "function") {
+ continue;
+ }
+
+ if (!(await resolver.canResolve(rootPath))) {
+ continue;
+ }
+
+ const detections = typeof resolver.detect === "function"
+ ? await resolver.detect(rootPath)
+ : [{
+ resolverName: resolver.name,
+ ecosystem: resolver.ecosystem,
+ workspaceFolder: rootPath,
+ lockfilePath: null,
+ manifestPath: null,
+ }];
+
+ for (const detection of detections) {
+ matches.push({
+ resolverName: resolver.name,
+ ecosystem: resolver.ecosystem,
+ workspaceFolder: rootPath,
+ lockfilePath: detection.lockfilePath || null,
+ manifestPath: detection.manifestPath || null,
+ sourceFile: detection.sourceFile
+ || path.basename(detection.lockfilePath || detection.manifestPath || ""),
+ });
+ }
+ }
+
+ return matches;
+ }
+
+ static async resolve(resolverName, lockfilePath, manifestPath, options = {}) {
+ const resolver = REGISTERED_RESOLVERS.find((candidate) => candidate.name === resolverName);
+ if (!resolver) {
+ throw new Error(`Unknown lockfile resolver: ${resolverName}`);
+ }
+
+ const workspaceFolder = getWorkspacePath(options.workspaceFolder || path.dirname(lockfilePath || manifestPath || ""));
+ const safeLockfilePath = lockfilePath
+ ? await resolveWorkspaceFilePath(lockfilePath, workspaceFolder)
+ : null;
+ const safeManifestPath = manifestPath
+ ? await resolveWorkspaceFilePath(manifestPath, workspaceFolder)
+ : null;
+
+ if (lockfilePath && !safeLockfilePath) {
+ throw new Error("Lockfile paths must stay within the workspace folder.");
+ }
+
+ if (manifestPath && !safeManifestPath) {
+ throw new Error("Manifest paths must stay within the workspace folder.");
+ }
+
+ return resolver.resolve({
+ workspaceFolder,
+ lockfilePath: safeLockfilePath,
+ manifestPath: safeManifestPath,
+ options,
+ });
+ }
+
+ static async resolveAll(workspaceFolder, options = {}) {
+ const matches = await LockfileResolver.detectResolvers(workspaceFolder);
+ const trees = [];
+
+ for (const match of matches) {
+ const tree = await LockfileResolver.resolve(
+ match.resolverName,
+ match.lockfilePath,
+ match.manifestPath,
+ {
+ ...options,
+ workspaceFolder: match.workspaceFolder,
+ detection: match,
+ }
+ );
+
+ if (tree) {
+ trees.push(tree);
+ }
+ }
+
+ return trees;
+ }
+}
+
+module.exports = {
+ LockfileResolver,
+};
diff --git a/util/manifestParser.js b/util/manifestParser.js
index 0200f3a..65b450c 100644
--- a/util/manifestParser.js
+++ b/util/manifestParser.js
@@ -1,9 +1,11 @@
-// Manifest parser - detects and parses project dependency manifests
-// Supports: npm (package.json), Python (requirements.txt, pyproject.toml),
-// Maven (pom.xml), Go (go.mod), Cargo (Cargo.toml)
-
-const fs = require("fs");
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
const path = require("path");
+const { parsePyprojectManifest } = require("./lockfileParsers/manifestHelpers");
+const {
+ getWorkspacePath,
+ pathExists,
+ readUtf8,
+} = require("./lockfileParsers/shared");
class ManifestParser {
/**
@@ -15,9 +17,7 @@ class ManifestParser {
* @returns {Array<{filePath: string, format: string, parserMethod: string}>}
*/
static async detectManifests(workspaceFolderOrPath) {
- const root = typeof workspaceFolderOrPath === "string"
- ? workspaceFolderOrPath
- : workspaceFolderOrPath.uri.fsPath;
+ const root = getWorkspacePath(workspaceFolderOrPath);
const manifests = [];
const checks = [
@@ -31,15 +31,13 @@ class ManifestParser {
for (const check of checks) {
const filePath = path.join(root, check.file);
- try {
- await fs.promises.access(filePath, fs.constants.R_OK);
+ if (await pathExists(filePath, root)) {
manifests.push({
- filePath: filePath,
+ filePath,
format: check.format,
parserMethod: check.parserMethod,
+ workspaceFolder: root,
});
- } catch (e) { // eslint-disable-line no-unused-vars
- // File doesn't exist or isn't readable, skip
}
}
@@ -54,7 +52,10 @@ class ManifestParser {
*/
static async parseManifest(manifest) {
try {
- const content = await fs.promises.readFile(manifest.filePath, "utf8");
+ const content = await readUtf8(
+ manifest.filePath,
+ manifest.workspaceFolder || path.dirname(manifest.filePath || "")
+ );
const parser = ManifestParser[manifest.parserMethod];
if (!parser) {
return [];
@@ -157,88 +158,17 @@ class ManifestParser {
}
/**
- * Parse pyproject.toml — basic line-by-line parsing for
- * [project.dependencies] and [tool.poetry.dependencies].
- * Not a full TOML parser; handles the common cases.
+ * Parse pyproject.toml via the shared lockfile manifest helper so
+ * Poetry and PEP 621 formats stay consistent with lockfile resolution.
*/
static parsePyproject(content, format) {
- const deps = [];
- const lines = content.split("\n");
- let inDepsSection = false;
- let inPoetryDeps = false;
-
- for (const rawLine of lines) {
- const line = rawLine.trim();
-
- // Detect section headers
- if (line.startsWith("[")) {
- inDepsSection = line === "[project.dependencies]" ||
- line === "[project]";
- inPoetryDeps = line === "[tool.poetry.dependencies]";
- continue;
- }
-
- // Handle [project] section with dependencies = [...] array
- if (inDepsSection && line.startsWith("dependencies")) {
- // dependencies = ["flask>=2.0", "requests"]
- const arrayMatch = line.match(/dependencies\s*=\s*\[(.*)\]/);
- if (arrayMatch) {
- const items = arrayMatch[1].split(",");
- for (const item of items) {
- const cleaned = item.trim().replace(/['"]/g, "");
- if (!cleaned) {
- continue;
- }
- const depMatch = cleaned.match(/^([a-zA-Z0-9_\-.]+)\s*([><=!~]+)?\s*(.*)?/);
- if (depMatch) {
- deps.push({
- name: depMatch[1],
- version: depMatch[3] ? depMatch[3].trim() : "",
- devDependency: false,
- format: format || "python",
- });
- }
- }
- inDepsSection = false;
- continue;
- }
- }
-
- // Handle Poetry-style: name = "^version" or name = {version = "^version"}
- if (inPoetryDeps) {
- if (!line || line.startsWith("#")) {
- continue;
- }
- // Skip python version requirement
- if (line.startsWith("python")) {
- continue;
- }
-
- // name = "^1.2.3"
- const simpleMatch = line.match(/^([a-zA-Z0-9_\-.]+)\s*=\s*"([^"]*)"/);
- if (simpleMatch) {
- deps.push({
- name: simpleMatch[1],
- version: ManifestParser._stripVersionPrefix(simpleMatch[2]),
- devDependency: false,
- format: format || "python",
- });
- continue;
- }
-
- // name = { version = "^1.2.3", ... }
- const complexMatch = line.match(/^([a-zA-Z0-9_\-.]+)\s*=\s*\{.*version\s*=\s*"([^"]*)"/);
- if (complexMatch) {
- deps.push({
- name: complexMatch[1],
- version: ManifestParser._stripVersionPrefix(complexMatch[2]),
- devDependency: false,
- format: format || "python",
- });
- }
- }
- }
- return deps;
+ const parsed = parsePyprojectManifest(content);
+ return parsed.dependencies.map((dependency) => ({
+ name: dependency.name,
+ version: dependency.version,
+ devDependency: dependency.isDevelopmentDependency,
+ format: format || "python",
+ }));
}
/**
@@ -414,7 +344,7 @@ class ManifestParser {
*/
static async findDependencyLocation(filePath, dependencyName, format) {
try {
- const content = await fs.promises.readFile(filePath, "utf8");
+ const content = await readUtf8(filePath, path.dirname(filePath));
const lines = content.split("\n");
switch (format) {
diff --git a/util/packageNameNormalizer.js b/util/packageNameNormalizer.js
new file mode 100644
index 0000000..060f343
--- /dev/null
+++ b/util/packageNameNormalizer.js
@@ -0,0 +1,143 @@
+// Copyright 2026 Cloudsmith Ltd. All rights reserved.
+const MAX_PACKAGE_NAME_LENGTH = 4096;
+
+const ECOSYSTEM_TO_FORMAT = {
+ npm: "npm",
+ maven: "maven",
+ gradle: "maven",
+ pypi: "python",
+ python: "python",
+ go: "go",
+ cargo: "cargo",
+ ruby: "ruby",
+ docker: "docker",
+ nuget: "nuget",
+ dart: "dart",
+ composer: "composer",
+ helm: "helm",
+ swift: "swift",
+ hex: "hex",
+ conda: "conda",
+};
+
+function sanitizePackageNameInput(name) {
+ const normalized = String(name == null ? "" : name)
+ .replace(/\0/g, "")
+ .trim();
+
+ if (!normalized || normalized.length > MAX_PACKAGE_NAME_LENGTH) {
+ return "";
+ }
+
+ return normalized;
+}
+
+function canonicalFormat(ecosystemOrFormat) {
+ const normalized = sanitizePackageNameInput(ecosystemOrFormat).toLowerCase();
+ return ECOSYSTEM_TO_FORMAT[normalized] || normalized;
+}
+
+function normalizePackageName(name, ecosystemOrFormat) {
+ const format = canonicalFormat(ecosystemOrFormat);
+ const rawName = sanitizePackageNameInput(name);
+ if (!rawName) {
+ return "";
+ }
+
+ if (format === "python") {
+ return rawName.toLowerCase().replace(/[-_.]+/g, "-");
+ }
+
+ return rawName.toLowerCase();
+}
+
+function getPackageLookupKeys(name, ecosystemOrFormat, identifiers) {
+ const format = canonicalFormat(ecosystemOrFormat);
+ const rawName = sanitizePackageNameInput(name);
+ if (!rawName) {
+ return [];
+ }
+
+ if (format === "maven") {
+ const artifactId = rawName.includes(":") ? rawName.split(":").slice(1).join(":") : rawName;
+ const keys = [normalizePackageName(rawName, format)];
+ if (artifactId) {
+ keys.push(normalizePackageName(artifactId, format));
+ }
+ if (identifiers && identifiers.group_id) {
+ keys.push(normalizePackageName(`${identifiers.group_id}:${rawName}`, format));
+ }
+ return [...new Set(keys.filter(Boolean))];
+ }
+
+ if (format === "docker") {
+ return buildDockerLookupKeys(rawName);
+ }
+
+ return [normalizePackageName(rawName, format)];
+}
+
+function getCloudsmithPackageLookupKeys(pkg, ecosystemOrFormat) {
+ if (!pkg || typeof pkg !== "object") {
+ return [];
+ }
+
+ const format = canonicalFormat(ecosystemOrFormat || pkg.format);
+ if (format !== "maven") {
+ return getPackageLookupKeys(pkg.name, format);
+ }
+
+ const identifiers = pkg.identifiers && typeof pkg.identifiers === "object" ? pkg.identifiers : {};
+ const keys = [normalizePackageName(pkg.name, format)];
+ if (identifiers.group_id) {
+ keys.push(normalizePackageName(`${identifiers.group_id}:${pkg.name}`, format));
+ }
+ return [...new Set(keys.filter(Boolean))];
+}
+
+function buildDockerLookupKeys(name) {
+ const raw = normalizePackageName(String(name || "").replace(/^\/+/, ""), "docker").replace(/^\/+/, "");
+ if (!raw) {
+ return [];
+ }
+
+ const segments = raw.split("/").filter(Boolean);
+ const keys = new Set([raw]);
+ const firstSegment = segments[0] || "";
+ const hasExplicitRegistry = segments.length > 1 && (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost");
+
+ if (!hasExplicitRegistry) {
+ if (segments.length === 1) {
+ keys.add(`library/${segments[0]}`);
+ keys.add(`docker.io/library/${segments[0]}`);
+ keys.add(`index.docker.io/library/${segments[0]}`);
+ } else {
+ keys.add(`docker.io/${raw}`);
+ keys.add(`index.docker.io/${raw}`);
+ if (raw.startsWith("library/")) {
+ keys.add(raw.slice("library/".length));
+ }
+ }
+ return [...keys];
+ }
+
+ const pathPart = segments.slice(1).join("/");
+ if (["docker.io", "index.docker.io", "registry-1.docker.io"].includes(firstSegment) && pathPart) {
+ keys.add(pathPart);
+ keys.add(`docker.io/${pathPart}`);
+ if (pathPart.startsWith("library/")) {
+ keys.add(pathPart.slice("library/".length));
+ }
+ }
+
+ return [...keys];
+}
+
+module.exports = {
+ ECOSYSTEM_TO_FORMAT,
+ canonicalFormat,
+ getCloudsmithPackageLookupKeys,
+ getPackageLookupKeys,
+ normalizePackageName,
+ sanitizePackageNameInput,
+};