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/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/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/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/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/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/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/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/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,
+};