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*`, "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, +};