diff --git a/test/fixtures/cargo/Cargo.lock b/test/fixtures/cargo/Cargo.lock new file mode 100644 index 0000000..27ce744 --- /dev/null +++ b/test/fixtures/cargo/Cargo.lock @@ -0,0 +1,25 @@ +[[package]] +name = "fixture-cargo" +version = "0.1.0" +dependencies = [ + "serde 1.0.0", + "tokio 1.37.0" +] + +[[package]] +name = "serde" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "tokio" +version = "1.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "bytes 1.6.0" +] + +[[package]] +name = "bytes" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/test/fixtures/cargo/Cargo.toml b/test/fixtures/cargo/Cargo.toml new file mode 100644 index 0000000..f29e154 --- /dev/null +++ b/test/fixtures/cargo/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "fixture-cargo" +version = "0.1.0" + +[dependencies] +serde = "1.0.0" + +[dev-dependencies] +tokio = { version = "1.37.0", features = ["full"] } diff --git a/test/fixtures/composer/composer.json b/test/fixtures/composer/composer.json new file mode 100644 index 0000000..9c39117 --- /dev/null +++ b/test/fixtures/composer/composer.json @@ -0,0 +1,8 @@ +{ + "require": { + "laravel/framework": "^11.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + } +} diff --git a/test/fixtures/composer/composer.lock b/test/fixtures/composer/composer.lock new file mode 100644 index 0000000..0603532 --- /dev/null +++ b/test/fixtures/composer/composer.lock @@ -0,0 +1,28 @@ +{ + "packages": [ + { + "name": "laravel/framework", + "version": "v11.0.0", + "require": { + "symfony/http-foundation": "^7.0", + "php": "^8.2" + } + }, + { + "name": "symfony/http-foundation", + "version": "v7.0.0", + "require": { + "php": "^8.2" + } + } + ], + "packages-dev": [ + { + "name": "phpunit/phpunit", + "version": "11.0.0", + "require": { + "php": "^8.2" + } + } + ] +} diff --git a/test/fixtures/dart/pubspec.lock b/test/fixtures/dart/pubspec.lock new file mode 100644 index 0000000..1384cb4 --- /dev/null +++ b/test/fixtures/dart/pubspec.lock @@ -0,0 +1,15 @@ +packages: + http: + dependency: "direct main" + description: + name: http + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + source: hosted + version: "1.25.2" +sdks: + dart: ">=3.0.0 <4.0.0" diff --git a/test/fixtures/dart/pubspec.yaml b/test/fixtures/dart/pubspec.yaml new file mode 100644 index 0000000..e7d0b60 --- /dev/null +++ b/test/fixtures/dart/pubspec.yaml @@ -0,0 +1,7 @@ +name: fixture_dart + +dependencies: + http: ^1.2.1 + +dev_dependencies: + test: ^1.25.2 diff --git a/test/fixtures/docker/Dockerfile b/test/fixtures/docker/Dockerfile new file mode 100644 index 0000000..1ee1611 --- /dev/null +++ b/test/fixtures/docker/Dockerfile @@ -0,0 +1,5 @@ +ARG BASE_IMAGE=python:3.11-slim +FROM --platform=linux/amd64 $BASE_IMAGE AS base +FROM base AS test +FROM scratch AS export +FROM alpine:3.19 diff --git a/test/fixtures/docker/docker-compose.yml b/test/fixtures/docker/docker-compose.yml new file mode 100644 index 0000000..d44c448 --- /dev/null +++ b/test/fixtures/docker/docker-compose.yml @@ -0,0 +1,7 @@ +services: + api: + image: redis:7.2 + worker: + build: . + db: + image: postgres:16 diff --git a/test/fixtures/go/go.mod b/test/fixtures/go/go.mod new file mode 100644 index 0000000..8877575 --- /dev/null +++ b/test/fixtures/go/go.mod @@ -0,0 +1,8 @@ +module example.com/fixture + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/stretchr/testify v1.8.4 // indirect +) diff --git a/test/fixtures/gradle/build.gradle b/test/fixtures/gradle/build.gradle new file mode 100644 index 0000000..1c95cc4 --- /dev/null +++ b/test/fixtures/gradle/build.gradle @@ -0,0 +1,8 @@ +plugins { + id "java" +} + +dependencies { + implementation "org.springframework:spring-core:6.1.0" + testImplementation "junit:junit:4.13.2" +} diff --git a/test/fixtures/gradle/gradle.lockfile b/test/fixtures/gradle/gradle.lockfile new file mode 100644 index 0000000..458d7fb --- /dev/null +++ b/test/fixtures/gradle/gradle.lockfile @@ -0,0 +1,2 @@ +org.springframework:spring-core:6.1.0=compileClasspath +junit:junit:4.13.2=testCompileClasspath diff --git a/test/fixtures/helm/Chart.lock b/test/fixtures/helm/Chart.lock new file mode 100644 index 0000000..3f291a3 --- /dev/null +++ b/test/fixtures/helm/Chart.lock @@ -0,0 +1,5 @@ +dependencies: + - name: redis + version: 19.6.0 +digest: sha256:fixture +generated: "2026-04-05T00:00:00Z" diff --git a/test/fixtures/helm/Chart.yaml b/test/fixtures/helm/Chart.yaml new file mode 100644 index 0000000..2c94405 --- /dev/null +++ b/test/fixtures/helm/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: fixture-chart +version: 0.1.0 +dependencies: + - name: redis + version: 19.6.0 diff --git a/test/fixtures/hex/mix.exs b/test/fixtures/hex/mix.exs new file mode 100644 index 0000000..61c6617 --- /dev/null +++ b/test/fixtures/hex/mix.exs @@ -0,0 +1,16 @@ +defmodule FixtureHex.MixProject do + use Mix.Project + + def project do + [ + app: :fixture_hex, + version: "0.1.0" + ] + end + + defp deps do + [ + {:jason, "~> 1.4"} + ] + end +end diff --git a/test/fixtures/hex/mix.lock b/test/fixtures/hex/mix.lock new file mode 100644 index 0000000..4847e99 --- /dev/null +++ b/test/fixtures/hex/mix.lock @@ -0,0 +1,3 @@ +%{ + "jason": {:hex, :jason, "1.4.1", "checksum", [:mix], [], "hexpm", "checksum"} +} diff --git a/test/fixtures/nuget/Fixture.csproj b/test/fixtures/nuget/Fixture.csproj new file mode 100644 index 0000000..cbf45f6 --- /dev/null +++ b/test/fixtures/nuget/Fixture.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/test/fixtures/nuget/packages.lock.json b/test/fixtures/nuget/packages.lock.json new file mode 100644 index 0000000..550fcac --- /dev/null +++ b/test/fixtures/nuget/packages.lock.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "dependencies": { + ".NETCoreApp,Version=v8.0": { + "Newtonsoft.Json": { + "type": "Direct", + "resolved": "13.0.3", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0" + } + } + } +} diff --git a/test/fixtures/ruby/Gemfile b/test/fixtures/ruby/Gemfile new file mode 100644 index 0000000..cc4b5e5 --- /dev/null +++ b/test/fixtures/ruby/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "rails", "~> 7.1.3" diff --git a/test/fixtures/ruby/Gemfile.lock b/test/fixtures/ruby/Gemfile.lock new file mode 100644 index 0000000..035bc10 --- /dev/null +++ b/test/fixtures/ruby/Gemfile.lock @@ -0,0 +1,9 @@ +GEM + remote: https://rubygems.org/ + specs: + rails (7.1.3) + actionpack (= 7.1.3) + actionpack (7.1.3) + +DEPENDENCIES + rails (~> 7.1.3) diff --git a/test/fixtures/swift/Package.resolved b/test/fixtures/swift/Package.resolved new file mode 100644 index 0000000..4294e8a --- /dev/null +++ b/test/fixtures/swift/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins": [ + { + "identity": "alamofire", + "kind": "remoteSourceControl", + "location": "https://github.com/Alamofire/Alamofire.git", + "state": { + "revision": "abcdef1234567890", + "version": "5.8.0" + } + } + ], + "version": 2 +} diff --git a/test/fixtures/swift/Package.swift b/test/fixtures/swift/Package.swift new file mode 100644 index 0000000..206e9ed --- /dev/null +++ b/test/fixtures/swift/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "FixtureSwift", + dependencies: [ + .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0") + ] +) diff --git a/test/lockfileParsers/cargoParser.test.js b/test/lockfileParsers/cargoParser.test.js new file mode 100644 index 0000000..c046811 --- /dev/null +++ b/test/lockfileParsers/cargoParser.test.js @@ -0,0 +1,119 @@ +const assert = require("assert"); +const path = require("path"); +const cargoParser = require("../../util/lockfileParsers/cargoParser"); +const { + makeTempWorkspace, + removeDirectory, + writeTextFile, +} = require("../helpers/fixtureWorkspace"); + +suite("cargoParser Test Suite", () => { + const fixtureDir = path.join(__dirname, "..", "fixtures", "cargo"); + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-cargo-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("resolves Cargo.lock uniquely, skips the root package, and marks direct dependencies from Cargo.toml", async () => { + const tree = await cargoParser.resolve({ + lockfilePath: path.join(fixtureDir, "Cargo.lock"), + manifestPath: path.join(fixtureDir, "Cargo.toml"), + }); + + assert.strictEqual(tree.sourceFile, "Cargo.lock"); + assert.strictEqual(tree.dependencies.length, 3); + assert.strictEqual(tree.dependencies.some((dependency) => dependency.name === "fixture-cargo"), false); + + const serde = tree.dependencies.find((dependency) => dependency.name === "serde"); + const tokio = tree.dependencies.find((dependency) => dependency.name === "tokio"); + const bytes = tree.dependencies.find((dependency) => dependency.name === "bytes"); + + assert.ok(serde); + assert.ok(tokio); + assert.ok(bytes); + assert.strictEqual(serde.isDirect, true); + assert.strictEqual(tokio.isDirect, true); + assert.strictEqual(bytes.isDirect, false); + assert.deepStrictEqual(bytes.parentChain, ["tokio"]); + }); + + test("detect returns no matches when Cargo files are missing", async () => { + const workspace = await createWorkspace(); + + const matches = await cargoParser.detect(workspace); + + assert.deepStrictEqual(matches, []); + assert.strictEqual(await cargoParser.canResolve(workspace), false); + }); + + test("throws for malformed Cargo.lock files", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "Cargo.lock"); + const manifestPath = path.join(workspace, "Cargo.toml"); + await writeTextFile(lockfilePath, "[[package]]\nname = \"broken\"\n"); + await writeTextFile(manifestPath, "[dependencies]\nserde = \"1.0.0\"\n"); + + await assert.rejects( + () => cargoParser.resolve({ lockfilePath, manifestPath }), + /Malformed Cargo\.lock: no package entries found/ + ); + }); + + test("deduplicates large Cargo graphs down to unique packages", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "Cargo.lock"); + const manifestPath = path.join(workspace, "Cargo.toml"); + const packageCount = 300; + const registrySource = "registry+https://github.com/rust-lang/crates.io-index"; + + const manifestLines = [ + "[package]", + 'name = "fixture-cargo"', + 'version = "0.1.0"', + "", + "[dependencies]", + 'crate-000 = "1.0.0"', + ]; + + const lockEntries = []; + for (let index = 0; index < packageCount; index += 1) { + const currentName = `crate-${String(index).padStart(3, "0")}`; + const nextName = index + 1 < packageCount + ? `crate-${String(index + 1).padStart(3, "0")}` + : null; + lockEntries.push( + [ + "[[package]]", + `name = "${currentName}"`, + 'version = "1.0.0"', + `source = "${registrySource}"`, + nextName + ? `dependencies = ["${nextName} 1.0.0"]` + : "", + "", + ].filter(Boolean).join("\n") + ); + } + + await writeTextFile(manifestPath, manifestLines.join("\n")); + await writeTextFile(lockfilePath, lockEntries.join("\n")); + + const tree = await cargoParser.resolve({ + lockfilePath, + manifestPath, + }); + + assert.strictEqual(tree.dependencies.length, packageCount); + assert.strictEqual( + new Set(tree.dependencies.map((dependency) => `${dependency.name}@${dependency.version}`)).size, + packageCount + ); + }); +}); diff --git a/test/lockfileParsers/dockerParser.test.js b/test/lockfileParsers/dockerParser.test.js new file mode 100644 index 0000000..ec6d670 --- /dev/null +++ b/test/lockfileParsers/dockerParser.test.js @@ -0,0 +1,78 @@ +const assert = require("assert"); +const path = require("path"); +const dockerParser = require("../../util/lockfileParsers/dockerParser"); +const { + makeTempWorkspace, + removeDirectory, + writeTextFile, +} = require("../helpers/fixtureWorkspace"); + +suite("dockerParser Test Suite", () => { + const fixtureDir = path.join(__dirname, "..", "fixtures", "docker"); + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-docker-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("parses Dockerfile FROM instructions and skips scratch and stage references", async () => { + const tree = await dockerParser.resolve({ + lockfilePath: path.join(fixtureDir, "Dockerfile"), + }); + + assert.strictEqual(tree.sourceFile, "Dockerfile"); + assert.deepStrictEqual( + tree.dependencies.map((dependency) => `${dependency.name}:${dependency.version}`), + ["python:3.11-slim", "alpine:3.19"] + ); + }); + + test("parses docker-compose images and skips build-only services", async () => { + const tree = await dockerParser.resolve({ + lockfilePath: path.join(fixtureDir, "docker-compose.yml"), + }); + + assert.strictEqual(tree.sourceFile, "docker-compose.yml"); + assert.deepStrictEqual( + tree.dependencies.map((dependency) => `${dependency.name}:${dependency.version}`), + ["redis:7.2", "postgres:16"] + ); + }); + + test("detect returns no matches when Docker manifests are missing", async () => { + const workspace = await createWorkspace(); + + const matches = await dockerParser.detect(workspace); + + assert.deepStrictEqual(matches, []); + assert.strictEqual(await dockerParser.canResolve(workspace), false); + }); + + test("detect returns no matches for invalid workspace roots", async () => { + const workspace = await createWorkspace(); + const matches = await dockerParser.detect(path.join(workspace, "missing-workspace")); + + assert.deepStrictEqual(matches, []); + }); + + test("ignores malformed FROM lines that do not resolve to image references", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "Dockerfile"); + await writeTextFile(lockfilePath, [ + "ARG BASE_IMAGE", + "FROM $BASE_IMAGE", + "FROM scratch", + "", + ].join("\n")); + + const tree = await dockerParser.resolve({ lockfilePath }); + + assert.strictEqual(tree.dependencies.length, 0); + }); +}); diff --git a/test/lockfileParsers/nugetParser.test.js b/test/lockfileParsers/nugetParser.test.js new file mode 100644 index 0000000..68454ca --- /dev/null +++ b/test/lockfileParsers/nugetParser.test.js @@ -0,0 +1,28 @@ +const assert = require("assert"); +const path = require("path"); +const nugetParser = require("../../util/lockfileParsers/nugetParser"); +const { + makeTempWorkspace, + removeDirectory, +} = require("../helpers/fixtureWorkspace"); + +suite("nugetParser Test Suite", () => { + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-nuget-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("detect returns no matches for invalid workspace roots", async () => { + const workspace = await createWorkspace(); + const matches = await nugetParser.detect(path.join(workspace, "missing-workspace")); + + assert.deepStrictEqual(matches, []); + }); +}); diff --git a/util/lockfileParsers/cargoParser.js b/util/lockfileParsers/cargoParser.js new file mode 100644 index 0000000..06199ff --- /dev/null +++ b/util/lockfileParsers/cargoParser.js @@ -0,0 +1,283 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + parseKeyValueLine, + pathExists, + readUtf8, +} = require("./shared"); +const { parseCargoTomlManifest } = require("./manifestHelpers"); + +const cargoParser = { + name: "cargoParser", + ecosystem: "cargo", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "Cargo.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "Cargo.toml"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "Cargo.lock"), workspaceFolder) + ? path.join(rootPath, "Cargo.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "Cargo.toml"), workspaceFolder) + ? path.join(rootPath, "Cargo.toml") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const manifestDependencies = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? parseCargoTomlManifest(await readUtf8(manifestPath, workspaceFolder)) + : []; + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + + if (!lockfilePath) { + return buildTree("cargo", sourceFile, manifestDependencies.map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "cargo", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const records = parseCargoLock(await readUtf8(lockfilePath, workspaceFolder)); + if (records.length === 0) { + throw new Error("Malformed Cargo.lock: no package entries found"); + } + const directNames = new Set(manifestDependencies.map((dependency) => dependency.name.toLowerCase())); + const recordsByName = new Map(); + const incomingCounts = new Map(); + + for (const record of records) { + if (!recordsByName.has(record.name.toLowerCase())) { + recordsByName.set(record.name.toLowerCase(), []); + } + recordsByName.get(record.name.toLowerCase()).push(record); + for (const dependency of record.dependencies) { + incomingCounts.set( + dependency.name.toLowerCase(), + (incomingCounts.get(dependency.name.toLowerCase()) || 0) + 1 + ); + } + } + + const rootRecords = manifestDependencies.length > 0 + ? manifestDependencies.map((dependency) => selectCargoRecord(recordsByName, dependency.name, dependency.version)).filter(Boolean) + : records.filter((record) => !incomingCounts.get(record.name.toLowerCase())); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildCargoDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + directNames + ))); + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + for (const record of records) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "cargo", + isDirect: directNames.has(record.name.toLowerCase()), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("cargo", sourceFile, dependencies); + }, +}; + +function parseCargoLock(content) { + const records = []; + let current = null; + let inDependenciesArray = false; + + const flushCurrent = () => { + if (!current || !current.name || !current.version) { + current = null; + inDependenciesArray = false; + return; + } + const source = String(current.source || "").trim(); + if (source && !source.startsWith("path+") && !source.startsWith("git+")) { + records.push(current); + } + current = null; + inDependenciesArray = false; + }; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + if (line === "[[package]]") { + flushCurrent(); + current = { name: "", version: "", source: "", dependencies: [] }; + continue; + } + if (!current) { + continue; + } + + if (inDependenciesArray) { + if (line === "]") { + inDependenciesArray = false; + continue; + } + const match = line.trim().replace(/,$/, "").replace(/^"|"$/g, "").match(/^([^ ]+)(?: ([^ ]+))?/); + if (match) { + current.dependencies.push({ + name: match[1], + version: match[2] ? match[2].replace(/^\(/, "").replace(/\)$/, "") : "", + }); + } + continue; + } + + if (line.startsWith("name =")) { + current.name = parseKeyValueLine(line).value.replace(/^"|"$/g, ""); + continue; + } + if (line.startsWith("version =")) { + current.version = parseKeyValueLine(line).value.replace(/^"|"$/g, ""); + continue; + } + if (line.startsWith("source =")) { + current.source = parseKeyValueLine(line).value.replace(/^"|"$/g, ""); + continue; + } + if (line.startsWith("dependencies = [")) { + inDependenciesArray = true; + const inline = line.slice(line.indexOf("[") + 1, line.lastIndexOf("]")); + if (inline.trim()) { + for (const item of inline.split(",")) { + const cleaned = item.trim().replace(/^"|"$/g, ""); + if (!cleaned) { + continue; + } + const match = cleaned.match(/^([^ ]+)(?: ([^ ]+))?/); + if (match) { + current.dependencies.push({ name: match[1], version: match[2] || "" }); + } + } + inDependenciesArray = false; + } + } + } + + flushCurrent(); + return deduplicateCargoRecords(records); +} + +function deduplicateCargoRecords(records) { + const seen = new Set(); + const results = []; + for (const record of records) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + results.push(record); + } + return results; +} + +function selectCargoRecord(recordsByName, name, version) { + const candidates = recordsByName.get(name.toLowerCase()) || []; + if (candidates.length === 0) { + return null; + } + if (version) { + const exactMatch = candidates.find((record) => record.version === version); + if (exactMatch) { + return exactMatch; + } + } + return candidates[0]; +} + +function buildCargoDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "cargo", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependency of record.dependencies) { + const childRecord = selectCargoRecord(recordsByName, dependency.name, dependency.version); + if (!childRecord) { + continue; + } + transitives.push(buildCargoDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + directNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "cargo", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: false, + }); +} + +module.exports = cargoParser; diff --git a/util/lockfileParsers/composerParser.js b/util/lockfileParsers/composerParser.js new file mode 100644 index 0000000..0f250fc --- /dev/null +++ b/util/lockfileParsers/composerParser.js @@ -0,0 +1,174 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + readJson, + pathExists, + readUtf8, +} = require("./shared"); +const { parseComposerManifest } = require("./manifestHelpers"); + +const composerParser = { + name: "composerParser", + ecosystem: "composer", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "composer.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "composer.json"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "composer.lock"), workspaceFolder) + ? path.join(rootPath, "composer.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "composer.json"), workspaceFolder) + ? path.join(rootPath, "composer.json") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + const manifestDependencies = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? parseComposerManifest(await readUtf8(manifestPath, workspaceFolder)) + : []; + + if (!lockfilePath) { + return buildTree("composer", sourceFile, manifestDependencies.map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "composer", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const root = await readJson(lockfilePath); + const records = []; + + for (const record of [...(root.packages || []), ...(root["packages-dev"] || [])]) { + if (!record || !record.name) { + continue; + } + records.push({ + name: record.name, + version: record.version || "", + dependencies: Object.keys(record.require || {}).filter((name) => name.includes("/") && !name.startsWith("ext-") && !name.startsWith("lib-") && name !== "php"), + }); + } + + const directNames = new Set(manifestDependencies.map((dependency) => dependency.name.toLowerCase())); + const recordsByName = new Map(records.map((record) => [record.name.toLowerCase(), record])); + const incomingCounts = new Map(); + for (const record of records) { + for (const dependencyName of record.dependencies) { + incomingCounts.set(dependencyName.toLowerCase(), (incomingCounts.get(dependencyName.toLowerCase()) || 0) + 1); + } + } + + const rootRecords = directNames.size > 0 + ? [...directNames].map((name) => recordsByName.get(name)).filter(Boolean) + : records.filter((record) => !incomingCounts.get(record.name.toLowerCase())); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildComposerDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + directNames + ))); + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + + for (const record of records) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "composer", + isDirect: directNames.has(record.name.toLowerCase()), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("composer", sourceFile, dependencies); + }, +}; + +function buildComposerDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "composer", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependencyName of record.dependencies) { + const childRecord = recordsByName.get(dependencyName.toLowerCase()); + if (!childRecord) { + continue; + } + transitives.push(buildComposerDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + directNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "composer", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: false, + }); +} + +module.exports = composerParser; diff --git a/util/lockfileParsers/dartParser.js b/util/lockfileParsers/dartParser.js new file mode 100644 index 0000000..7e3689b --- /dev/null +++ b/util/lockfileParsers/dartParser.js @@ -0,0 +1,124 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + countIndent, + createDependency, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, + stripYamlComment, +} = require("./shared"); +const { parsePubspecManifest } = require("./manifestHelpers"); + +const dartParser = { + name: "dartParser", + ecosystem: "dart", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "pubspec.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "pubspec.yaml"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "pubspec.lock"), workspaceFolder) + ? path.join(rootPath, "pubspec.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "pubspec.yaml"), workspaceFolder) + ? path.join(rootPath, "pubspec.yaml") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + if (!lockfilePath) { + return buildTree("dart", sourceFile, parsePubspecManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "dart", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const dependencies = []; + let inPackages = false; + let current = null; + + const flushCurrent = () => { + if (!current || !current.name) { + current = null; + return; + } + dependencies.push(createDependency({ + name: current.name, + version: current.version, + ecosystem: "dart", + isDirect: !String(current.dependencyType || "").toLowerCase().includes("transitive"), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: String(current.dependencyType || "").toLowerCase().includes("dev"), + })); + current = null; + }; + + for (const rawLine of String(await readUtf8(lockfilePath, workspaceFolder)).split(/\r?\n/)) { + const line = stripYamlComment(rawLine).trim(); + if (!line) { + continue; + } + + const indent = countIndent(rawLine); + if (indent === 0 && line === "packages:") { + inPackages = true; + continue; + } + if (indent === 0 && line.endsWith(":") && line !== "packages:") { + inPackages = false; + flushCurrent(); + continue; + } + if (!inPackages) { + continue; + } + if (indent === 2 && line.endsWith(":")) { + flushCurrent(); + current = { name: line.slice(0, -1), version: "", dependencyType: "" }; + continue; + } + if (!current) { + continue; + } + if (indent === 4 && line.startsWith("dependency:")) { + current.dependencyType = line.slice("dependency:".length).trim().replace(/^["']|["']$/g, ""); + } + if (indent === 4 && line.startsWith("version:")) { + current.version = line.slice("version:".length).trim().replace(/^["']|["']$/g, ""); + } + } + + flushCurrent(); + return buildTree("dart", sourceFile, dependencies); + }, +}; + +module.exports = dartParser; diff --git a/util/lockfileParsers/dockerParser.js b/util/lockfileParsers/dockerParser.js new file mode 100644 index 0000000..7abf4d7 --- /dev/null +++ b/util/lockfileParsers/dockerParser.js @@ -0,0 +1,300 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + countIndent, + createDependency, + getSourceFileName, + getWorkspacePath, + readUtf8, + resolveWorkspaceFilePath, + stripYamlComment, +} = require("./shared"); + +const dockerParser = { + name: "dockerParser", + ecosystem: "docker", + + async canResolve(workspaceFolder) { + const matches = await this.detect(workspaceFolder); + return matches.length > 0; + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const safeRootPath = await resolveWorkspaceFilePath(rootPath, workspaceFolder); + if (!safeRootPath) { + return []; + } + const entries = []; + const allFiles = await require("fs").promises.readdir(safeRootPath); + + for (const fileName of allFiles.sort()) { + const isDockerfile = fileName === "Dockerfile" || fileName.startsWith("Dockerfile."); + const isComposeFile = [ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", + ].includes(fileName); + if (!isDockerfile && !isComposeFile) { + continue; + } + entries.push({ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath: path.join(safeRootPath, fileName), + manifestPath: null, + sourceFile: fileName, + }); + } + + return entries; + }, + + async resolve({ lockfilePath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath); + const content = await readUtf8(lockfilePath, workspaceFolder); + const dependencies = isComposeFileName(sourceFile) + ? parseCompose(content, sourceFile) + : parseDockerfile(content, sourceFile); + return buildTree("docker", sourceFile, dependencies); + }, +}; + +function parseDockerfile(content, sourceFile) { + const dependencies = []; + const stageAliases = new Set(); + const argDefaults = new Map(); + + for (const instruction of toLogicalDockerLines(content)) { + const cleaned = stripDockerComment(instruction).trim(); + if (!cleaned) { + continue; + } + + if (/^ARG\s+/i.test(cleaned)) { + const definition = cleaned.replace(/^ARG\s+/i, ""); + const [name, value] = definition.split("=", 2); + if (name && value) { + argDefaults.set(name.trim(), resolveDockerArgs(value.trim(), argDefaults)); + } + continue; + } + + if (!/^FROM\s+/i.test(cleaned)) { + continue; + } + + const parsed = parseFromInstruction(cleaned, argDefaults, stageAliases); + if (!parsed) { + continue; + } + if (parsed.alias) { + stageAliases.add(parsed.alias.toLowerCase()); + } + if (!parsed.isDependency) { + continue; + } + + dependencies.push(createDependency({ + name: parsed.name, + version: parsed.version, + ecosystem: "docker", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return dependencies; +} + +function parseCompose(content, sourceFile) { + const dependencies = []; + let servicesIndent = null; + let currentService = null; + + const flushCurrentService = () => { + if (!currentService) { + return; + } + if (!currentService.hasBuild && currentService.image) { + const parsed = parseDockerImageReference(currentService.image); + if (parsed && parsed.name.toLowerCase() !== "scratch") { + dependencies.push(createDependency({ + name: parsed.name, + version: parsed.version, + ecosystem: "docker", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + } + currentService = null; + }; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const cleaned = stripYamlComment(rawLine).trim(); + if (!cleaned) { + continue; + } + + const indent = countIndent(rawLine); + if (cleaned === "services:") { + flushCurrentService(); + servicesIndent = indent; + continue; + } + if (servicesIndent != null && indent <= servicesIndent && cleaned.endsWith(":")) { + flushCurrentService(); + servicesIndent = null; + } + if (servicesIndent == null || indent <= servicesIndent) { + continue; + } + + if (indent === servicesIndent + 2 && cleaned.endsWith(":")) { + flushCurrentService(); + currentService = { indent, hasBuild: false, image: "" }; + continue; + } + + if (!currentService || indent <= currentService.indent || cleaned.startsWith("- ")) { + continue; + } + + if (cleaned.startsWith("build:")) { + currentService.hasBuild = true; + continue; + } + if (cleaned.startsWith("image:")) { + currentService.image = unquote(cleaned.slice("image:".length).trim()); + } + } + + flushCurrentService(); + return dependencies; +} + +function toLogicalDockerLines(content) { + const lines = []; + let current = ""; + for (const rawLine of String(content || "").split(/\r?\n/)) { + const trimmed = rawLine.trimEnd(); + if (!trimmed) { + if (current) { + lines.push(current); + current = ""; + } + continue; + } + + const continues = trimmed.endsWith("\\"); + const segment = continues ? trimmed.slice(0, -1).trimEnd() : trimmed; + current += current ? ` ${segment}` : segment; + if (!continues) { + lines.push(current); + current = ""; + } + } + if (current) { + lines.push(current); + } + return lines; +} + +function stripDockerComment(line) { + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const previous = index > 0 ? line[index - 1] : ""; + if (char === "'" && !inDoubleQuote && previous !== "\\") { + inSingleQuote = !inSingleQuote; + continue; + } + if (char === "\"" && !inSingleQuote && previous !== "\\") { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (char === "#" && !inSingleQuote && !inDoubleQuote) { + return line.slice(0, index); + } + } + return line; +} + +function resolveDockerArgs(value, args) { + return String(value || "") + .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-+?])([^}]*))?}/g, (_match, name, operator, fallback) => { + if (args.has(name)) { + return args.get(name); + } + return operator === "-" || operator === ":-" ? fallback : _match; + }) + .replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, name) => (args.has(name) ? args.get(name) : match)); +} + +function parseFromInstruction(line, argDefaults, stageAliases) { + const parts = line.split(/\s+/).filter(Boolean); + let index = 1; + while (parts[index] && parts[index].startsWith("--")) { + index += 1; + } + const imageToken = parts[index]; + if (!imageToken) { + return null; + } + const alias = parts[index + 1] && /^AS$/i.test(parts[index + 1]) ? parts[index + 2] : ""; + const resolvedImage = resolveDockerArgs(unquote(imageToken), argDefaults).trim(); + if (!resolvedImage) { + return null; + } + const stageReference = stageAliases.has(resolvedImage.toLowerCase()); + const parsed = parseDockerImageReference(resolvedImage); + if (!parsed) { + return null; + } + return { + ...parsed, + alias: alias ? unquote(alias) : "", + isDependency: !stageReference && parsed.name.toLowerCase() !== "scratch", + }; +} + +function parseDockerImageReference(reference) { + const raw = unquote(reference); + if (!raw || raw.includes("$")) { + return null; + } + const withoutDigest = raw.split("@")[0]; + const digest = raw.includes("@") ? raw.split("@")[1] : ""; + const lastSlash = withoutDigest.lastIndexOf("/"); + const lastColon = withoutDigest.lastIndexOf(":"); + const hasTag = lastColon > lastSlash; + const name = hasTag ? withoutDigest.slice(0, lastColon) : withoutDigest; + const version = hasTag ? withoutDigest.slice(lastColon + 1) : digest || "latest"; + if (!name) { + return null; + } + return { name, version }; +} + +function unquote(value) { + return String(value || "").trim().replace(/^["']|["']$/g, ""); +} + +function isComposeFileName(fileName) { + return ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"].includes(fileName); +} + +module.exports = dockerParser; diff --git a/util/lockfileParsers/goParser.js b/util/lockfileParsers/goParser.js new file mode 100644 index 0000000..2ec141b --- /dev/null +++ b/util/lockfileParsers/goParser.js @@ -0,0 +1,82 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); + +const goParser = { + name: "goParser", + ecosystem: "go", + + async canResolve(workspaceFolder) { + return pathExists(path.join(getWorkspacePath(workspaceFolder), "go.mod"), workspaceFolder); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const manifestPath = path.join(rootPath, "go.mod"); + if (!(await pathExists(manifestPath, workspaceFolder))) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath: manifestPath, + manifestPath, + sourceFile: "go.mod", + }]; + }, + + async resolve({ manifestPath, workspaceFolder }) { + const dependencies = []; + const sourceFile = getSourceFileName(manifestPath); + let inRequireBlock = false; + + for (const rawLine of String(await readUtf8(manifestPath, workspaceFolder)).split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("//")) { + continue; + } + if (line === "require (") { + inRequireBlock = true; + continue; + } + if (line === ")" && inRequireBlock) { + inRequireBlock = false; + continue; + } + + const lineToParse = line.startsWith("require ") ? line.slice("require ".length).trim() : line; + if (!inRequireBlock && !line.startsWith("require ")) { + continue; + } + + const cleaned = lineToParse.split("//")[0].trim(); + const parts = cleaned.split(/\s+/); + if (parts.length < 2) { + continue; + } + + dependencies.push(createDependency({ + name: parts[0], + version: parts[1].replace(/^v/, ""), + ecosystem: "go", + isDirect: !rawLine.includes("// indirect"), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: rawLine.includes("// indirect"), + })); + } + + return buildTree("go", sourceFile, dependencies); + }, +}; + +module.exports = goParser; diff --git a/util/lockfileParsers/gradleParser.js b/util/lockfileParsers/gradleParser.js new file mode 100644 index 0000000..180bba9 --- /dev/null +++ b/util/lockfileParsers/gradleParser.js @@ -0,0 +1,124 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parseBuildGradleManifest } = require("./manifestHelpers"); + +const BUILD_FILES = ["build.gradle", "build.gradle.kts"]; + +const gradleParser = { + name: "gradleParser", + ecosystem: "gradle", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + for (const buildFile of BUILD_FILES) { + if (await pathExists(path.join(rootPath, buildFile), workspaceFolder)) { + return true; + } + } + return false; + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + for (const buildFile of BUILD_FILES) { + const manifestPath = path.join(rootPath, buildFile); + if (!(await pathExists(manifestPath, workspaceFolder))) { + continue; + } + const lockfilePath = await pathExists(path.join(rootPath, "gradle.lockfile"), workspaceFolder) + ? path.join(rootPath, "gradle.lockfile") + : null; + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: buildFile, + }]; + } + return []; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const directDependencies = parseBuildGradleManifest(await readUtf8(manifestPath, workspaceFolder)); + const sourceFile = getSourceFileName(manifestPath); + + if (!lockfilePath) { + return buildTree("gradle", sourceFile, directDependencies.map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "gradle", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const lockVersions = parseGradleLockfile(await readUtf8(lockfilePath, workspaceFolder)); + const dependencies = []; + + for (const directDependency of directDependencies) { + const resolvedVersion = lockVersions.get(directDependency.name) || directDependency.version; + dependencies.push(createDependency({ + name: directDependency.name, + version: resolvedVersion, + ecosystem: "gradle", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: directDependency.isDevelopmentDependency, + })); + } + + for (const [name, version] of lockVersions.entries()) { + dependencies.push(createDependency({ + name, + version, + ecosystem: "gradle", + isDirect: directDependencies.some((dependency) => dependency.name === name), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("gradle", sourceFile, deduplicateDeps(dependencies)); + }, +}; + +function parseGradleLockfile(content) { + const versions = new Map(); + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const entry = line.split("=", 1)[0].trim(); + const parts = entry.split(":"); + if (parts.length < 3) { + continue; + } + versions.set(`${parts[0]}:${parts[1]}`, parts[2]); + } + + return versions; +} + +module.exports = gradleParser; diff --git a/util/lockfileParsers/helmParser.js b/util/lockfileParsers/helmParser.js new file mode 100644 index 0000000..abc5a61 --- /dev/null +++ b/util/lockfileParsers/helmParser.js @@ -0,0 +1,62 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parseChartManifest } = require("./manifestHelpers"); + +const helmParser = { + name: "helmParser", + ecosystem: "helm", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "Chart.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "Chart.yaml"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "Chart.lock"), workspaceFolder) + ? path.join(rootPath, "Chart.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "Chart.yaml"), workspaceFolder) + ? path.join(rootPath, "Chart.yaml") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourcePath = lockfilePath || manifestPath; + const sourceFile = getSourceFileName(sourcePath); + const dependencies = parseChartManifest(await readUtf8(sourcePath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "helm", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + + return buildTree("helm", sourceFile, dependencies); + }, +}; + +module.exports = helmParser; diff --git a/util/lockfileParsers/hexParser.js b/util/lockfileParsers/hexParser.js new file mode 100644 index 0000000..a0808b2 --- /dev/null +++ b/util/lockfileParsers/hexParser.js @@ -0,0 +1,86 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parseMixExsManifest } = require("./manifestHelpers"); + +const hexParser = { + name: "hexParser", + ecosystem: "hex", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "mix.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "mix.exs"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "mix.lock"), workspaceFolder) + ? path.join(rootPath, "mix.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "mix.exs"), workspaceFolder) + ? path.join(rootPath, "mix.exs") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + if (!lockfilePath) { + return buildTree("hex", sourceFile, parseMixExsManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "hex", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }))); + } + + const directNames = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? new Set(parseMixExsManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => dependency.name.toLowerCase())) + : new Set(); + const entryPattern = /"([^"]+)"\s*:\s*\{\s*:hex,\s*(?::"[^"]+"|:[^,]+)\s*,\s*"([^"]+)"/g; + const dependencies = []; + for (const match of String(await readUtf8(lockfilePath, workspaceFolder)).matchAll(entryPattern)) { + dependencies.push(createDependency({ + name: match[1], + version: match[2], + ecosystem: "hex", + isDirect: directNames.size === 0 || directNames.has(match[1].toLowerCase()), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + if (dependencies.length === 0) { + throw new Error("Malformed mix.lock: no Hex package entries found"); + } + + return buildTree("hex", sourceFile, dependencies); + }, +}; + +module.exports = hexParser; diff --git a/util/lockfileParsers/nugetParser.js b/util/lockfileParsers/nugetParser.js new file mode 100644 index 0000000..95f8c7c --- /dev/null +++ b/util/lockfileParsers/nugetParser.js @@ -0,0 +1,190 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const fs = require("fs"); +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + pathExists, + readJson, + readUtf8, + resolveWorkspaceFilePath, +} = require("./shared"); +const { parseCsprojManifest } = require("./manifestHelpers"); + +const nugetParser = { + name: "nugetParser", + ecosystem: "nuget", + + async canResolve(workspaceFolder) { + const matches = await this.detect(workspaceFolder); + return matches.length > 0; + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const safeRootPath = await resolveWorkspaceFilePath(rootPath, workspaceFolder); + if (!safeRootPath) { + return []; + } + const entries = await fs.promises.readdir(safeRootPath); + const csprojPath = entries.find((entry) => entry.toLowerCase().endsWith(".csproj")); + const lockfilePath = await pathExists(path.join(safeRootPath, "packages.lock.json"), workspaceFolder) + ? path.join(safeRootPath, "packages.lock.json") + : null; + const manifestPath = csprojPath ? path.join(safeRootPath, csprojPath) : null; + + if (!lockfilePath && !manifestPath) { + return []; + } + + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + const manifestDependencies = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? parseCsprojManifest(await readUtf8(manifestPath, workspaceFolder)) + : []; + const directNames = new Set(manifestDependencies.map((dependency) => dependency.name.toLowerCase())); + + if (!lockfilePath) { + return buildTree("nuget", sourceFile, manifestDependencies.map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "nuget", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const root = await readJson(lockfilePath, workspaceFolder); + const dependencyRoot = root && root.dependencies && typeof root.dependencies === "object" + ? root.dependencies + : null; + if (!dependencyRoot) { + throw new Error("Malformed packages.lock.json: missing dependencies object"); + } + + const recordsByName = new Map(); + for (const frameworkDependencies of Object.values(dependencyRoot)) { + if (!frameworkDependencies || typeof frameworkDependencies !== "object") { + continue; + } + for (const [name, details] of Object.entries(frameworkDependencies)) { + const dependencies = details && details.dependencies && typeof details.dependencies === "object" + ? Object.keys(details.dependencies) + : []; + const existing = recordsByName.get(name.toLowerCase()); + recordsByName.set(name.toLowerCase(), { + name, + version: details.resolved || "", + dependencies: deduplicateStringValues([...(existing ? existing.dependencies : []), ...dependencies]), + isDirect: Boolean(existing && existing.isDirect) || String(details.type || "").toLowerCase() === "direct", + }); + } + } + + const rootRecords = manifestDependencies.length > 0 + ? manifestDependencies.map((dependency) => recordsByName.get(dependency.name.toLowerCase())).filter(Boolean) + : [...recordsByName.values()].filter((record) => record.isDirect); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildNugetDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + directNames + ))); + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + + for (const record of recordsByName.values()) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "nuget", + isDirect: directNames.has(record.name.toLowerCase()) || record.isDirect, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("nuget", sourceFile, dependencies); + }, +}; + +function buildNugetDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "nuget", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()) || record.isDirect, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependencyName of record.dependencies) { + const childRecord = recordsByName.get(dependencyName.toLowerCase()); + if (!childRecord) { + continue; + } + transitives.push(buildNugetDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + directNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "nuget", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()) || record.isDirect, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: false, + }); +} + +function deduplicateStringValues(values) { + return [...new Set(values.filter(Boolean))]; +} + +module.exports = nugetParser; diff --git a/util/lockfileParsers/rubyParser.js b/util/lockfileParsers/rubyParser.js new file mode 100644 index 0000000..c673653 --- /dev/null +++ b/util/lockfileParsers/rubyParser.js @@ -0,0 +1,217 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + countIndent, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parseGemfileManifest } = require("./manifestHelpers"); + +const rubyParser = { + name: "rubyParser", + ecosystem: "ruby", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "Gemfile.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "Gemfile"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "Gemfile.lock"), workspaceFolder) + ? path.join(rootPath, "Gemfile.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "Gemfile"), workspaceFolder) + ? path.join(rootPath, "Gemfile") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + if (!lockfilePath) { + const dependencies = parseGemfileManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "ruby", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + })); + return buildTree("ruby", sourceFile, dependencies); + } + + const directNames = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? new Set(parseGemfileManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => dependency.name.toLowerCase())) + : null; + const records = parseGemfileLock(await readUtf8(lockfilePath, workspaceFolder)); + const recordsByName = new Map(); + const incomingCounts = new Map(); + + for (const record of records) { + recordsByName.set(record.name.toLowerCase(), record); + for (const dependencyName of record.dependencies) { + incomingCounts.set(dependencyName.toLowerCase(), (incomingCounts.get(dependencyName.toLowerCase()) || 0) + 1); + } + } + + const rootRecords = directNames && directNames.size > 0 + ? [...directNames].map((name) => recordsByName.get(name)).filter(Boolean) + : records.filter((record) => !incomingCounts.get(record.name.toLowerCase())); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildRubyDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + directNames || new Set() + ))); + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + for (const record of records) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "ruby", + isDirect: directNames ? directNames.has(record.name.toLowerCase()) : false, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("ruby", sourceFile, dependencies); + }, +}; + +function parseGemfileLock(content) { + const records = []; + let section = ""; + let inSpecs = false; + let current = null; + + const flushCurrent = () => { + if (current && current.name && current.version) { + records.push(current); + } + current = null; + }; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const trimmed = rawLine.trimEnd(); + if (!trimmed) { + continue; + } + const indent = countIndent(rawLine); + const line = trimmed.trim(); + + if (indent === 0 && /^[A-Z][A-Z0-9_ ]+$/.test(line)) { + flushCurrent(); + section = line; + inSpecs = false; + continue; + } + if (section === "GEM" && indent === 2 && line === "specs:") { + inSpecs = true; + continue; + } + if (!inSpecs) { + continue; + } + if (indent === 4) { + flushCurrent(); + const match = line.match(/^([^\s(]+) \(([^)]+)\)/); + if (!match) { + continue; + } + current = { name: match[1], version: match[2], dependencies: [] }; + continue; + } + if (indent >= 6 && current) { + const dependencyName = line.split(" ", 1)[0].split("(", 1)[0].replace(/!$/, "").trim(); + if (dependencyName) { + current.dependencies.push(dependencyName); + } + } + } + + flushCurrent(); + return records; +} + +function buildRubyDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "ruby", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependencyName of record.dependencies) { + const childRecord = recordsByName.get(dependencyName.toLowerCase()); + if (!childRecord) { + continue; + } + transitives.push(buildRubyDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + directNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "ruby", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: false, + }); +} + +module.exports = rubyParser; diff --git a/util/lockfileParsers/swiftParser.js b/util/lockfileParsers/swiftParser.js new file mode 100644 index 0000000..0e21f88 --- /dev/null +++ b/util/lockfileParsers/swiftParser.js @@ -0,0 +1,89 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + getSourceFileName, + getWorkspacePath, + readJson, + pathExists, + readUtf8, +} = require("./shared"); +const { normalizeSwiftIdentity, parsePackageSwiftManifest } = require("./manifestHelpers"); + +const swiftParser = { + name: "swiftParser", + ecosystem: "swift", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "Package.resolved"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "Package.swift"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "Package.resolved"), workspaceFolder) + ? path.join(rootPath, "Package.resolved") + : null; + const manifestPath = await pathExists(path.join(rootPath, "Package.swift"), workspaceFolder) + ? path.join(rootPath, "Package.swift") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + if (!lockfilePath) { + return buildTree("swift", sourceFile, parsePackageSwiftManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "swift", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }))); + } + + const manifestDirectNames = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? new Set(parsePackageSwiftManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => dependency.name)) + : new Set(); + const root = await readJson(lockfilePath); + const pins = Array.isArray(root.pins) + ? root.pins + : (root.object && Array.isArray(root.object.pins) ? root.object.pins : []); + if (pins.length === 0) { + throw new Error("Malformed Package.resolved: missing pins array"); + } + + return buildTree("swift", sourceFile, pins.map((pin) => { + const state = pin.state || {}; + const identity = normalizeSwiftIdentity(pin.identity || pin.package || pin.location || ""); + return createDependency({ + name: identity, + version: state.version || state.revision || state.branch || "", + ecosystem: "swift", + isDirect: manifestDirectNames.size === 0 || manifestDirectNames.has(identity), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + })); + }, +}; + +module.exports = swiftParser; diff --git a/util/lockfileResolver.js b/util/lockfileResolver.js new file mode 100644 index 0000000..844a51e --- /dev/null +++ b/util/lockfileResolver.js @@ -0,0 +1,140 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const npmParser = require("./lockfileParsers/npmParser"); +const pythonParser = require("./lockfileParsers/pythonParser"); +const mavenParser = require("./lockfileParsers/mavenParser"); +const gradleParser = require("./lockfileParsers/gradleParser"); +const goParser = require("./lockfileParsers/goParser"); +const cargoParser = require("./lockfileParsers/cargoParser"); +const rubyParser = require("./lockfileParsers/rubyParser"); +const dockerParser = require("./lockfileParsers/dockerParser"); +const nugetParser = require("./lockfileParsers/nugetParser"); +const dartParser = require("./lockfileParsers/dartParser"); +const composerParser = require("./lockfileParsers/composerParser"); +const helmParser = require("./lockfileParsers/helmParser"); +const swiftParser = require("./lockfileParsers/swiftParser"); +const hexParser = require("./lockfileParsers/hexParser"); +const { + getWorkspacePath, + resolveWorkspaceFilePath, +} = require("./lockfileParsers/shared"); + +const REGISTERED_RESOLVERS = [ + npmParser, + pythonParser, + mavenParser, + gradleParser, + goParser, + cargoParser, + rubyParser, + dockerParser, + nugetParser, + dartParser, + composerParser, + helmParser, + swiftParser, + hexParser, +]; + +class LockfileResolver { + static getResolvers() { + return REGISTERED_RESOLVERS.slice(); + } + + static async detectResolvers(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const matches = []; + + for (const resolver of REGISTERED_RESOLVERS) { + if (!resolver || typeof resolver.canResolve !== "function") { + continue; + } + + if (!(await resolver.canResolve(rootPath))) { + continue; + } + + const detections = typeof resolver.detect === "function" + ? await resolver.detect(rootPath) + : [{ + resolverName: resolver.name, + ecosystem: resolver.ecosystem, + workspaceFolder: rootPath, + lockfilePath: null, + manifestPath: null, + }]; + + for (const detection of detections) { + matches.push({ + resolverName: resolver.name, + ecosystem: resolver.ecosystem, + workspaceFolder: rootPath, + lockfilePath: detection.lockfilePath || null, + manifestPath: detection.manifestPath || null, + sourceFile: detection.sourceFile + || path.basename(detection.lockfilePath || detection.manifestPath || ""), + }); + } + } + + return matches; + } + + static async resolve(resolverName, lockfilePath, manifestPath, options = {}) { + const resolver = REGISTERED_RESOLVERS.find((candidate) => candidate.name === resolverName); + if (!resolver) { + throw new Error(`Unknown lockfile resolver: ${resolverName}`); + } + + const workspaceFolder = getWorkspacePath(options.workspaceFolder || path.dirname(lockfilePath || manifestPath || "")); + const safeLockfilePath = lockfilePath + ? await resolveWorkspaceFilePath(lockfilePath, workspaceFolder) + : null; + const safeManifestPath = manifestPath + ? await resolveWorkspaceFilePath(manifestPath, workspaceFolder) + : null; + + if (lockfilePath && !safeLockfilePath) { + throw new Error("Lockfile paths must stay within the workspace folder."); + } + + if (manifestPath && !safeManifestPath) { + throw new Error("Manifest paths must stay within the workspace folder."); + } + + return resolver.resolve({ + workspaceFolder, + lockfilePath: safeLockfilePath, + manifestPath: safeManifestPath, + options, + }); + } + + static async resolveAll(workspaceFolder, options = {}) { + const matches = await LockfileResolver.detectResolvers(workspaceFolder); + const trees = []; + + for (const match of matches) { + const tree = await LockfileResolver.resolve( + match.resolverName, + match.lockfilePath, + match.manifestPath, + { + ...options, + workspaceFolder: match.workspaceFolder, + detection: match, + } + ); + + if (tree) { + trees.push(tree); + } + } + + return trees; + } +} + +module.exports = { + LockfileResolver, +};