From 52bb9768144eac83bfa76b9e82271b10fcb2e356 Mon Sep 17 00:00:00 2001 From: Devon Lawler Date: Wed, 8 Apr 2026 15:38:55 -0400 Subject: [PATCH] feat: integrate dependency health v2 provider and tree models --- models/dependencyHealthNode.js | 667 +++-- models/dependencySourceGroupNode.js | 35 + models/dependencySummaryNode.js | 175 ++ test/dependencyHealthProvider.test.js | 399 ++- test/dependencyLicenseEnricher.test.js | 68 + test/dependencyPolicyEnricher.test.js | 51 + test/dependencyVulnEnricher.test.js | 183 ++ test/fixtures/cargo/Cargo.lock | 25 + test/fixtures/cargo/Cargo.toml | 9 + test/fixtures/composer/composer.json | 8 + test/fixtures/composer/composer.lock | 28 + test/fixtures/dart/pubspec.lock | 15 + test/fixtures/dart/pubspec.yaml | 7 + test/fixtures/docker/Dockerfile | 5 + test/fixtures/docker/docker-compose.yml | 7 + test/fixtures/go/go.mod | 8 + test/fixtures/gradle/build.gradle | 8 + test/fixtures/gradle/gradle.lockfile | 2 + test/fixtures/helm/Chart.lock | 5 + test/fixtures/helm/Chart.yaml | 6 + test/fixtures/hex/mix.exs | 16 + test/fixtures/hex/mix.lock | 3 + test/fixtures/maven/dependency-tree.txt | 3 + test/fixtures/maven/pom.xml | 14 + test/fixtures/npm/package-lock.json | 29 + test/fixtures/npm/package.json | 8 + test/fixtures/npm/pnpm-lock.yaml | 20 + test/fixtures/npm/yarn.lock | 10 + test/fixtures/nuget/Fixture.csproj | 5 + test/fixtures/nuget/packages.lock.json | 18 + test/fixtures/python/Pipfile.lock | 12 + test/fixtures/python/poetry.lock | 14 + test/fixtures/python/pyproject.toml | 15 + test/fixtures/python/requirements.txt | 2 + test/fixtures/python/uv.lock | 18 + test/fixtures/ruby/Gemfile | 3 + test/fixtures/ruby/Gemfile.lock | 9 + test/fixtures/swift/Package.resolved | 14 + test/fixtures/swift/Package.swift | 9 + test/foundDependencyKey.test.js | 104 + test/helpers/fixtureWorkspace.js | 46 + test/lockfileParsers/cargoParser.test.js | 119 + test/lockfileParsers/dockerParser.test.js | 78 + test/lockfileParsers/mavenParser.test.js | 81 + test/lockfileParsers/npmParser.test.js | 258 ++ test/lockfileParsers/nugetParser.test.js | 28 + test/lockfileParsers/pythonParser.test.js | 109 + test/lockfileResolver.test.js | 204 ++ test/treeVisualization.test.js | 198 ++ test/upstreamGapAnalyzer.test.js | 136 + test/upstreamPullService.test.js | 312 +++ util/dependencyLicenseEnricher.js | 75 + util/dependencyPolicyEnricher.js | 67 + util/dependencyVulnEnricher.js | 442 +++ util/formatIcons.js | 82 + util/foundDependencyKey.js | 21 + util/lockfileParsers/cargoParser.js | 283 ++ util/lockfileParsers/composerParser.js | 174 ++ util/lockfileParsers/dartParser.js | 124 + util/lockfileParsers/dockerParser.js | 300 +++ util/lockfileParsers/goParser.js | 82 + util/lockfileParsers/gradleParser.js | 124 + util/lockfileParsers/helmParser.js | 62 + util/lockfileParsers/hexParser.js | 86 + util/lockfileParsers/manifestHelpers.js | 650 +++++ util/lockfileParsers/mavenParser.js | 181 ++ util/lockfileParsers/npmParser.js | 836 ++++++ util/lockfileParsers/nugetParser.js | 190 ++ util/lockfileParsers/pythonParser.js | 383 +++ util/lockfileParsers/rubyParser.js | 217 ++ util/lockfileParsers/shared.js | 412 +++ util/lockfileParsers/swiftParser.js | 89 + util/lockfileResolver.js | 140 + util/manifestParser.js | 120 +- util/packageNameNormalizer.js | 143 + util/registryEndpoints.js | 600 +++++ util/upstreamChecker.js | 114 +- util/upstreamGapAnalyzer.js | 213 ++ util/upstreamPullService.js | 1231 +++++++++ views/dependencyHealthProvider.js | 2948 ++++++++++++++++++--- 80 files changed, 13293 insertions(+), 702 deletions(-) create mode 100644 models/dependencySourceGroupNode.js create mode 100644 models/dependencySummaryNode.js create mode 100644 test/dependencyLicenseEnricher.test.js create mode 100644 test/dependencyPolicyEnricher.test.js create mode 100644 test/dependencyVulnEnricher.test.js create mode 100644 test/fixtures/cargo/Cargo.lock create mode 100644 test/fixtures/cargo/Cargo.toml create mode 100644 test/fixtures/composer/composer.json create mode 100644 test/fixtures/composer/composer.lock create mode 100644 test/fixtures/dart/pubspec.lock create mode 100644 test/fixtures/dart/pubspec.yaml create mode 100644 test/fixtures/docker/Dockerfile create mode 100644 test/fixtures/docker/docker-compose.yml create mode 100644 test/fixtures/go/go.mod create mode 100644 test/fixtures/gradle/build.gradle create mode 100644 test/fixtures/gradle/gradle.lockfile create mode 100644 test/fixtures/helm/Chart.lock create mode 100644 test/fixtures/helm/Chart.yaml create mode 100644 test/fixtures/hex/mix.exs create mode 100644 test/fixtures/hex/mix.lock create mode 100644 test/fixtures/maven/dependency-tree.txt create mode 100644 test/fixtures/maven/pom.xml create mode 100644 test/fixtures/npm/package-lock.json create mode 100644 test/fixtures/npm/package.json create mode 100644 test/fixtures/npm/pnpm-lock.yaml create mode 100644 test/fixtures/npm/yarn.lock create mode 100644 test/fixtures/nuget/Fixture.csproj create mode 100644 test/fixtures/nuget/packages.lock.json create mode 100644 test/fixtures/python/Pipfile.lock create mode 100644 test/fixtures/python/poetry.lock create mode 100644 test/fixtures/python/pyproject.toml create mode 100644 test/fixtures/python/requirements.txt create mode 100644 test/fixtures/python/uv.lock create mode 100644 test/fixtures/ruby/Gemfile create mode 100644 test/fixtures/ruby/Gemfile.lock create mode 100644 test/fixtures/swift/Package.resolved create mode 100644 test/fixtures/swift/Package.swift create mode 100644 test/foundDependencyKey.test.js create mode 100644 test/helpers/fixtureWorkspace.js create mode 100644 test/lockfileParsers/cargoParser.test.js create mode 100644 test/lockfileParsers/dockerParser.test.js create mode 100644 test/lockfileParsers/mavenParser.test.js create mode 100644 test/lockfileParsers/npmParser.test.js create mode 100644 test/lockfileParsers/nugetParser.test.js create mode 100644 test/lockfileParsers/pythonParser.test.js create mode 100644 test/lockfileResolver.test.js create mode 100644 test/treeVisualization.test.js create mode 100644 test/upstreamGapAnalyzer.test.js create mode 100644 test/upstreamPullService.test.js create mode 100644 util/dependencyLicenseEnricher.js create mode 100644 util/dependencyPolicyEnricher.js create mode 100644 util/dependencyVulnEnricher.js create mode 100644 util/formatIcons.js create mode 100644 util/foundDependencyKey.js create mode 100644 util/lockfileParsers/cargoParser.js create mode 100644 util/lockfileParsers/composerParser.js create mode 100644 util/lockfileParsers/dartParser.js create mode 100644 util/lockfileParsers/dockerParser.js create mode 100644 util/lockfileParsers/goParser.js create mode 100644 util/lockfileParsers/gradleParser.js create mode 100644 util/lockfileParsers/helmParser.js create mode 100644 util/lockfileParsers/hexParser.js create mode 100644 util/lockfileParsers/manifestHelpers.js create mode 100644 util/lockfileParsers/mavenParser.js create mode 100644 util/lockfileParsers/npmParser.js create mode 100644 util/lockfileParsers/nugetParser.js create mode 100644 util/lockfileParsers/pythonParser.js create mode 100644 util/lockfileParsers/rubyParser.js create mode 100644 util/lockfileParsers/shared.js create mode 100644 util/lockfileParsers/swiftParser.js create mode 100644 util/lockfileResolver.js create mode 100644 util/packageNameNormalizer.js create mode 100644 util/registryEndpoints.js create mode 100644 util/upstreamGapAnalyzer.js create mode 100644 util/upstreamPullService.js diff --git a/models/dependencyHealthNode.js b/models/dependencyHealthNode.js index 27b99ad..a087681 100644 --- a/models/dependencyHealthNode.js +++ b/models/dependencyHealthNode.js @@ -1,259 +1,568 @@ -// Dependency health node treeview - represents a single dependency from the project manifest -// cross-referenced against Cloudsmith - +// Copyright 2026 Cloudsmith Ltd. All rights reserved. const vscode = require("vscode"); const { LicenseClassifier } = require("../util/licenseClassifier"); +const { getFormatIconPath } = require("../util/formatIcons"); +const { canonicalFormat } = require("../util/packageNameNormalizer"); class DependencyHealthNode { - /** - * @param {{name: string, version: string, devDependency: boolean, format: string}} dep - * Parsed dependency from the project manifest. - * @param {Object|null} cloudsmithMatch - * Matching package from Cloudsmith API, or null if not found. - * @param {vscode.ExtensionContext} context - */ - constructor(dep, cloudsmithMatch, context) { - this.context = context; + constructor(dep, cloudsmithMatchOrContext, maybeContext, maybeOptions) { + const hasExplicitCloudsmithMatch = arguments.length >= 3 + || ( + cloudsmithMatchOrContext + && typeof cloudsmithMatchOrContext === "object" + && ( + Object.prototype.hasOwnProperty.call(cloudsmithMatchOrContext, "status_str") + || Object.prototype.hasOwnProperty.call(cloudsmithMatchOrContext, "slug_perm") + || Object.prototype.hasOwnProperty.call(cloudsmithMatchOrContext, "namespace") + ) + ); + + this.context = hasExplicitCloudsmithMatch ? maybeContext : cloudsmithMatchOrContext; + this.options = hasExplicitCloudsmithMatch ? (maybeOptions || {}) : (maybeContext || {}); this.name = dep.name; this.declaredVersion = dep.version; - this.format = dep.format; - this.isDev = dep.devDependency; - this.isDirect = dep.isDirect !== false; // default to direct if not specified - this.cloudsmithMatch = cloudsmithMatch; - - // Derive state from the Cloudsmith match + this.format = dep.format || canonicalFormat(dep.ecosystem); + this.ecosystem = dep.ecosystem || this.format; + this.sourceFile = dep.sourceFile || null; + this.isDev = Boolean(dep.devDependency || dep.isDevelopmentDependency); + this.isDirect = dep.isDirect !== false; + this.parent = dep.parent || (Array.isArray(dep.parentChain) ? dep.parentChain[dep.parentChain.length - 1] : null); + this.parentChain = Array.isArray(dep.parentChain) ? dep.parentChain.slice() : []; + this.transitives = Array.isArray(dep.transitives) ? dep.transitives.slice() : []; + this.cloudsmithMatch = dep.cloudsmithPackage + || dep.cloudsmithMatch + || (hasExplicitCloudsmithMatch ? cloudsmithMatchOrContext : null); + this.cloudsmithStatus = dep.cloudsmithStatus || (this.cloudsmithMatch ? "FOUND" : null); + this.vulnerabilities = dep.vulnerabilities || null; + this.licenseData = dep.license || null; + this.policy = dep.policy || null; + this.upstreamStatus = dep.upstreamStatus || null; + this.upstreamDetail = dep.upstreamDetail || null; + this._childMode = this.options.childMode || "details"; + this._treeChildren = Array.isArray(this.options.treeChildren) ? this.options.treeChildren.slice() : []; + this._duplicateReference = Boolean(this.options.duplicateReference); + this._firstOccurrencePath = this.options.firstOccurrencePath || null; + this._dimmedForFilter = Boolean(this.options.dimmedForFilter); + this._treeChildFactory = typeof this.options.treeChildFactory === "function" + ? this.options.treeChildFactory + : null; + this.licenseInfo = this._deriveLicenseInfo(); this.state = this._deriveState(); - // Store fields from the Cloudsmith match for command compatibility - if (cloudsmithMatch) { - this.namespace = cloudsmithMatch.namespace; - this.repository = cloudsmithMatch.repository; - this.slug_perm = { id: "Slug", value: cloudsmithMatch.slug_perm }; - this.slug_perm_raw = cloudsmithMatch.slug_perm; - this.version = { id: "Version", value: cloudsmithMatch.version }; - this.status_str = { id: "Status", value: cloudsmithMatch.status_str }; - this.self_webapp_url = cloudsmithMatch.self_webapp_url || null; - this.checksum_sha256 = cloudsmithMatch.checksum_sha256 || null; - this.version_digest = cloudsmithMatch.version_digest || null; - this.tags_raw = cloudsmithMatch.tags || {}; - this.cdn_url = cloudsmithMatch.cdn_url || null; - this.filename = cloudsmithMatch.filename || null; - this.num_vulnerabilities = cloudsmithMatch.num_vulnerabilities || 0; - this.max_severity = cloudsmithMatch.max_severity || null; - this.status_reason = cloudsmithMatch.status_reason || null; - this.licenseInfo = LicenseClassifier.inspect(cloudsmithMatch); - this.spdx_license = this.licenseInfo.spdxLicense; - this.raw_license = this.licenseInfo.rawLicense; - this.license = this.licenseInfo.displayValue; - this.license_url = this.licenseInfo.licenseUrl; + if (this.cloudsmithMatch) { + this.namespace = this.cloudsmithMatch.namespace; + this.repository = this.cloudsmithMatch.repository; + this.slug_perm = { id: "Slug", value: this.cloudsmithMatch.slug_perm }; + this.slug_perm_raw = this.cloudsmithMatch.slug_perm; + this.version = { id: "Version", value: this.cloudsmithMatch.version }; + this.status_str = { id: "Status", value: this.cloudsmithMatch.status_str }; + this.self_webapp_url = this.cloudsmithMatch.self_webapp_url || null; + this.checksum_sha256 = this.cloudsmithMatch.checksum_sha256 || null; + this.version_digest = this.cloudsmithMatch.version_digest || null; + this.tags_raw = this.cloudsmithMatch.tags || {}; + this.cdn_url = this.cloudsmithMatch.cdn_url || null; + this.filename = this.cloudsmithMatch.filename || null; + this.num_vulnerabilities = this.cloudsmithMatch.num_vulnerabilities || 0; + this.max_severity = this.cloudsmithMatch.max_severity || null; + this.status_reason = this.cloudsmithMatch.status_reason || null; } + this.spdx_license = this.licenseInfo.spdxLicense; + this.raw_license = this.licenseInfo.rawLicense; + this.license = this.licenseInfo.displayValue; + this.license_url = this.licenseInfo.licenseUrl; } - /** - * Derive the health state from the Cloudsmith match. - * @returns {"available"|"quarantined"|"violated"|"not_found"|"syncing"} - */ - _deriveState() { - if (!this.cloudsmithMatch) { - return "not_found"; + _deriveLicenseInfo() { + if (this.licenseData) { + return LicenseClassifier.inspect({ + license: this.licenseData.display || this.licenseData.raw || null, + spdx_license: this.licenseData.spdx || null, + license_url: this.licenseData.url || null, + }); } - const match = this.cloudsmithMatch; + if (this.cloudsmithMatch) { + return LicenseClassifier.inspect(this.cloudsmithMatch); + } - if (match.status_str === "Quarantined") { - return "quarantined"; + return LicenseClassifier.inspect(null); + } + + _deriveState() { + if (this.cloudsmithStatus === "CHECKING") { + return "checking"; } - if (match.status_str !== "Completed") { - return "syncing"; + if (this.cloudsmithStatus !== "FOUND" || !this.cloudsmithMatch) { + return "not_found"; } - if (match.deny_policy_violated || match.policy_violated) { + if (this._isQuarantined()) { + return "quarantined"; + } + + if ( + this._hasVulnerabilities() + || this._hasPolicyViolation() + || this._hasRestrictiveLicense() + || this._hasWeakCopyleftLicense() + ) { return "violated"; } return "available"; } + _hasVulnerabilities() { + return Boolean(this._getVulnerabilityData() && this._getVulnerabilityData().count > 0); + } + + _hasCriticalVulnerability() { + const vulnerabilities = this._getVulnerabilityData(); + return Boolean(vulnerabilities && vulnerabilities.count > 0 && vulnerabilities.maxSeverity === "Critical"); + } + + _hasHighVulnerability() { + const vulnerabilities = this._getVulnerabilityData(); + return Boolean(vulnerabilities && vulnerabilities.count > 0 && vulnerabilities.maxSeverity === "High"); + } + + _hasMediumOrLowVulnerability() { + return this._hasVulnerabilities() + && !this._hasCriticalVulnerability() + && !this._hasHighVulnerability(); + } + + _hasRestrictiveLicense() { + return Boolean( + (this.licenseData && this.licenseData.classification === "restrictive") + || this.licenseInfo.tier === "restrictive" + ); + } + + _hasWeakCopyleftLicense() { + return Boolean( + (this.licenseData && this.licenseData.classification === "weak_copyleft") + || this.licenseInfo.tier === "cautious" + ); + } + + _hasPolicyViolation() { + const policy = this._getPolicyData(); + return Boolean(policy && policy.violated); + } + + _isQuarantined() { + const policy = this._getPolicyData(); + return Boolean(policy && (policy.quarantined || policy.denied)); + } + + _getLicenseLabel() { + if (this.licenseData) { + return this.licenseData.display || this.licenseData.spdx || this.licenseData.raw || null; + } + + return this.licenseInfo.displayValue || null; + } + + _shouldFlagRestrictiveLicenses() { + const config = vscode.workspace.getConfiguration("cloudsmith-vsc"); + return config.get("flagRestrictiveLicenses") !== false; + } + + _getContextValue() { + if (this.cloudsmithStatus === "CHECKING") { + return "dependencyHealthSyncing"; + } + + if (this.cloudsmithStatus !== "FOUND") { + if (this.upstreamStatus === "reachable") { + return "dependencyHealthUpstreamReachable"; + } + + if (this.upstreamStatus === "no_proxy" || this.upstreamStatus === "unreachable") { + return "dependencyHealthUpstreamUnreachable"; + } + + return "dependencyHealthMissing"; + } + + if (this._isQuarantined()) { + return "dependencyHealthQuarantined"; + } + + if (this._hasVulnerabilities()) { + return "dependencyHealthVulnerable"; + } + + return "dependencyHealthFound"; + } + _getStateIcon() { - switch (this.state) { - case "available": - return new vscode.ThemeIcon("check", new vscode.ThemeColor("testing.iconPassed")); - case "quarantined": - return new vscode.ThemeIcon("error", new vscode.ThemeColor("errorForeground")); - case "violated": - return new vscode.ThemeIcon("warning", new vscode.ThemeColor("editorWarning.foreground")); - case "syncing": - return new vscode.ThemeIcon("sync"); - case "not_found": - default: - return new vscode.ThemeIcon("question", new vscode.ThemeColor("descriptionForeground")); + if (this.cloudsmithStatus === "CHECKING") { + return new vscode.ThemeIcon("loading~spin"); + } + + if (this.cloudsmithStatus !== "FOUND") { + return getFormatIconPath(this.format, this.context && this.context.extensionPath, { + fallbackIcon: new vscode.ThemeIcon("package", new vscode.ThemeColor("descriptionForeground")), + }); + } + + if (this._isQuarantined()) { + return new vscode.ThemeIcon("error", new vscode.ThemeColor("errorForeground")); + } + + if (this._hasCriticalVulnerability()) { + return new vscode.ThemeIcon("error", new vscode.ThemeColor("errorForeground")); + } + + if (this._hasHighVulnerability() || this._hasRestrictiveLicense()) { + return new vscode.ThemeIcon("warning", new vscode.ThemeColor("charts.orange")); + } + + if (this._hasMediumOrLowVulnerability() || this._hasWeakCopyleftLicense() || this._hasPolicyViolation()) { + return new vscode.ThemeIcon("warning", new vscode.ThemeColor("charts.yellow")); } + + return new vscode.ThemeIcon("check", new vscode.ThemeColor("testing.iconPassed")); + } + + _buildVersionPrefix() { + return this.declaredVersion ? this.declaredVersion : "Unknown version"; } - _getStateDescription() { - switch (this.state) { - case "available": - return "Available"; - case "quarantined": - return "Quarantined"; - case "violated": - return "Policy violation"; - case "syncing": - return "Syncing"; - case "not_found": - default: - return "Not found in Cloudsmith"; + _buildVulnerabilityDescription() { + const vulnerabilities = this._getVulnerabilityData(); + if (!vulnerabilities || vulnerabilities.count === 0) { + return null; + } + + if ( + vulnerabilities.detailsLoaded + && vulnerabilities.maxSeverity + && vulnerabilities.severityCounts + && vulnerabilities.severityCounts[vulnerabilities.maxSeverity] + ) { + const maxCount = vulnerabilities.severityCounts[vulnerabilities.maxSeverity]; + return `Vulnerabilities found (${maxCount} ${vulnerabilities.maxSeverity})`; } + + const summary = vulnerabilities.maxSeverity + ? `${vulnerabilities.count} ${vulnerabilities.maxSeverity}` + : String(vulnerabilities.count); + return `Vulnerabilities found (${summary})`; + } + + _buildMissingDescription() { + return "Not found in Cloudsmith"; + } + + _buildDescription() { + if (this._duplicateReference) { + return `${this._buildVersionPrefix()} (see first occurrence)`; + } + + let detail; + if (this.cloudsmithStatus === "CHECKING") { + detail = "Checking coverage"; + } else if (this.cloudsmithStatus !== "FOUND") { + detail = this._buildMissingDescription(); + } else if (this._isQuarantined()) { + detail = "Quarantined"; + } else if (this._hasVulnerabilities()) { + detail = this._buildVulnerabilityDescription(); + } else if (this._shouldFlagRestrictiveLicenses() && this._hasRestrictiveLicense()) { + detail = this._getLicenseLabel() + ? `Restrictive license (${this._getLicenseLabel()})` + : "Restrictive license"; + } else if (this._hasWeakCopyleftLicense()) { + detail = this._getLicenseLabel() + ? `Weak copyleft license (${this._getLicenseLabel()})` + : "Weak copyleft license"; + } else if (this._hasPolicyViolation()) { + detail = "Policy violation"; + } else { + detail = "No issues found"; + } + + if (this._dimmedForFilter && this.cloudsmithStatus === "FOUND") { + detail += " · context"; + } + + return `${this._buildVersionPrefix()} — ${detail}`; } _buildTooltip() { - const lines = [`${this.name} ${this.declaredVersion}`]; + const lines = [`${this.name} ${this.declaredVersion || ""}`.trim()]; lines.push(`Format: ${this.format}`); + lines.push(`Relationship: ${this._getRelationshipLabel()}`); if (this.isDev) { lines.push("Development dependency"); } lines.push(""); - if (!this.cloudsmithMatch) { + if (this.cloudsmithStatus === "CHECKING") { + lines.push("Coverage check in progress."); + } else if (this.cloudsmithStatus !== "FOUND" || !this.cloudsmithMatch) { lines.push("Not found in the configured Cloudsmith workspace."); - lines.push("This package may need to be uploaded or fetched through an upstream."); - } else { - const match = this.cloudsmithMatch; - lines.push(`Cloudsmith version: ${match.version}`); - lines.push(`Status: ${match.status_str}`); - if (match.policy_violated) { - lines.push("Policy violated: yes"); + if (this.upstreamDetail) { + lines.push(this.upstreamDetail); + } else { + lines.push("This package may need to be uploaded or fetched through an upstream."); } - if (match.deny_policy_violated) { - lines.push("Deny policy violated: yes"); - } - if (match.license_policy_violated) { - lines.push("License policy violated: yes"); - } - if (match.vulnerability_policy_violated) { - lines.push("Vulnerability policy violated: yes"); - } - if (match.num_vulnerabilities > 0) { - lines.push(`Vulnerabilities: ${match.num_vulnerabilities} (${match.max_severity || "Unknown"})`); + } else { + lines.push(`Found in Cloudsmith (${this.cloudsmithMatch.repository})`); + const policy = this._getPolicyData(); + if (policy && policy.status) { + lines.push(`Status: ${policy.status}`); + } else if (this.cloudsmithMatch.status_str) { + lines.push(`Status: ${this.cloudsmithMatch.status_str}`); } - const classification = this.licenseInfo || LicenseClassifier.inspect(match); - if (classification.displayValue) { - lines.push(`License: ${classification.label} (${classification.metadata.label})`); - if (classification.spdxLicense && classification.spdxLicense !== classification.label) { - lines.push(`Canonical SPDX: ${classification.spdxLicense}`); - } - if (classification.overrideApplied) { - lines.push("License classification includes a local restrictive override."); + + const vulnerabilities = this._getVulnerabilityData(); + if (vulnerabilities) { + if (vulnerabilities && vulnerabilities.count > 0) { + const severitySummary = Object.entries(vulnerabilities.severityCounts || {}) + .map(([severity, count]) => `${count} ${severity}`) + .join(", "); + const suffix = severitySummary + ? ` (${severitySummary})` + : vulnerabilities.maxSeverity + ? ` (${vulnerabilities.maxSeverity})` + : ""; + lines.push(`Vulnerabilities: ${vulnerabilities.count}${suffix}`); + + if (Array.isArray(vulnerabilities.entries)) { + for (const entry of vulnerabilities.entries) { + const fixText = entry.fixVersion ? `Fix: ${entry.fixVersion}` : "No fix available"; + lines.push(` ${entry.cveId} (${entry.severity}) — ${fixText}`); + } + } + } else { + lines.push("Vulnerabilities: none known"); } } - if (this.state === "quarantined" || this.state === "violated") { - lines.push(""); - lines.push("Right-click \u2192 Explain quarantine or find safe version"); + if (this.licenseData) { + lines.push( + `License: ${this._getLicenseLabel() || "No license detected"} (${formatLicenseClassification(this.licenseData.classification)})` + ); + } else if (this.licenseInfo.displayValue) { + lines.push( + `License: ${this.licenseInfo.displayValue} (${formatLicenseClassification(classificationFromTier(this.licenseInfo.tier))})` + ); + } else { + lines.push("License: No license detected"); } - } - return lines.join("\n"); - } + if (policy && policy.violated) { + lines.push(`Policy violated: ${policy.denied ? "deny" : "yes"}`); + } - _getContextValue() { - switch (this.state) { - case "quarantined": - return "dependencyHealthBlocked"; - case "violated": - return "dependencyHealthViolated"; - case "available": - return "dependencyHealth"; - case "not_found": - return "dependencyHealthNotFound"; - case "syncing": - return "dependencyHealthSyncing"; - default: - return "dependencyHealth"; + if (policy && policy.statusReason) { + lines.push(`Policy reason: ${policy.statusReason}`); + } } - } - - /** Sort key: lower = more urgent (quarantined first). */ - get sortOrder() { - const order = { quarantined: 0, violated: 1, not_found: 2, syncing: 3, available: 4 }; - return order[this.state] != null ? order[this.state] : 5; - } - - getTreeItem() { - const devLabel = this.isDev ? " (dev)" : ""; - const indirectLabel = !this.isDirect ? " (indirect)" : ""; - const versionLabel = this.declaredVersion ? ` ${this.declaredVersion}` : ""; - const desc = this.state === "quarantined" - ? `${this._getStateDescription()} \u2014 right-click for details` - : this._getStateDescription(); + if (this._duplicateReference && this._firstOccurrencePath) { + lines.push(""); + lines.push(`See first occurrence: ${this._firstOccurrencePath}`); + } - return { - label: `${this.name}${versionLabel}${devLabel}${indirectLabel}`, - description: desc, - tooltip: this._buildTooltip(), - collapsibleState: this.cloudsmithMatch - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.None, - contextValue: this._getContextValue(), - iconPath: this._getStateIcon(), - }; + return lines.join("\n"); } - getChildren() { - if (!this.cloudsmithMatch) { + _buildDetailsChildren() { + if (!this.cloudsmithMatch || this.state === "checking") { return []; } const PackageDetailsNode = require("./packageDetailsNode"); const children = []; - const match = this.cloudsmithMatch; - // Status - children.push(new PackageDetailsNode({ id: "Status", value: match.status_str }, this.context)); + children.push(new PackageDetailsNode({ + id: "Status", + value: this.policy && this.policy.status ? this.policy.status : this.cloudsmithMatch.status_str, + }, this.context)); - // Cloudsmith Version - children.push(new PackageDetailsNode({ id: "Version", value: match.version }, this.context)); + children.push(new PackageDetailsNode({ + id: "Version", + value: this.cloudsmithMatch.version, + }, this.context)); - // License with classification const config = vscode.workspace.getConfiguration("cloudsmith-vsc"); if (config.get("showLicenseIndicators") !== false && this.licenseInfo && this.licenseInfo.displayValue) { const LicenseNode = require("./licenseNode"); children.push(new LicenseNode(this.licenseInfo, this.context)); } - // Vulnerability summary - if (match.num_vulnerabilities > 0) { + const vulnerabilities = this._getVulnerabilityData(); + if (vulnerabilities && vulnerabilities.count > 0) { const VulnerabilitySummaryNode = require("./vulnerabilitySummaryNode"); children.push(new VulnerabilitySummaryNode({ - namespace: match.namespace, - repository: match.repository, - slug_perm: match.slug_perm, - num_vulnerabilities: match.num_vulnerabilities, - max_severity: match.max_severity, + namespace: this.cloudsmithMatch.namespace, + repository: this.cloudsmithMatch.repository, + slug_perm: this.cloudsmithMatch.slug_perm, + num_vulnerabilities: vulnerabilities.count, + max_severity: vulnerabilities.maxSeverity, }, this.context)); } - // Policy Violated - const policyValue = match.policy_violated ? "Yes" : "No"; - children.push(new PackageDetailsNode({ id: "Policy violated", value: policyValue }, this.context)); + const policy = this._getPolicyData(); + if (policy) { + children.push(new PackageDetailsNode({ + id: "Policy violated", + value: policy.violated ? "Yes" : "No", + }, this.context)); - // Quarantine Reason (if quarantined) - if (match.status_str === "Quarantined" && match.status_reason) { - const truncated = match.status_reason.length > 80 - ? match.status_reason.substring(0, 80) + "..." - : match.status_reason; - const reasonNode = new PackageDetailsNode({ - id: "Quarantine reason", - value: truncated, - }, this.context); - children.push(reasonNode); + if (policy.statusReason) { + children.push(new PackageDetailsNode({ + id: "Policy reason", + value: policy.statusReason, + }, this.context)); + } } return children; } + + _getVulnerabilityData() { + if (this.vulnerabilities) { + return this.vulnerabilities; + } + + if (!this.cloudsmithMatch) { + return null; + } + + const count = Number( + this.cloudsmithMatch.vulnerability_scan_results_count + || this.cloudsmithMatch.num_vulnerabilities + || 0 + ); + if (!Number.isFinite(count) || count <= 0) { + return { + count: 0, + maxSeverity: null, + cveIds: [], + hasFixAvailable: false, + severityCounts: {}, + entries: [], + detailsLoaded: false, + }; + } + + const maxSeverity = this.cloudsmithMatch.max_severity || null; + const severityCounts = maxSeverity ? { [maxSeverity]: 1 } : {}; + return { + count, + maxSeverity, + cveIds: [], + hasFixAvailable: false, + severityCounts, + entries: [], + detailsLoaded: false, + }; + } + + _getPolicyData() { + if (this.policy) { + return this.policy; + } + + if (!this.cloudsmithMatch) { + return null; + } + + const status = String(this.cloudsmithMatch.status_str || "").trim() || null; + const quarantined = status === "Quarantined"; + const denied = quarantined || Boolean(this.cloudsmithMatch.deny_policy_violated); + const violated = denied + || Boolean(this.cloudsmithMatch.policy_violated) + || Boolean(this.cloudsmithMatch.license_policy_violated) + || Boolean(this.cloudsmithMatch.vulnerability_policy_violated); + + return { + violated, + denied, + quarantined, + status, + statusReason: String(this.cloudsmithMatch.status_reason || "").trim() || null, + }; + } + + _getRelationshipLabel() { + if (this.isDirect) { + return "Direct"; + } + + const firstParent = this.parentChain[0] || this.parent || "unknown"; + return `Transitive (via ${firstParent})`; + } + + getTreeItem() { + const item = new vscode.TreeItem( + `${this.name}${this.isDev ? " (dev)" : ""}`, + this._getCollapsibleState() + ); + item.description = this._buildDescription(); + item.tooltip = this._buildTooltip(); + item.contextValue = this._getContextValue(); + item.iconPath = this._getStateIcon(); + return item; + } + + _getCollapsibleState() { + if (this._childMode === "tree") { + if (this._duplicateReference || this._treeChildren.length === 0) { + return vscode.TreeItemCollapsibleState.None; + } + return vscode.TreeItemCollapsibleState.Collapsed; + } + + return this.cloudsmithMatch + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None; + } + + getChildren() { + if (this._childMode === "tree") { + if (!this._treeChildFactory || this._duplicateReference || this._treeChildren.length === 0) { + return []; + } + return this._treeChildFactory(this._treeChildren); + } + + return this._buildDetailsChildren(); + } +} + +function formatLicenseClassification(classification) { + switch (classification) { + case "permissive": + return "Permissive"; + case "weak_copyleft": + return "Weak copyleft"; + case "restrictive": + return "Restrictive"; + default: + return "Unclassified"; + } +} + +function classificationFromTier(tier) { + switch (tier) { + case "permissive": + return "permissive"; + case "cautious": + return "weak_copyleft"; + case "restrictive": + return "restrictive"; + default: + return "unknown"; + } } module.exports = DependencyHealthNode; diff --git a/models/dependencySourceGroupNode.js b/models/dependencySourceGroupNode.js new file mode 100644 index 0000000..baa879f --- /dev/null +++ b/models/dependencySourceGroupNode.js @@ -0,0 +1,35 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const vscode = require("vscode"); + +class DependencySourceGroupNode { + constructor(tree, provider) { + this.tree = tree; + this.provider = provider; + } + + getTreeItem() { + const directCount = this.tree.dependencies.filter((dependency) => dependency.isDirect).length; + const transitiveCount = this.tree.dependencies.length - directCount; + const item = new vscode.TreeItem( + this.tree.sourceFile, + vscode.TreeItemCollapsibleState.Collapsed + ); + item.description = `${this.tree.dependencies.length} dependencies ` + + `(${directCount} direct, ${transitiveCount} transitive)`; + item.tooltip = [ + this.tree.sourceFile, + `${this.tree.dependencies.length} dependencies`, + `${directCount} direct`, + `${transitiveCount} transitive`, + ].join("\n"); + item.contextValue = "dependencyHealthSourceGroup"; + item.iconPath = new vscode.ThemeIcon("folder-library"); + return item; + } + + getChildren() { + return this.provider.buildDependencyNodesForTree(this.tree); + } +} + +module.exports = DependencySourceGroupNode; diff --git a/models/dependencySummaryNode.js b/models/dependencySummaryNode.js new file mode 100644 index 0000000..c3c8a8a --- /dev/null +++ b/models/dependencySummaryNode.js @@ -0,0 +1,175 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const vscode = require("vscode"); + +class DependencySummaryNode { + constructor(summary) { + this.summary = { + total: 0, + direct: 0, + transitive: 0, + found: 0, + notFound: 0, + reachableViaUpstream: 0, + unreachableViaUpstream: 0, + ecosystems: {}, + coveragePercent: 0, + checking: 0, + vulnerable: 0, + severityCounts: {}, + restrictiveLicenses: 0, + weakCopyleftLicenses: 0, + permissiveLicenses: 0, + unknownLicenses: 0, + policyViolations: 0, + quarantined: 0, + filterMode: null, + filterLabel: null, + filteredCount: 0, + ...summary, + }; + } + + getTreeItem() { + const item = new vscode.TreeItem(buildPrimaryLabel(this.summary), vscode.TreeItemCollapsibleState.None); + item.description = buildSecondaryLabel(this.summary); + item.tooltip = buildTooltip(this.summary); + item.contextValue = "dependencyHealthSummary"; + item.iconPath = this.summary.checking > 0 + ? new vscode.ThemeIcon("loading~spin") + : new vscode.ThemeIcon("graph"); + return item; + } + + getChildren() { + return []; + } +} + +function buildPrimaryLabel(summary) { + if (summary.filterMode && summary.filterLabel) { + return `Showing ${summary.filteredCount} of ${summary.total} dependencies (filtered: ${summary.filterLabel})`; + } + + const parts = [ + `${summary.total} dependencies (${summary.direct} direct, ${summary.transitive} transitive)`, + `${summary.coveragePercent}% coverage`, + ]; + + if (summary.vulnerable > 0) { + parts.push(`${summary.vulnerable} vulnerable`); + } + + if (summary.restrictiveLicenses > 0) { + parts.push(`${summary.restrictiveLicenses} restrictive licenses`); + } + + return parts.join(" · "); +} + +function buildSecondaryLabel(summary) { + const parts = []; + const severityParts = buildSeverityParts(summary.severityCounts); + + if (severityParts.length > 0) { + parts.push(severityParts.join(" · ")); + } + + if (summary.quarantined > 0) { + parts.push(`${summary.quarantined} would be quarantined by policy`); + } else if (summary.policyViolations > 0) { + parts.push(`${summary.policyViolations} policy violations`); + } + + if (summary.notFound > 0) { + const upstreamParts = [`${summary.notFound} not found in Cloudsmith`]; + if (summary.reachableViaUpstream > 0) { + upstreamParts.push(`${summary.reachableViaUpstream} reachable via configured upstream proxies`); + } + if (summary.unreachableViaUpstream > 0) { + upstreamParts.push(`${summary.unreachableViaUpstream} not reachable`); + } + parts.push(upstreamParts.join(" · ")); + } + + if (parts.length > 0) { + return parts.join(" · "); + } + + const ecosystemEntries = Object.entries(summary.ecosystems || {}); + if (ecosystemEntries.length > 1) { + return ecosystemEntries + .map(([ecosystem, count]) => `${formatEcosystemLabel(ecosystem)}: ${count}`) + .join(" · "); + } + + return ""; +} + +function buildSeverityParts(severityCounts) { + const order = ["Critical", "High", "Medium", "Low"]; + return order + .filter((severity) => severityCounts && severityCounts[severity] > 0) + .map((severity) => `${severityCounts[severity]} ${severity}`); +} + +function buildTooltip(summary) { + const lines = [ + `${summary.total} total dependencies`, + `${summary.direct} direct`, + `${summary.transitive} transitive`, + `${summary.found} covered in Cloudsmith`, + `${summary.notFound} not found`, + `${summary.coveragePercent}% coverage`, + ]; + + if (summary.vulnerable > 0) { + lines.push(`${summary.vulnerable} vulnerable`); + for (const part of buildSeverityParts(summary.severityCounts)) { + lines.push(` ${part}`); + } + } + + if (summary.restrictiveLicenses > 0 || summary.weakCopyleftLicenses > 0 || summary.unknownLicenses > 0) { + lines.push(""); + lines.push("License summary"); + lines.push(` ${summary.permissiveLicenses} permissive`); + lines.push(` ${summary.weakCopyleftLicenses} weak copyleft`); + lines.push(` ${summary.restrictiveLicenses} restrictive`); + lines.push(` ${summary.unknownLicenses} unknown`); + } + + if (summary.policyViolations > 0 || summary.quarantined > 0) { + lines.push(""); + lines.push(`Policy violations: ${summary.policyViolations}`); + lines.push(`Would be quarantined: ${summary.quarantined}`); + } + + if (summary.notFound > 0) { + lines.push(""); + lines.push(`Reachable via upstream: ${summary.reachableViaUpstream}`); + lines.push(`Not reachable: ${summary.unreachableViaUpstream}`); + } + + const ecosystemEntries = Object.entries(summary.ecosystems || {}); + if (ecosystemEntries.length > 0) { + lines.push(""); + for (const [ecosystem, count] of ecosystemEntries) { + lines.push(`${formatEcosystemLabel(ecosystem)}: ${count}`); + } + } + + return lines.join("\n"); +} + +function formatEcosystemLabel(ecosystem) { + const value = String(ecosystem || ""); + if (!value) { + return ""; + } + if (value === "npm") { + return "npm"; + } + return value.charAt(0).toUpperCase() + value.slice(1); +} + +module.exports = DependencySummaryNode; diff --git a/test/dependencyHealthProvider.test.js b/test/dependencyHealthProvider.test.js index 4cf3ea2..7e9a0ea 100644 --- a/test/dependencyHealthProvider.test.js +++ b/test/dependencyHealthProvider.test.js @@ -1,24 +1,413 @@ const assert = require("assert"); -const { DependencyHealthProvider } = require("../views/dependencyHealthProvider"); +const vscode = require("vscode"); +const { + DependencyHealthProvider, + matchCoverageCandidates, +} = require("../views/dependencyHealthProvider"); +const { normalizePackageName } = require("../util/packageNameNormalizer"); suite("DependencyHealthProvider Test Suite", () => { - test("getChildren() shows the signed-out state when disconnected before the first scan", async () => { - const context = { + function createContext(isConnected = "true") { + return { secrets: { onDidChange() {}, async get(key) { if (key === "cloudsmith-vsc.isConnected") { - return "false"; + return isConnected; } return null; }, }, + workspaceState: { + get() { + return null; + }, + async update() {}, + }, + }; + } + + function createDependency(name, version, format = "npm") { + return { + name, + version, + format, + ecosystem: format, + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + cloudsmithStatus: "CHECKING", + cloudsmithPackage: null, + sourceFile: "package-lock.json", + isDevelopmentDependency: false, + }; + } + + function createFoundDependency(name, version) { + return { + ...createDependency(name, version), + cloudsmithStatus: "FOUND", + cloudsmithPackage: { + namespace: "workspace", + repository: "repo", + slug_perm: `${name}/${version}`, + }, }; + } + + function cloneTrees(trees) { + return JSON.parse(JSON.stringify(trees)); + } - const provider = new DependencyHealthProvider(context); + function buildCoverageIndex(dependencies) { + const index = new Map(); + + for (const dependency of dependencies) { + const nameKey = normalizePackageName(dependency.name, dependency.format); + const versionKey = dependency.version.toLowerCase(); + if (!index.has(nameKey)) { + index.set(nameKey, new Map()); + } + index.get(nameKey).set(versionKey, [{ + name: dependency.name, + version: dependency.version, + }]); + } + + return index; + } + + setup(() => { + DependencyHealthProvider.packageIndexCache.clear(); + }); + + test("getChildren() shows the signed-out state when disconnected before the first scan", async () => { + const provider = new DependencyHealthProvider(createContext("false")); const nodes = await provider.getChildren(); assert.strictEqual(nodes.length, 1); assert.strictEqual(nodes[0].getTreeItem().label, "Connect to Cloudsmith"); }); + + test("_runCoverageChecks batches tree rebuilds and refreshes while preserving matches", async () => { + const provider = new DependencyHealthProvider(createContext()); + const dependencies = Array.from({ length: 51 }, (_, index) => createDependency(`package-${index}`, "1.0.0")); + const trees = [{ + ecosystem: "npm", + sourceFile: "package-lock.json", + dependencies, + }]; + + provider._fullTrees = cloneTrees(trees); + provider._displayTrees = cloneTrees(trees); + + let rebuildCount = 0; + let refreshCount = 0; + const progressUpdates = []; + + provider._rebuildSummary = () => { + rebuildCount += 1; + }; + provider.refresh = () => { + refreshCount += 1; + }; + provider._fetchPackageIndex = async () => ({ + error: null, + tooLarge: false, + index: buildCoverageIndex(dependencies), + }); + + await provider._runCoverageChecks( + "workspace", + "repo", + dependencies.length, + { + report(update) { + progressUpdates.push(update); + }, + }, + { isCancellationRequested: false } + ); + + assert.strictEqual(rebuildCount, 2); + assert.strictEqual(refreshCount, 2); + assert.strictEqual(progressUpdates.length, 2); + assert.strictEqual(progressUpdates[0].message, "Matching coverage... 50/51"); + assert.strictEqual(progressUpdates[1].message, "Matching coverage... 51/51"); + assert.strictEqual( + provider._fullTrees[0].dependencies.every((dependency) => dependency.cloudsmithStatus === "FOUND"), + true + ); + assert.strictEqual( + provider._displayTrees[0].dependencies.every((dependency) => dependency.cloudsmithStatus === "FOUND"), + true + ); + }); + + test("_runCoverageChecks fetches package indices for multiple formats in parallel", async () => { + const provider = new DependencyHealthProvider(createContext()); + const npmDependency = createDependency("left-pad", "1.0.0", "npm"); + const pythonDependency = createDependency("requests", "2.31.0", "python"); + + provider._fullTrees = [ + { + ecosystem: "npm", + sourceFile: "package-lock.json", + dependencies: [npmDependency], + }, + { + ecosystem: "python", + sourceFile: "requirements.txt", + dependencies: [pythonDependency], + }, + ]; + provider._displayTrees = cloneTrees(provider._fullTrees); + + const resolvers = new Map(); + let inFlight = 0; + let maxInFlight = 0; + + provider._rebuildSummary = () => {}; + provider.refresh = () => {}; + provider._fetchPackageIndex = async (_workspace, _repo, format) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + + return new Promise((resolve) => { + resolvers.set(format, () => { + inFlight -= 1; + const dependency = format === "npm" ? npmDependency : pythonDependency; + resolve({ + error: null, + tooLarge: false, + index: buildCoverageIndex([dependency]), + }); + }); + }); + }; + + const runPromise = provider._runCoverageChecks( + "workspace", + "repo", + 2, + { report() {} }, + { isCancellationRequested: false } + ); + + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(maxInFlight, 2); + + resolvers.get("npm")(); + resolvers.get("python")(); + await runPromise; + }); + + test("_fetchPackageIndex fetches remaining pages concurrently after page one", async () => { + const provider = new DependencyHealthProvider(createContext()); + const requestedPages = []; + const pageResolvers = new Map(); + + provider._fetchSinglePage = async (_workspace, _repo, _format, page) => { + requestedPages.push(page); + if (page === 1) { + return { + error: null, + pagination: { + count: 3, + pageTotal: 3, + }, + data: [{ + name: "page-one", + version: "1.0.0", + }], + }; + } + + return new Promise((resolve) => { + pageResolvers.set(page, resolve); + }); + }; + + const fetchPromise = provider._fetchPackageIndex("workspace", "repo", "npm"); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepStrictEqual(requestedPages, [1, 2, 3]); + + pageResolvers.get(2)({ + error: null, + data: [{ + name: "page-two", + version: "1.0.0", + }], + }); + pageResolvers.get(3)({ + error: null, + data: [{ + name: "page-three", + version: "1.0.0", + }], + }); + + const result = await fetchPromise; + assert.strictEqual(result.error, null); + assert.strictEqual(result.totalCount, 3); + assert.strictEqual(result.index.get("page-two").has("1.0.0"), true); + assert.strictEqual(result.index.get("page-three").has("1.0.0"), true); + }); + + test("matchCoverageCandidates returns null when fallback results do not match the dependency name", () => { + const match = matchCoverageCandidates( + [ + { name: "left-pad-plus", version: "1.0.0", format: "npm" }, + { name: "pad-left", version: "1.0.0", format: "npm" }, + ], + createDependency("left-pad", "1.0.0") + ); + + assert.strictEqual(match, null); + }); + + test("matchCoverageCandidates falls back to a name match when versions differ", () => { + const nameOnlyMatch = { name: "left-pad", version: "1.1.0", format: "npm" }; + const match = matchCoverageCandidates( + [ + { name: "left-pad-plus", version: "1.0.0", format: "npm" }, + nameOnlyMatch, + ], + createDependency("left-pad", "1.0.0") + ); + + assert.strictEqual(match, nameOnlyMatch); + }); + + test("_runLicenseEnrichment flushes multiple progress patches in one refresh", async () => { + const provider = new DependencyHealthProvider(createContext(), null, { + enrichLicenses: async (_dependencies, options = {}) => { + options.onProgress(new Map([ + ["workspace:repo:left-pad/1.0.0", { spdx: "MIT" }], + ])); + options.onProgress(new Map([ + ["workspace:repo:left-pad/1.0.0", { spdx: "Apache-2.0" }], + ])); + }, + }); + + const trees = [{ + ecosystem: "npm", + sourceFile: "package-lock.json", + dependencies: [createFoundDependency("left-pad", "1.0.0")], + }]; + provider._fullTrees = cloneTrees(trees); + provider._displayTrees = cloneTrees(trees); + + let rebuildCount = 0; + let refreshCount = 0; + provider._rebuildSummary = () => { + rebuildCount += 1; + }; + provider.refresh = () => { + refreshCount += 1; + }; + + await provider._runLicenseEnrichment(provider._fullTrees[0].dependencies, { isCancellationRequested: false }); + + assert.strictEqual(rebuildCount, 1); + assert.strictEqual(refreshCount, 1); + assert.strictEqual(provider._fullTrees[0].dependencies[0].license.spdx, "Apache-2.0"); + assert.strictEqual(provider._displayTrees[0].dependencies[0].license.spdx, "Apache-2.0"); + }); + + test("pullSingleDependency refreshes coverage after a successful single-package pull", async () => { + const originalWithProgress = vscode.window.withProgress; + const originalShowInformationMessage = vscode.window.showInformationMessage; + const originalShowErrorMessage = vscode.window.showErrorMessage; + const notifications = []; + let refreshArgs = null; + + vscode.window.withProgress = async (_options, task) => task( + { report() {} }, + { + onCancellationRequested() { + return { dispose() {} }; + }, + } + ); + vscode.window.showInformationMessage = async (message) => { + notifications.push(message); + }; + vscode.window.showErrorMessage = async (message) => { + notifications.push(`error:${message}`); + }; + + try { + const provider = new DependencyHealthProvider(createContext(), null, { + upstreamPullService: { + async prepareSingle({ dependency }) { + return { + workspace: "workspace-a", + repository: { slug: "repo-b" }, + dependency, + plan: { skippedDependencies: [] }, + }; + }, + async execute() { + return { + canceled: false, + pullResult: { + total: 1, + cached: 1, + alreadyExisted: 0, + notFound: 0, + formatMismatched: 0, + errors: 0, + networkErrors: 0, + authFailed: 0, + skipped: 0, + details: [{ + status: "cached", + dependency: { + name: "requests", + version: "2.31.0", + format: "python", + }, + }], + }, + }; + }, + }, + }); + + provider.lastWorkspace = "workspace-a"; + provider.lastRepo = "repo-a"; + provider._updateContexts = async () => {}; + provider.refresh = () => {}; + provider._refreshSingleDependencyAfterPull = async (workspace, repo, dependency) => { + refreshArgs = { workspace, repo, dependency }; + }; + + await provider.pullSingleDependency({ + name: "requests", + version: "2.31.0", + format: "python", + ecosystem: "python", + }); + + assert.deepStrictEqual(refreshArgs, { + workspace: "workspace-a", + repo: "repo-b", + dependency: { + name: "requests", + version: "2.31.0", + format: "python", + ecosystem: "python", + }, + }); + assert.deepStrictEqual(notifications, ["requests@2.31.0 cached in repo-b"]); + } finally { + vscode.window.withProgress = originalWithProgress; + vscode.window.showInformationMessage = originalShowInformationMessage; + vscode.window.showErrorMessage = originalShowErrorMessage; + } + }); }); diff --git a/test/dependencyLicenseEnricher.test.js b/test/dependencyLicenseEnricher.test.js new file mode 100644 index 0000000..9a97dde --- /dev/null +++ b/test/dependencyLicenseEnricher.test.js @@ -0,0 +1,68 @@ +const assert = require("assert"); +const vscode = require("vscode"); +const { + enrichLicenses, +} = require("../util/dependencyLicenseEnricher"); + +suite("dependencyLicenseEnricher", () => { + let originalGetConfiguration; + + setup(() => { + originalGetConfiguration = vscode.workspace.getConfiguration; + vscode.workspace.getConfiguration = () => ({ + get() { + return undefined; + }, + }); + }); + + teardown(() => { + vscode.workspace.getConfiguration = originalGetConfiguration; + }); + + test("classifies found dependencies using package index license metadata", async () => { + const dependencies = [ + { + name: "express", + version: "4.18.2", + format: "npm", + ecosystem: "npm", + cloudsmithStatus: "FOUND", + cloudsmithPackage: { + namespace: "workspace-a", + repository: "production-npm", + slug_perm: "pkg-1", + license: "MIT", + }, + }, + { + name: "copyleft-lib", + version: "1.0.0", + format: "npm", + ecosystem: "npm", + cloudsmithStatus: "FOUND", + cloudsmithPackage: { + namespace: "workspace-a", + repository: "production-npm", + slug_perm: "pkg-2", + spdx_license: "LGPL-2.1", + }, + }, + { + name: "missing-lib", + version: "1.0.0", + format: "npm", + ecosystem: "npm", + cloudsmithStatus: "NOT_FOUND", + }, + ]; + + const enriched = await enrichLicenses(dependencies); + + assert.strictEqual(enriched[0].license.classification, "permissive"); + assert.strictEqual(enriched[0].license.spdx, "MIT"); + assert.strictEqual(enriched[1].license.classification, "weak_copyleft"); + assert.strictEqual(enriched[1].license.spdx, "LGPL-2.1"); + assert.strictEqual(enriched[2].license, undefined); + }); +}); diff --git a/test/dependencyPolicyEnricher.test.js b/test/dependencyPolicyEnricher.test.js new file mode 100644 index 0000000..3e156ec --- /dev/null +++ b/test/dependencyPolicyEnricher.test.js @@ -0,0 +1,51 @@ +const assert = require("assert"); +const { + enrichPolicies, +} = require("../util/dependencyPolicyEnricher"); + +suite("dependencyPolicyEnricher", () => { + test("maps package index policy fields onto dependency objects", async () => { + const dependencies = [ + { + name: "spotipy", + version: "2.25.0", + format: "python", + ecosystem: "python", + cloudsmithStatus: "FOUND", + cloudsmithPackage: { + namespace: "workspace-a", + repository: "production-pypi", + slug_perm: "pkg-1", + status_str: "Quarantined", + deny_policy_violated: true, + policy_violated: true, + status_reason: "Blocked by policy", + }, + }, + { + name: "clean-lib", + version: "1.0.0", + format: "npm", + ecosystem: "npm", + cloudsmithStatus: "FOUND", + cloudsmithPackage: { + namespace: "workspace-a", + repository: "production-npm", + slug_perm: "pkg-2", + status_str: "Completed", + policy_violated: false, + }, + }, + ]; + + const enriched = await enrichPolicies(dependencies); + + assert.strictEqual(enriched[0].policy.violated, true); + assert.strictEqual(enriched[0].policy.denied, true); + assert.strictEqual(enriched[0].policy.quarantined, true); + assert.strictEqual(enriched[0].policy.status, "Quarantined"); + assert.strictEqual(enriched[0].policy.statusReason, "Blocked by policy"); + assert.strictEqual(enriched[1].policy.violated, false); + assert.strictEqual(enriched[1].policy.denied, false); + }); +}); diff --git a/test/dependencyVulnEnricher.test.js b/test/dependencyVulnEnricher.test.js new file mode 100644 index 0000000..936a609 --- /dev/null +++ b/test/dependencyVulnEnricher.test.js @@ -0,0 +1,183 @@ +const assert = require("assert"); +const { + clearVulnerabilityCache, + enrichVulnerabilities, + getVulnerabilityCacheSize, +} = require("../util/dependencyVulnEnricher"); + +suite("dependencyVulnEnricher", () => { + function createFoundDependency(slug, count = 1) { + return { + name: `pkg-${slug}`, + version: "1.0.0", + format: "maven", + ecosystem: "maven", + isDirect: false, + cloudsmithStatus: "FOUND", + cloudsmithPackage: { + namespace: "workspace-a", + repository: "production-maven", + slug_perm: slug, + vulnerability_scan_results_count: count, + max_severity: "High", + }, + }; + } + + setup(() => { + clearVulnerabilityCache(); + }); + + test("hydrates vulnerability summaries from the detail endpoint", async () => { + const calls = []; + const dependencies = [createFoundDependency("pkg-1", 2)]; + + const enriched = await enrichVulnerabilities(dependencies, "workspace-a", { + cloudsmithAPI: { + async getV2(endpoint) { + calls.push(endpoint); + return { + results: [ + { + vulnerability_id: "CVE-2024-1234", + severity: "High", + fix_version: "10.1.20", + }, + { + vulnerability_id: "CVE-2024-5678", + severity: "Medium", + }, + ], + }; + }, + }, + }); + + assert.deepStrictEqual(calls, [ + "vulnerabilities/workspace-a/production-maven/pkg-1/", + ]); + assert.strictEqual(enriched[0].vulnerabilities.count, 2); + assert.strictEqual(enriched[0].vulnerabilities.maxSeverity, "High"); + assert.deepStrictEqual(enriched[0].vulnerabilities.cveIds, [ + "CVE-2024-1234", + "CVE-2024-5678", + ]); + assert.strictEqual(enriched[0].vulnerabilities.hasFixAvailable, true); + assert.strictEqual(enriched[0].vulnerabilities.severityCounts.High, 1); + assert.strictEqual(enriched[0].vulnerabilities.severityCounts.Medium, 1); + }); + + test("skips vulnerability lookups for dependencies not found in Cloudsmith", async () => { + let calls = 0; + const dependencies = [ + { + name: "accepts", + version: "1.3.8", + format: "npm", + ecosystem: "npm", + isDirect: false, + cloudsmithStatus: "NOT_FOUND", + cloudsmithPackage: null, + }, + ]; + + const enriched = await enrichVulnerabilities(dependencies, "workspace-a", { + cloudsmithAPI: { + async getV2() { + calls += 1; + return { results: [] }; + }, + }, + }); + + assert.strictEqual(calls, 0); + assert.strictEqual(enriched[0].vulnerabilities, undefined); + }); + + test("deletes expired cache entries on read when the refresh does not replace them", async () => { + const originalNow = Date.now; + let now = 1_000; + + try { + Date.now = () => now; + + await enrichVulnerabilities([ + createFoundDependency("pkg-1"), + createFoundDependency("pkg-2"), + ], "workspace-a", { + cloudsmithAPI: { + async getV2() { + return { + results: [{ + vulnerability_id: "CVE-2024-1234", + severity: "High", + }], + }; + }, + }, + }); + + assert.strictEqual(getVulnerabilityCacheSize(), 2); + + now += 20 * 60 * 1000; + + const enriched = await enrichVulnerabilities([createFoundDependency("pkg-1")], "workspace-a", { + cloudsmithAPI: { + async getV2() { + return "temporarily unavailable"; + }, + }, + }); + + assert.strictEqual(getVulnerabilityCacheSize(), 1); + assert.strictEqual(enriched[0].vulnerabilities.count, 1); + assert.strictEqual(enriched[0].vulnerabilities.detailsLoaded, false); + } finally { + Date.now = originalNow; + } + }); + + test("prunes expired entries before inserting when the cache reaches the soft size cap", async () => { + const originalNow = Date.now; + let now = 1_000; + + try { + Date.now = () => now; + + const dependencies = Array.from({ length: 5000 }, (_, index) => createFoundDependency(`pkg-${index}`)); + await enrichVulnerabilities(dependencies, "workspace-a", { + cloudsmithAPI: { + async getV2() { + return { + results: [{ + vulnerability_id: "CVE-2024-1234", + severity: "High", + }], + }; + }, + }, + }); + + assert.strictEqual(getVulnerabilityCacheSize(), 5000); + + now += 20 * 60 * 1000; + + await enrichVulnerabilities([createFoundDependency("pkg-fresh")], "workspace-a", { + cloudsmithAPI: { + async getV2() { + return { + results: [{ + vulnerability_id: "CVE-2024-5678", + severity: "Medium", + }], + }; + }, + }, + }); + + assert.strictEqual(getVulnerabilityCacheSize(), 1); + } finally { + Date.now = originalNow; + } + }); +}); diff --git a/test/fixtures/cargo/Cargo.lock b/test/fixtures/cargo/Cargo.lock new file mode 100644 index 0000000..27ce744 --- /dev/null +++ b/test/fixtures/cargo/Cargo.lock @@ -0,0 +1,25 @@ +[[package]] +name = "fixture-cargo" +version = "0.1.0" +dependencies = [ + "serde 1.0.0", + "tokio 1.37.0" +] + +[[package]] +name = "serde" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "tokio" +version = "1.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "bytes 1.6.0" +] + +[[package]] +name = "bytes" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/test/fixtures/cargo/Cargo.toml b/test/fixtures/cargo/Cargo.toml new file mode 100644 index 0000000..f29e154 --- /dev/null +++ b/test/fixtures/cargo/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "fixture-cargo" +version = "0.1.0" + +[dependencies] +serde = "1.0.0" + +[dev-dependencies] +tokio = { version = "1.37.0", features = ["full"] } diff --git a/test/fixtures/composer/composer.json b/test/fixtures/composer/composer.json new file mode 100644 index 0000000..9c39117 --- /dev/null +++ b/test/fixtures/composer/composer.json @@ -0,0 +1,8 @@ +{ + "require": { + "laravel/framework": "^11.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + } +} diff --git a/test/fixtures/composer/composer.lock b/test/fixtures/composer/composer.lock new file mode 100644 index 0000000..0603532 --- /dev/null +++ b/test/fixtures/composer/composer.lock @@ -0,0 +1,28 @@ +{ + "packages": [ + { + "name": "laravel/framework", + "version": "v11.0.0", + "require": { + "symfony/http-foundation": "^7.0", + "php": "^8.2" + } + }, + { + "name": "symfony/http-foundation", + "version": "v7.0.0", + "require": { + "php": "^8.2" + } + } + ], + "packages-dev": [ + { + "name": "phpunit/phpunit", + "version": "11.0.0", + "require": { + "php": "^8.2" + } + } + ] +} diff --git a/test/fixtures/dart/pubspec.lock b/test/fixtures/dart/pubspec.lock new file mode 100644 index 0000000..1384cb4 --- /dev/null +++ b/test/fixtures/dart/pubspec.lock @@ -0,0 +1,15 @@ +packages: + http: + dependency: "direct main" + description: + name: http + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + source: hosted + version: "1.25.2" +sdks: + dart: ">=3.0.0 <4.0.0" diff --git a/test/fixtures/dart/pubspec.yaml b/test/fixtures/dart/pubspec.yaml new file mode 100644 index 0000000..e7d0b60 --- /dev/null +++ b/test/fixtures/dart/pubspec.yaml @@ -0,0 +1,7 @@ +name: fixture_dart + +dependencies: + http: ^1.2.1 + +dev_dependencies: + test: ^1.25.2 diff --git a/test/fixtures/docker/Dockerfile b/test/fixtures/docker/Dockerfile new file mode 100644 index 0000000..1ee1611 --- /dev/null +++ b/test/fixtures/docker/Dockerfile @@ -0,0 +1,5 @@ +ARG BASE_IMAGE=python:3.11-slim +FROM --platform=linux/amd64 $BASE_IMAGE AS base +FROM base AS test +FROM scratch AS export +FROM alpine:3.19 diff --git a/test/fixtures/docker/docker-compose.yml b/test/fixtures/docker/docker-compose.yml new file mode 100644 index 0000000..d44c448 --- /dev/null +++ b/test/fixtures/docker/docker-compose.yml @@ -0,0 +1,7 @@ +services: + api: + image: redis:7.2 + worker: + build: . + db: + image: postgres:16 diff --git a/test/fixtures/go/go.mod b/test/fixtures/go/go.mod new file mode 100644 index 0000000..8877575 --- /dev/null +++ b/test/fixtures/go/go.mod @@ -0,0 +1,8 @@ +module example.com/fixture + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/stretchr/testify v1.8.4 // indirect +) diff --git a/test/fixtures/gradle/build.gradle b/test/fixtures/gradle/build.gradle new file mode 100644 index 0000000..1c95cc4 --- /dev/null +++ b/test/fixtures/gradle/build.gradle @@ -0,0 +1,8 @@ +plugins { + id "java" +} + +dependencies { + implementation "org.springframework:spring-core:6.1.0" + testImplementation "junit:junit:4.13.2" +} diff --git a/test/fixtures/gradle/gradle.lockfile b/test/fixtures/gradle/gradle.lockfile new file mode 100644 index 0000000..458d7fb --- /dev/null +++ b/test/fixtures/gradle/gradle.lockfile @@ -0,0 +1,2 @@ +org.springframework:spring-core:6.1.0=compileClasspath +junit:junit:4.13.2=testCompileClasspath diff --git a/test/fixtures/helm/Chart.lock b/test/fixtures/helm/Chart.lock new file mode 100644 index 0000000..3f291a3 --- /dev/null +++ b/test/fixtures/helm/Chart.lock @@ -0,0 +1,5 @@ +dependencies: + - name: redis + version: 19.6.0 +digest: sha256:fixture +generated: "2026-04-05T00:00:00Z" diff --git a/test/fixtures/helm/Chart.yaml b/test/fixtures/helm/Chart.yaml new file mode 100644 index 0000000..2c94405 --- /dev/null +++ b/test/fixtures/helm/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: fixture-chart +version: 0.1.0 +dependencies: + - name: redis + version: 19.6.0 diff --git a/test/fixtures/hex/mix.exs b/test/fixtures/hex/mix.exs new file mode 100644 index 0000000..61c6617 --- /dev/null +++ b/test/fixtures/hex/mix.exs @@ -0,0 +1,16 @@ +defmodule FixtureHex.MixProject do + use Mix.Project + + def project do + [ + app: :fixture_hex, + version: "0.1.0" + ] + end + + defp deps do + [ + {:jason, "~> 1.4"} + ] + end +end diff --git a/test/fixtures/hex/mix.lock b/test/fixtures/hex/mix.lock new file mode 100644 index 0000000..4847e99 --- /dev/null +++ b/test/fixtures/hex/mix.lock @@ -0,0 +1,3 @@ +%{ + "jason": {:hex, :jason, "1.4.1", "checksum", [:mix], [], "hexpm", "checksum"} +} diff --git a/test/fixtures/maven/dependency-tree.txt b/test/fixtures/maven/dependency-tree.txt new file mode 100644 index 0000000..51150ba --- /dev/null +++ b/test/fixtures/maven/dependency-tree.txt @@ -0,0 +1,3 @@ +[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:3.2.0:compile +[INFO] | \- org.springframework:spring-core:jar:6.1.0:compile +[INFO] \- junit:junit:jar:4.13.2:test diff --git a/test/fixtures/maven/pom.xml b/test/fixtures/maven/pom.xml new file mode 100644 index 0000000..1146e69 --- /dev/null +++ b/test/fixtures/maven/pom.xml @@ -0,0 +1,14 @@ + + + + org.springframework.boot + spring-boot-starter-web + 3.2.0 + + + junit + junit + test + + + diff --git a/test/fixtures/npm/package-lock.json b/test/fixtures/npm/package-lock.json new file mode 100644 index 0000000..5b9e20e --- /dev/null +++ b/test/fixtures/npm/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "fixture-app", + "lockfileVersion": 3, + "packages": { + "": { + "name": "fixture-app", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2", + "@scope/pkg": "^1.0.0" + } + }, + "node_modules/express": { + "version": "4.18.2", + "dependencies": { + "accepts": "~1.3.8" + } + }, + "node_modules/accepts": { + "version": "1.3.8" + }, + "node_modules/@scope/pkg": { + "version": "1.0.0" + }, + "node_modules/another/node_modules/accepts": { + "version": "1.3.8" + } + } +} diff --git a/test/fixtures/npm/package.json b/test/fixtures/npm/package.json new file mode 100644 index 0000000..71c3c31 --- /dev/null +++ b/test/fixtures/npm/package.json @@ -0,0 +1,8 @@ +{ + "name": "fixture-app", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2", + "@scope/pkg": "^1.0.0" + } +} diff --git a/test/fixtures/npm/pnpm-lock.yaml b/test/fixtures/npm/pnpm-lock.yaml new file mode 100644 index 0000000..6888293 --- /dev/null +++ b/test/fixtures/npm/pnpm-lock.yaml @@ -0,0 +1,20 @@ +lockfileVersion: "9.0" +importers: + .: + dependencies: + express: + specifier: ^4.18.2 + version: 4.18.2 + "@scope/pkg": + specifier: ^1.0.0 + version: 1.0.0 +packages: + express@4.18.2: + dependencies: + accepts: 1.3.8 + accepts@1.3.8: + resolution: + integrity: sha512-abc + "@scope/pkg@1.0.0": + resolution: + integrity: sha512-def diff --git a/test/fixtures/npm/yarn.lock b/test/fixtures/npm/yarn.lock new file mode 100644 index 0000000..69e5716 --- /dev/null +++ b/test/fixtures/npm/yarn.lock @@ -0,0 +1,10 @@ +express@^4.18.2: + version "4.18.2" + dependencies: + accepts "~1.3.8" + +accepts@~1.3.8: + version "1.3.8" + +"@scope/pkg@^1.0.0": + version "1.0.0" diff --git a/test/fixtures/nuget/Fixture.csproj b/test/fixtures/nuget/Fixture.csproj new file mode 100644 index 0000000..cbf45f6 --- /dev/null +++ b/test/fixtures/nuget/Fixture.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/test/fixtures/nuget/packages.lock.json b/test/fixtures/nuget/packages.lock.json new file mode 100644 index 0000000..550fcac --- /dev/null +++ b/test/fixtures/nuget/packages.lock.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "dependencies": { + ".NETCoreApp,Version=v8.0": { + "Newtonsoft.Json": { + "type": "Direct", + "resolved": "13.0.3", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0" + } + } + } +} diff --git a/test/fixtures/python/Pipfile.lock b/test/fixtures/python/Pipfile.lock new file mode 100644 index 0000000..5687d4c --- /dev/null +++ b/test/fixtures/python/Pipfile.lock @@ -0,0 +1,12 @@ +{ + "default": { + "flask": { + "version": "==2.3.0" + } + }, + "develop": { + "pytest": { + "version": "==8.2.0" + } + } +} diff --git a/test/fixtures/python/poetry.lock b/test/fixtures/python/poetry.lock new file mode 100644 index 0000000..d2d422d --- /dev/null +++ b/test/fixtures/python/poetry.lock @@ -0,0 +1,14 @@ +[[package]] +name = "flask" +version = "2.3.0" + +[package.dependencies] +click = ">=8.0" + +[[package]] +name = "requests" +version = "2.28.0" + +[[package]] +name = "click" +version = "8.1.7" diff --git a/test/fixtures/python/pyproject.toml b/test/fixtures/python/pyproject.toml new file mode 100644 index 0000000..5f49c62 --- /dev/null +++ b/test/fixtures/python/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "fixture-python" +version = "0.1.0" + +[tool.poetry.dependencies] +python = "^3.11" +# Web +flask = "^2.3.0" +requests = { version = "^2.28.0", optional = true } # client + +[project] +name = "fixture-python" +dependencies = [ + "fastapi==0.111.0", # API +] diff --git a/test/fixtures/python/requirements.txt b/test/fixtures/python/requirements.txt new file mode 100644 index 0000000..56868ab --- /dev/null +++ b/test/fixtures/python/requirements.txt @@ -0,0 +1,2 @@ +flask==2.3.0 +requests>=2.28.0 diff --git a/test/fixtures/python/uv.lock b/test/fixtures/python/uv.lock new file mode 100644 index 0000000..f003f62 --- /dev/null +++ b/test/fixtures/python/uv.lock @@ -0,0 +1,18 @@ +[[package]] +name = "fixture-python" +version = "0.1.0" +source = { editable = "." } +dependencies = [{ name = "fastapi" }] + +[[package]] +name = "fastapi" +version = "0.111.0" +dependencies = ["starlette", "pydantic"] + +[[package]] +name = "starlette" +version = "0.37.2" + +[[package]] +name = "pydantic" +version = "2.7.0" diff --git a/test/fixtures/ruby/Gemfile b/test/fixtures/ruby/Gemfile new file mode 100644 index 0000000..cc4b5e5 --- /dev/null +++ b/test/fixtures/ruby/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "rails", "~> 7.1.3" diff --git a/test/fixtures/ruby/Gemfile.lock b/test/fixtures/ruby/Gemfile.lock new file mode 100644 index 0000000..035bc10 --- /dev/null +++ b/test/fixtures/ruby/Gemfile.lock @@ -0,0 +1,9 @@ +GEM + remote: https://rubygems.org/ + specs: + rails (7.1.3) + actionpack (= 7.1.3) + actionpack (7.1.3) + +DEPENDENCIES + rails (~> 7.1.3) diff --git a/test/fixtures/swift/Package.resolved b/test/fixtures/swift/Package.resolved new file mode 100644 index 0000000..4294e8a --- /dev/null +++ b/test/fixtures/swift/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins": [ + { + "identity": "alamofire", + "kind": "remoteSourceControl", + "location": "https://github.com/Alamofire/Alamofire.git", + "state": { + "revision": "abcdef1234567890", + "version": "5.8.0" + } + } + ], + "version": 2 +} diff --git a/test/fixtures/swift/Package.swift b/test/fixtures/swift/Package.swift new file mode 100644 index 0000000..206e9ed --- /dev/null +++ b/test/fixtures/swift/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "FixtureSwift", + dependencies: [ + .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0") + ] +) diff --git a/test/foundDependencyKey.test.js b/test/foundDependencyKey.test.js new file mode 100644 index 0000000..ecea5b2 --- /dev/null +++ b/test/foundDependencyKey.test.js @@ -0,0 +1,104 @@ +const assert = require("assert"); +const { getFoundDependencyKey } = require("../util/foundDependencyKey"); + +suite("foundDependencyKey", () => { + test("builds a lowercase trimmed key for valid found dependencies", () => { + const key = getFoundDependencyKey({ + cloudsmithPackage: { + namespace: " Workspace-A ", + repository: " Production-NPM ", + slug_perm: "Pkg-1", + }, + }); + + assert.strictEqual(key, "workspace-a:production-npm:pkg-1"); + }); + + test("uses slug fallbacks in priority order", () => { + assert.strictEqual(getFoundDependencyKey({ + cloudsmithPackage: { + namespace: "workspace-a", + repository: "repo-a", + slug_perm: "slug-perm", + slugPerm: "slug-perm-camel", + slug: "slug-value", + identifier: "identifier-value", + }, + }), "workspace-a:repo-a:slug-perm"); + + assert.strictEqual(getFoundDependencyKey({ + cloudsmithPackage: { + namespace: "workspace-a", + repository: "repo-a", + slugPerm: "slug-perm-camel", + slug: "slug-value", + identifier: "identifier-value", + }, + }), "workspace-a:repo-a:slug-perm-camel"); + + assert.strictEqual(getFoundDependencyKey({ + cloudsmithPackage: { + namespace: "workspace-a", + repository: "repo-a", + slug: "slug-value", + identifier: "identifier-value", + }, + }), "workspace-a:repo-a:slug-value"); + + assert.strictEqual(getFoundDependencyKey({ + cloudsmithPackage: { + namespace: "workspace-a", + repository: "repo-a", + identifier: "identifier-value", + }, + }), "workspace-a:repo-a:identifier-value"); + }); + + test("returns null for null or undefined dependency inputs", () => { + assert.strictEqual(getFoundDependencyKey(null), null); + assert.strictEqual(getFoundDependencyKey(undefined), null); + }); + + test("returns null when cloudsmithPackage is missing", () => { + assert.strictEqual(getFoundDependencyKey({}), null); + assert.strictEqual(getFoundDependencyKey({ cloudsmithPackage: null }), null); + }); + + test("returns null when namespace, repository, or slug fields are blank", () => { + assert.strictEqual(getFoundDependencyKey({ + cloudsmithPackage: { + namespace: " ", + repository: "repo-a", + slug_perm: "slug-a", + }, + }), null); + + assert.strictEqual(getFoundDependencyKey({ + cloudsmithPackage: { + namespace: "workspace-a", + repository: " ", + slug_perm: "slug-a", + }, + }), null); + + assert.strictEqual(getFoundDependencyKey({ + cloudsmithPackage: { + namespace: "workspace-a", + repository: "repo-a", + slug_perm: " ", + }, + }), null); + }); + + test("keeps the key format workspace repo slug with lowercase trimmed values", () => { + const key = getFoundDependencyKey({ + cloudsmithPackage: { + namespace: " Workspace-A ", + repository: " Repo-A ", + slugPerm: " slug-value ", + }, + }); + + assert.strictEqual(key, "workspace-a:repo-a:slug-value"); + }); +}); diff --git a/test/helpers/fixtureWorkspace.js b/test/helpers/fixtureWorkspace.js new file mode 100644 index 0000000..c074d2d --- /dev/null +++ b/test/helpers/fixtureWorkspace.js @@ -0,0 +1,46 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +async function makeTempWorkspace(prefix = "cloudsmith-lockfile-") { + return fs.promises.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +async function copyFixtureDir(fixtureName, targetDir) { + const sourceDir = path.join(__dirname, "..", "fixtures", fixtureName); + await copyDirectory(sourceDir, targetDir); +} + +async function copyDirectory(sourceDir, targetDir) { + await fs.promises.mkdir(targetDir, { recursive: true }); + const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true }); + + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + + if (entry.isDirectory()) { + await copyDirectory(sourcePath, targetPath); + continue; + } + + await fs.promises.copyFile(sourcePath, targetPath); + } +} + +async function writeTextFile(targetPath, content) { + await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.promises.writeFile(targetPath, content, "utf8"); +} + +async function removeDirectory(targetDir) { + await fs.promises.rm(targetDir, { recursive: true, force: true }); +} + +module.exports = { + copyDirectory, + copyFixtureDir, + makeTempWorkspace, + removeDirectory, + writeTextFile, +}; diff --git a/test/lockfileParsers/cargoParser.test.js b/test/lockfileParsers/cargoParser.test.js new file mode 100644 index 0000000..c046811 --- /dev/null +++ b/test/lockfileParsers/cargoParser.test.js @@ -0,0 +1,119 @@ +const assert = require("assert"); +const path = require("path"); +const cargoParser = require("../../util/lockfileParsers/cargoParser"); +const { + makeTempWorkspace, + removeDirectory, + writeTextFile, +} = require("../helpers/fixtureWorkspace"); + +suite("cargoParser Test Suite", () => { + const fixtureDir = path.join(__dirname, "..", "fixtures", "cargo"); + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-cargo-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("resolves Cargo.lock uniquely, skips the root package, and marks direct dependencies from Cargo.toml", async () => { + const tree = await cargoParser.resolve({ + lockfilePath: path.join(fixtureDir, "Cargo.lock"), + manifestPath: path.join(fixtureDir, "Cargo.toml"), + }); + + assert.strictEqual(tree.sourceFile, "Cargo.lock"); + assert.strictEqual(tree.dependencies.length, 3); + assert.strictEqual(tree.dependencies.some((dependency) => dependency.name === "fixture-cargo"), false); + + const serde = tree.dependencies.find((dependency) => dependency.name === "serde"); + const tokio = tree.dependencies.find((dependency) => dependency.name === "tokio"); + const bytes = tree.dependencies.find((dependency) => dependency.name === "bytes"); + + assert.ok(serde); + assert.ok(tokio); + assert.ok(bytes); + assert.strictEqual(serde.isDirect, true); + assert.strictEqual(tokio.isDirect, true); + assert.strictEqual(bytes.isDirect, false); + assert.deepStrictEqual(bytes.parentChain, ["tokio"]); + }); + + test("detect returns no matches when Cargo files are missing", async () => { + const workspace = await createWorkspace(); + + const matches = await cargoParser.detect(workspace); + + assert.deepStrictEqual(matches, []); + assert.strictEqual(await cargoParser.canResolve(workspace), false); + }); + + test("throws for malformed Cargo.lock files", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "Cargo.lock"); + const manifestPath = path.join(workspace, "Cargo.toml"); + await writeTextFile(lockfilePath, "[[package]]\nname = \"broken\"\n"); + await writeTextFile(manifestPath, "[dependencies]\nserde = \"1.0.0\"\n"); + + await assert.rejects( + () => cargoParser.resolve({ lockfilePath, manifestPath }), + /Malformed Cargo\.lock: no package entries found/ + ); + }); + + test("deduplicates large Cargo graphs down to unique packages", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "Cargo.lock"); + const manifestPath = path.join(workspace, "Cargo.toml"); + const packageCount = 300; + const registrySource = "registry+https://github.com/rust-lang/crates.io-index"; + + const manifestLines = [ + "[package]", + 'name = "fixture-cargo"', + 'version = "0.1.0"', + "", + "[dependencies]", + 'crate-000 = "1.0.0"', + ]; + + const lockEntries = []; + for (let index = 0; index < packageCount; index += 1) { + const currentName = `crate-${String(index).padStart(3, "0")}`; + const nextName = index + 1 < packageCount + ? `crate-${String(index + 1).padStart(3, "0")}` + : null; + lockEntries.push( + [ + "[[package]]", + `name = "${currentName}"`, + 'version = "1.0.0"', + `source = "${registrySource}"`, + nextName + ? `dependencies = ["${nextName} 1.0.0"]` + : "", + "", + ].filter(Boolean).join("\n") + ); + } + + await writeTextFile(manifestPath, manifestLines.join("\n")); + await writeTextFile(lockfilePath, lockEntries.join("\n")); + + const tree = await cargoParser.resolve({ + lockfilePath, + manifestPath, + }); + + assert.strictEqual(tree.dependencies.length, packageCount); + assert.strictEqual( + new Set(tree.dependencies.map((dependency) => `${dependency.name}@${dependency.version}`)).size, + packageCount + ); + }); +}); diff --git a/test/lockfileParsers/dockerParser.test.js b/test/lockfileParsers/dockerParser.test.js new file mode 100644 index 0000000..ec6d670 --- /dev/null +++ b/test/lockfileParsers/dockerParser.test.js @@ -0,0 +1,78 @@ +const assert = require("assert"); +const path = require("path"); +const dockerParser = require("../../util/lockfileParsers/dockerParser"); +const { + makeTempWorkspace, + removeDirectory, + writeTextFile, +} = require("../helpers/fixtureWorkspace"); + +suite("dockerParser Test Suite", () => { + const fixtureDir = path.join(__dirname, "..", "fixtures", "docker"); + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-docker-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("parses Dockerfile FROM instructions and skips scratch and stage references", async () => { + const tree = await dockerParser.resolve({ + lockfilePath: path.join(fixtureDir, "Dockerfile"), + }); + + assert.strictEqual(tree.sourceFile, "Dockerfile"); + assert.deepStrictEqual( + tree.dependencies.map((dependency) => `${dependency.name}:${dependency.version}`), + ["python:3.11-slim", "alpine:3.19"] + ); + }); + + test("parses docker-compose images and skips build-only services", async () => { + const tree = await dockerParser.resolve({ + lockfilePath: path.join(fixtureDir, "docker-compose.yml"), + }); + + assert.strictEqual(tree.sourceFile, "docker-compose.yml"); + assert.deepStrictEqual( + tree.dependencies.map((dependency) => `${dependency.name}:${dependency.version}`), + ["redis:7.2", "postgres:16"] + ); + }); + + test("detect returns no matches when Docker manifests are missing", async () => { + const workspace = await createWorkspace(); + + const matches = await dockerParser.detect(workspace); + + assert.deepStrictEqual(matches, []); + assert.strictEqual(await dockerParser.canResolve(workspace), false); + }); + + test("detect returns no matches for invalid workspace roots", async () => { + const workspace = await createWorkspace(); + const matches = await dockerParser.detect(path.join(workspace, "missing-workspace")); + + assert.deepStrictEqual(matches, []); + }); + + test("ignores malformed FROM lines that do not resolve to image references", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "Dockerfile"); + await writeTextFile(lockfilePath, [ + "ARG BASE_IMAGE", + "FROM $BASE_IMAGE", + "FROM scratch", + "", + ].join("\n")); + + const tree = await dockerParser.resolve({ lockfilePath }); + + assert.strictEqual(tree.dependencies.length, 0); + }); +}); diff --git a/test/lockfileParsers/mavenParser.test.js b/test/lockfileParsers/mavenParser.test.js new file mode 100644 index 0000000..a95cdba --- /dev/null +++ b/test/lockfileParsers/mavenParser.test.js @@ -0,0 +1,81 @@ +const assert = require("assert"); +const path = require("path"); +const mavenParser = require("../../util/lockfileParsers/mavenParser"); +const { + makeTempWorkspace, + removeDirectory, + writeTextFile, +} = require("../helpers/fixtureWorkspace"); + +suite("mavenParser Test Suite", () => { + const fixtureDir = path.join(__dirname, "..", "fixtures", "maven"); + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-maven-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("hydrates direct dependencies from pom.xml and transitives from dependency-tree.txt", async () => { + const tree = await mavenParser.resolve({ + lockfilePath: path.join(fixtureDir, "dependency-tree.txt"), + manifestPath: path.join(fixtureDir, "pom.xml"), + }); + + assert.strictEqual(tree.sourceFile, "pom.xml"); + assert.strictEqual(tree.dependencies.length, 3); + + const starter = tree.dependencies.find((dependency) => ( + dependency.name === "org.springframework.boot:spring-boot-starter-web" + )); + const springCore = tree.dependencies.find((dependency) => dependency.name === "org.springframework:spring-core"); + const junit = tree.dependencies.find((dependency) => dependency.name === "junit:junit"); + + assert.ok(starter); + assert.ok(springCore); + assert.ok(junit); + assert.strictEqual(starter.isDirect, true); + assert.strictEqual(springCore.isDirect, false); + assert.strictEqual(junit.version, "4.13.2"); + assert.strictEqual(junit.isDevelopmentDependency, true); + }); + + test("detect returns no matches when pom.xml is missing", async () => { + const workspace = await createWorkspace(); + + const matches = await mavenParser.detect(workspace); + + assert.deepStrictEqual(matches, []); + assert.strictEqual(await mavenParser.canResolve(workspace), false); + }); + + test("ignores malformed dependency tree lines and still returns manifest dependencies", async () => { + const workspace = await createWorkspace(); + const manifestPath = path.join(workspace, "pom.xml"); + const lockfilePath = path.join(workspace, "dependency-tree.txt"); + await writeTextFile(manifestPath, [ + "", + " ", + " ", + " org.springframework.boot", + " spring-boot-starter", + " 3.2.0", + " ", + " ", + "", + "", + ].join("\n")); + await writeTextFile(lockfilePath, "this is not a Maven dependency tree\n"); + + const tree = await mavenParser.resolve({ lockfilePath, manifestPath }); + + assert.strictEqual(tree.dependencies.length, 1); + assert.strictEqual(tree.dependencies[0].name, "org.springframework.boot:spring-boot-starter"); + assert.strictEqual(tree.dependencies[0].isDirect, true); + }); +}); diff --git a/test/lockfileParsers/npmParser.test.js b/test/lockfileParsers/npmParser.test.js new file mode 100644 index 0000000..1a5f0ca --- /dev/null +++ b/test/lockfileParsers/npmParser.test.js @@ -0,0 +1,258 @@ +const assert = require("assert"); +const path = require("path"); +const npmParser = require("../../util/lockfileParsers/npmParser"); +const { + makeTempWorkspace, + removeDirectory, + writeTextFile, +} = require("../helpers/fixtureWorkspace"); + +suite("npmParser Test Suite", () => { + const fixtureDir = path.join(__dirname, "..", "fixtures", "npm"); + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-npm-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("resolves package-lock.json with deduplication, scoped packages, and root skipping", async () => { + const tree = await npmParser.resolve({ + lockfilePath: path.join(fixtureDir, "package-lock.json"), + manifestPath: path.join(fixtureDir, "package.json"), + options: { maxDependenciesToScan: 10000 }, + }); + + assert.strictEqual(tree.sourceFile, "package-lock.json"); + assert.strictEqual(tree.dependencies.length, 3); + assert.strictEqual(tree.dependencies.some((dependency) => dependency.name === "fixture-app"), false); + + const express = tree.dependencies.find((dependency) => dependency.name === "express"); + const accepts = tree.dependencies.find((dependency) => dependency.name === "accepts"); + const scoped = tree.dependencies.find((dependency) => dependency.name === "@scope/pkg"); + + assert.ok(express); + assert.ok(accepts); + assert.ok(scoped); + assert.strictEqual(express.isDirect, true); + assert.strictEqual(accepts.isDirect, false); + assert.deepStrictEqual(accepts.parentChain, ["express"]); + assert.strictEqual(scoped.version, "1.0.0"); + }); + + test("resolves yarn.lock fixtures", async () => { + const tree = await npmParser.resolve({ + lockfilePath: path.join(fixtureDir, "yarn.lock"), + manifestPath: path.join(fixtureDir, "package.json"), + options: { maxDependenciesToScan: 10000 }, + }); + + assert.strictEqual(tree.sourceFile, "yarn.lock"); + assert.strictEqual(tree.dependencies.length, 3); + assert.ok(tree.dependencies.some((dependency) => dependency.name === "@scope/pkg")); + }); + + test("preserves multiple resolved versions for the same yarn package", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "yarn.lock"); + const manifestPath = path.join(workspace, "package.json"); + + await writeTextFile(manifestPath, JSON.stringify({ + name: "fixture-app", + version: "1.0.0", + dependencies: { + "package-a": "^1.0.0", + "package-b": "^1.0.0", + }, + }, null, 2)); + + await writeTextFile(lockfilePath, [ + "package-a@^1.0.0:", + ' version "1.0.0"', + " dependencies:", + ' left-pad "^1.0.0"', + "", + "package-b@^1.0.0:", + ' version "1.0.0"', + " dependencies:", + ' left-pad "^2.0.0"', + "", + "left-pad@^1.0.0:", + ' version "1.0.1"', + "", + "left-pad@^2.0.0:", + ' version "2.0.0"', + "", + ].join("\n")); + + const tree = await npmParser.resolve({ + lockfilePath, + manifestPath, + options: { maxDependenciesToScan: 10000 }, + }); + + const packageKeys = tree.dependencies.map((dependency) => `${dependency.name}@${dependency.version}`); + const packageA = tree.dependencies.find((dependency) => dependency.name === "package-a"); + const packageB = tree.dependencies.find((dependency) => dependency.name === "package-b"); + + assert.ok(packageA); + assert.ok(packageB); + assert.strictEqual(packageA.transitives[0].version, "1.0.1"); + assert.strictEqual(packageB.transitives[0].version, "2.0.0"); + assert.ok(packageKeys.includes("left-pad@1.0.1")); + assert.ok(packageKeys.includes("left-pad@2.0.0")); + assert.strictEqual(packageKeys.filter((key) => key.startsWith("left-pad@")).length, 2); + }); + + test("resolves pnpm-lock.yaml fixtures", async () => { + const tree = await npmParser.resolve({ + lockfilePath: path.join(fixtureDir, "pnpm-lock.yaml"), + manifestPath: path.join(fixtureDir, "package.json"), + options: { maxDependenciesToScan: 10000 }, + }); + + assert.strictEqual(tree.sourceFile, "pnpm-lock.yaml"); + assert.strictEqual(tree.dependencies.length, 3); + assert.ok(tree.dependencies.some((dependency) => dependency.name === "accepts")); + }); + + test("detect returns no matches when npm lockfiles are missing", async () => { + const workspace = await createWorkspace(); + + const matches = await npmParser.detect(workspace); + + assert.deepStrictEqual(matches, []); + assert.strictEqual(await npmParser.canResolve(workspace), false); + }); + + test("resolve ignores manifests outside the provided workspace folder", async () => { + const workspace = await createWorkspace(); + const outsideWorkspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "package-lock.json"); + const manifestPath = path.join(outsideWorkspace, "package.json"); + + await writeTextFile(lockfilePath, JSON.stringify({ + name: "fixture-app", + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/express": { + version: "4.18.2", + }, + }, + }, null, 2)); + await writeTextFile(manifestPath, JSON.stringify({ + dependencies: { + express: "^4.18.0", + }, + }, null, 2)); + + const tree = await npmParser.resolve({ + workspaceFolder: workspace, + lockfilePath, + manifestPath, + options: { maxDependenciesToScan: 10000 }, + }); + + const express = tree.dependencies.find((dependency) => dependency.name === "express"); + + assert.ok(express); + assert.strictEqual( + express.isDirect, + false, + "out-of-workspace manifests should not influence direct dependency classification" + ); + }); + + test("throws for malformed package-lock.json files", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "package-lock.json"); + const manifestPath = path.join(workspace, "package.json"); + await writeTextFile(lockfilePath, "{\n \"name\": \"broken\"\n}\n"); + await writeTextFile(manifestPath, "{\n \"dependencies\": {}\n}\n"); + + await assert.rejects( + () => npmParser.resolve({ + lockfilePath, + manifestPath, + options: { maxDependenciesToScan: 10000 }, + }), + /missing packages object/ + ); + }); + + test("adds a warning when the unique dependency count exceeds the scan cap", async () => { + const tree = await npmParser.resolve({ + lockfilePath: path.join(fixtureDir, "package-lock.json"), + manifestPath: path.join(fixtureDir, "package.json"), + options: { maxDependenciesToScan: 2 }, + }); + + assert.strictEqual(tree.warnings.length, 1); + assert.match(tree.warnings[0], /Display is capped at 2 dependencies/); + }); + + test("includes orphaned package-lock entries once even when duplicate package records share a key", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "package-lock.json"); + const manifestPath = path.join(workspace, "package.json"); + + await writeTextFile(manifestPath, JSON.stringify({ + name: "fixture-app", + version: "1.0.0", + dependencies: { + express: "1.0.0", + }, + }, null, 2)); + + await writeTextFile(lockfilePath, JSON.stringify({ + name: "fixture-app", + version: "1.0.0", + lockfileVersion: 3, + packages: { + "": { + dependencies: { + express: "1.0.0", + }, + }, + "node_modules/express": { + version: "1.0.0", + dependencies: { + accepts: "1.0.0", + shared: "1.0.0", + }, + }, + "node_modules/accepts": { + version: "1.0.0", + }, + "node_modules/shared": { + version: "1.0.0", + }, + "node_modules/express/node_modules/shared": { + version: "1.0.0", + }, + "node_modules/orphan": { + version: "2.0.0", + }, + }, + }, null, 2)); + + const tree = await npmParser.resolve({ + lockfilePath, + manifestPath, + options: { maxDependenciesToScan: 10000 }, + }); + + const packageKeys = tree.dependencies.map((dependency) => `${dependency.name}@${dependency.version}`); + + assert.strictEqual(packageKeys.filter((key) => key === "shared@1.0.0").length, 1); + assert.strictEqual(packageKeys.filter((key) => key === "orphan@2.0.0").length, 1); + assert.ok(packageKeys.includes("express@1.0.0")); + assert.ok(packageKeys.includes("accepts@1.0.0")); + }); +}); diff --git a/test/lockfileParsers/nugetParser.test.js b/test/lockfileParsers/nugetParser.test.js new file mode 100644 index 0000000..68454ca --- /dev/null +++ b/test/lockfileParsers/nugetParser.test.js @@ -0,0 +1,28 @@ +const assert = require("assert"); +const path = require("path"); +const nugetParser = require("../../util/lockfileParsers/nugetParser"); +const { + makeTempWorkspace, + removeDirectory, +} = require("../helpers/fixtureWorkspace"); + +suite("nugetParser Test Suite", () => { + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-nuget-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("detect returns no matches for invalid workspace roots", async () => { + const workspace = await createWorkspace(); + const matches = await nugetParser.detect(path.join(workspace, "missing-workspace")); + + assert.deepStrictEqual(matches, []); + }); +}); diff --git a/test/lockfileParsers/pythonParser.test.js b/test/lockfileParsers/pythonParser.test.js new file mode 100644 index 0000000..f040ea1 --- /dev/null +++ b/test/lockfileParsers/pythonParser.test.js @@ -0,0 +1,109 @@ +const assert = require("assert"); +const path = require("path"); +const pythonParser = require("../../util/lockfileParsers/pythonParser"); +const { + makeTempWorkspace, + removeDirectory, + writeTextFile, +} = require("../helpers/fixtureWorkspace"); + +suite("pythonParser Test Suite", () => { + const fixtureDir = path.join(__dirname, "..", "fixtures", "python"); + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace("cloudsmith-python-parser-"); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("resolves poetry.lock and keeps all package entries while marking directs from pyproject.toml", async () => { + const tree = await pythonParser.resolve({ + lockfilePath: path.join(fixtureDir, "poetry.lock"), + manifestPath: path.join(fixtureDir, "pyproject.toml"), + }); + + assert.strictEqual(tree.sourceFile, "poetry.lock"); + assert.strictEqual(tree.dependencies.length, 3); + + const flask = tree.dependencies.find((dependency) => dependency.name === "flask"); + const requests = tree.dependencies.find((dependency) => dependency.name === "requests"); + const click = tree.dependencies.find((dependency) => dependency.name === "click"); + + assert.ok(flask); + assert.ok(requests); + assert.ok(click); + assert.strictEqual(flask.isDirect, true); + assert.strictEqual(requests.isDirect, true); + assert.strictEqual(click.isDirect, false); + assert.deepStrictEqual(click.parentChain, ["flask"]); + }); + + test("skips the editable uv root package and resolves its transitive dependencies", async () => { + const tree = await pythonParser.resolve({ + lockfilePath: path.join(fixtureDir, "uv.lock"), + manifestPath: path.join(fixtureDir, "pyproject.toml"), + }); + + assert.strictEqual(tree.sourceFile, "uv.lock"); + assert.strictEqual(tree.dependencies.some((dependency) => dependency.name === "fixture-python"), false); + assert.strictEqual(tree.dependencies.length, 3); + assert.ok(tree.dependencies.some((dependency) => dependency.name === "fastapi" && dependency.isDirect)); + assert.ok(tree.dependencies.some((dependency) => dependency.name === "starlette" && !dependency.isDirect)); + assert.ok(tree.dependencies.some((dependency) => dependency.name === "pydantic" && !dependency.isDirect)); + }); + + test("warns when only requirements.txt is available", async () => { + const tree = await pythonParser.resolve({ + lockfilePath: path.join(fixtureDir, "requirements.txt"), + }); + + assert.strictEqual(tree.sourceFile, "requirements.txt"); + assert.strictEqual(tree.dependencies.length, 2); + assert.strictEqual(tree.dependencies.every((dependency) => dependency.isDirect), true); + assert.strictEqual(tree.warnings.length, 1); + assert.match(tree.warnings[0], /requirements\.txt does not encode transitive dependencies/i); + }); + + test("detect returns no matches when Python dependency files are missing", async () => { + const workspace = await createWorkspace(); + + const matches = await pythonParser.detect(workspace); + + assert.deepStrictEqual(matches, []); + assert.strictEqual(await pythonParser.canResolve(workspace), false); + }); + + test("resolve rejects lockfiles outside the provided workspace folder", async () => { + const workspace = await createWorkspace(); + const outsideWorkspace = await createWorkspace(); + const lockfilePath = path.join(outsideWorkspace, "requirements.txt"); + + await writeTextFile(lockfilePath, "requests==2.31.0\n"); + + await assert.rejects( + () => pythonParser.resolve({ + workspaceFolder: workspace, + lockfilePath, + }), + /Refusing to read files outside the workspace folder/ + ); + }); + + test("throws for malformed poetry.lock files", async () => { + const workspace = await createWorkspace(); + const lockfilePath = path.join(workspace, "poetry.lock"); + const manifestPath = path.join(workspace, "pyproject.toml"); + await writeTextFile(lockfilePath, "[metadata]\nlock-version = \"2.0\"\n"); + await writeTextFile(manifestPath, "[tool.poetry.dependencies]\nflask = \"^2.3.0\"\n"); + + await assert.rejects( + () => pythonParser.resolve({ lockfilePath, manifestPath }), + /no package entries found/ + ); + }); +}); diff --git a/test/lockfileResolver.test.js b/test/lockfileResolver.test.js new file mode 100644 index 0000000..7493348 --- /dev/null +++ b/test/lockfileResolver.test.js @@ -0,0 +1,204 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const { LockfileResolver } = require("../util/lockfileResolver"); +const { deduplicateDeps } = require("../util/lockfileParsers/shared"); +const { buildPackageIndex, findCoverageMatch } = require("../views/dependencyHealthProvider"); +const { + copyFixtureDir, + makeTempWorkspace, + removeDirectory, + writeTextFile, +} = require("./helpers/fixtureWorkspace"); + +suite("LockfileResolver Test Suite", () => { + const tempDirs = []; + + async function createWorkspace() { + const workspace = await makeTempWorkspace(); + tempDirs.push(workspace); + return workspace; + } + + suiteTeardown(async () => { + await Promise.all(tempDirs.map((tempDir) => removeDirectory(tempDir))); + }); + + test("detectResolvers finds multiple ecosystems in one workspace", async () => { + const workspace = await createWorkspace(); + await copyFixtureDir("npm", workspace); + await copyFixtureDir("docker", workspace); + await copyFixtureDir("ruby", workspace); + + const resolvers = await LockfileResolver.detectResolvers(workspace); + const resolverKeys = new Set(resolvers.map((resolver) => `${resolver.resolverName}:${resolver.sourceFile}`)); + + assert.ok(resolverKeys.has("npmParser:package-lock.json")); + assert.ok(resolverKeys.has("dockerParser:Dockerfile")); + assert.ok(resolverKeys.has("dockerParser:docker-compose.yml")); + assert.ok(resolverKeys.has("rubyParser:Gemfile.lock")); + }); + + test("resolveAll returns separate dependency trees grouped by source file", async () => { + const workspace = await createWorkspace(); + await copyFixtureDir("npm", workspace); + await copyFixtureDir("docker", workspace); + + const trees = await LockfileResolver.resolveAll(workspace, { maxDependenciesToScan: 10000 }); + const bySource = new Map(trees.map((tree) => [tree.sourceFile, tree])); + + assert.strictEqual(trees.length, 3); + assert.ok(bySource.has("package-lock.json")); + assert.ok(bySource.has("Dockerfile")); + assert.ok(bySource.has("docker-compose.yml")); + assert.strictEqual(bySource.get("package-lock.json").ecosystem, "npm"); + assert.strictEqual(bySource.get("Dockerfile").ecosystem, "docker"); + assert.strictEqual(bySource.get("docker-compose.yml").ecosystem, "docker"); + }); + + test("deduplicateDeps keeps a single package and prefers direct dependencies", () => { + const dependencies = [ + { + ecosystem: "npm", + name: "accepts", + version: "1.3.8", + isDirect: false, + parent: "express", + parentChain: ["express"], + }, + { + ecosystem: "npm", + name: "accepts", + version: "1.3.8", + isDirect: true, + parent: null, + parentChain: [], + }, + { + ecosystem: "npm", + name: "express", + version: "4.18.2", + isDirect: true, + parent: null, + parentChain: [], + }, + ]; + + const deduplicated = deduplicateDeps(dependencies); + + assert.strictEqual(deduplicated.length, 2); + const accepts = deduplicated.find((dependency) => dependency.name === "accepts"); + assert.ok(accepts); + assert.strictEqual(accepts.isDirect, true); + assert.deepStrictEqual(accepts.parentChain, []); + }); + + test("coverage matching normalizes Python package names", () => { + const cloudsmithPackage = { + name: "scikit-learn", + version: "1.4.0", + format: "python", + }; + const index = buildPackageIndex([cloudsmithPackage], "python"); + + const match = findCoverageMatch(index, { + name: "scikit_learn", + version: "1.4.0", + format: "python", + }); + + assert.strictEqual(match, cloudsmithPackage); + }); + + test("coverage matching normalizes Python case, hyphen, underscore, and dot variants", () => { + const cloudsmithPackage = { + name: "Requests-HTML", + version: "0.10.0", + format: "python", + }; + const index = buildPackageIndex([cloudsmithPackage], "python"); + const variants = [ + "requests_html", + "requests.html", + "REQUESTS-HTML", + ]; + + for (const variant of variants) { + const match = findCoverageMatch(index, { + name: variant, + version: "0.10.0", + format: "python", + }); + assert.strictEqual(match, cloudsmithPackage); + } + }); + + test("coverage matching indexes Maven packages by groupId and artifactId", () => { + const cloudsmithPackage = { + name: "spring-boot-starter", + version: "3.2.0", + format: "maven", + identifiers: { + group_id: "org.springframework.boot", + }, + }; + const index = buildPackageIndex([cloudsmithPackage], "maven"); + + const match = findCoverageMatch(index, { + name: "org.springframework.boot:spring-boot-starter", + version: "3.2.0", + format: "maven", + }); + + assert.strictEqual(match, cloudsmithPackage); + }); + + test("secondary parser fixtures can be resolved through the registry", async () => { + const fixtureNames = [ + "gradle", + "go", + "nuget", + "dart", + "composer", + "helm", + "swift", + "hex", + ]; + + for (const fixtureName of fixtureNames) { + const workspace = await createWorkspace(); + await copyFixtureDir(fixtureName, workspace); + + const trees = await LockfileResolver.resolveAll(workspace, { maxDependenciesToScan: 10000 }); + + assert.strictEqual(trees.length, 1, `${fixtureName} should resolve to exactly one tree`); + assert.ok(trees[0].dependencies.length > 0, `${fixtureName} should resolve at least one dependency`); + } + }); + + test("detectResolvers ignores symlinked lockfiles that point outside the workspace", async () => { + const workspace = await createWorkspace(); + const outsideDir = await createWorkspace(); + const outsideLockfile = path.join(outsideDir, "package-lock.json"); + const workspaceLockfile = path.join(workspace, "package-lock.json"); + + await writeTextFile( + outsideLockfile, + JSON.stringify({ + packages: { + "": { + dependencies: {}, + }, + }, + }) + ); + await fs.promises.symlink(outsideLockfile, workspaceLockfile); + + const resolvers = await LockfileResolver.detectResolvers(workspace); + + assert.strictEqual( + resolvers.some((resolver) => resolver.resolverName === "npmParser"), + false + ); + }); +}); diff --git a/test/treeVisualization.test.js b/test/treeVisualization.test.js new file mode 100644 index 0000000..db28eba --- /dev/null +++ b/test/treeVisualization.test.js @@ -0,0 +1,198 @@ +const assert = require("assert"); +const vscode = require("vscode"); +const { + DependencyHealthProvider, + FILTER_MODES, + buildDependencyHealthReport, + buildDependencySummary, +} = require("../views/dependencyHealthProvider"); + +suite("tree visualization", () => { + let originalGetConfiguration; + + setup(() => { + originalGetConfiguration = vscode.workspace.getConfiguration; + vscode.workspace.getConfiguration = () => ({ + get(key) { + if (key === "dependencyTreeDefaultView") { + return "tree"; + } + if (key === "showLicenseIndicators") { + return true; + } + if (key === "flagRestrictiveLicenses") { + return true; + } + return undefined; + }, + }); + }); + + teardown(() => { + vscode.workspace.getConfiguration = originalGetConfiguration; + }); + + function createContext() { + return { + workspaceState: { + get() { + return null; + }, + async update() {}, + }, + secrets: { + onDidChange() { + return { dispose() {} }; + }, + async get() { + return "true"; + }, + }, + }; + } + + function createFoundPackage(slug) { + return { + namespace: "workspace-a", + repository: "production-npm", + slug_perm: slug, + status_str: "Completed", + version: "1.0.0", + license: "MIT", + }; + } + + function createTree() { + const vulnerableLeaf = { + name: "shared-lib", + version: "1.0.0", + format: "npm", + ecosystem: "npm", + isDirect: false, + parent: "alpha", + parentChain: ["alpha"], + transitives: [], + cloudsmithStatus: "FOUND", + cloudsmithPackage: createFoundPackage("shared"), + vulnerabilities: { + count: 1, + maxSeverity: "High", + cveIds: ["CVE-2024-1234"], + hasFixAvailable: true, + severityCounts: { High: 1 }, + entries: [{ cveId: "CVE-2024-1234", severity: "High", fixVersion: "1.0.1" }], + detailsLoaded: true, + }, + sourceFile: "package-lock.json", + }; + + const duplicateLeaf = { + ...vulnerableLeaf, + parent: "beta", + parentChain: ["beta"], + }; + + const alpha = { + name: "alpha", + version: "2.0.0", + format: "npm", + ecosystem: "npm", + isDirect: true, + parent: null, + parentChain: [], + transitives: [vulnerableLeaf], + cloudsmithStatus: "FOUND", + cloudsmithPackage: createFoundPackage("alpha"), + sourceFile: "package-lock.json", + }; + + const beta = { + name: "beta", + version: "3.0.0", + format: "npm", + ecosystem: "npm", + isDirect: true, + parent: null, + parentChain: [], + transitives: [duplicateLeaf], + cloudsmithStatus: "FOUND", + cloudsmithPackage: createFoundPackage("beta"), + sourceFile: "package-lock.json", + }; + + return { + ecosystem: "npm", + sourceFile: "package-lock.json", + dependencies: [alpha, beta, vulnerableLeaf], + }; + } + + test("tree mode expands direct dependencies and collapses duplicate diamonds", () => { + const provider = new DependencyHealthProvider(createContext(), null); + const tree = createTree(); + provider._displayTrees = [tree]; + provider._fullTrees = [tree]; + provider._viewMode = "tree"; + provider._rebuildSummary(); + + const rootNodes = provider.buildDependencyNodesForTree(tree); + assert.strictEqual(rootNodes.length, 2); + assert.strictEqual(rootNodes[0].getTreeItem().collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); + + const alphaChildren = rootNodes[0].getChildren(); + assert.strictEqual(alphaChildren.length, 1); + assert.strictEqual(alphaChildren[0].name, "shared-lib"); + assert.strictEqual(alphaChildren[0].getTreeItem().collapsibleState, vscode.TreeItemCollapsibleState.None); + + const betaChildren = rootNodes[1].getChildren(); + assert.strictEqual(betaChildren.length, 1); + assert.match(betaChildren[0].getTreeItem().description, /see first occurrence/); + assert.strictEqual(betaChildren[0].getTreeItem().collapsibleState, vscode.TreeItemCollapsibleState.None); + }); + + test("filtered tree keeps only the ancestor path to vulnerable dependencies", async () => { + const provider = new DependencyHealthProvider(createContext(), null); + const tree = createTree(); + tree.dependencies[1] = { + ...tree.dependencies[1], + transitives: [], + }; + provider._displayTrees = [tree]; + provider._fullTrees = [tree]; + provider._viewMode = "tree"; + await provider.setFilterMode(FILTER_MODES.VULNERABLE); + + const rootNodes = provider.buildDependencyNodesForTree(tree); + assert.strictEqual(rootNodes.length, 1); + assert.strictEqual(rootNodes[0].name, "alpha"); + assert.match(rootNodes[0].getTreeItem().description, /context/); + assert.strictEqual(rootNodes[0].getChildren()[0].name, "shared-lib"); + }); + + test("dependency health report includes vulnerability and upstream sections", () => { + const tree = createTree(); + const uncovered = { + name: "missing-lib", + version: "0.1.0", + format: "npm", + ecosystem: "npm", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + cloudsmithStatus: "NOT_FOUND", + upstreamStatus: "reachable", + upstreamDetail: "npm proxy on production", + sourceFile: "package-lock.json", + }; + tree.dependencies.push(uncovered); + + const summary = buildDependencySummary([tree], [tree], {}); + const report = buildDependencyHealthReport("fixture-app", tree.dependencies, summary, "2026-04-05"); + + assert.match(report, /## Vulnerable Dependencies/); + assert.match(report, /\| shared-lib \| 1.0.0 \| Transitive \| High \| CVE-2024-1234 \| Yes \(1.0.1\) \|/); + assert.match(report, /## Uncovered Dependencies/); + assert.match(report, /\| missing-lib \| 0.1.0 \| npm \| Reachable \| npm proxy on production \|/); + }); +}); diff --git a/test/upstreamGapAnalyzer.test.js b/test/upstreamGapAnalyzer.test.js new file mode 100644 index 0000000..fa2e8d4 --- /dev/null +++ b/test/upstreamGapAnalyzer.test.js @@ -0,0 +1,136 @@ +const assert = require("assert"); +const { + analyzeUpstreamGaps, +} = require("../util/upstreamGapAnalyzer"); + +suite("upstreamGapAnalyzer", () => { + function createState(entries = {}) { + return { + groupedUpstreams: new Map(Object.entries(entries)), + }; + } + + test("classifies uncovered dependencies as reachable when a matching proxy exists", async () => { + const dependencies = [ + { + name: "accepts", + version: "1.3.8", + format: "npm", + ecosystem: "npm", + cloudsmithStatus: "NOT_FOUND", + }, + ]; + + const enriched = await analyzeUpstreamGaps(dependencies, "workspace-a", ["production"], { + upstreamChecker: { + async getRepositoryUpstreamState() { + return createState({ + npm: [ + { name: "npm", is_active: true }, + ], + }); + }, + }, + }); + + assert.strictEqual(enriched[0].upstreamStatus, "reachable"); + assert.strictEqual(enriched[0].upstreamDetail, "npm proxy on production"); + }); + + test("classifies supported formats with no proxy as no_proxy", async () => { + const dependencies = [ + { + name: "requests", + version: "2.31.0", + format: "python", + ecosystem: "python", + cloudsmithStatus: "NOT_FOUND", + }, + ]; + + const enriched = await analyzeUpstreamGaps(dependencies, "workspace-a", ["production"], { + upstreamChecker: { + async getRepositoryUpstreamState() { + return createState(); + }, + }, + }); + + assert.strictEqual(enriched[0].upstreamStatus, "no_proxy"); + assert.strictEqual(enriched[0].upstreamDetail, "No upstream proxy configured for python"); + }); + + test("classifies unsupported formats as unreachable", async () => { + const dependencies = [ + { + name: "custom-lib", + version: "1.0.0", + format: "custom", + ecosystem: "custom", + cloudsmithStatus: "NOT_FOUND", + }, + ]; + + const enriched = await analyzeUpstreamGaps(dependencies, "workspace-a", ["production"], { + upstreamChecker: { + async getRepositoryUpstreamState() { + return createState(); + }, + }, + }); + + assert.strictEqual(enriched[0].upstreamStatus, "unreachable"); + assert.strictEqual(enriched[0].upstreamDetail, "Not available through Cloudsmith"); + }); + + test("limits upstream repository lookups to five concurrent requests and emits one final patch", async () => { + const dependencies = [ + { + name: "accepts", + version: "1.3.8", + format: "npm", + ecosystem: "npm", + cloudsmithStatus: "NOT_FOUND", + }, + ]; + const repositories = Array.from({ length: 12 }, (_, index) => `repo-${index + 1}`); + const progressEvents = []; + let inFlight = 0; + let maxInFlight = 0; + + const enriched = await analyzeUpstreamGaps(dependencies, "workspace-a", repositories, { + onProgress: (patchMap, meta) => { + progressEvents.push({ + size: patchMap.size, + completed: meta.completed, + total: meta.total, + }); + }, + upstreamChecker: { + async getRepositoryUpstreamState(_workspace, repo) { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + + if (repo === "repo-9") { + return createState({ + npm: [ + { name: "npm", is_active: true }, + ], + }); + } + + return createState(); + }, + }, + }); + + assert.ok(maxInFlight <= 5); + assert.strictEqual(progressEvents.filter((event) => event.size > 0).length, 1); + assert.strictEqual(progressEvents[progressEvents.length - 1].completed, repositories.length); + assert.strictEqual(progressEvents[progressEvents.length - 1].size, 1); + assert.strictEqual(enriched[0].upstreamStatus, "reachable"); + assert.strictEqual(enriched[0].upstreamDetail, "npm proxy on repo-9"); + }); +}); diff --git a/test/upstreamPullService.test.js b/test/upstreamPullService.test.js new file mode 100644 index 0000000..bad0b01 --- /dev/null +++ b/test/upstreamPullService.test.js @@ -0,0 +1,312 @@ +const assert = require("assert"); +const { + buildRegistryTriggerPlan, +} = require("../util/registryEndpoints"); +const { + UpstreamPullService, +} = require("../util/upstreamPullService"); + +function createResponse(status, body, headers = {}) { + return { + status, + headers: { + get(name) { + const lowerName = String(name || "").toLowerCase(); + return headers[lowerName] || headers[name] || null; + }, + }, + async text() { + return body; + }, + }; +} + +suite("UpstreamPullService", () => { + test("builds canonical registry trigger URLs for supported formats", () => { + const mavenPlan = buildRegistryTriggerPlan("workspace", "repo", { + name: "com.example:demo-app", + version: "1.2.3", + format: "maven", + }); + assert.strictEqual( + mavenPlan.request.url, + "https://dl.cloudsmith.io/basic/workspace/repo/maven/com/example/demo-app/1.2.3/demo-app-1.2.3.pom" + ); + + const npmPlan = buildRegistryTriggerPlan("workspace", "repo", { + name: "@scope/widget", + version: "4.5.6", + format: "npm", + }); + assert.strictEqual( + npmPlan.request.url, + "https://npm.cloudsmith.io/workspace/repo/%40scope/widget/-/widget-4.5.6.tgz" + ); + + const goPlan = buildRegistryTriggerPlan("workspace", "repo", { + name: "github.com/MyOrg/MyModule", + version: "v1.0.0", + format: "go", + }); + assert.strictEqual( + goPlan.request.url, + "https://golang.cloudsmith.io/workspace/repo/github.com/!my!org/!my!module/@v/v1.0.0.info" + ); + + const cargoPlan = buildRegistryTriggerPlan("workspace", "repo", { + name: "serde", + version: "1.0.0", + format: "cargo", + }); + assert.strictEqual( + cargoPlan.request.url, + "https://cargo.cloudsmith.io/workspace/repo/se/rd/serde" + ); + }); + + test("prepare builds a mixed-ecosystem confirmation with skipped formats", async () => { + const warnings = []; + const service = new UpstreamPullService({}, { + fetchRepositories: async () => [{ slug: "repo", name: "Repo" }], + upstreamChecker: { + async getRepositoryUpstreamState() { + return { + groupedUpstreams: new Map([ + ["maven", [{ name: "Maven Central", is_active: true }]], + ]), + }; + }, + }, + showQuickPick: async (items) => items[0], + showWarningMessage: async (message, _options, action) => { + warnings.push(message); + return action; + }, + showErrorMessage: async () => {}, + showInformationMessage: async () => {}, + }); + + const prepared = await service.prepare({ + workspace: "workspace", + repositoryHint: "repo", + dependencies: [ + { + name: "com.example:demo-app", + version: "1.2.3", + format: "maven", + cloudsmithStatus: "NOT_FOUND", + }, + { + name: "requests", + version: "2.31.0", + format: "python", + cloudsmithStatus: "NOT_FOUND", + }, + ], + }); + + assert.ok(prepared); + assert.strictEqual(prepared.plan.pullableDependencies.length, 1); + assert.strictEqual(prepared.plan.skippedDependencies.length, 1); + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /Pull 1 of 2 dependencies through repo\?/); + assert.match(warnings[0], /1 Maven will be pulled\./); + assert.match( + warnings[0], + /1 Python will be skipped \(no matching upstream is configured on this repository\)\./ + ); + }); + + test("pulls Python dependencies via same-host redirects using manual auth-preserving requests", async () => { + const calls = []; + const initialIndexUrl = "https://dl.cloudsmith.io/basic/workspace/repo/python/simple/requests/"; + const redirectedIndexUrl = "https://dl.cloudsmith.io/basic/workspace/repo/python/simple/requests/index.html"; + const artifactUrl = "https://dl.cloudsmith.io/basic/workspace/repo/python/packages/requests-2.31.0-py3-none-any.whl"; + const authorizationHeader = `Basic ${Buffer.from("token:api-key").toString("base64")}`; + const service = new UpstreamPullService({}, { + credentialManager: { + async getApiKey() { + return "api-key"; + }, + }, + fetchImpl: async (url, options) => { + calls.push({ url, options }); + if (url === initialIndexUrl) { + return createResponse(302, "", { + location: redirectedIndexUrl, + }); + } + if (url === redirectedIndexUrl) { + return createResponse(200, 'requests'); + } + if (url === artifactUrl) { + return createResponse(200, ""); + } + throw new Error(`Unexpected URL: ${url}`); + }, + showErrorMessage: async () => {}, + showInformationMessage: async () => {}, + showWarningMessage: async () => {}, + }); + + const result = await service.execute({ + workspace: "workspace", + repository: { slug: "repo" }, + plan: { + pullableDependencies: [{ + name: "requests", + version: "2.31.0", + format: "python", + cloudsmithStatus: "NOT_FOUND", + }], + skippedDependencies: [], + }, + }); + + assert.strictEqual(result.canceled, false); + assert.strictEqual(result.pullResult.cached, 1); + assert.strictEqual(calls.length, 3); + assert.deepStrictEqual( + calls.map((call) => call.url), + [initialIndexUrl, redirectedIndexUrl, artifactUrl] + ); + assert.strictEqual(calls.every((call) => call.options.redirect === "manual"), true); + assert.strictEqual( + calls.every((call) => call.options.headers.Authorization === authorizationHeader), + true + ); + }); + + test("rejects redirects to untrusted hosts before forwarding credentials", async () => { + const calls = []; + const service = new UpstreamPullService({}, { + credentialManager: { + async getApiKey() { + return "api-key"; + }, + }, + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return createResponse(302, "", { + location: "https://example.com/requests-2.31.0.whl", + }); + }, + showErrorMessage: async () => {}, + showInformationMessage: async () => {}, + showWarningMessage: async () => {}, + }); + + const result = await service.execute({ + workspace: "workspace", + repository: { slug: "repo" }, + plan: { + pullableDependencies: [{ + name: "requests", + version: "2.31.0", + format: "python", + cloudsmithStatus: "NOT_FOUND", + }], + skippedDependencies: [], + }, + }); + + assert.strictEqual(result.canceled, false); + assert.strictEqual(result.pullResult.cached, 0); + assert.strictEqual(result.pullResult.errors, 1); + assert.strictEqual(calls.length, 1); + assert.match(result.pullResult.details[0].errorMessage, /redirect target was rejected/i); + }); + + test("stops after three authentication failures before expanding concurrency", async () => { + const calls = []; + const errors = []; + const service = new UpstreamPullService({}, { + credentialManager: { + async getApiKey() { + return "api-key"; + }, + }, + fetchImpl: async (url) => { + calls.push(url); + return createResponse(401, ""); + }, + showErrorMessage: async (message) => { + errors.push(message); + }, + showInformationMessage: async () => {}, + showWarningMessage: async () => {}, + }); + + const dependencies = Array.from({ length: 5 }, (_, index) => ({ + name: `package-${index}`, + version: "1.0.0", + format: "npm", + cloudsmithStatus: "NOT_FOUND", + })); + + const result = await service.execute({ + workspace: "workspace", + repository: { slug: "repo" }, + plan: { + pullableDependencies: dependencies, + skippedDependencies: [], + }, + }); + + assert.strictEqual(calls.length, 3); + assert.strictEqual(result.pullResult.errors, 5); + assert.strictEqual(result.pullResult.authFailed, 5); + assert.deepStrictEqual(errors, [ + "Authentication failed. Check your API key in Cloudsmith settings.", + ]); + }); + + test("prepareSingle only offers repositories with a matching upstream", async () => { + const quickPickCalls = []; + let warningCalls = 0; + const service = new UpstreamPullService({}, { + fetchRepositories: async () => [ + { slug: "repo-a", name: "Repo A" }, + { slug: "repo-b", name: "Repo B" }, + ], + upstreamChecker: { + async getRepositoryUpstreamState(_workspace, repo) { + return { + groupedUpstreams: new Map([ + ["python", repo === "repo-b" ? [{ name: "PyPI", is_active: true }] : []], + ]), + }; + }, + }, + showQuickPick: async (items) => { + quickPickCalls.push(items); + return items[0]; + }, + showErrorMessage: async () => {}, + showInformationMessage: async () => {}, + showWarningMessage: async () => { + warningCalls += 1; + }, + }); + + const prepared = await service.prepareSingle({ + workspace: "workspace", + repositoryHint: "repo-b", + dependency: { + name: "requests", + version: "2.31.0", + format: "python", + cloudsmithStatus: "NOT_FOUND", + }, + }); + + assert.ok(prepared); + assert.strictEqual(prepared.repository.slug, "repo-b"); + assert.strictEqual(prepared.plan.pullableDependencies.length, 1); + assert.strictEqual(quickPickCalls.length, 1); + assert.strictEqual(quickPickCalls[0].length, 1); + assert.strictEqual(quickPickCalls[0][0].label, "repo-b"); + assert.match(quickPickCalls[0][0].detail, /Python upstream \(PyPI\)/); + assert.strictEqual(warningCalls, 0); + }); +}); diff --git a/util/dependencyLicenseEnricher.js b/util/dependencyLicenseEnricher.js new file mode 100644 index 0000000..89cfd21 --- /dev/null +++ b/util/dependencyLicenseEnricher.js @@ -0,0 +1,75 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const { LicenseClassifier } = require("./licenseClassifier"); +const { getFoundDependencyKey } = require("./foundDependencyKey"); + +function toLicenseClassification(tier) { + switch (tier) { + case "permissive": + return "permissive"; + case "cautious": + return "weak_copyleft"; + case "restrictive": + return "restrictive"; + default: + return "unknown"; + } +} + +function buildLicensePatch(dependencies) { + const patchMap = new Map(); + + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + if (dependency.cloudsmithStatus !== "FOUND" || !dependency.cloudsmithPackage) { + continue; + } + + const key = getFoundDependencyKey(dependency); + if (!key || patchMap.has(key)) { + continue; + } + + const inspection = LicenseClassifier.inspect(dependency.cloudsmithPackage); + const spdx = inspection.spdxLicense || inspection.canonicalValue || inspection.displayValue || null; + + patchMap.set(key, { + spdx, + display: inspection.displayValue || spdx || null, + url: inspection.licenseUrl || null, + classification: toLicenseClassification(inspection.tier), + classifierTier: inspection.tier, + raw: inspection.rawLicense || inspection.raw || null, + overrideApplied: Boolean(inspection.overrideApplied), + }); + } + + return patchMap; +} + +function applyLicensePatch(dependencies, patchMap) { + return (Array.isArray(dependencies) ? dependencies : []).map((dependency) => { + const key = getFoundDependencyKey(dependency); + if (!key || !patchMap.has(key)) { + return dependency; + } + + return { + ...dependency, + license: patchMap.get(key), + }; + }); +} + +async function enrichLicenses(dependencies, options = {}) { + const onProgress = typeof options.onProgress === "function" ? options.onProgress : null; + const patchMap = buildLicensePatch(dependencies); + + if (onProgress && patchMap.size > 0) { + onProgress(new Map(patchMap), { stage: "licenses" }); + } + + return applyLicensePatch(dependencies, patchMap); +} + +module.exports = { + enrichLicenses, +}; diff --git a/util/dependencyPolicyEnricher.js b/util/dependencyPolicyEnricher.js new file mode 100644 index 0000000..e738c21 --- /dev/null +++ b/util/dependencyPolicyEnricher.js @@ -0,0 +1,67 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const { getFoundDependencyKey } = require("./foundDependencyKey"); + +function buildPolicyPatch(dependencies) { + const patchMap = new Map(); + + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + if (dependency.cloudsmithStatus !== "FOUND" || !dependency.cloudsmithPackage) { + continue; + } + + const key = getFoundDependencyKey(dependency); + if (!key || patchMap.has(key)) { + continue; + } + + const pkg = dependency.cloudsmithPackage; + const status = String(pkg.status_str || "").trim() || null; + const quarantined = status === "Quarantined"; + const denied = quarantined || Boolean(pkg.deny_policy_violated); + const violated = denied + || Boolean(pkg.policy_violated) + || Boolean(pkg.license_policy_violated) + || Boolean(pkg.vulnerability_policy_violated); + + patchMap.set(key, { + violated, + denied, + quarantined, + status, + statusReason: String(pkg.status_reason || "").trim() || null, + vulnerabilityViolated: Boolean(pkg.vulnerability_policy_violated), + licenseViolated: Boolean(pkg.license_policy_violated), + }); + } + + return patchMap; +} + +function applyPolicyPatch(dependencies, patchMap) { + return (Array.isArray(dependencies) ? dependencies : []).map((dependency) => { + const key = getFoundDependencyKey(dependency); + if (!key || !patchMap.has(key)) { + return dependency; + } + + return { + ...dependency, + policy: patchMap.get(key), + }; + }); +} + +async function enrichPolicies(dependencies, options = {}) { + const onProgress = typeof options.onProgress === "function" ? options.onProgress : null; + const patchMap = buildPolicyPatch(dependencies); + + if (onProgress && patchMap.size > 0) { + onProgress(new Map(patchMap), { stage: "policy" }); + } + + return applyPolicyPatch(dependencies, patchMap); +} + +module.exports = { + enrichPolicies, +}; diff --git a/util/dependencyVulnEnricher.js b/util/dependencyVulnEnricher.js new file mode 100644 index 0000000..d9683f3 --- /dev/null +++ b/util/dependencyVulnEnricher.js @@ -0,0 +1,442 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const { CloudsmithAPI } = require("./cloudsmithAPI"); +const { getFoundDependencyKey } = require("./foundDependencyKey"); + +const VULNERABILITY_CACHE_TTL_MS = 10 * 60 * 1000; +const VULNERABILITY_CACHE_MAX_SIZE = 5000; +const VULNERABILITY_CONCURRENCY = 10; +const vulnerabilityCache = new Map(); + +function severityRank(severity) { + switch (String(severity || "").trim().toLowerCase()) { + case "critical": + return 4; + case "high": + return 3; + case "medium": + return 2; + case "low": + return 1; + default: + return 0; + } +} + +function canonicalSeverity(severity) { + const normalized = String(severity || "").trim().toLowerCase(); + switch (normalized) { + case "critical": + return "Critical"; + case "high": + return "High"; + case "medium": + return "Medium"; + case "low": + return "Low"; + default: + return severity ? String(severity).trim() : null; + } +} + +function getIndicatorCount(packageModel) { + const rawCount = packageModel && ( + packageModel.vulnerability_scan_results_count + || packageModel.num_vulnerabilities + || packageModel.vulnerabilityCount + ); + const count = Number(rawCount); + return Number.isFinite(count) && count > 0 ? count : 0; +} + +function buildEmptySummary(packageModel) { + return { + count: 0, + maxSeverity: canonicalSeverity(packageModel && packageModel.max_severity), + cveIds: [], + hasFixAvailable: false, + severityCounts: {}, + entries: [], + detailsLoaded: false, + policyViolated: Boolean(packageModel && packageModel.vulnerability_policy_violated), + }; +} + +function buildIndicatorSummary(packageModel) { + const count = getIndicatorCount(packageModel); + if (count === 0) { + return buildEmptySummary(packageModel); + } + + const maxSeverity = canonicalSeverity(packageModel && packageModel.max_severity); + const severityCounts = {}; + if (maxSeverity) { + severityCounts[maxSeverity] = 1; + } + + return { + count, + maxSeverity, + cveIds: [], + hasFixAvailable: false, + severityCounts, + entries: [], + detailsLoaded: false, + policyViolated: Boolean(packageModel && packageModel.vulnerability_policy_violated), + }; +} + +function extractVulnerabilityEntries(payload) { + if (Array.isArray(payload)) { + return payload; + } + + if (!payload || typeof payload !== "object") { + return []; + } + + if (Array.isArray(payload.results)) { + return payload.results; + } + + if (Array.isArray(payload.vulnerabilities)) { + return payload.vulnerabilities; + } + + if (Array.isArray(payload.items)) { + return payload.items; + } + + if (!Array.isArray(payload.scans)) { + return []; + } + + const results = []; + for (const scan of payload.scans) { + if (!scan || !Array.isArray(scan.results)) { + continue; + } + results.push(...scan.results); + } + + return results; +} + +function extractFixVersion(entry) { + const candidates = [ + entry && entry.fixed_version, + entry && entry.fix_version, + entry && entry.fixedVersion, + entry && entry.fixVersion, + entry && entry.suggested_fix, + entry && entry.suggestedFix, + ]; + + if (entry && Array.isArray(entry.fixed_in_versions) && entry.fixed_in_versions.length > 0) { + candidates.push(entry.fixed_in_versions[0]); + } + + if (entry && Array.isArray(entry.fix_versions) && entry.fix_versions.length > 0) { + candidates.push(entry.fix_versions[0]); + } + + for (const candidate of candidates) { + const value = String(candidate || "").trim(); + if (value) { + return value; + } + } + + return null; +} + +function normalizeEntry(entry) { + const severity = canonicalSeverity( + entry && ( + entry.severity + || entry.severity_label + || entry.max_severity + ) + ) || "Unknown"; + + const cveId = String( + (entry && ( + entry.vulnerability_id + || entry.identifier + || entry.id + || entry.name + )) || "Unknown" + ).trim(); + + const fixVersion = extractFixVersion(entry); + + return { + cveId, + severity, + description: String(entry && (entry.title || entry.description || "") || "").trim(), + fixVersion, + hasFixAvailable: Boolean(fixVersion), + }; +} + +function summarizeEntries(entries, fallbackSummary) { + const severityCounts = {}; + const cveIds = []; + const normalizedEntries = []; + let maxSeverity = null; + let hasFixAvailable = false; + + for (const entry of entries.map(normalizeEntry)) { + normalizedEntries.push(entry); + if (!cveIds.includes(entry.cveId)) { + cveIds.push(entry.cveId); + } + severityCounts[entry.severity] = (severityCounts[entry.severity] || 0) + 1; + if (!maxSeverity || severityRank(entry.severity) > severityRank(maxSeverity)) { + maxSeverity = entry.severity; + } + if (entry.hasFixAvailable) { + hasFixAvailable = true; + } + } + + return { + count: normalizedEntries.length || fallbackSummary.count || 0, + maxSeverity: maxSeverity || fallbackSummary.maxSeverity || null, + cveIds, + hasFixAvailable, + severityCounts, + entries: normalizedEntries, + detailsLoaded: true, + policyViolated: Boolean(fallbackSummary.policyViolated), + }; +} + +function isCancellationRequested(cancellationToken) { + return Boolean(cancellationToken && cancellationToken.isCancellationRequested); +} + +function sortGroups(left, right) { + if (left.priority !== right.priority) { + return left.priority - right.priority; + } + + if (left.workspace !== right.workspace) { + return left.workspace.localeCompare(right.workspace); + } + + if (left.repo !== right.repo) { + return left.repo.localeCompare(right.repo); + } + + return left.name.localeCompare(right.name); +} + +function collectPackageGroups(dependencies) { + const groups = new Map(); + + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + if (dependency.cloudsmithStatus !== "FOUND" || !dependency.cloudsmithPackage) { + continue; + } + + const packageKey = getFoundDependencyKey(dependency); + if (!packageKey) { + continue; + } + + const existing = groups.get(packageKey); + const priority = dependency.isDirect ? 0 : 1; + + if (!existing) { + groups.set(packageKey, { + key: packageKey, + packageModel: dependency.cloudsmithPackage, + workspace: String(dependency.cloudsmithPackage.namespace || "").toLowerCase(), + repo: String(dependency.cloudsmithPackage.repository || "").toLowerCase(), + name: String(dependency.name || "").toLowerCase(), + priority, + }); + continue; + } + + if (priority < existing.priority) { + existing.priority = priority; + } + } + + return [...groups.values()].sort(sortGroups); +} + +async function runPool(items, concurrency, worker) { + const workers = []; + let index = 0; + const poolSize = Math.max(1, Math.min(concurrency, items.length || 1)); + + for (let workerIndex = 0; workerIndex < poolSize; workerIndex += 1) { + workers.push((async () => { + while (index < items.length) { + const item = items[index]; + index += 1; + if (item === undefined) { + break; + } + await worker(item); + } + })()); + } + + await Promise.all(workers); +} + +function pruneExpiredVulnerabilityCache(now = Date.now()) { + for (const [cacheKey, cacheEntry] of vulnerabilityCache.entries()) { + if (!cacheEntry || cacheEntry.expiresAt <= now) { + vulnerabilityCache.delete(cacheKey); + } + } +} + +function getCachedVulnerabilitySummary(packageKey) { + const cached = vulnerabilityCache.get(packageKey); + if (!cached) { + return null; + } + + if (cached.expiresAt > Date.now()) { + return cached.value; + } + + vulnerabilityCache.delete(packageKey); + return null; +} + +function setCachedVulnerabilitySummary(packageKey, value) { + if (vulnerabilityCache.size >= VULNERABILITY_CACHE_MAX_SIZE) { + pruneExpiredVulnerabilityCache(); + } + + vulnerabilityCache.set(packageKey, { + expiresAt: Date.now() + VULNERABILITY_CACHE_TTL_MS, + value, + }); +} + +async function fetchVulnerabilitySummary(api, packageModel, fallbackSummary, cancellationToken) { + const packageKey = getFoundDependencyKey({ cloudsmithPackage: packageModel }); + if (!packageKey) { + return fallbackSummary; + } + + const cachedValue = getCachedVulnerabilitySummary(packageKey); + if (cachedValue) { + return cachedValue; + } + + if (isCancellationRequested(cancellationToken)) { + return fallbackSummary; + } + + const workspace = encodeURIComponent(String(packageModel.namespace || "").trim()); + const repo = encodeURIComponent(String(packageModel.repository || "").trim()); + const identifier = encodeURIComponent(String( + packageModel.slug_perm + || packageModel.slugPerm + || packageModel.slug + || packageModel.identifier + || "" + ).trim()); + + if (!workspace || !repo || !identifier) { + return fallbackSummary; + } + + const response = await api.getV2(`vulnerabilities/${workspace}/${repo}/${identifier}/`); + if (typeof response === "string") { + return fallbackSummary; + } + + const summary = summarizeEntries(extractVulnerabilityEntries(response), fallbackSummary); + setCachedVulnerabilitySummary(packageKey, summary); + return summary; +} + +function applyVulnerabilityPatch(dependencies, patchMap) { + return (Array.isArray(dependencies) ? dependencies : []).map((dependency) => { + const packageKey = getFoundDependencyKey(dependency); + if (!packageKey || !patchMap.has(packageKey)) { + return dependency; + } + + return { + ...dependency, + vulnerabilities: patchMap.get(packageKey), + }; + }); +} + +async function enrichVulnerabilities(dependencies, workspace, options = {}) { + const onProgress = typeof options.onProgress === "function" ? options.onProgress : null; + const cancellationToken = options.cancellationToken || null; + const api = options.cloudsmithAPI || new CloudsmithAPI(options.context); + const groups = collectPackageGroups(dependencies); + const patchMap = new Map(); + const detailTargets = []; + + for (const group of groups) { + const indicatorSummary = buildIndicatorSummary(group.packageModel); + patchMap.set(group.key, indicatorSummary); + if (indicatorSummary.count > 0) { + detailTargets.push({ + ...group, + fallbackSummary: indicatorSummary, + }); + } + } + + if (onProgress && patchMap.size > 0) { + onProgress(new Map(patchMap), { + completed: 0, + total: detailTargets.length, + workspace, + stage: "initial", + }); + } + + let completed = 0; + await runPool(detailTargets, VULNERABILITY_CONCURRENCY, async (target) => { + if (isCancellationRequested(cancellationToken)) { + return; + } + + const summary = await fetchVulnerabilitySummary( + api, + target.packageModel, + target.fallbackSummary, + cancellationToken + ); + + patchMap.set(target.key, summary); + completed += 1; + + if (onProgress) { + onProgress(new Map([[target.key, summary]]), { + completed, + total: detailTargets.length, + workspace, + stage: "details", + }); + } + }); + + return applyVulnerabilityPatch(dependencies, patchMap); +} + +module.exports = { + clearVulnerabilityCache() { + vulnerabilityCache.clear(); + }, + enrichVulnerabilities, + getVulnerabilityCacheSize() { + return vulnerabilityCache.size; + }, +}; diff --git a/util/formatIcons.js b/util/formatIcons.js new file mode 100644 index 0000000..720fa51 --- /dev/null +++ b/util/formatIcons.js @@ -0,0 +1,82 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const fs = require("fs"); +const vscode = require("vscode"); +const { canonicalFormat } = require("./packageNameNormalizer"); + +const FORMAT_ICON_KEYS = Object.freeze({ + cargo: "cargo", + composer: "composer", + conda: "conda", + dart: "dart", + docker: "docker", + elixir: "elixir", + gem: "ruby", + go: "go", + golang: "go", + gradle: "maven", + helm: "helm", + hex: "elixir", + maven: "maven", + npm: "npm", + nuget: "nuget", + php: "php", + pypi: "python", + python: "python", + ruby: "ruby", + rust: "rust", + swift: "swift", +}); + +const warnedMissingIcons = new Set(); + +function getFormatIconPath(format, extensionPath, options = {}) { + const fallbackIcon = Object.prototype.hasOwnProperty.call(options, "fallbackIcon") + ? options.fallbackIcon + : new vscode.ThemeIcon("package"); + const normalizedFormat = canonicalFormat(format); + if (!normalizedFormat || !extensionPath) { + return fallbackIcon; + } + + const iconKey = FORMAT_ICON_KEYS[normalizedFormat] || normalizedFormat; + const iconPath = resolveThemedIconPath(extensionPath, iconKey); + if (iconPath) { + return iconPath; + } + + warnMissingIconOnce(normalizedFormat); + return fallbackIcon; +} + +function resolveThemedIconPath(extensionPath, iconKey) { + if (!extensionPath || !iconKey) { + return null; + } + + const extensionUri = vscode.Uri.file(extensionPath); + const dark = vscode.Uri.joinPath(extensionUri, "media", "vscode_icons", `file_type_${iconKey}.svg`); + if (!fs.existsSync(dark.fsPath)) { + return null; + } + + const lightCandidate = vscode.Uri.joinPath(extensionUri, "media", "vscode_icons", `file_type_light_${iconKey}.svg`); + return { + light: fs.existsSync(lightCandidate.fsPath) ? lightCandidate : dark, + dark, + }; +} + +function warnMissingIconOnce(format) { + const normalizedFormat = canonicalFormat(format); + if (!normalizedFormat || warnedMissingIcons.has(normalizedFormat)) { + return; + } + + warnedMissingIcons.add(normalizedFormat); + console.warn(`No format icon found for ecosystem '${normalizedFormat}', using generic icon`); +} + +module.exports = { + FORMAT_ICON_KEYS, + getFormatIconPath, +}; diff --git a/util/foundDependencyKey.js b/util/foundDependencyKey.js new file mode 100644 index 0000000..7ddc13f --- /dev/null +++ b/util/foundDependencyKey.js @@ -0,0 +1,21 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +function getFoundDependencyKey(dependency) { + if (!dependency || !dependency.cloudsmithPackage) { + return null; + } + + const pkg = dependency.cloudsmithPackage; + const workspace = String(pkg.namespace || "").trim().toLowerCase(); + const repo = String(pkg.repository || "").trim().toLowerCase(); + const slug = String(pkg.slug_perm || pkg.slugPerm || pkg.slug || pkg.identifier || "").trim().toLowerCase(); + + if (!workspace || !repo || !slug) { + return null; + } + + return `${workspace}:${repo}:${slug}`; +} + +module.exports = { + getFoundDependencyKey, +}; diff --git a/util/lockfileParsers/cargoParser.js b/util/lockfileParsers/cargoParser.js new file mode 100644 index 0000000..06199ff --- /dev/null +++ b/util/lockfileParsers/cargoParser.js @@ -0,0 +1,283 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + parseKeyValueLine, + pathExists, + readUtf8, +} = require("./shared"); +const { parseCargoTomlManifest } = require("./manifestHelpers"); + +const cargoParser = { + name: "cargoParser", + ecosystem: "cargo", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "Cargo.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "Cargo.toml"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "Cargo.lock"), workspaceFolder) + ? path.join(rootPath, "Cargo.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "Cargo.toml"), workspaceFolder) + ? path.join(rootPath, "Cargo.toml") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const manifestDependencies = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? parseCargoTomlManifest(await readUtf8(manifestPath, workspaceFolder)) + : []; + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + + if (!lockfilePath) { + return buildTree("cargo", sourceFile, manifestDependencies.map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "cargo", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const records = parseCargoLock(await readUtf8(lockfilePath, workspaceFolder)); + if (records.length === 0) { + throw new Error("Malformed Cargo.lock: no package entries found"); + } + const directNames = new Set(manifestDependencies.map((dependency) => dependency.name.toLowerCase())); + const recordsByName = new Map(); + const incomingCounts = new Map(); + + for (const record of records) { + if (!recordsByName.has(record.name.toLowerCase())) { + recordsByName.set(record.name.toLowerCase(), []); + } + recordsByName.get(record.name.toLowerCase()).push(record); + for (const dependency of record.dependencies) { + incomingCounts.set( + dependency.name.toLowerCase(), + (incomingCounts.get(dependency.name.toLowerCase()) || 0) + 1 + ); + } + } + + const rootRecords = manifestDependencies.length > 0 + ? manifestDependencies.map((dependency) => selectCargoRecord(recordsByName, dependency.name, dependency.version)).filter(Boolean) + : records.filter((record) => !incomingCounts.get(record.name.toLowerCase())); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildCargoDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + directNames + ))); + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + for (const record of records) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "cargo", + isDirect: directNames.has(record.name.toLowerCase()), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("cargo", sourceFile, dependencies); + }, +}; + +function parseCargoLock(content) { + const records = []; + let current = null; + let inDependenciesArray = false; + + const flushCurrent = () => { + if (!current || !current.name || !current.version) { + current = null; + inDependenciesArray = false; + return; + } + const source = String(current.source || "").trim(); + if (source && !source.startsWith("path+") && !source.startsWith("git+")) { + records.push(current); + } + current = null; + inDependenciesArray = false; + }; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + if (line === "[[package]]") { + flushCurrent(); + current = { name: "", version: "", source: "", dependencies: [] }; + continue; + } + if (!current) { + continue; + } + + if (inDependenciesArray) { + if (line === "]") { + inDependenciesArray = false; + continue; + } + const match = line.trim().replace(/,$/, "").replace(/^"|"$/g, "").match(/^([^ ]+)(?: ([^ ]+))?/); + if (match) { + current.dependencies.push({ + name: match[1], + version: match[2] ? match[2].replace(/^\(/, "").replace(/\)$/, "") : "", + }); + } + continue; + } + + if (line.startsWith("name =")) { + current.name = parseKeyValueLine(line).value.replace(/^"|"$/g, ""); + continue; + } + if (line.startsWith("version =")) { + current.version = parseKeyValueLine(line).value.replace(/^"|"$/g, ""); + continue; + } + if (line.startsWith("source =")) { + current.source = parseKeyValueLine(line).value.replace(/^"|"$/g, ""); + continue; + } + if (line.startsWith("dependencies = [")) { + inDependenciesArray = true; + const inline = line.slice(line.indexOf("[") + 1, line.lastIndexOf("]")); + if (inline.trim()) { + for (const item of inline.split(",")) { + const cleaned = item.trim().replace(/^"|"$/g, ""); + if (!cleaned) { + continue; + } + const match = cleaned.match(/^([^ ]+)(?: ([^ ]+))?/); + if (match) { + current.dependencies.push({ name: match[1], version: match[2] || "" }); + } + } + inDependenciesArray = false; + } + } + } + + flushCurrent(); + return deduplicateCargoRecords(records); +} + +function deduplicateCargoRecords(records) { + const seen = new Set(); + const results = []; + for (const record of records) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + results.push(record); + } + return results; +} + +function selectCargoRecord(recordsByName, name, version) { + const candidates = recordsByName.get(name.toLowerCase()) || []; + if (candidates.length === 0) { + return null; + } + if (version) { + const exactMatch = candidates.find((record) => record.version === version); + if (exactMatch) { + return exactMatch; + } + } + return candidates[0]; +} + +function buildCargoDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "cargo", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependency of record.dependencies) { + const childRecord = selectCargoRecord(recordsByName, dependency.name, dependency.version); + if (!childRecord) { + continue; + } + transitives.push(buildCargoDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + directNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "cargo", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: false, + }); +} + +module.exports = cargoParser; diff --git a/util/lockfileParsers/composerParser.js b/util/lockfileParsers/composerParser.js new file mode 100644 index 0000000..0f250fc --- /dev/null +++ b/util/lockfileParsers/composerParser.js @@ -0,0 +1,174 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + readJson, + pathExists, + readUtf8, +} = require("./shared"); +const { parseComposerManifest } = require("./manifestHelpers"); + +const composerParser = { + name: "composerParser", + ecosystem: "composer", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "composer.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "composer.json"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "composer.lock"), workspaceFolder) + ? path.join(rootPath, "composer.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "composer.json"), workspaceFolder) + ? path.join(rootPath, "composer.json") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + const manifestDependencies = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? parseComposerManifest(await readUtf8(manifestPath, workspaceFolder)) + : []; + + if (!lockfilePath) { + return buildTree("composer", sourceFile, manifestDependencies.map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "composer", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const root = await readJson(lockfilePath); + const records = []; + + for (const record of [...(root.packages || []), ...(root["packages-dev"] || [])]) { + if (!record || !record.name) { + continue; + } + records.push({ + name: record.name, + version: record.version || "", + dependencies: Object.keys(record.require || {}).filter((name) => name.includes("/") && !name.startsWith("ext-") && !name.startsWith("lib-") && name !== "php"), + }); + } + + const directNames = new Set(manifestDependencies.map((dependency) => dependency.name.toLowerCase())); + const recordsByName = new Map(records.map((record) => [record.name.toLowerCase(), record])); + const incomingCounts = new Map(); + for (const record of records) { + for (const dependencyName of record.dependencies) { + incomingCounts.set(dependencyName.toLowerCase(), (incomingCounts.get(dependencyName.toLowerCase()) || 0) + 1); + } + } + + const rootRecords = directNames.size > 0 + ? [...directNames].map((name) => recordsByName.get(name)).filter(Boolean) + : records.filter((record) => !incomingCounts.get(record.name.toLowerCase())); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildComposerDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + directNames + ))); + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + + for (const record of records) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "composer", + isDirect: directNames.has(record.name.toLowerCase()), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("composer", sourceFile, dependencies); + }, +}; + +function buildComposerDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "composer", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependencyName of record.dependencies) { + const childRecord = recordsByName.get(dependencyName.toLowerCase()); + if (!childRecord) { + continue; + } + transitives.push(buildComposerDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + directNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "composer", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: false, + }); +} + +module.exports = composerParser; diff --git a/util/lockfileParsers/dartParser.js b/util/lockfileParsers/dartParser.js new file mode 100644 index 0000000..7e3689b --- /dev/null +++ b/util/lockfileParsers/dartParser.js @@ -0,0 +1,124 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + countIndent, + createDependency, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, + stripYamlComment, +} = require("./shared"); +const { parsePubspecManifest } = require("./manifestHelpers"); + +const dartParser = { + name: "dartParser", + ecosystem: "dart", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "pubspec.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "pubspec.yaml"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "pubspec.lock"), workspaceFolder) + ? path.join(rootPath, "pubspec.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "pubspec.yaml"), workspaceFolder) + ? path.join(rootPath, "pubspec.yaml") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + if (!lockfilePath) { + return buildTree("dart", sourceFile, parsePubspecManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "dart", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const dependencies = []; + let inPackages = false; + let current = null; + + const flushCurrent = () => { + if (!current || !current.name) { + current = null; + return; + } + dependencies.push(createDependency({ + name: current.name, + version: current.version, + ecosystem: "dart", + isDirect: !String(current.dependencyType || "").toLowerCase().includes("transitive"), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: String(current.dependencyType || "").toLowerCase().includes("dev"), + })); + current = null; + }; + + for (const rawLine of String(await readUtf8(lockfilePath, workspaceFolder)).split(/\r?\n/)) { + const line = stripYamlComment(rawLine).trim(); + if (!line) { + continue; + } + + const indent = countIndent(rawLine); + if (indent === 0 && line === "packages:") { + inPackages = true; + continue; + } + if (indent === 0 && line.endsWith(":") && line !== "packages:") { + inPackages = false; + flushCurrent(); + continue; + } + if (!inPackages) { + continue; + } + if (indent === 2 && line.endsWith(":")) { + flushCurrent(); + current = { name: line.slice(0, -1), version: "", dependencyType: "" }; + continue; + } + if (!current) { + continue; + } + if (indent === 4 && line.startsWith("dependency:")) { + current.dependencyType = line.slice("dependency:".length).trim().replace(/^["']|["']$/g, ""); + } + if (indent === 4 && line.startsWith("version:")) { + current.version = line.slice("version:".length).trim().replace(/^["']|["']$/g, ""); + } + } + + flushCurrent(); + return buildTree("dart", sourceFile, dependencies); + }, +}; + +module.exports = dartParser; diff --git a/util/lockfileParsers/dockerParser.js b/util/lockfileParsers/dockerParser.js new file mode 100644 index 0000000..7abf4d7 --- /dev/null +++ b/util/lockfileParsers/dockerParser.js @@ -0,0 +1,300 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + countIndent, + createDependency, + getSourceFileName, + getWorkspacePath, + readUtf8, + resolveWorkspaceFilePath, + stripYamlComment, +} = require("./shared"); + +const dockerParser = { + name: "dockerParser", + ecosystem: "docker", + + async canResolve(workspaceFolder) { + const matches = await this.detect(workspaceFolder); + return matches.length > 0; + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const safeRootPath = await resolveWorkspaceFilePath(rootPath, workspaceFolder); + if (!safeRootPath) { + return []; + } + const entries = []; + const allFiles = await require("fs").promises.readdir(safeRootPath); + + for (const fileName of allFiles.sort()) { + const isDockerfile = fileName === "Dockerfile" || fileName.startsWith("Dockerfile."); + const isComposeFile = [ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", + ].includes(fileName); + if (!isDockerfile && !isComposeFile) { + continue; + } + entries.push({ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath: path.join(safeRootPath, fileName), + manifestPath: null, + sourceFile: fileName, + }); + } + + return entries; + }, + + async resolve({ lockfilePath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath); + const content = await readUtf8(lockfilePath, workspaceFolder); + const dependencies = isComposeFileName(sourceFile) + ? parseCompose(content, sourceFile) + : parseDockerfile(content, sourceFile); + return buildTree("docker", sourceFile, dependencies); + }, +}; + +function parseDockerfile(content, sourceFile) { + const dependencies = []; + const stageAliases = new Set(); + const argDefaults = new Map(); + + for (const instruction of toLogicalDockerLines(content)) { + const cleaned = stripDockerComment(instruction).trim(); + if (!cleaned) { + continue; + } + + if (/^ARG\s+/i.test(cleaned)) { + const definition = cleaned.replace(/^ARG\s+/i, ""); + const [name, value] = definition.split("=", 2); + if (name && value) { + argDefaults.set(name.trim(), resolveDockerArgs(value.trim(), argDefaults)); + } + continue; + } + + if (!/^FROM\s+/i.test(cleaned)) { + continue; + } + + const parsed = parseFromInstruction(cleaned, argDefaults, stageAliases); + if (!parsed) { + continue; + } + if (parsed.alias) { + stageAliases.add(parsed.alias.toLowerCase()); + } + if (!parsed.isDependency) { + continue; + } + + dependencies.push(createDependency({ + name: parsed.name, + version: parsed.version, + ecosystem: "docker", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return dependencies; +} + +function parseCompose(content, sourceFile) { + const dependencies = []; + let servicesIndent = null; + let currentService = null; + + const flushCurrentService = () => { + if (!currentService) { + return; + } + if (!currentService.hasBuild && currentService.image) { + const parsed = parseDockerImageReference(currentService.image); + if (parsed && parsed.name.toLowerCase() !== "scratch") { + dependencies.push(createDependency({ + name: parsed.name, + version: parsed.version, + ecosystem: "docker", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + } + currentService = null; + }; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const cleaned = stripYamlComment(rawLine).trim(); + if (!cleaned) { + continue; + } + + const indent = countIndent(rawLine); + if (cleaned === "services:") { + flushCurrentService(); + servicesIndent = indent; + continue; + } + if (servicesIndent != null && indent <= servicesIndent && cleaned.endsWith(":")) { + flushCurrentService(); + servicesIndent = null; + } + if (servicesIndent == null || indent <= servicesIndent) { + continue; + } + + if (indent === servicesIndent + 2 && cleaned.endsWith(":")) { + flushCurrentService(); + currentService = { indent, hasBuild: false, image: "" }; + continue; + } + + if (!currentService || indent <= currentService.indent || cleaned.startsWith("- ")) { + continue; + } + + if (cleaned.startsWith("build:")) { + currentService.hasBuild = true; + continue; + } + if (cleaned.startsWith("image:")) { + currentService.image = unquote(cleaned.slice("image:".length).trim()); + } + } + + flushCurrentService(); + return dependencies; +} + +function toLogicalDockerLines(content) { + const lines = []; + let current = ""; + for (const rawLine of String(content || "").split(/\r?\n/)) { + const trimmed = rawLine.trimEnd(); + if (!trimmed) { + if (current) { + lines.push(current); + current = ""; + } + continue; + } + + const continues = trimmed.endsWith("\\"); + const segment = continues ? trimmed.slice(0, -1).trimEnd() : trimmed; + current += current ? ` ${segment}` : segment; + if (!continues) { + lines.push(current); + current = ""; + } + } + if (current) { + lines.push(current); + } + return lines; +} + +function stripDockerComment(line) { + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const previous = index > 0 ? line[index - 1] : ""; + if (char === "'" && !inDoubleQuote && previous !== "\\") { + inSingleQuote = !inSingleQuote; + continue; + } + if (char === "\"" && !inSingleQuote && previous !== "\\") { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (char === "#" && !inSingleQuote && !inDoubleQuote) { + return line.slice(0, index); + } + } + return line; +} + +function resolveDockerArgs(value, args) { + return String(value || "") + .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-+?])([^}]*))?}/g, (_match, name, operator, fallback) => { + if (args.has(name)) { + return args.get(name); + } + return operator === "-" || operator === ":-" ? fallback : _match; + }) + .replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, name) => (args.has(name) ? args.get(name) : match)); +} + +function parseFromInstruction(line, argDefaults, stageAliases) { + const parts = line.split(/\s+/).filter(Boolean); + let index = 1; + while (parts[index] && parts[index].startsWith("--")) { + index += 1; + } + const imageToken = parts[index]; + if (!imageToken) { + return null; + } + const alias = parts[index + 1] && /^AS$/i.test(parts[index + 1]) ? parts[index + 2] : ""; + const resolvedImage = resolveDockerArgs(unquote(imageToken), argDefaults).trim(); + if (!resolvedImage) { + return null; + } + const stageReference = stageAliases.has(resolvedImage.toLowerCase()); + const parsed = parseDockerImageReference(resolvedImage); + if (!parsed) { + return null; + } + return { + ...parsed, + alias: alias ? unquote(alias) : "", + isDependency: !stageReference && parsed.name.toLowerCase() !== "scratch", + }; +} + +function parseDockerImageReference(reference) { + const raw = unquote(reference); + if (!raw || raw.includes("$")) { + return null; + } + const withoutDigest = raw.split("@")[0]; + const digest = raw.includes("@") ? raw.split("@")[1] : ""; + const lastSlash = withoutDigest.lastIndexOf("/"); + const lastColon = withoutDigest.lastIndexOf(":"); + const hasTag = lastColon > lastSlash; + const name = hasTag ? withoutDigest.slice(0, lastColon) : withoutDigest; + const version = hasTag ? withoutDigest.slice(lastColon + 1) : digest || "latest"; + if (!name) { + return null; + } + return { name, version }; +} + +function unquote(value) { + return String(value || "").trim().replace(/^["']|["']$/g, ""); +} + +function isComposeFileName(fileName) { + return ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"].includes(fileName); +} + +module.exports = dockerParser; diff --git a/util/lockfileParsers/goParser.js b/util/lockfileParsers/goParser.js new file mode 100644 index 0000000..2ec141b --- /dev/null +++ b/util/lockfileParsers/goParser.js @@ -0,0 +1,82 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); + +const goParser = { + name: "goParser", + ecosystem: "go", + + async canResolve(workspaceFolder) { + return pathExists(path.join(getWorkspacePath(workspaceFolder), "go.mod"), workspaceFolder); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const manifestPath = path.join(rootPath, "go.mod"); + if (!(await pathExists(manifestPath, workspaceFolder))) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath: manifestPath, + manifestPath, + sourceFile: "go.mod", + }]; + }, + + async resolve({ manifestPath, workspaceFolder }) { + const dependencies = []; + const sourceFile = getSourceFileName(manifestPath); + let inRequireBlock = false; + + for (const rawLine of String(await readUtf8(manifestPath, workspaceFolder)).split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("//")) { + continue; + } + if (line === "require (") { + inRequireBlock = true; + continue; + } + if (line === ")" && inRequireBlock) { + inRequireBlock = false; + continue; + } + + const lineToParse = line.startsWith("require ") ? line.slice("require ".length).trim() : line; + if (!inRequireBlock && !line.startsWith("require ")) { + continue; + } + + const cleaned = lineToParse.split("//")[0].trim(); + const parts = cleaned.split(/\s+/); + if (parts.length < 2) { + continue; + } + + dependencies.push(createDependency({ + name: parts[0], + version: parts[1].replace(/^v/, ""), + ecosystem: "go", + isDirect: !rawLine.includes("// indirect"), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: rawLine.includes("// indirect"), + })); + } + + return buildTree("go", sourceFile, dependencies); + }, +}; + +module.exports = goParser; diff --git a/util/lockfileParsers/gradleParser.js b/util/lockfileParsers/gradleParser.js new file mode 100644 index 0000000..180bba9 --- /dev/null +++ b/util/lockfileParsers/gradleParser.js @@ -0,0 +1,124 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parseBuildGradleManifest } = require("./manifestHelpers"); + +const BUILD_FILES = ["build.gradle", "build.gradle.kts"]; + +const gradleParser = { + name: "gradleParser", + ecosystem: "gradle", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + for (const buildFile of BUILD_FILES) { + if (await pathExists(path.join(rootPath, buildFile), workspaceFolder)) { + return true; + } + } + return false; + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + for (const buildFile of BUILD_FILES) { + const manifestPath = path.join(rootPath, buildFile); + if (!(await pathExists(manifestPath, workspaceFolder))) { + continue; + } + const lockfilePath = await pathExists(path.join(rootPath, "gradle.lockfile"), workspaceFolder) + ? path.join(rootPath, "gradle.lockfile") + : null; + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: buildFile, + }]; + } + return []; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const directDependencies = parseBuildGradleManifest(await readUtf8(manifestPath, workspaceFolder)); + const sourceFile = getSourceFileName(manifestPath); + + if (!lockfilePath) { + return buildTree("gradle", sourceFile, directDependencies.map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "gradle", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const lockVersions = parseGradleLockfile(await readUtf8(lockfilePath, workspaceFolder)); + const dependencies = []; + + for (const directDependency of directDependencies) { + const resolvedVersion = lockVersions.get(directDependency.name) || directDependency.version; + dependencies.push(createDependency({ + name: directDependency.name, + version: resolvedVersion, + ecosystem: "gradle", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: directDependency.isDevelopmentDependency, + })); + } + + for (const [name, version] of lockVersions.entries()) { + dependencies.push(createDependency({ + name, + version, + ecosystem: "gradle", + isDirect: directDependencies.some((dependency) => dependency.name === name), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("gradle", sourceFile, deduplicateDeps(dependencies)); + }, +}; + +function parseGradleLockfile(content) { + const versions = new Map(); + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const entry = line.split("=", 1)[0].trim(); + const parts = entry.split(":"); + if (parts.length < 3) { + continue; + } + versions.set(`${parts[0]}:${parts[1]}`, parts[2]); + } + + return versions; +} + +module.exports = gradleParser; diff --git a/util/lockfileParsers/helmParser.js b/util/lockfileParsers/helmParser.js new file mode 100644 index 0000000..abc5a61 --- /dev/null +++ b/util/lockfileParsers/helmParser.js @@ -0,0 +1,62 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parseChartManifest } = require("./manifestHelpers"); + +const helmParser = { + name: "helmParser", + ecosystem: "helm", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "Chart.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "Chart.yaml"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "Chart.lock"), workspaceFolder) + ? path.join(rootPath, "Chart.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "Chart.yaml"), workspaceFolder) + ? path.join(rootPath, "Chart.yaml") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourcePath = lockfilePath || manifestPath; + const sourceFile = getSourceFileName(sourcePath); + const dependencies = parseChartManifest(await readUtf8(sourcePath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "helm", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + + return buildTree("helm", sourceFile, dependencies); + }, +}; + +module.exports = helmParser; diff --git a/util/lockfileParsers/hexParser.js b/util/lockfileParsers/hexParser.js new file mode 100644 index 0000000..a0808b2 --- /dev/null +++ b/util/lockfileParsers/hexParser.js @@ -0,0 +1,86 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parseMixExsManifest } = require("./manifestHelpers"); + +const hexParser = { + name: "hexParser", + ecosystem: "hex", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "mix.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "mix.exs"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "mix.lock"), workspaceFolder) + ? path.join(rootPath, "mix.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "mix.exs"), workspaceFolder) + ? path.join(rootPath, "mix.exs") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + if (!lockfilePath) { + return buildTree("hex", sourceFile, parseMixExsManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "hex", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }))); + } + + const directNames = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? new Set(parseMixExsManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => dependency.name.toLowerCase())) + : new Set(); + const entryPattern = /"([^"]+)"\s*:\s*\{\s*:hex,\s*(?::"[^"]+"|:[^,]+)\s*,\s*"([^"]+)"/g; + const dependencies = []; + for (const match of String(await readUtf8(lockfilePath, workspaceFolder)).matchAll(entryPattern)) { + dependencies.push(createDependency({ + name: match[1], + version: match[2], + ecosystem: "hex", + isDirect: directNames.size === 0 || directNames.has(match[1].toLowerCase()), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + if (dependencies.length === 0) { + throw new Error("Malformed mix.lock: no Hex package entries found"); + } + + return buildTree("hex", sourceFile, dependencies); + }, +}; + +module.exports = hexParser; diff --git a/util/lockfileParsers/manifestHelpers.js b/util/lockfileParsers/manifestHelpers.js new file mode 100644 index 0000000..3aa59d9 --- /dev/null +++ b/util/lockfileParsers/manifestHelpers.js @@ -0,0 +1,650 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const { + escapeRegExp, + normalizeVersion, + parseInlineTomlValue, + parseKeyValueLine, + parseQuotedArray, + stripTomlComment, + stripYamlComment, +} = require("./shared"); + +function parsePackageJsonManifest(content) { + let parsed; + try { + parsed = JSON.parse(content); + } catch { + return { + dependencies: [], + directNames: new Set(), + devNames: new Set(), + }; + } + + const dependencies = []; + const directNames = new Set(); + const devNames = new Set(); + + const addSection = (sectionName, isDevelopmentDependency) => { + const section = parsed[sectionName]; + if (!section || typeof section !== "object") { + return; + } + + for (const [name, version] of Object.entries(section)) { + dependencies.push({ + name, + version: normalizeVersion(version), + isDevelopmentDependency, + }); + if (isDevelopmentDependency) { + devNames.add(name); + } else { + directNames.add(name); + } + } + }; + + addSection("dependencies", false); + addSection("devDependencies", true); + addSection("optionalDependencies", false); + addSection("peerDependencies", false); + + return { + dependencies, + directNames, + devNames, + }; +} + +function parsePyprojectManifest(content) { + const lines = String(content || "").split(/\r?\n/); + const dependencies = []; + const directNames = new Set(); + const devNames = new Set(); + let projectName = ""; + let section = ""; + let collectingProjectDependencies = false; + let projectDependenciesBuffer = ""; + + const flushProjectDependencies = () => { + if (!projectDependenciesBuffer) { + return; + } + for (const item of parseQuotedArray(projectDependenciesBuffer)) { + const parsed = parseRequirementSpec(item); + if (!parsed) { + continue; + } + dependencies.push({ + ...parsed, + isDevelopmentDependency: false, + }); + directNames.add(parsed.name); + } + projectDependenciesBuffer = ""; + collectingProjectDependencies = false; + }; + + for (const rawLine of lines) { + const withoutComment = stripTomlComment(rawLine); + const line = withoutComment.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + if (collectingProjectDependencies) { + projectDependenciesBuffer += projectDependenciesBuffer ? ` ${line}` : line; + if (projectDependenciesBuffer.includes("]")) { + flushProjectDependencies(); + } + continue; + } + + if (line.startsWith("[") && line.endsWith("]")) { + section = line; + continue; + } + + if (section === "[project]" && line.startsWith("name =")) { + projectName = unquote(parseKeyValueLine(line).value); + continue; + } + + if (section === "[tool.poetry]" && line.startsWith("name =")) { + projectName = unquote(parseKeyValueLine(line).value); + continue; + } + + if (section === "[project]" && line.startsWith("dependencies")) { + projectDependenciesBuffer = parseKeyValueLine(line).value; + if (projectDependenciesBuffer.includes("]")) { + flushProjectDependencies(); + } else { + collectingProjectDependencies = true; + } + continue; + } + + if ( + section === "[tool.poetry.dependencies]" + || section === "[tool.poetry.dev-dependencies]" + || /^\[tool\.poetry\.group\.[^.]+\.dependencies]$/.test(section) + ) { + const parts = parseKeyValueLine(line); + if (!parts) { + continue; + } + + const name = parts.key; + if (!name || name.toLowerCase() === "python") { + continue; + } + + const rawValue = parts.value; + const version = rawValue.startsWith("{") + ? normalizeVersion(parseInlineTomlValue(rawValue, "version")) + : normalizeVersion(unquote(rawValue)); + const isDevelopmentDependency = section !== "[tool.poetry.dependencies]"; + + dependencies.push({ + name, + version: version === "*" ? "" : version, + isDevelopmentDependency, + }); + + if (isDevelopmentDependency) { + devNames.add(name); + } else { + directNames.add(name); + } + } + } + + return { + projectName, + dependencies, + directNames, + devNames, + }; +} + +function parseRequirementSpec(spec) { + const rawSpec = String(spec || "").trim().replace(/^["']|["']$/g, ""); + if (!rawSpec) { + return null; + } + + const withoutMarker = rawSpec.split(";")[0].trim(); + const match = withoutMarker.match(/^([A-Za-z0-9_.-]+)(?:\[[^\]]+])?\s*(.*)$/); + if (!match) { + return null; + } + + return { + name: match[1], + version: normalizeVersion(match[2] || ""), + }; +} + +function parseCargoTomlManifest(content) { + const dependencies = []; + let section = ""; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const line = stripTomlComment(rawLine).trim(); + if (!line) { + continue; + } + + if (line.startsWith("[") && line.endsWith("]")) { + section = line; + continue; + } + + if (![ + "[dependencies]", + "[dev-dependencies]", + "[build-dependencies]", + "[workspace.dependencies]", + ].includes(section)) { + continue; + } + + const parts = parseKeyValueLine(line); + if (!parts) { + continue; + } + + const declaredName = parts.key; + const rawValue = parts.value; + const actualName = parseInlineTomlValue(rawValue, "package") || declaredName; + const version = rawValue.startsWith("{") + ? normalizeVersion(parseInlineTomlValue(rawValue, "version")) + : normalizeVersion(unquote(rawValue)); + + dependencies.push({ + name: actualName, + version, + isDevelopmentDependency: section !== "[dependencies]" && section !== "[workspace.dependencies]", + }); + } + + return dependencies; +} + +function parseGemfileManifest(content) { + const dependencies = []; + const pattern = /^\s*gem\s+["']([^"']+)["'](?:\s*,\s*["']([^"']+)["'])?/; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const line = stripRubyComment(rawLine).trim(); + const match = line.match(pattern); + if (!match) { + continue; + } + dependencies.push({ + name: match[1], + version: normalizeVersion(match[2] || ""), + isDevelopmentDependency: false, + }); + } + + return dependencies; +} + +function parseBuildGradleManifest(content) { + const dependencies = []; + const lines = String(content || "").split(/\r?\n/); + let inDependenciesBlock = false; + let braceDepth = 0; + let dependencyBlockDepth = 0; + + for (const rawLine of lines) { + const line = stripJavaLikeComment(rawLine).trim(); + if (!line) { + braceDepth += countBraces(rawLine); + continue; + } + + if (!inDependenciesBlock && /^dependencies\s*\{/.test(line)) { + inDependenciesBlock = true; + dependencyBlockDepth = braceDepth + 1; + } else if (!inDependenciesBlock && line === "dependencies") { + inDependenciesBlock = true; + dependencyBlockDepth = braceDepth + 1; + } else if (inDependenciesBlock) { + const parsed = parseGradleDependencyLine(line); + if (parsed) { + dependencies.push(parsed); + } + } + + braceDepth += countBraces(rawLine); + if (inDependenciesBlock && braceDepth < dependencyBlockDepth) { + inDependenciesBlock = false; + dependencyBlockDepth = 0; + } + } + + return dedupeManifestDeps(dependencies); +} + +function parseGradleDependencyLine(line) { + const match = line.match(/^\s*([A-Za-z][A-Za-z0-9_-]*)\s*\(?\s*["']([^"']+)["']/); + if (!match) { + return null; + } + + const configuration = match[1].toLowerCase(); + const coordinates = match[2].split(":").filter(Boolean); + if (coordinates.length < 2) { + return null; + } + + return { + name: `${coordinates[0]}:${coordinates[1]}`, + version: coordinates[2] ? normalizeVersion(coordinates[2]) : "", + isDevelopmentDependency: configuration.includes("test"), + }; +} + +function parseCsprojManifest(content) { + const dependencies = []; + const inlinePattern = /]*)\/>/gi; + const blockPattern = /]*)>([\s\S]*?)<\/PackageReference>/gi; + + const parseAttributes = (attributesText, blockText) => { + const includeMatch = attributesText.match(/\b(?:Include|Update)="([^"]+)"/i); + if (!includeMatch) { + return; + } + + let version = ""; + const attributeVersionMatch = attributesText.match(/\bVersion="([^"]+)"/i); + if (attributeVersionMatch) { + version = attributeVersionMatch[1]; + } else if (blockText) { + const nestedVersionMatch = blockText.match(/\s*([^<]+)\s*<\/Version>/i); + if (nestedVersionMatch) { + version = nestedVersionMatch[1]; + } + } + + dependencies.push({ + name: includeMatch[1].trim(), + version: normalizeVersion(version), + isDevelopmentDependency: false, + }); + }; + + for (const match of content.matchAll(inlinePattern)) { + parseAttributes(match[1], ""); + } + + for (const match of content.matchAll(blockPattern)) { + parseAttributes(match[1], match[2]); + } + + return dedupeManifestDeps(dependencies); +} + +function parsePubspecManifest(content) { + const dependencies = []; + let section = ""; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const lineWithoutComment = stripYamlComment(rawLine); + const line = lineWithoutComment.trim(); + if (!line) { + continue; + } + + const indent = rawLine.search(/\S/); + if (indent === 0 && line.endsWith(":")) { + section = line.slice(0, -1); + continue; + } + + if (!["dependencies", "dev_dependencies"].includes(section) || indent !== 2) { + continue; + } + + if (line.startsWith("-")) { + continue; + } + + const name = line.split(":", 1)[0].trim(); + const rawValue = line.includes(":") ? line.split(":").slice(1).join(":").trim() : ""; + const version = rawValue.startsWith("{") + ? normalizeVersion(parseYamlInlineValue(rawValue, "version")) + : normalizeVersion(unquote(rawValue)); + + if (!name) { + continue; + } + + dependencies.push({ + name, + version, + isDevelopmentDependency: section === "dev_dependencies", + }); + } + + return dedupeManifestDeps(dependencies); +} + +function parseComposerManifest(content) { + let parsed; + try { + parsed = JSON.parse(content); + } catch { + return []; + } + + const dependencies = []; + for (const [name, version] of Object.entries(parsed.require || {})) { + if (!isComposerPackageName(name)) { + continue; + } + dependencies.push({ name, version: normalizeVersion(version), isDevelopmentDependency: false }); + } + for (const [name, version] of Object.entries(parsed["require-dev"] || {})) { + if (!isComposerPackageName(name)) { + continue; + } + dependencies.push({ name, version: normalizeVersion(version), isDevelopmentDependency: true }); + } + return dedupeManifestDeps(dependencies); +} + +function parseChartManifest(content) { + return parseSimpleYamlDependencyList(content, "dependencies"); +} + +function parsePackageSwiftManifest(content) { + const dependencies = []; + const pattern = /\.package\s*\(([\s\S]*?)\)/g; + + for (const match of content.matchAll(pattern)) { + const declaration = match[1]; + const identityMatch = declaration.match(/\b(?:name|id|identity)\s*:\s*"([^"]+)"/); + const urlMatch = declaration.match(/\burl\s*:\s*"([^"]+)"/); + const versionMatch = declaration.match(/\b(?:from|exact|branch|revision)\s*:\s*"([^"]+)"/); + const name = normalizeSwiftIdentity(identityMatch ? identityMatch[1] : urlMatch ? urlMatch[1] : ""); + if (!name) { + continue; + } + dependencies.push({ + name, + version: normalizeVersion(versionMatch ? versionMatch[1] : ""), + isDevelopmentDependency: false, + }); + } + + return dedupeManifestDeps(dependencies); +} + +function normalizeSwiftIdentity(name) { + return String(name || "") + .trim() + .split("/") + .filter(Boolean) + .pop() + ?.replace(/\.git$/i, "") + .toLowerCase() || ""; +} + +function parseMixExsManifest(content) { + const dependencies = []; + const depsBlockMatch = content.match(/defp\s+deps\s+do\s*\[([\s\S]*?)\]\s*end/m); + if (!depsBlockMatch) { + return []; + } + + const pattern = /\{\s*:([A-Za-z0-9_]+)\s*,\s*"([^"]*)"/g; + for (const match of depsBlockMatch[1].matchAll(pattern)) { + dependencies.push({ + name: match[1], + version: normalizeVersion(match[2]), + isDevelopmentDependency: false, + }); + } + return dedupeManifestDeps(dependencies); +} + +function parsePomManifest(content) { + const dependencies = []; + const dependencyBlocks = content.match(/[\s\S]*?<\/dependency>/gi) || []; + + for (const block of dependencyBlocks) { + const groupId = matchXmlValue(block, "groupId"); + const artifactId = matchXmlValue(block, "artifactId"); + const scope = matchXmlValue(block, "scope"); + if (!groupId || !artifactId) { + continue; + } + + let version = matchXmlValue(block, "version"); + if (version && /\$\{[^}]+}/.test(version)) { + version = ""; + } + + dependencies.push({ + name: `${groupId}:${artifactId}`, + version: normalizeVersion(version), + isDevelopmentDependency: scope === "test", + }); + } + + return dedupeManifestDeps(dependencies); +} + +function parseSimpleYamlDependencyList(content, sectionName) { + const dependencies = []; + let inSection = false; + let currentDependency = null; + + const flushCurrent = () => { + if (!currentDependency || !currentDependency.name) { + currentDependency = null; + return; + } + dependencies.push({ + name: currentDependency.name, + version: normalizeVersion(currentDependency.version), + isDevelopmentDependency: false, + }); + currentDependency = null; + }; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const lineWithoutComment = stripYamlComment(rawLine); + const line = lineWithoutComment.trim(); + if (!line) { + continue; + } + + const indent = rawLine.search(/\S/); + if (indent === 0 && line === `${sectionName}:`) { + inSection = true; + continue; + } + if (indent === 0 && line.endsWith(":") && line !== `${sectionName}:`) { + inSection = false; + flushCurrent(); + continue; + } + if (!inSection) { + continue; + } + + if (indent === 2 && line.startsWith("- ")) { + flushCurrent(); + currentDependency = { name: "", version: "" }; + const remainder = line.slice(2).trim(); + if (remainder.startsWith("name:")) { + currentDependency.name = remainder.slice("name:".length).trim(); + } + continue; + } + + if (!currentDependency) { + continue; + } + + if (indent >= 4 && line.startsWith("name:")) { + currentDependency.name = line.slice("name:".length).trim(); + } + if (indent >= 4 && line.startsWith("version:")) { + currentDependency.version = line.slice("version:".length).trim(); + } + } + + flushCurrent(); + return dedupeManifestDeps(dependencies); +} + +function dedupeManifestDeps(dependencies) { + const seen = new Set(); + const results = []; + for (const dependency of dependencies) { + const key = `${dependency.name.toLowerCase()}:${dependency.version.toLowerCase()}:${dependency.isDevelopmentDependency}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + results.push(dependency); + } + return results; +} + +function parseYamlInlineValue(block, key) { + const match = String(block || "").match(new RegExp(`${escapeRegExp(key)}\\s*:\\s*([^,}]+)`)); + return match ? unquote(match[1].trim()) : ""; +} + +function unquote(value) { + return String(value || "").trim().replace(/^["']|["']$/g, ""); +} + +function stripRubyComment(line) { + let inSingleQuote = false; + let inDoubleQuote = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const previous = index > 0 ? line[index - 1] : ""; + if (char === "'" && !inDoubleQuote && previous !== "\\") { + inSingleQuote = !inSingleQuote; + continue; + } + if (char === "\"" && !inSingleQuote && previous !== "\\") { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (char === "#" && !inSingleQuote && !inDoubleQuote) { + return line.slice(0, index); + } + } + return line; +} + +function stripJavaLikeComment(line) { + return String(line || "").replace(/\/\/.*$/, "").trimEnd(); +} + +function countBraces(line) { + const openBraces = (line.match(/\{/g) || []).length; + const closeBraces = (line.match(/\}/g) || []).length; + return openBraces - closeBraces; +} + +function isComposerPackageName(name) { + return typeof name === "string" + && name.includes("/") + && !name.startsWith("ext-") + && !name.startsWith("lib-") + && name !== "php"; +} + +function matchXmlValue(block, tagName) { + const match = String(block || "").match(new RegExp(`<${tagName}>\\s*([^<]+)\\s*`, "i")); + return match ? match[1].trim() : ""; +} + +module.exports = { + normalizeSwiftIdentity, + parseBuildGradleManifest, + parseCargoTomlManifest, + parseChartManifest, + parseComposerManifest, + parseCsprojManifest, + parseGemfileManifest, + parseMixExsManifest, + parsePackageJsonManifest, + parsePackageSwiftManifest, + parsePomManifest, + parsePubspecManifest, + parsePyprojectManifest, + parseRequirementSpec, +}; diff --git a/util/lockfileParsers/mavenParser.js b/util/lockfileParsers/mavenParser.js new file mode 100644 index 0000000..173a006 --- /dev/null +++ b/util/lockfileParsers/mavenParser.js @@ -0,0 +1,181 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parsePomManifest } = require("./manifestHelpers"); + +const TREE_FILE_CANDIDATES = [ + "dependency-tree.txt", + path.join("target", "dependency-tree.txt"), + path.join(".mvn", "dependency-tree.txt"), +]; + +const mavenParser = { + name: "mavenParser", + ecosystem: "maven", + + async canResolve(workspaceFolder) { + return await pathExists(path.join(getWorkspacePath(workspaceFolder), "pom.xml"), workspaceFolder); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const pomPath = path.join(rootPath, "pom.xml"); + if (!(await pathExists(pomPath, workspaceFolder))) { + return []; + } + + let lockfilePath = null; + for (const candidate of TREE_FILE_CANDIDATES) { + const candidatePath = path.join(rootPath, candidate); + if (await pathExists(candidatePath, workspaceFolder)) { + lockfilePath = candidatePath; + break; + } + } + + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath: pomPath, + sourceFile: "pom.xml", + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const directDependencies = parsePomManifest(await readUtf8(manifestPath, workspaceFolder)) + .map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "maven", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile: getSourceFileName(manifestPath), + isDevelopmentDependency: dependency.isDevelopmentDependency, + })); + + if (!lockfilePath) { + return buildTree("maven", getSourceFileName(manifestPath), directDependencies); + } + + const treeRoots = parseDependencyTree(await readUtf8(lockfilePath, workspaceFolder)); + const hydratedDirectDependencies = directDependencies.map((dependency) => { + const matchingTreeNode = treeRoots.find((node) => ( + node.name === dependency.name + && (!dependency.version || node.version === dependency.version || !node.version) + )) || treeRoots.find((node) => node.name === dependency.name); + + if (!matchingTreeNode) { + return dependency; + } + + return { + ...dependency, + version: dependency.version || matchingTreeNode.version, + transitives: matchingTreeNode.children.map((child) => toMavenDependency(child, [dependency.name], getSourceFileName(manifestPath))), + }; + }); + + let dependencies = deduplicateDeps(flattenDependencies(hydratedDirectDependencies)); + for (const rootNode of treeRoots) { + appendTreeNodeIfMissing(rootNode, dependencies, getSourceFileName(manifestPath)); + } + + return buildTree("maven", getSourceFileName(manifestPath), dependencies); + }, +}; + +function parseDependencyTree(content) { + const roots = []; + const stack = []; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const body = rawLine.replace(/^\[INFO\]\s*/, ""); + if (!body.trim()) { + continue; + } + + const markerIndex = body.search(/[+\\]-/); + if (markerIndex === -1) { + continue; + } + + const depth = Math.floor(markerIndex / 3); + const coordinates = body.slice(markerIndex + 2).trim().replace(/\s+\(\*\)$/, ""); + const node = parseMavenCoordinate(coordinates); + if (!node) { + continue; + } + + while (stack.length > depth) { + stack.pop(); + } + + if (stack.length === 0) { + roots.push(node); + } else { + stack[stack.length - 1].children.push(node); + } + stack.push(node); + } + + return roots; +} + +function parseMavenCoordinate(coordinates) { + const withoutScopeHint = coordinates.split(" -> ")[0].trim(); + const parts = withoutScopeHint.split(":"); + if (parts.length < 4) { + return null; + } + + const resolvedVersion = coordinates.includes(" -> ") + ? coordinates.split(" -> ")[1].trim().split(" ")[0] + : parts[3]; + + return { + name: `${parts[0]}:${parts[1]}`, + version: resolvedVersion || "", + children: [], + }; +} + +function toMavenDependency(node, parentChain, sourceFile) { + return createDependency({ + name: node.name, + version: node.version, + ecosystem: "maven", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(node.children.map((child) => toMavenDependency(child, parentChain.concat(node.name), sourceFile))), + sourceFile, + isDevelopmentDependency: false, + }); +} + +function appendTreeNodeIfMissing(node, dependencies, sourceFile) { + const key = `${node.name.toLowerCase()}@${node.version.toLowerCase()}`; + const exists = dependencies.some((dependency) => ( + `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key + )); + if (!exists) { + dependencies.push(toMavenDependency(node, [], sourceFile)); + } + for (const child of node.children) { + appendTreeNodeIfMissing(child, dependencies, sourceFile); + } +} + +module.exports = mavenParser; diff --git a/util/lockfileParsers/npmParser.js b/util/lockfileParsers/npmParser.js new file mode 100644 index 0000000..8df4970 --- /dev/null +++ b/util/lockfileParsers/npmParser.js @@ -0,0 +1,836 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + normalizeVersion, + readJson, + pathExists, + readUtf8, + statSafe, + stripYamlComment, +} = require("./shared"); +const { parsePackageJsonManifest } = require("./manifestHelpers"); + +const LOCKFILE_NAMES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]; + +const npmParser = { + name: "npmParser", + ecosystem: "npm", + + async canResolve(workspaceFolder) { + const matches = await this.detect(workspaceFolder); + return matches.length > 0; + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + for (const fileName of LOCKFILE_NAMES) { + const lockfilePath = path.join(rootPath, fileName); + if (await pathExists(lockfilePath, workspaceFolder)) { + const manifestPath = await pathExists(path.join(rootPath, "package.json"), workspaceFolder) + ? path.join(rootPath, "package.json") + : null; + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: fileName, + }]; + } + } + return []; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder, options = {} }) { + const sourceFile = getSourceFileName(lockfilePath); + const manifest = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? parsePackageJsonManifest(await readUtf8(manifestPath, workspaceFolder)) + : { dependencies: [], directNames: new Set(), devNames: new Set() }; + + if (sourceFile === "package-lock.json") { + return parsePackageLock(lockfilePath, manifest, workspaceFolder, options); + } + + if (sourceFile === "yarn.lock") { + return parseYarnLock(lockfilePath, manifest, workspaceFolder, options); + } + + if (sourceFile === "pnpm-lock.yaml") { + return parsePnpmLock(lockfilePath, manifest, workspaceFolder, options); + } + + throw new Error(`Unsupported npm lockfile: ${sourceFile}`); + }, +}; + +async function parsePackageLock(lockfilePath, manifest, workspaceFolder, options) { + const warnings = []; + const stats = await statSafe(lockfilePath, workspaceFolder); + if (stats && stats.size > 50 * 1024 * 1024) { + warnings.push("Large package-lock.json detected. Parsing may take longer than usual."); + } + + const root = await readJson(lockfilePath, workspaceFolder); + const packages = root && typeof root === "object" && root.packages && typeof root.packages === "object" + ? root.packages + : null; + + if (!packages) { + throw new Error("Malformed package-lock.json: missing packages object"); + } + + const rootEntry = packages[""] || {}; + const rootDependencyMap = { + ...(rootEntry.dependencies || {}), + ...(rootEntry.optionalDependencies || {}), + ...(rootEntry.devDependencies || {}), + }; + + const mergedManifestVersionHints = new Map(); + for (const dependency of manifest.dependencies) { + mergedManifestVersionHints.set(dependency.name, dependency.version); + } + for (const [name, version] of Object.entries(rootDependencyMap)) { + if (!mergedManifestVersionHints.has(name)) { + mergedManifestVersionHints.set(name, normalizeVersion(version)); + } + } + + const uniqueEntries = new Map(); + const nameIndex = new Map(); + const nameIndexKeys = new Map(); + + for (const [packagePath, packageInfo] of Object.entries(packages)) { + if (packagePath === "" || !packageInfo || typeof packageInfo !== "object") { + continue; + } + + const name = extractPackageLockName(packagePath); + const version = String(packageInfo.version || "").trim(); + if (!name || !version) { + continue; + } + + const key = `${name.toLowerCase()}@${version.toLowerCase()}`; + const existing = uniqueEntries.get(key); + const dependencies = { + ...(packageInfo.dependencies || {}), + ...(packageInfo.optionalDependencies || {}), + }; + const merged = existing || { + key, + name, + version, + dependencies: {}, + }; + Object.assign(merged.dependencies, dependencies); + uniqueEntries.set(key, merged); + + const existingByName = nameIndex.get(name) || []; + const existingKeys = nameIndexKeys.get(name) || new Set(); + if (!existingKeys.has(key)) { + existingKeys.add(key); + existingByName.push(merged); + nameIndex.set(name, existingByName); + nameIndexKeys.set(name, existingKeys); + } + } + + const directNames = manifest.directNames.size > 0 || manifest.devNames.size > 0 + ? new Set([...manifest.directNames, ...manifest.devNames]) + : new Set(Object.keys(rootDependencyMap)); + + const directRoots = []; + const seenDirectKeys = new Set(); + + for (const directName of directNames) { + const entry = selectEntryByName(nameIndex, directName, mergedManifestVersionHints.get(directName)); + const dependency = buildNpmDependency(entry, directName, [], nameIndex, new Set(), { + sourceFile: getSourceFileName(lockfilePath), + directNames, + devNames: manifest.devNames, + }); + const key = `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}`; + if (!seenDirectKeys.has(key)) { + seenDirectKeys.add(key); + directRoots.push(dependency); + } + } + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + const addedKeys = new Set(); + collectDependencyKeys(dependencies, addedKeys); + for (const entry of uniqueEntries.values()) { + const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`; + if (addedKeys.has(key)) { + continue; + } + addedKeys.add(key); + dependencies.push(createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: false, + parent: null, + parentChain: [], + transitives: [], + sourceFile: getSourceFileName(lockfilePath), + isDevelopmentDependency: manifest.devNames.has(entry.name), + })); + } + + if (options.maxDependenciesToScan && dependencies.length > options.maxDependenciesToScan) { + warnings.push( + `Large npm dependency tree (${dependencies.length} unique packages). ` + + `Display is capped at ${options.maxDependenciesToScan} dependencies.` + ); + } + + return buildTree("npm", getSourceFileName(lockfilePath), dependencies, warnings); +} + +function collectDependencyKeys(dependencies, addedKeys) { + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + addedKeys.add(`${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}`); + if (Array.isArray(dependency.transitives) && dependency.transitives.length > 0) { + collectDependencyKeys(dependency.transitives, addedKeys); + } + } +} + +function buildNpmDependency(entry, fallbackName, parentChain, nameIndex, visiting, context) { + if (!entry) { + return createDependency({ + name: fallbackName, + version: "", + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile: context.sourceFile, + isDevelopmentDependency: context.devNames.has(fallbackName), + }); + } + + if (visiting.has(entry.key)) { + return createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile: context.sourceFile, + isDevelopmentDependency: context.devNames.has(entry.name), + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(entry.key); + const nextParentChain = parentChain.concat(entry.name); + const transitives = []; + + for (const [dependencyName, versionHint] of Object.entries(entry.dependencies || {})) { + const childEntry = selectEntryByName(nameIndex, dependencyName, normalizeVersion(versionHint)); + if (childEntry && nextVisiting.has(childEntry.key)) { + continue; + } + transitives.push(buildNpmDependency( + childEntry, + dependencyName, + nextParentChain, + nameIndex, + nextVisiting, + context + )); + } + + return createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: parentChain.length === 0 || context.directNames.has(entry.name), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile: context.sourceFile, + isDevelopmentDependency: context.devNames.has(entry.name), + }); +} + +function selectEntryByName(nameIndex, dependencyName, versionHint) { + const entries = nameIndex.get(dependencyName) || []; + if (entries.length === 0) { + return null; + } + + const normalizedHint = normalizeVersion(versionHint); + if (normalizedHint) { + const exactMatch = entries.find((entry) => entry.version === normalizedHint); + if (exactMatch) { + return exactMatch; + } + } + + return entries[0]; +} + +function extractPackageLockName(packagePath) { + const marker = "node_modules/"; + const lastMarkerIndex = packagePath.lastIndexOf(marker); + const relativePath = lastMarkerIndex === -1 + ? packagePath + : packagePath.slice(lastMarkerIndex + marker.length); + const segments = relativePath.split("/").filter(Boolean); + if (segments.length === 0) { + return ""; + } + if (segments[0].startsWith("@") && segments.length >= 2) { + return `${segments[0]}/${segments[1]}`; + } + return segments[0]; +} + +async function parseYarnLock(lockfilePath, manifest, workspaceFolder, options) { + const content = await readUtf8(lockfilePath, workspaceFolder); + const parsed = parseYarnEntries(content); + if (parsed.entries.size === 0) { + throw new Error("Malformed yarn.lock: no package entries found"); + } + + const sourceFile = getSourceFileName(lockfilePath); + const manifestVersionHints = new Map(); + for (const dependency of manifest.dependencies) { + manifestVersionHints.set(dependency.name, dependency.version); + } + const directNames = manifest.directNames.size > 0 || manifest.devNames.size > 0 + ? new Set([...manifest.directNames, ...manifest.devNames]) + : new Set([...parsed.entries.values()].map((entry) => entry.name)); + + const directRoots = []; + for (const directName of directNames) { + const entry = selectYarnEntry(parsed, directName, manifestVersionHints.get(directName)); + directRoots.push(buildYarnDependency( + entry, + directName, + [], + parsed, + new Set(), + sourceFile, + manifest.devNames + )); + } + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + for (const entry of parsed.entries.values()) { + const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: false, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: manifest.devNames.has(entry.name), + })); + } + + const warnings = []; + if (options.maxDependenciesToScan && dependencies.length > options.maxDependenciesToScan) { + warnings.push( + `Large npm dependency tree (${dependencies.length} unique packages). ` + + `Display is capped at ${options.maxDependenciesToScan} dependencies.` + ); + } + + return buildTree("npm", sourceFile, dependencies, warnings); +} + +function buildYarnDependency(entry, fallbackName, parentChain, parsedEntries, visiting, sourceFile, devNames) { + if (!entry) { + return createDependency({ + name: fallbackName, + version: "", + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: devNames.has(fallbackName), + }); + } + + const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: devNames.has(entry.name), + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(entry.name); + const transitives = []; + + for (const dependencyName of Object.keys(entry.dependencies || {})) { + const versionHint = entry.dependencies[dependencyName]; + transitives.push(buildYarnDependency( + selectYarnEntry(parsedEntries, dependencyName, versionHint), + dependencyName, + nextParentChain, + parsedEntries, + nextVisiting, + sourceFile, + devNames + )); + } + + return createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: devNames.has(entry.name), + }); +} + +function parseYarnEntries(content) { + const entries = new Map(); + const entriesByName = new Map(); + const selectorIndex = new Map(); + const lines = String(content || "").split(/\r?\n/); + let currentEntry = null; + let inDependencies = false; + + const flushCurrent = () => { + if (currentEntry && currentEntry.name && currentEntry.version) { + const key = `${currentEntry.name.toLowerCase()}@${currentEntry.version.toLowerCase()}`; + const existing = entries.get(key); + if (existing) { + Object.assign(existing.dependencies, currentEntry.dependencies); + for (const selector of currentEntry.selectors || []) { + if (!existing.selectors.includes(selector)) { + existing.selectors.push(selector); + } + selectorIndex.set(selector, key); + } + } else { + currentEntry.key = key; + entries.set(key, currentEntry); + if (!entriesByName.has(currentEntry.name)) { + entriesByName.set(currentEntry.name, []); + } + entriesByName.get(currentEntry.name).push(currentEntry); + for (const selector of currentEntry.selectors || []) { + selectorIndex.set(selector, key); + } + } + } + currentEntry = null; + inDependencies = false; + }; + + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ""); + const trimmed = line.trim(); + if (!trimmed) { + flushCurrent(); + continue; + } + if (trimmed === "__metadata:") { + flushCurrent(); + continue; + } + + if (!line.startsWith(" ")) { + flushCurrent(); + const header = trimmed.replace(/:$/, ""); + const selectors = header.split(",").map((selector) => selector.trim().replace(/^["']|["']$/g, "")); + const primarySelector = selectors[0] || ""; + const name = parseYarnSelectorName(primarySelector); + if (!name) { + continue; + } + currentEntry = { + name, + version: "", + dependencies: {}, + selectors, + }; + continue; + } + + if (!currentEntry) { + continue; + } + + if (trimmed === "dependencies:") { + inDependencies = true; + continue; + } + + const versionMatch = trimmed.match(/^version\s+"([^"]+)"/); + if (versionMatch) { + currentEntry.version = versionMatch[1]; + inDependencies = false; + continue; + } + + if (inDependencies) { + const dependencyMatch = trimmed.match(/^("?[^"\s]+"?)\s+"([^"]+)"/); + if (!dependencyMatch) { + continue; + } + const dependencyName = dependencyMatch[1].replace(/^["']|["']$/g, ""); + currentEntry.dependencies[dependencyName] = dependencyMatch[2]; + } + } + + flushCurrent(); + return { + entries, + entriesByName, + selectorIndex, + }; +} + +function selectYarnEntry(parsedEntries, dependencyName, versionHint) { + if (!parsedEntries || !dependencyName) { + return null; + } + + const normalizedName = String(dependencyName || "").trim(); + if (!normalizedName) { + return null; + } + + const normalizedHint = String(versionHint || "").trim().replace(/^["']|["']$/g, ""); + if (normalizedHint) { + const exactSelectorKey = `${normalizedName}@${normalizedHint}`; + const selectedKey = parsedEntries.selectorIndex.get(exactSelectorKey); + if (selectedKey && parsedEntries.entries.has(selectedKey)) { + return parsedEntries.entries.get(selectedKey); + } + } + + const candidates = parsedEntries.entriesByName.get(normalizedName) || []; + if (candidates.length === 0) { + return null; + } + + if (normalizedHint) { + const exactVersionMatch = candidates.find((entry) => entry.version === normalizedHint); + if (exactVersionMatch) { + return exactVersionMatch; + } + } + + return candidates[0]; +} + +function parseYarnSelectorName(selector) { + const normalizedSelector = selector.trim().replace(/^["']|["']$/g, ""); + if (!normalizedSelector) { + return ""; + } + + if (normalizedSelector.startsWith("@")) { + const secondAt = normalizedSelector.indexOf("@", 1); + return secondAt === -1 ? normalizedSelector : normalizedSelector.slice(0, secondAt); + } + + const atIndex = normalizedSelector.indexOf("@"); + return atIndex === -1 ? normalizedSelector : normalizedSelector.slice(0, atIndex); +} + +async function parsePnpmLock(lockfilePath, manifest, workspaceFolder, options) { + const parsed = parsePnpmEntries(await readUtf8(lockfilePath, workspaceFolder)); + if (parsed.packageEntries.size === 0) { + throw new Error("Malformed pnpm-lock.yaml: no package entries found"); + } + + const sourceFile = getSourceFileName(lockfilePath); + const directNames = manifest.directNames.size > 0 || manifest.devNames.size > 0 + ? new Set([...manifest.directNames, ...manifest.devNames]) + : new Set(parsed.directVersions.keys()); + const directRoots = []; + + for (const directName of directNames) { + const entry = selectPnpmEntry(parsed.packageEntries, directName, parsed.directVersions.get(directName)); + directRoots.push(buildPnpmDependency( + entry, + directName, + [], + parsed.packageEntries, + new Set(), + sourceFile, + manifest.devNames + )); + } + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + for (const entry of parsed.packageEntries.values()) { + const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: false, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: manifest.devNames.has(entry.name), + })); + } + + const warnings = []; + if (options.maxDependenciesToScan && dependencies.length > options.maxDependenciesToScan) { + warnings.push( + `Large npm dependency tree (${dependencies.length} unique packages). ` + + `Display is capped at ${options.maxDependenciesToScan} dependencies.` + ); + } + + return buildTree("npm", sourceFile, dependencies, warnings); +} + +function buildPnpmDependency(entry, fallbackName, parentChain, packageEntries, visiting, sourceFile, devNames) { + if (!entry) { + return createDependency({ + name: fallbackName, + version: "", + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: devNames.has(fallbackName), + }); + } + + const key = `${entry.name.toLowerCase()}@${entry.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: devNames.has(entry.name), + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(entry.name); + const transitives = []; + + for (const [dependencyName, versionHint] of Object.entries(entry.dependencies || {})) { + transitives.push(buildPnpmDependency( + selectPnpmEntry(packageEntries, dependencyName, versionHint), + dependencyName, + nextParentChain, + packageEntries, + nextVisiting, + sourceFile, + devNames + )); + } + + return createDependency({ + name: entry.name, + version: entry.version, + ecosystem: "npm", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: devNames.has(entry.name), + }); +} + +function selectPnpmEntry(packageEntries, dependencyName, versionHint) { + const normalizedHint = normalizeVersion(versionHint).split("(")[0].trim(); + const entries = [...packageEntries.values()].filter((entry) => entry.name === dependencyName); + if (entries.length === 0) { + return null; + } + if (normalizedHint) { + const exactMatch = entries.find((entry) => entry.version === normalizedHint); + if (exactMatch) { + return exactMatch; + } + } + return entries[0]; +} + +function parsePnpmEntries(content) { + const packageEntries = new Map(); + const directVersions = new Map(); + const lines = String(content || "").split(/\r?\n/); + let section = ""; + let currentPackage = null; + let currentPackageSubsection = ""; + let inImporter = false; + let importerSection = ""; + let currentImporterPackage = ""; + + const flushPackage = () => { + if (!currentPackage || !currentPackage.name || !currentPackage.version) { + currentPackage = null; + currentPackageSubsection = ""; + return; + } + packageEntries.set(`${currentPackage.name.toLowerCase()}@${currentPackage.version.toLowerCase()}`, currentPackage); + currentPackage = null; + currentPackageSubsection = ""; + }; + + for (const rawLine of lines) { + const lineWithoutComment = stripYamlComment(rawLine); + const line = lineWithoutComment.trimEnd(); + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const indent = rawLine.search(/\S/); + if (indent === 0 && trimmed === "importers:") { + flushPackage(); + section = "importers"; + inImporter = false; + continue; + } + if (indent === 0 && trimmed === "packages:") { + flushPackage(); + section = "packages"; + continue; + } + if (indent === 0 && trimmed.endsWith(":") && !["importers:", "packages:"].includes(trimmed)) { + flushPackage(); + section = ""; + continue; + } + + if (section === "importers") { + if (indent === 2 && trimmed.endsWith(":")) { + inImporter = true; + importerSection = ""; + currentImporterPackage = ""; + continue; + } + if (!inImporter) { + continue; + } + if (indent === 4 && trimmed.endsWith(":")) { + importerSection = trimmed.slice(0, -1); + currentImporterPackage = ""; + continue; + } + if (!["dependencies", "devDependencies", "optionalDependencies"].includes(importerSection)) { + continue; + } + if (indent === 6 && trimmed.endsWith(":")) { + currentImporterPackage = trimmed.slice(0, -1).replace(/^["']|["']$/g, ""); + continue; + } + if (indent === 8 && trimmed.startsWith("version:") && currentImporterPackage) { + directVersions.set( + currentImporterPackage, + normalizeVersion(trimmed.slice("version:".length).trim()).split("(")[0].trim() + ); + } + continue; + } + + if (section === "packages") { + if (indent === 2 && trimmed.endsWith(":")) { + flushPackage(); + const parsedKey = parsePnpmPackageKey(trimmed.slice(0, -1)); + if (!parsedKey) { + continue; + } + currentPackage = { + ...parsedKey, + dependencies: {}, + }; + continue; + } + if (!currentPackage) { + continue; + } + if (indent === 4 && trimmed.endsWith(":")) { + currentPackageSubsection = trimmed.slice(0, -1); + continue; + } + if (!["dependencies", "optionalDependencies"].includes(currentPackageSubsection)) { + continue; + } + if (indent === 6 && trimmed.includes(":")) { + const parts = trimmed.split(":", 2); + currentPackage.dependencies[parts[0].trim()] = normalizeVersion(parts[1].trim()).split("(")[0].trim(); + } + } + } + + flushPackage(); + return { + packageEntries, + directVersions, + }; +} + +function parsePnpmPackageKey(rawKey) { + const cleaned = rawKey.replace(/^\/+/, "").trim().replace(/^["']|["']$/g, ""); + if (!cleaned) { + return null; + } + + const withoutPeerSuffix = cleaned.split("(")[0]; + const atIndex = withoutPeerSuffix.lastIndexOf("@"); + if (atIndex <= 0) { + return null; + } + + return { + name: withoutPeerSuffix.slice(0, atIndex), + version: withoutPeerSuffix.slice(atIndex + 1), + }; +} + +module.exports = npmParser; diff --git a/util/lockfileParsers/nugetParser.js b/util/lockfileParsers/nugetParser.js new file mode 100644 index 0000000..95f8c7c --- /dev/null +++ b/util/lockfileParsers/nugetParser.js @@ -0,0 +1,190 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const fs = require("fs"); +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + pathExists, + readJson, + readUtf8, + resolveWorkspaceFilePath, +} = require("./shared"); +const { parseCsprojManifest } = require("./manifestHelpers"); + +const nugetParser = { + name: "nugetParser", + ecosystem: "nuget", + + async canResolve(workspaceFolder) { + const matches = await this.detect(workspaceFolder); + return matches.length > 0; + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const safeRootPath = await resolveWorkspaceFilePath(rootPath, workspaceFolder); + if (!safeRootPath) { + return []; + } + const entries = await fs.promises.readdir(safeRootPath); + const csprojPath = entries.find((entry) => entry.toLowerCase().endsWith(".csproj")); + const lockfilePath = await pathExists(path.join(safeRootPath, "packages.lock.json"), workspaceFolder) + ? path.join(safeRootPath, "packages.lock.json") + : null; + const manifestPath = csprojPath ? path.join(safeRootPath, csprojPath) : null; + + if (!lockfilePath && !manifestPath) { + return []; + } + + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + const manifestDependencies = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? parseCsprojManifest(await readUtf8(manifestPath, workspaceFolder)) + : []; + const directNames = new Set(manifestDependencies.map((dependency) => dependency.name.toLowerCase())); + + if (!lockfilePath) { + return buildTree("nuget", sourceFile, manifestDependencies.map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "nuget", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + }))); + } + + const root = await readJson(lockfilePath, workspaceFolder); + const dependencyRoot = root && root.dependencies && typeof root.dependencies === "object" + ? root.dependencies + : null; + if (!dependencyRoot) { + throw new Error("Malformed packages.lock.json: missing dependencies object"); + } + + const recordsByName = new Map(); + for (const frameworkDependencies of Object.values(dependencyRoot)) { + if (!frameworkDependencies || typeof frameworkDependencies !== "object") { + continue; + } + for (const [name, details] of Object.entries(frameworkDependencies)) { + const dependencies = details && details.dependencies && typeof details.dependencies === "object" + ? Object.keys(details.dependencies) + : []; + const existing = recordsByName.get(name.toLowerCase()); + recordsByName.set(name.toLowerCase(), { + name, + version: details.resolved || "", + dependencies: deduplicateStringValues([...(existing ? existing.dependencies : []), ...dependencies]), + isDirect: Boolean(existing && existing.isDirect) || String(details.type || "").toLowerCase() === "direct", + }); + } + } + + const rootRecords = manifestDependencies.length > 0 + ? manifestDependencies.map((dependency) => recordsByName.get(dependency.name.toLowerCase())).filter(Boolean) + : [...recordsByName.values()].filter((record) => record.isDirect); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildNugetDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + directNames + ))); + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + + for (const record of recordsByName.values()) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "nuget", + isDirect: directNames.has(record.name.toLowerCase()) || record.isDirect, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("nuget", sourceFile, dependencies); + }, +}; + +function buildNugetDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "nuget", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()) || record.isDirect, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependencyName of record.dependencies) { + const childRecord = recordsByName.get(dependencyName.toLowerCase()); + if (!childRecord) { + continue; + } + transitives.push(buildNugetDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + directNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "nuget", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()) || record.isDirect, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: false, + }); +} + +function deduplicateStringValues(values) { + return [...new Set(values.filter(Boolean))]; +} + +module.exports = nugetParser; diff --git a/util/lockfileParsers/pythonParser.js b/util/lockfileParsers/pythonParser.js new file mode 100644 index 0000000..80decf9 --- /dev/null +++ b/util/lockfileParsers/pythonParser.js @@ -0,0 +1,383 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + normalizeVersion, + parseInlineTomlValue, + parseKeyValueLine, + readJson, + parseQuotedArray, + pathExists, + readUtf8, + stripTomlComment, +} = require("./shared"); +const { + parsePyprojectManifest, + parseRequirementSpec, +} = require("./manifestHelpers"); +const { normalizePackageName } = require("../packageNameNormalizer"); + +const SOURCE_PRIORITY = ["uv.lock", "poetry.lock", "Pipfile.lock", "requirements.txt"]; + +const pythonParser = { + name: "pythonParser", + ecosystem: "python", + + async canResolve(workspaceFolder) { + const matches = await this.detect(workspaceFolder); + return matches.length > 0; + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + for (const fileName of SOURCE_PRIORITY) { + const lockfilePath = path.join(rootPath, fileName); + if (await pathExists(lockfilePath, workspaceFolder)) { + const pyprojectPath = path.join(rootPath, "pyproject.toml"); + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath: await pathExists(pyprojectPath, workspaceFolder) ? pyprojectPath : null, + sourceFile: fileName, + }]; + } + } + return []; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath); + const pyproject = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? parsePyprojectManifest(await readUtf8(manifestPath, workspaceFolder)) + : { projectName: "", dependencies: [], directNames: new Set(), devNames: new Set() }; + + if (sourceFile === "requirements.txt") { + return parseRequirements(lockfilePath, workspaceFolder); + } + if (sourceFile === "Pipfile.lock") { + return parsePipfile(lockfilePath, workspaceFolder); + } + if (sourceFile === "poetry.lock" || sourceFile === "uv.lock") { + return parseTomlLock(lockfilePath, pyproject, workspaceFolder, sourceFile === "uv.lock"); + } + + throw new Error(`Unsupported Python dependency source: ${sourceFile}`); + }, +}; + +async function parseRequirements(lockfilePath, workspaceFolder) { + const dependencies = []; + + for (const rawLine of String(await readUtf8(lockfilePath, workspaceFolder)).split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#") || line.startsWith("-")) { + continue; + } + const parsed = parseRequirementSpec(line); + if (!parsed) { + throw new Error(`Malformed requirements.txt entry: ${line}`); + } + dependencies.push(createDependency({ + name: parsed.name, + version: parsed.version, + ecosystem: "python", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile: getSourceFileName(lockfilePath), + isDevelopmentDependency: false, + })); + } + + return buildTree("python", getSourceFileName(lockfilePath), dependencies, [ + "requirements.txt does not encode transitive dependencies. Showing direct requirements only.", + ]); +} + +async function parsePipfile(lockfilePath, workspaceFolder) { + const root = await readJson(lockfilePath, workspaceFolder); + const dependencies = []; + + for (const [name, details] of Object.entries(root.default || {})) { + dependencies.push(createDependency({ + name, + version: normalizeVersion(details && details.version), + ecosystem: "python", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile: getSourceFileName(lockfilePath), + isDevelopmentDependency: false, + })); + } + + for (const [name, details] of Object.entries(root.develop || {})) { + dependencies.push(createDependency({ + name, + version: normalizeVersion(details && details.version), + ecosystem: "python", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile: getSourceFileName(lockfilePath), + isDevelopmentDependency: true, + })); + } + + return buildTree("python", getSourceFileName(lockfilePath), deduplicateDeps(dependencies)); +} + +async function parseTomlLock(lockfilePath, pyproject, workspaceFolder, skipEditableRoot) { + const records = parsePythonPackageRecords( + await readUtf8(lockfilePath, workspaceFolder), + skipEditableRoot + ); + if (records.length === 0) { + throw new Error(`Malformed ${getSourceFileName(lockfilePath)}: no package entries found`); + } + + const sourceFile = getSourceFileName(lockfilePath); + const normalizedDirectNames = pyproject.directNames.size > 0 || pyproject.devNames.size > 0 + ? new Set( + [...pyproject.directNames, ...pyproject.devNames] + .map((name) => normalizePackageName(name, "python")) + ) + : new Set(records.filter((record) => record.isRootDependency).map((record) => record.normalizedName)); + + const recordsByName = new Map(); + const incomingCounts = new Map(); + for (const record of records) { + if (!recordsByName.has(record.normalizedName)) { + recordsByName.set(record.normalizedName, []); + } + recordsByName.get(record.normalizedName).push(record); + for (const dependencyName of record.dependencies) { + const normalizedDependencyName = normalizePackageName(dependencyName, "python"); + incomingCounts.set( + normalizedDependencyName, + (incomingCounts.get(normalizedDependencyName) || 0) + 1 + ); + } + } + + const rootRecords = normalizedDirectNames.size > 0 + ? [...normalizedDirectNames].map((name) => (recordsByName.get(name) || [])[0]).filter(Boolean) + : records.filter((record) => !incomingCounts.get(record.normalizedName)); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildPythonDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + new Set([...pyproject.devNames].map((name) => normalizePackageName(name, "python"))) + ))); + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + for (const record of records) { + const key = `${record.normalizedName}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => ( + `${normalizePackageName(dependency.name, "python")}@${dependency.version.toLowerCase()}` === key + ))) { + continue; + } + + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "python", + isDirect: normalizedDirectNames.has(record.normalizedName), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("python", sourceFile, dependencies); +} + +function buildPythonDependency(record, parentChain, recordsByName, visiting, sourceFile, normalizedDevNames) { + const key = `${record.normalizedName}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "python", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: normalizedDevNames.has(record.normalizedName), + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependencyName of record.dependencies) { + const normalizedDependencyName = normalizePackageName(dependencyName, "python"); + const childRecord = (recordsByName.get(normalizedDependencyName) || [])[0]; + if (!childRecord) { + continue; + } + transitives.push(buildPythonDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + normalizedDevNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "python", + isDirect: parentChain.length === 0, + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: normalizedDevNames.has(record.normalizedName), + }); +} + +function parsePythonPackageRecords(content, skipEditableRoot) { + const records = []; + let current = null; + let section = ""; + let metadataDirectNames = []; + + const flushCurrent = () => { + if (!current || !current.name || !current.version) { + current = null; + return; + } + const isEditableRoot = skipEditableRoot && current.sourceEditable === "."; + if (!isEditableRoot) { + records.push({ + ...current, + normalizedName: normalizePackageName(current.name, "python"), + isRootDependency: metadataDirectNames.includes(normalizePackageName(current.name, "python")), + }); + } + current = null; + }; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const line = stripTomlComment(rawLine).trim(); + if (!line || line.startsWith("#")) { + continue; + } + + if (line === "[[package]]") { + flushCurrent(); + current = { + name: "", + version: "", + dependencies: [], + sourceEditable: "", + }; + section = "package"; + continue; + } + + if (line === "[package.dependencies]") { + section = "package.dependencies"; + continue; + } + + if (line === "[metadata]") { + flushCurrent(); + section = "metadata"; + continue; + } + + if (line.startsWith("[") && line.endsWith("]")) { + section = ""; + continue; + } + + if (section === "package" && current) { + if (line.startsWith("name =")) { + current.name = parseKeyValueLine(line).value.replace(/^["']|["']$/g, ""); + continue; + } + if (line.startsWith("version =")) { + current.version = parseKeyValueLine(line).value.replace(/^["']|["']$/g, ""); + continue; + } + if (line.startsWith("source =")) { + current.sourceEditable = parseInlineTomlValue(parseKeyValueLine(line).value, "editable"); + continue; + } + if (line.startsWith("dependencies =")) { + const value = parseKeyValueLine(line).value; + if (value.startsWith("[")) { + current.dependencies.push(...parsePythonDependencyArray(value)); + } else if (value.startsWith("{")) { + current.dependencies.push(...parsePythonDependencyInlineObjects(value)); + } + } + continue; + } + + if (section === "package.dependencies" && current) { + const parts = parseKeyValueLine(line); + if (parts && parts.key) { + current.dependencies.push(parts.key.replace(/^["']|["']$/g, "")); + } + continue; + } + + if (section === "metadata") { + if (line.startsWith("direct-dependencies =") || line.startsWith("root-dependencies =")) { + metadataDirectNames = parseQuotedArray(parseKeyValueLine(line).value) + .map((name) => normalizePackageName(name, "python")); + } + } + } + + flushCurrent(); + return records; +} + +function parsePythonDependencyArray(value) { + const names = []; + for (const item of parseQuotedArray(value)) { + const parsed = parseRequirementSpec(item); + if (parsed) { + names.push(parsed.name); + } + } + if (names.length > 0) { + return names; + } + return parsePythonDependencyInlineObjects(value); +} + +function parsePythonDependencyInlineObjects(value) { + const names = []; + const pattern = /name\s*=\s*"([^"]+)"/g; + for (const match of String(value || "").matchAll(pattern)) { + names.push(match[1]); + } + return names; +} + +module.exports = pythonParser; diff --git a/util/lockfileParsers/rubyParser.js b/util/lockfileParsers/rubyParser.js new file mode 100644 index 0000000..c673653 --- /dev/null +++ b/util/lockfileParsers/rubyParser.js @@ -0,0 +1,217 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + countIndent, + createDependency, + deduplicateDeps, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + pathExists, + readUtf8, +} = require("./shared"); +const { parseGemfileManifest } = require("./manifestHelpers"); + +const rubyParser = { + name: "rubyParser", + ecosystem: "ruby", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "Gemfile.lock"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "Gemfile"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "Gemfile.lock"), workspaceFolder) + ? path.join(rootPath, "Gemfile.lock") + : null; + const manifestPath = await pathExists(path.join(rootPath, "Gemfile"), workspaceFolder) + ? path.join(rootPath, "Gemfile") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + if (!lockfilePath) { + const dependencies = parseGemfileManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "ruby", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: dependency.isDevelopmentDependency, + })); + return buildTree("ruby", sourceFile, dependencies); + } + + const directNames = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? new Set(parseGemfileManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => dependency.name.toLowerCase())) + : null; + const records = parseGemfileLock(await readUtf8(lockfilePath, workspaceFolder)); + const recordsByName = new Map(); + const incomingCounts = new Map(); + + for (const record of records) { + recordsByName.set(record.name.toLowerCase(), record); + for (const dependencyName of record.dependencies) { + incomingCounts.set(dependencyName.toLowerCase(), (incomingCounts.get(dependencyName.toLowerCase()) || 0) + 1); + } + } + + const rootRecords = directNames && directNames.size > 0 + ? [...directNames].map((name) => recordsByName.get(name)).filter(Boolean) + : records.filter((record) => !incomingCounts.get(record.name.toLowerCase())); + + const directRoots = deduplicateDeps(rootRecords.map((record) => buildRubyDependency( + record, + [], + recordsByName, + new Set(), + sourceFile, + directNames || new Set() + ))); + + let dependencies = deduplicateDeps(flattenDependencies(directRoots)); + for (const record of records) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (dependencies.some((dependency) => `${dependency.name.toLowerCase()}@${dependency.version.toLowerCase()}` === key)) { + continue; + } + dependencies.push(createDependency({ + name: record.name, + version: record.version, + ecosystem: "ruby", + isDirect: directNames ? directNames.has(record.name.toLowerCase()) : false, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + })); + } + + return buildTree("ruby", sourceFile, dependencies); + }, +}; + +function parseGemfileLock(content) { + const records = []; + let section = ""; + let inSpecs = false; + let current = null; + + const flushCurrent = () => { + if (current && current.name && current.version) { + records.push(current); + } + current = null; + }; + + for (const rawLine of String(content || "").split(/\r?\n/)) { + const trimmed = rawLine.trimEnd(); + if (!trimmed) { + continue; + } + const indent = countIndent(rawLine); + const line = trimmed.trim(); + + if (indent === 0 && /^[A-Z][A-Z0-9_ ]+$/.test(line)) { + flushCurrent(); + section = line; + inSpecs = false; + continue; + } + if (section === "GEM" && indent === 2 && line === "specs:") { + inSpecs = true; + continue; + } + if (!inSpecs) { + continue; + } + if (indent === 4) { + flushCurrent(); + const match = line.match(/^([^\s(]+) \(([^)]+)\)/); + if (!match) { + continue; + } + current = { name: match[1], version: match[2], dependencies: [] }; + continue; + } + if (indent >= 6 && current) { + const dependencyName = line.split(" ", 1)[0].split("(", 1)[0].replace(/!$/, "").trim(); + if (dependencyName) { + current.dependencies.push(dependencyName); + } + } + } + + flushCurrent(); + return records; +} + +function buildRubyDependency(record, parentChain, recordsByName, visiting, sourceFile, directNames) { + const key = `${record.name.toLowerCase()}@${record.version.toLowerCase()}`; + if (visiting.has(key)) { + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "ruby", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + } + + const nextVisiting = new Set(visiting); + nextVisiting.add(key); + const nextParentChain = parentChain.concat(record.name); + const transitives = []; + + for (const dependencyName of record.dependencies) { + const childRecord = recordsByName.get(dependencyName.toLowerCase()); + if (!childRecord) { + continue; + } + transitives.push(buildRubyDependency( + childRecord, + nextParentChain, + recordsByName, + nextVisiting, + sourceFile, + directNames + )); + } + + return createDependency({ + name: record.name, + version: record.version, + ecosystem: "ruby", + isDirect: parentChain.length === 0 || directNames.has(record.name.toLowerCase()), + parent: parentChain[parentChain.length - 1] || null, + parentChain, + transitives: deduplicateDeps(transitives), + sourceFile, + isDevelopmentDependency: false, + }); +} + +module.exports = rubyParser; diff --git a/util/lockfileParsers/shared.js b/util/lockfileParsers/shared.js new file mode 100644 index 0000000..f4be6a1 --- /dev/null +++ b/util/lockfileParsers/shared.js @@ -0,0 +1,412 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const fs = require("fs"); +const path = require("path"); + +const LARGE_FILE_THRESHOLD_BYTES = 50 * 1024 * 1024; +const WORKSPACE_PATH_ERROR = "Refusing to read files outside the workspace folder."; + +function getWorkspacePath(workspaceFolder) { + if (!workspaceFolder) { + return ""; + } + + if (typeof workspaceFolder === "string") { + return workspaceFolder; + } + + if (workspaceFolder.uri && workspaceFolder.uri.fsPath) { + return workspaceFolder.uri.fsPath; + } + + return String(workspaceFolder); +} + +async function pathExists(targetPath, workspaceFolder) { + const safePath = await resolveWorkspaceFilePath(targetPath, workspaceFolder); + if (!safePath) { + return false; + } + + try { + await fs.promises.access(safePath, fs.constants.R_OK); + return true; + } catch { + return false; + } +} + +function getCandidateWorkspaceRoot(targetPath, workspaceFolder) { + const workspacePath = getWorkspacePath(workspaceFolder); + if (workspacePath) { + return workspacePath; + } + + const rawTargetPath = String(targetPath || "").trim(); + if (!rawTargetPath) { + return ""; + } + + return path.dirname(path.resolve(rawTargetPath)); +} + +async function resolveWorkspaceRoot(targetPath, workspaceFolder) { + const candidateRoot = getCandidateWorkspaceRoot(targetPath, workspaceFolder); + if (!candidateRoot) { + return ""; + } + + try { + return await fs.promises.realpath(candidateRoot); + } catch { + return path.resolve(candidateRoot); + } +} + +function isWithinWorkspace(workspaceRoot, targetPath) { + if (!workspaceRoot || !targetPath) { + return false; + } + + const relativePath = path.relative(workspaceRoot, targetPath); + return relativePath === "" + || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +async function resolveWorkspaceFilePath(targetPath, workspaceFolder) { + const rawTargetPath = String(targetPath || "").trim(); + if (!rawTargetPath) { + return null; + } + + const resolvedTargetPath = path.resolve(rawTargetPath); + const workspaceRoot = await resolveWorkspaceRoot(resolvedTargetPath, workspaceFolder); + if (!workspaceRoot) { + return null; + } + + let realTargetPath; + try { + realTargetPath = await fs.promises.realpath(resolvedTargetPath); + } catch { + return null; + } + + return isWithinWorkspace(workspaceRoot, realTargetPath) + ? realTargetPath + : null; +} + +async function readUtf8(targetPath, workspaceFolder) { + const safePath = await resolveWorkspaceFilePath(targetPath, workspaceFolder); + if (!safePath) { + throw new Error(WORKSPACE_PATH_ERROR); + } + + return fs.promises.readFile(safePath, "utf8"); +} + +async function readJson(targetPath, workspaceFolder) { + return JSON.parse(await readUtf8(targetPath, workspaceFolder)); +} + +async function statSafe(targetPath, workspaceFolder) { + const safePath = await resolveWorkspaceFilePath(targetPath, workspaceFolder); + if (!safePath) { + return null; + } + + try { + return await fs.promises.stat(safePath); + } catch { + return null; + } +} + +function getSourceFileName(targetPath) { + return path.basename(targetPath || ""); +} + +function normalizeVersion(version) { + if (version == null) { + return ""; + } + + return String(version) + .trim() + .replace(/^["']|["']$/g, "") + .replace(/^[~^<>=! ]+/, "") + .trim(); +} + +function createDependency({ + name, + version, + ecosystem, + isDirect, + parent, + parentChain, + transitives, + sourceFile, + isDevelopmentDependency, +}) { + return { + name: String(name || "").trim(), + version: String(version || "").trim(), + ecosystem: String(ecosystem || "").trim(), + isDirect: Boolean(isDirect), + parent: parent || null, + parentChain: Array.isArray(parentChain) ? parentChain.slice() : [], + transitives: Array.isArray(transitives) ? transitives.slice() : [], + cloudsmithStatus: null, + cloudsmithPackage: null, + sourceFile: sourceFile || null, + isDevelopmentDependency: Boolean(isDevelopmentDependency), + }; +} + +function flattenDependencies(dependencies) { + const flattened = []; + + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + flattened.push(dependency); + if (Array.isArray(dependency.transitives) && dependency.transitives.length > 0) { + flattened.push(...flattenDependencies(dependency.transitives)); + } + } + + return flattened; +} + +function dependencyKey(dependency) { + return [ + String(dependency.ecosystem || "").trim().toLowerCase(), + String(dependency.name || "").trim().toLowerCase(), + String(dependency.version || "").trim().toLowerCase(), + ].join(":"); +} + +function deduplicateDeps(dependencies) { + const unique = []; + const seen = new Map(); + + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + const key = dependencyKey(dependency); + const existing = seen.get(key); + if (!existing) { + seen.set(key, dependency); + unique.push(dependency); + continue; + } + + if (!existing.isDirect && dependency.isDirect) { + const index = unique.indexOf(existing); + if (index !== -1) { + unique[index] = dependency; + } + seen.set(key, dependency); + continue; + } + + if ( + Array.isArray(existing.parentChain) && + existing.parentChain.length === 0 && + Array.isArray(dependency.parentChain) && + dependency.parentChain.length > 0 + ) { + const merged = { + ...existing, + parent: dependency.parent, + parentChain: dependency.parentChain.slice(), + }; + const index = unique.indexOf(existing); + if (index !== -1) { + unique[index] = merged; + } + seen.set(key, merged); + } + } + + return unique; +} + +function buildTree(ecosystem, sourceFile, dependencies, warnings) { + return { + ecosystem, + sourceFile, + dependencies: deduplicateDeps(dependencies), + warnings: Array.isArray(warnings) ? warnings.slice() : [], + }; +} + +function stripTomlComment(line) { + if (typeof line !== "string" || !line.includes("#")) { + return line || ""; + } + + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const previous = index > 0 ? line[index - 1] : ""; + if (char === "'" && !inDoubleQuote && previous !== "\\") { + inSingleQuote = !inSingleQuote; + continue; + } + if (char === "\"" && !inSingleQuote && previous !== "\\") { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (char === "#" && !inSingleQuote && !inDoubleQuote) { + return line.slice(0, index); + } + } + + return line; +} + +function stripYamlComment(line) { + if (typeof line !== "string" || !line.includes("#")) { + return line || ""; + } + + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const previous = index > 0 ? line[index - 1] : ""; + if (char === "'" && !inDoubleQuote && previous !== "\\") { + inSingleQuote = !inSingleQuote; + continue; + } + if (char === "\"" && !inSingleQuote && previous !== "\\") { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (char === "#" && !inSingleQuote && !inDoubleQuote) { + return line.slice(0, index); + } + } + + return line; +} + +function countIndent(line) { + if (typeof line !== "string") { + return 0; + } + + const firstNonWhitespace = line.search(/\S/); + return firstNonWhitespace === -1 ? line.length : firstNonWhitespace; +} + +function parseQuotedArray(rawValue) { + const value = String(rawValue || "").trim(); + if (!value.startsWith("[") || !value.endsWith("]")) { + return []; + } + + const results = []; + let current = ""; + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 1; index < value.length - 1; index += 1) { + const char = value[index]; + const previous = index > 0 ? value[index - 1] : ""; + + if (char === "'" && !inDoubleQuote && previous !== "\\") { + inSingleQuote = !inSingleQuote; + current += char; + continue; + } + if (char === "\"" && !inSingleQuote && previous !== "\\") { + inDoubleQuote = !inDoubleQuote; + current += char; + continue; + } + + if (char === "," && !inSingleQuote && !inDoubleQuote) { + const cleaned = current.trim().replace(/^["']|["']$/g, ""); + if (cleaned) { + results.push(cleaned); + } + current = ""; + continue; + } + + current += char; + } + + const cleaned = current.trim().replace(/^["']|["']$/g, ""); + if (cleaned) { + results.push(cleaned); + } + + return results; +} + +function parseInlineTomlValue(block, key) { + if (typeof block !== "string" || !block.includes("{")) { + return ""; + } + + const expression = new RegExp(`${escapeRegExp(key)}\\s*=\\s*(\"([^\"]*)\"|'([^']*)'|([^,}]+))`); + const match = block.match(expression); + if (!match) { + return ""; + } + + return (match[2] || match[3] || match[4] || "").trim(); +} + +function escapeRegExp(value) { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function firstDefined(...values) { + for (const value of values) { + if (value != null && value !== "") { + return value; + } + } + return ""; +} + +function parseKeyValueLine(line) { + if (typeof line !== "string" || !line.includes("=")) { + return null; + } + + const separatorIndex = line.indexOf("="); + return { + key: line.slice(0, separatorIndex).trim(), + value: line.slice(separatorIndex + 1).trim(), + }; +} + +module.exports = { + LARGE_FILE_THRESHOLD_BYTES, + buildTree, + countIndent, + createDependency, + deduplicateDeps, + dependencyKey, + escapeRegExp, + firstDefined, + flattenDependencies, + getSourceFileName, + getWorkspacePath, + normalizeVersion, + parseInlineTomlValue, + readJson, + parseKeyValueLine, + parseQuotedArray, + pathExists, + readUtf8, + resolveWorkspaceFilePath, + statSafe, + stripTomlComment, + stripYamlComment, +}; diff --git a/util/lockfileParsers/swiftParser.js b/util/lockfileParsers/swiftParser.js new file mode 100644 index 0000000..0e21f88 --- /dev/null +++ b/util/lockfileParsers/swiftParser.js @@ -0,0 +1,89 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const { + buildTree, + createDependency, + getSourceFileName, + getWorkspacePath, + readJson, + pathExists, + readUtf8, +} = require("./shared"); +const { normalizeSwiftIdentity, parsePackageSwiftManifest } = require("./manifestHelpers"); + +const swiftParser = { + name: "swiftParser", + ecosystem: "swift", + + async canResolve(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + return (await pathExists(path.join(rootPath, "Package.resolved"), workspaceFolder)) + || (await pathExists(path.join(rootPath, "Package.swift"), workspaceFolder)); + }, + + async detect(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const lockfilePath = await pathExists(path.join(rootPath, "Package.resolved"), workspaceFolder) + ? path.join(rootPath, "Package.resolved") + : null; + const manifestPath = await pathExists(path.join(rootPath, "Package.swift"), workspaceFolder) + ? path.join(rootPath, "Package.swift") + : null; + if (!lockfilePath && !manifestPath) { + return []; + } + return [{ + resolverName: this.name, + ecosystem: this.ecosystem, + lockfilePath, + manifestPath, + sourceFile: getSourceFileName(lockfilePath || manifestPath), + }]; + }, + + async resolve({ lockfilePath, manifestPath, workspaceFolder }) { + const sourceFile = getSourceFileName(lockfilePath || manifestPath); + if (!lockfilePath) { + return buildTree("swift", sourceFile, parsePackageSwiftManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => createDependency({ + name: dependency.name, + version: dependency.version, + ecosystem: "swift", + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }))); + } + + const manifestDirectNames = manifestPath && await pathExists(manifestPath, workspaceFolder) + ? new Set(parsePackageSwiftManifest(await readUtf8(manifestPath, workspaceFolder)).map((dependency) => dependency.name)) + : new Set(); + const root = await readJson(lockfilePath); + const pins = Array.isArray(root.pins) + ? root.pins + : (root.object && Array.isArray(root.object.pins) ? root.object.pins : []); + if (pins.length === 0) { + throw new Error("Malformed Package.resolved: missing pins array"); + } + + return buildTree("swift", sourceFile, pins.map((pin) => { + const state = pin.state || {}; + const identity = normalizeSwiftIdentity(pin.identity || pin.package || pin.location || ""); + return createDependency({ + name: identity, + version: state.version || state.revision || state.branch || "", + ecosystem: "swift", + isDirect: manifestDirectNames.size === 0 || manifestDirectNames.has(identity), + parent: null, + parentChain: [], + transitives: [], + sourceFile, + isDevelopmentDependency: false, + }); + })); + }, +}; + +module.exports = swiftParser; diff --git a/util/lockfileResolver.js b/util/lockfileResolver.js new file mode 100644 index 0000000..844a51e --- /dev/null +++ b/util/lockfileResolver.js @@ -0,0 +1,140 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const path = require("path"); +const npmParser = require("./lockfileParsers/npmParser"); +const pythonParser = require("./lockfileParsers/pythonParser"); +const mavenParser = require("./lockfileParsers/mavenParser"); +const gradleParser = require("./lockfileParsers/gradleParser"); +const goParser = require("./lockfileParsers/goParser"); +const cargoParser = require("./lockfileParsers/cargoParser"); +const rubyParser = require("./lockfileParsers/rubyParser"); +const dockerParser = require("./lockfileParsers/dockerParser"); +const nugetParser = require("./lockfileParsers/nugetParser"); +const dartParser = require("./lockfileParsers/dartParser"); +const composerParser = require("./lockfileParsers/composerParser"); +const helmParser = require("./lockfileParsers/helmParser"); +const swiftParser = require("./lockfileParsers/swiftParser"); +const hexParser = require("./lockfileParsers/hexParser"); +const { + getWorkspacePath, + resolveWorkspaceFilePath, +} = require("./lockfileParsers/shared"); + +const REGISTERED_RESOLVERS = [ + npmParser, + pythonParser, + mavenParser, + gradleParser, + goParser, + cargoParser, + rubyParser, + dockerParser, + nugetParser, + dartParser, + composerParser, + helmParser, + swiftParser, + hexParser, +]; + +class LockfileResolver { + static getResolvers() { + return REGISTERED_RESOLVERS.slice(); + } + + static async detectResolvers(workspaceFolder) { + const rootPath = getWorkspacePath(workspaceFolder); + const matches = []; + + for (const resolver of REGISTERED_RESOLVERS) { + if (!resolver || typeof resolver.canResolve !== "function") { + continue; + } + + if (!(await resolver.canResolve(rootPath))) { + continue; + } + + const detections = typeof resolver.detect === "function" + ? await resolver.detect(rootPath) + : [{ + resolverName: resolver.name, + ecosystem: resolver.ecosystem, + workspaceFolder: rootPath, + lockfilePath: null, + manifestPath: null, + }]; + + for (const detection of detections) { + matches.push({ + resolverName: resolver.name, + ecosystem: resolver.ecosystem, + workspaceFolder: rootPath, + lockfilePath: detection.lockfilePath || null, + manifestPath: detection.manifestPath || null, + sourceFile: detection.sourceFile + || path.basename(detection.lockfilePath || detection.manifestPath || ""), + }); + } + } + + return matches; + } + + static async resolve(resolverName, lockfilePath, manifestPath, options = {}) { + const resolver = REGISTERED_RESOLVERS.find((candidate) => candidate.name === resolverName); + if (!resolver) { + throw new Error(`Unknown lockfile resolver: ${resolverName}`); + } + + const workspaceFolder = getWorkspacePath(options.workspaceFolder || path.dirname(lockfilePath || manifestPath || "")); + const safeLockfilePath = lockfilePath + ? await resolveWorkspaceFilePath(lockfilePath, workspaceFolder) + : null; + const safeManifestPath = manifestPath + ? await resolveWorkspaceFilePath(manifestPath, workspaceFolder) + : null; + + if (lockfilePath && !safeLockfilePath) { + throw new Error("Lockfile paths must stay within the workspace folder."); + } + + if (manifestPath && !safeManifestPath) { + throw new Error("Manifest paths must stay within the workspace folder."); + } + + return resolver.resolve({ + workspaceFolder, + lockfilePath: safeLockfilePath, + manifestPath: safeManifestPath, + options, + }); + } + + static async resolveAll(workspaceFolder, options = {}) { + const matches = await LockfileResolver.detectResolvers(workspaceFolder); + const trees = []; + + for (const match of matches) { + const tree = await LockfileResolver.resolve( + match.resolverName, + match.lockfilePath, + match.manifestPath, + { + ...options, + workspaceFolder: match.workspaceFolder, + detection: match, + } + ); + + if (tree) { + trees.push(tree); + } + } + + return trees; + } +} + +module.exports = { + LockfileResolver, +}; diff --git a/util/manifestParser.js b/util/manifestParser.js index 0200f3a..65b450c 100644 --- a/util/manifestParser.js +++ b/util/manifestParser.js @@ -1,9 +1,11 @@ -// Manifest parser - detects and parses project dependency manifests -// Supports: npm (package.json), Python (requirements.txt, pyproject.toml), -// Maven (pom.xml), Go (go.mod), Cargo (Cargo.toml) - -const fs = require("fs"); +// Copyright 2026 Cloudsmith Ltd. All rights reserved. const path = require("path"); +const { parsePyprojectManifest } = require("./lockfileParsers/manifestHelpers"); +const { + getWorkspacePath, + pathExists, + readUtf8, +} = require("./lockfileParsers/shared"); class ManifestParser { /** @@ -15,9 +17,7 @@ class ManifestParser { * @returns {Array<{filePath: string, format: string, parserMethod: string}>} */ static async detectManifests(workspaceFolderOrPath) { - const root = typeof workspaceFolderOrPath === "string" - ? workspaceFolderOrPath - : workspaceFolderOrPath.uri.fsPath; + const root = getWorkspacePath(workspaceFolderOrPath); const manifests = []; const checks = [ @@ -31,15 +31,13 @@ class ManifestParser { for (const check of checks) { const filePath = path.join(root, check.file); - try { - await fs.promises.access(filePath, fs.constants.R_OK); + if (await pathExists(filePath, root)) { manifests.push({ - filePath: filePath, + filePath, format: check.format, parserMethod: check.parserMethod, + workspaceFolder: root, }); - } catch (e) { // eslint-disable-line no-unused-vars - // File doesn't exist or isn't readable, skip } } @@ -54,7 +52,10 @@ class ManifestParser { */ static async parseManifest(manifest) { try { - const content = await fs.promises.readFile(manifest.filePath, "utf8"); + const content = await readUtf8( + manifest.filePath, + manifest.workspaceFolder || path.dirname(manifest.filePath || "") + ); const parser = ManifestParser[manifest.parserMethod]; if (!parser) { return []; @@ -157,88 +158,17 @@ class ManifestParser { } /** - * Parse pyproject.toml — basic line-by-line parsing for - * [project.dependencies] and [tool.poetry.dependencies]. - * Not a full TOML parser; handles the common cases. + * Parse pyproject.toml via the shared lockfile manifest helper so + * Poetry and PEP 621 formats stay consistent with lockfile resolution. */ static parsePyproject(content, format) { - const deps = []; - const lines = content.split("\n"); - let inDepsSection = false; - let inPoetryDeps = false; - - for (const rawLine of lines) { - const line = rawLine.trim(); - - // Detect section headers - if (line.startsWith("[")) { - inDepsSection = line === "[project.dependencies]" || - line === "[project]"; - inPoetryDeps = line === "[tool.poetry.dependencies]"; - continue; - } - - // Handle [project] section with dependencies = [...] array - if (inDepsSection && line.startsWith("dependencies")) { - // dependencies = ["flask>=2.0", "requests"] - const arrayMatch = line.match(/dependencies\s*=\s*\[(.*)\]/); - if (arrayMatch) { - const items = arrayMatch[1].split(","); - for (const item of items) { - const cleaned = item.trim().replace(/['"]/g, ""); - if (!cleaned) { - continue; - } - const depMatch = cleaned.match(/^([a-zA-Z0-9_\-.]+)\s*([><=!~]+)?\s*(.*)?/); - if (depMatch) { - deps.push({ - name: depMatch[1], - version: depMatch[3] ? depMatch[3].trim() : "", - devDependency: false, - format: format || "python", - }); - } - } - inDepsSection = false; - continue; - } - } - - // Handle Poetry-style: name = "^version" or name = {version = "^version"} - if (inPoetryDeps) { - if (!line || line.startsWith("#")) { - continue; - } - // Skip python version requirement - if (line.startsWith("python")) { - continue; - } - - // name = "^1.2.3" - const simpleMatch = line.match(/^([a-zA-Z0-9_\-.]+)\s*=\s*"([^"]*)"/); - if (simpleMatch) { - deps.push({ - name: simpleMatch[1], - version: ManifestParser._stripVersionPrefix(simpleMatch[2]), - devDependency: false, - format: format || "python", - }); - continue; - } - - // name = { version = "^1.2.3", ... } - const complexMatch = line.match(/^([a-zA-Z0-9_\-.]+)\s*=\s*\{.*version\s*=\s*"([^"]*)"/); - if (complexMatch) { - deps.push({ - name: complexMatch[1], - version: ManifestParser._stripVersionPrefix(complexMatch[2]), - devDependency: false, - format: format || "python", - }); - } - } - } - return deps; + const parsed = parsePyprojectManifest(content); + return parsed.dependencies.map((dependency) => ({ + name: dependency.name, + version: dependency.version, + devDependency: dependency.isDevelopmentDependency, + format: format || "python", + })); } /** @@ -414,7 +344,7 @@ class ManifestParser { */ static async findDependencyLocation(filePath, dependencyName, format) { try { - const content = await fs.promises.readFile(filePath, "utf8"); + const content = await readUtf8(filePath, path.dirname(filePath)); const lines = content.split("\n"); switch (format) { diff --git a/util/packageNameNormalizer.js b/util/packageNameNormalizer.js new file mode 100644 index 0000000..060f343 --- /dev/null +++ b/util/packageNameNormalizer.js @@ -0,0 +1,143 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const MAX_PACKAGE_NAME_LENGTH = 4096; + +const ECOSYSTEM_TO_FORMAT = { + npm: "npm", + maven: "maven", + gradle: "maven", + pypi: "python", + python: "python", + go: "go", + cargo: "cargo", + ruby: "ruby", + docker: "docker", + nuget: "nuget", + dart: "dart", + composer: "composer", + helm: "helm", + swift: "swift", + hex: "hex", + conda: "conda", +}; + +function sanitizePackageNameInput(name) { + const normalized = String(name == null ? "" : name) + .replace(/\0/g, "") + .trim(); + + if (!normalized || normalized.length > MAX_PACKAGE_NAME_LENGTH) { + return ""; + } + + return normalized; +} + +function canonicalFormat(ecosystemOrFormat) { + const normalized = sanitizePackageNameInput(ecosystemOrFormat).toLowerCase(); + return ECOSYSTEM_TO_FORMAT[normalized] || normalized; +} + +function normalizePackageName(name, ecosystemOrFormat) { + const format = canonicalFormat(ecosystemOrFormat); + const rawName = sanitizePackageNameInput(name); + if (!rawName) { + return ""; + } + + if (format === "python") { + return rawName.toLowerCase().replace(/[-_.]+/g, "-"); + } + + return rawName.toLowerCase(); +} + +function getPackageLookupKeys(name, ecosystemOrFormat, identifiers) { + const format = canonicalFormat(ecosystemOrFormat); + const rawName = sanitizePackageNameInput(name); + if (!rawName) { + return []; + } + + if (format === "maven") { + const artifactId = rawName.includes(":") ? rawName.split(":").slice(1).join(":") : rawName; + const keys = [normalizePackageName(rawName, format)]; + if (artifactId) { + keys.push(normalizePackageName(artifactId, format)); + } + if (identifiers && identifiers.group_id) { + keys.push(normalizePackageName(`${identifiers.group_id}:${rawName}`, format)); + } + return [...new Set(keys.filter(Boolean))]; + } + + if (format === "docker") { + return buildDockerLookupKeys(rawName); + } + + return [normalizePackageName(rawName, format)]; +} + +function getCloudsmithPackageLookupKeys(pkg, ecosystemOrFormat) { + if (!pkg || typeof pkg !== "object") { + return []; + } + + const format = canonicalFormat(ecosystemOrFormat || pkg.format); + if (format !== "maven") { + return getPackageLookupKeys(pkg.name, format); + } + + const identifiers = pkg.identifiers && typeof pkg.identifiers === "object" ? pkg.identifiers : {}; + const keys = [normalizePackageName(pkg.name, format)]; + if (identifiers.group_id) { + keys.push(normalizePackageName(`${identifiers.group_id}:${pkg.name}`, format)); + } + return [...new Set(keys.filter(Boolean))]; +} + +function buildDockerLookupKeys(name) { + const raw = normalizePackageName(String(name || "").replace(/^\/+/, ""), "docker").replace(/^\/+/, ""); + if (!raw) { + return []; + } + + const segments = raw.split("/").filter(Boolean); + const keys = new Set([raw]); + const firstSegment = segments[0] || ""; + const hasExplicitRegistry = segments.length > 1 && (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost"); + + if (!hasExplicitRegistry) { + if (segments.length === 1) { + keys.add(`library/${segments[0]}`); + keys.add(`docker.io/library/${segments[0]}`); + keys.add(`index.docker.io/library/${segments[0]}`); + } else { + keys.add(`docker.io/${raw}`); + keys.add(`index.docker.io/${raw}`); + if (raw.startsWith("library/")) { + keys.add(raw.slice("library/".length)); + } + } + return [...keys]; + } + + const pathPart = segments.slice(1).join("/"); + if (["docker.io", "index.docker.io", "registry-1.docker.io"].includes(firstSegment) && pathPart) { + keys.add(pathPart); + keys.add(`docker.io/${pathPart}`); + if (pathPart.startsWith("library/")) { + keys.add(pathPart.slice("library/".length)); + } + } + + return [...keys]; +} + +module.exports = { + ECOSYSTEM_TO_FORMAT, + canonicalFormat, + getCloudsmithPackageLookupKeys, + getPackageLookupKeys, + normalizePackageName, + sanitizePackageNameInput, +}; diff --git a/util/registryEndpoints.js b/util/registryEndpoints.js new file mode 100644 index 0000000..3475759 --- /dev/null +++ b/util/registryEndpoints.js @@ -0,0 +1,600 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const { + canonicalFormat, + sanitizePackageNameInput, +} = require("./packageNameNormalizer"); + +const CLOUDSMITH_HOST_SUFFIX = ".cloudsmith.io"; +const MAX_REGISTRY_VALUE_LENGTH = 4096; + +const UNSUPPORTED_PULL_FORMATS = new Set([ + "alpine", + "conda", + "deb", + "generic", + "huggingface", + "raw", + "rpm", +]); + +const DOCKER_MANIFEST_ACCEPT = + "application/vnd.docker.distribution.manifest.v2+json, " + + "application/vnd.docker.distribution.manifest.list.v2+json, " + + "application/vnd.oci.image.manifest.v1+json, " + + "application/vnd.oci.image.index.v1+json"; + +function formatForEcosystem(ecosystemOrFormat) { + const normalized = canonicalFormat(ecosystemOrFormat); + return normalized || null; +} + +function formatForDependency(dependency) { + return formatForEcosystem(dependency && (dependency.format || dependency.ecosystem)); +} + +function isPullUnsupportedFormat(format) { + const normalized = formatForEcosystem(format); + return Boolean(normalized && UNSUPPORTED_PULL_FORMATS.has(normalized)); +} + +function encodePathSegment(value) { + const normalized = String(value == null ? "" : value) + .replace(/\0/g, "") + .trim(); + + if (!normalized || normalized.length > MAX_REGISTRY_VALUE_LENGTH) { + return ""; + } + + if (normalized === ".") { + return "%2E"; + } + + if (normalized === "..") { + return "%2E%2E"; + } + + return encodeURIComponent(normalized); +} + +function encodePath(value) { + return String(value == null ? "" : value) + .replace(/\0/g, "") + .trim() + .split("/") + .filter(Boolean) + .map((segment) => encodePathSegment(segment)) + .join("/"); +} + +function normalizePythonName(name) { + return sanitizePackageNameInput(name).toLowerCase().replace(/[-_.]+/g, "-"); +} + +function encodeGoModulePath(modulePath) { + return [...String(modulePath || "")] + .map((character) => { + if (character === "!") { + return "!!"; + } + if (character >= "A" && character <= "Z") { + return `!${character.toLowerCase()}`; + } + return character; + }) + .join(""); +} + +function cargoIndexPath(crateName) { + const normalized = String(crateName || "").trim().toLowerCase(); + if (!normalized) { + return null; + } + + if (normalized.length <= 2) { + return encodePathSegment(normalized); + } + + if (normalized.length === 3) { + return `1/${encodePathSegment(normalized)}`; + } + + return [ + encodePathSegment(normalized.slice(0, 2)), + encodePathSegment(normalized.slice(2, 4)), + encodePathSegment(normalized), + ].join("/"); +} + +function buildNpmPackagePath(name) { + const rawName = sanitizePackageNameInput(name); + if (!rawName) { + return null; + } + + if (!rawName.startsWith("@")) { + if (rawName.includes("/")) { + return null; + } + + const encodedName = encodePathSegment(rawName); + return { + segments: [encodedName], + tarballBaseName: encodedName, + }; + } + + const separatorIndex = rawName.indexOf("/"); + if ( + separatorIndex <= 1 + || separatorIndex === rawName.length - 1 + || rawName.indexOf("/", separatorIndex + 1) !== -1 + ) { + return null; + } + + const scope = rawName.slice(0, separatorIndex); + const packageName = rawName.slice(separatorIndex + 1); + + return { + segments: [encodePathSegment(scope), encodePathSegment(packageName)], + tarballBaseName: encodePathSegment(packageName), + }; +} + +function buildMavenCoordinates(dependency) { + const name = sanitizePackageNameInput(dependency && dependency.name); + const version = String(dependency && dependency.version || "") + .replace(/\0/g, "") + .trim(); + const coordinates = name.split(":", 3); + + if (coordinates.length < 2 || !version) { + return null; + } + + const groupId = coordinates[0].trim(); + const artifactId = coordinates[1].trim(); + if (!groupId || !artifactId) { + return null; + } + + const groupPath = groupId + .split(".") + .filter(Boolean) + .map((segment) => encodePathSegment(segment)) + .join("/"); + + if (!groupPath) { + return null; + } + + return { + groupPath, + artifactId: encodePathSegment(artifactId), + version: encodePathSegment(version), + }; +} + +function buildComposerCoordinates(name) { + const rawName = sanitizePackageNameInput(name); + const separatorIndex = rawName.indexOf("/"); + if ( + separatorIndex <= 0 + || separatorIndex === rawName.length - 1 + || rawName.indexOf("/", separatorIndex + 1) !== -1 + ) { + return null; + } + + const vendor = rawName.slice(0, separatorIndex); + const packageName = rawName.slice(separatorIndex + 1); + + return { + vendor: encodePathSegment(vendor), + package: encodePathSegment(packageName), + packageName: `${vendor}/${packageName}`, + }; +} + +function buildSwiftCoordinates(name) { + const parts = sanitizePackageNameInput(name).split("/").filter(Boolean); + if (parts.length < 2) { + return null; + } + + return { + scope: parts.slice(0, -1).map((part) => encodePathSegment(part)).join("/"), + name: encodePathSegment(parts[parts.length - 1]), + }; +} + +function buildRegistryTriggerPlan(workspace, repo, dependency) { + const format = formatForDependency(dependency); + if (!format || isPullUnsupportedFormat(format)) { + return null; + } + + const safeWorkspace = encodePathSegment(workspace); + const safeRepo = encodePathSegment(repo); + const version = encodePathSegment(dependency && dependency.version); + + switch (format) { + case "maven": { + const coordinates = buildMavenCoordinates(dependency); + if (!coordinates) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://dl.cloudsmith.io/basic/${safeWorkspace}/${safeRepo}/maven/${coordinates.groupPath}/${coordinates.artifactId}/${coordinates.version}/${coordinates.artifactId}-${coordinates.version}.pom`, + headers: {}, + }, + }; + } + case "npm": { + const packagePath = buildNpmPackagePath(dependency && dependency.name); + if (!packagePath || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://npm.cloudsmith.io/${safeWorkspace}/${safeRepo}/${packagePath.segments.join("/")}/-/${packagePath.tarballBaseName}-${version}.tgz`, + headers: {}, + }, + }; + } + case "python": { + const normalizedName = normalizePythonName(dependency && dependency.name); + if (!normalizedName) { + return null; + } + return { + format, + strategy: "python-simple-index", + request: { + method: "GET", + url: `https://dl.cloudsmith.io/basic/${safeWorkspace}/${safeRepo}/python/simple/${encodePathSegment(normalizedName)}/`, + headers: {}, + }, + }; + } + case "go": { + const modulePath = encodeGoModulePath(String(dependency && dependency.name || "").trim()); + if (!modulePath || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://golang.cloudsmith.io/${safeWorkspace}/${safeRepo}/${modulePath}/@v/${version}.info`, + headers: {}, + }, + }; + } + case "cargo": { + const indexPath = cargoIndexPath(dependency && dependency.name); + if (!indexPath) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://cargo.cloudsmith.io/${safeWorkspace}/${safeRepo}/${indexPath}`, + headers: {}, + }, + }; + } + case "ruby": { + const name = encodePathSegment(dependency && dependency.name); + if (!name || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://dl.cloudsmith.io/basic/${safeWorkspace}/${safeRepo}/ruby/gems/${name}-${version}.gem`, + headers: {}, + }, + }; + } + case "nuget": { + const name = encodePathSegment(dependency && dependency.name); + if (!name || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://nuget.cloudsmith.io/${safeWorkspace}/${safeRepo}/v3/package/${name}/${version}/${name}.${version}.nupkg`, + headers: {}, + }, + }; + } + case "docker": { + const image = encodePath(dependency && dependency.name); + if (!image || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://docker.cloudsmith.io/v2/${safeWorkspace}/${safeRepo}/${image}/manifests/${version}`, + headers: { + Accept: DOCKER_MANIFEST_ACCEPT, + }, + }, + }; + } + case "helm": { + const name = encodePathSegment(dependency && dependency.name); + if (!name || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://dl.cloudsmith.io/basic/${safeWorkspace}/${safeRepo}/helm/charts/${name}-${version}.tgz`, + headers: {}, + }, + }; + } + case "dart": { + const name = encodePathSegment(dependency && dependency.name); + if (!name) { + return null; + } + return { + format, + strategy: "dart-api", + request: { + method: "GET", + url: `https://dart.cloudsmith.io/${safeWorkspace}/${safeRepo}/api/packages/${name}`, + headers: {}, + }, + }; + } + case "composer": { + const coordinates = buildComposerCoordinates(dependency && dependency.name); + if (!coordinates) { + return null; + } + return { + format, + strategy: "composer-p2", + packageName: coordinates.packageName, + request: { + method: "GET", + url: `https://composer.cloudsmith.io/${safeWorkspace}/${safeRepo}/p2/${coordinates.vendor}/${coordinates.package}.json`, + headers: {}, + }, + }; + } + case "hex": { + const name = encodePathSegment(dependency && dependency.name); + if (!name || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://dl.cloudsmith.io/basic/${safeWorkspace}/${safeRepo}/hex/tarballs/${name}-${version}.tar`, + headers: {}, + }, + }; + } + case "swift": { + const coordinates = buildSwiftCoordinates(dependency && dependency.name); + if (!coordinates || !coordinates.scope || !coordinates.name || !version) { + return null; + } + return { + format, + strategy: "direct", + request: { + method: "GET", + url: `https://dl.cloudsmith.io/basic/${safeWorkspace}/${safeRepo}/swift/${coordinates.scope}/${coordinates.name}/${version}.zip`, + headers: {}, + }, + }; + } + default: + return null; + } +} + +function isTrustedCloudsmithHost(host) { + const normalizedHost = String(host || "").trim().toLowerCase(); + return normalizedHost === "cloudsmith.io" || normalizedHost.endsWith(CLOUDSMITH_HOST_SUFFIX); +} + +function isTrustedRegistryUrl(candidateUrl) { + try { + const parsed = new URL(candidateUrl); + return parsed.protocol === "https:" && isTrustedCloudsmithHost(parsed.host); + } catch { + return false; + } +} + +function resolveAndValidateRegistryUrl(candidate, baseUrl) { + if (!candidate) { + return null; + } + + let resolved; + try { + resolved = new URL(candidate, baseUrl); + } catch { + return null; + } + + if (!isTrustedRegistryUrl(resolved.toString())) { + return null; + } + + return resolved.toString(); +} + +function collectHrefValues(html) { + const hrefs = []; + const pattern = /]*\bhref\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/gi; + let match = pattern.exec(String(html || "")); + + while (match) { + hrefs.push(match[1] || match[2] || match[3] || ""); + match = pattern.exec(String(html || "")); + } + + return hrefs.filter(Boolean); +} + +function scorePythonArtifact(url, version) { + const normalizedVersion = String(version || "").trim().toLowerCase(); + const fileName = decodeURIComponent(String(url || "").split("/").pop() || "").toLowerCase(); + + if (!fileName) { + return -1; + } + + let score = 0; + if (normalizedVersion) { + if (!fileName.includes(normalizedVersion)) { + return -1; + } + score += 10; + } + + if (fileName.endsWith(".whl")) { + score += 2; + } else if (fileName.endsWith(".tar.gz") || fileName.endsWith(".zip")) { + score += 1; + } + + return score; +} + +function findPythonDistributionUrl(html, version, baseUrl) { + const candidates = collectHrefValues(html) + .map((href) => resolveAndValidateRegistryUrl(href, baseUrl)) + .filter(Boolean) + .map((url) => ({ + url, + score: scorePythonArtifact(url, version), + })) + .filter((candidate) => candidate.score >= 0) + .sort((left, right) => right.score - left.score || left.url.localeCompare(right.url)); + + return candidates.length > 0 ? candidates[0].url : null; +} + +function parseDartArchiveUrl(body, version, baseUrl) { + let payload; + try { + payload = JSON.parse(String(body || "")); + } catch { + return null; + } + + const wantedVersion = String(version || "").trim(); + const candidates = []; + + if (payload && payload.latest && payload.latest.version === wantedVersion && payload.latest.archive_url) { + candidates.push(payload.latest.archive_url); + } + + if (Array.isArray(payload && payload.versions)) { + for (const entry of payload.versions) { + if (entry && entry.version === wantedVersion && entry.archive_url) { + candidates.push(entry.archive_url); + } + } + } else if (payload && payload.versions && typeof payload.versions === "object") { + const entry = payload.versions[wantedVersion]; + if (entry && entry.archive_url) { + candidates.push(entry.archive_url); + } + } + + if (payload && payload.version === wantedVersion && payload.archive_url) { + candidates.push(payload.archive_url); + } + + for (const candidate of candidates) { + const resolved = resolveAndValidateRegistryUrl(candidate, baseUrl); + if (resolved) { + return resolved; + } + } + + return null; +} + +function parseComposerDistUrl(body, packageName, version, baseUrl) { + let payload; + try { + payload = JSON.parse(String(body || "")); + } catch { + return null; + } + + const entries = []; + const normalizedPackageName = sanitizePackageNameInput(packageName); + + if (payload && payload.packages && typeof payload.packages === "object") { + if (Array.isArray(payload.packages[normalizedPackageName])) { + entries.push(...payload.packages[normalizedPackageName]); + } else { + for (const value of Object.values(payload.packages)) { + if (Array.isArray(value)) { + entries.push(...value); + } + } + } + } + + if (Array.isArray(payload)) { + entries.push(...payload); + } + + const matchedEntry = entries.find((entry) => entry && entry.version === version) + || entries.find(Boolean); + const distUrl = matchedEntry + && matchedEntry.dist + && typeof matchedEntry.dist === "object" + ? matchedEntry.dist.url + : null; + + return resolveAndValidateRegistryUrl(distUrl, baseUrl); +} + +module.exports = { + buildRegistryTriggerPlan, + findPythonDistributionUrl, + formatForDependency, + isPullUnsupportedFormat, + isTrustedRegistryUrl, + parseComposerDistUrl, + parseDartArchiveUrl, + resolveAndValidateRegistryUrl, +}; diff --git a/util/upstreamChecker.js b/util/upstreamChecker.js index 354693c..943de51 100644 --- a/util/upstreamChecker.js +++ b/util/upstreamChecker.js @@ -1,6 +1,4 @@ -// Upstream proxy resolution checker. -// Provides a "what if I pull this?" dry run for packages that don't exist locally. - +// Copyright 2026 Cloudsmith Ltd. All rights reserved. const { CloudsmithAPI } = require("./cloudsmithAPI"); const { CredentialManager } = require("./credentialManager"); const { SearchQueryBuilder } = require("./searchQueryBuilder"); @@ -156,6 +154,19 @@ function isAbortError(error) { return error && (error.name === "AbortError" || error.code === "ABORT_ERR"); } +function getActiveUpstreamsFromRepositoryState(state, format) { + if (!state || !(state.groupedUpstreams instanceof Map)) { + return []; + } + + const upstreams = state.groupedUpstreams.get(format); + if (!Array.isArray(upstreams)) { + return []; + } + + return upstreams.filter((upstream) => upstream && upstream.is_active !== false); +} + function getUpstreamRequestOptions(apiKey, signal) { const headers = { accept: "application/json", @@ -450,6 +461,11 @@ class UpstreamChecker { return state.upstreams; } + async getActiveRepositoryUpstreamsForFormat(workspace, repo, format, options = {}) { + const state = await this.getRepositoryUpstreamState(workspace, repo, options); + return getActiveUpstreamsFromRepositoryState(state, format); + } + /** * Orchestrate a full upstream resolution preview. * Checks local existence and upstream configs for a package preview. @@ -492,7 +508,7 @@ class UpstreamChecker { } _isCacheObjectRecord(value) { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); + return isCacheObjectRecord(value); } _logRepositoryUpstreamCacheError(action, workspace, repo, error) { @@ -749,98 +765,15 @@ class UpstreamChecker { } _getRequestOptions(apiKey, signal) { - const headers = { - accept: "application/json", - "content-type": "application/json", - }; - - if (apiKey) { - headers["X-Api-Key"] = apiKey; - } - - const requestOptions = { - method: "GET", - headers, - }; - - if (signal) { - requestOptions.signal = signal; - } - - return requestOptions; + return getUpstreamRequestOptions(apiKey, signal); } _isAbortError(error) { - return error && (error.name === "AbortError" || error.code === "ABORT_ERR"); + return isAbortError(error); } _isWarningWorthyFormatError(message) { - const normalized = typeof message === "string" ? message.toLowerCase() : ""; - if (!normalized) { - return true; - } - - const benignKeywords = [ - "response status: 404", - "not found", - "unsupported", - "not applicable", - "unknown format", - "no upstream", - "does not exist", - ]; - if (benignKeywords.some((keyword) => normalized.includes(keyword))) { - return false; - } - - const statusMatch = normalized.match(/response status:\s*(\d{3})/); - if (statusMatch) { - const statusCode = Number(statusMatch[1]); - if ( - statusCode === 401 || - statusCode === 403 || - statusCode === 407 || - statusCode === 408 || - statusCode === 429 - ) { - return true; - } - if (statusCode >= 500) { - return true; - } - if (statusCode >= 400) { - return true; - } - } - - const warningKeywords = [ - "blocked ", - "redirect", - "fetch failed", - "network", - "timed out", - "timeout", - "unauthorized", - "forbidden", - "permission", - "access denied", - "server error", - "bad gateway", - "service unavailable", - "gateway timeout", - "econn", - "enotfound", - "eai_again", - "socket", - "tls", - "certificate", - ]; - - if (warningKeywords.some((keyword) => normalized.includes(keyword))) { - return true; - } - - return true; + return isWarningWorthyUpstreamFormatError(message); } } @@ -857,6 +790,7 @@ async function getUpstreamDataForFormats(context, workspace, repo, formats, opti module.exports = { getAllUpstreamData, getUpstreamDataForFormats, + getActiveUpstreamsFromRepositoryState, isBenignUpstreamFormatError, SUPPORTED_UPSTREAM_FORMATS, UpstreamChecker, diff --git a/util/upstreamGapAnalyzer.js b/util/upstreamGapAnalyzer.js new file mode 100644 index 0000000..2dac342 --- /dev/null +++ b/util/upstreamGapAnalyzer.js @@ -0,0 +1,213 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const { canonicalFormat, normalizePackageName } = require("./packageNameNormalizer"); +const { normalizeUpstreamFormat } = require("./upstreamFormats"); +const { UpstreamChecker } = require("./upstreamChecker"); + +const UPSTREAM_REPO_CONCURRENCY = 5; + +function getUncoveredDependencyKey(dependency) { + const format = canonicalFormat(dependency && (dependency.format || dependency.ecosystem)); + const normalizedName = normalizePackageName(dependency && dependency.name, format); + const version = String(dependency && dependency.version || "").trim().toLowerCase(); + + if (!format || !normalizedName) { + return null; + } + + return `${format}:${normalizedName}:${version}`; +} + +function formatLabel(format) { + const normalized = String(format || "").trim(); + if (!normalized) { + return "package"; + } + if (normalized === "npm") { + return "npm"; + } + if (normalized === "python") { + return "PyPI"; + } + if (normalized === "go") { + return "Go"; + } + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +function buildProxyLabel(upstream, format) { + const configuredName = String(upstream && upstream.name || "").trim(); + if (!configuredName) { + return `${formatLabel(format)} proxy`; + } + return configuredName.toLowerCase().includes("proxy") + ? configuredName + : `${configuredName} proxy`; +} + +function buildReachableDetail(snapshot, upstream, format) { + return `${buildProxyLabel(upstream, format)} on ${snapshot.repo}`; +} + +function classifyDependency(dependency, snapshots) { + const key = getUncoveredDependencyKey(dependency); + const format = canonicalFormat(dependency && (dependency.format || dependency.ecosystem)); + if (!key || !format) { + return { + upstreamStatus: "unreachable", + upstreamDetail: "Not available through Cloudsmith", + }; + } + + const upstreamFormat = normalizeUpstreamFormat(format); + if (!upstreamFormat) { + return { + upstreamStatus: "unreachable", + upstreamDetail: "Not available through Cloudsmith", + }; + } + + for (const snapshot of snapshots) { + const formatUpstreams = Array.isArray(snapshot.groupedUpstreams.get(upstreamFormat)) + ? snapshot.groupedUpstreams.get(upstreamFormat) + : []; + const activeUpstream = formatUpstreams.find((upstream) => upstream.is_active !== false); + if (!activeUpstream) { + continue; + } + + return { + upstreamStatus: "reachable", + upstreamDetail: buildReachableDetail(snapshot, activeUpstream, upstreamFormat), + }; + } + + return { + upstreamStatus: "no_proxy", + upstreamDetail: `No upstream proxy configured for ${upstreamFormat}`, + }; +} + +function buildGapPatch(uncoveredDependencies, snapshots) { + const patchMap = new Map(); + + for (const dependency of Array.isArray(uncoveredDependencies) ? uncoveredDependencies : []) { + if (dependency.cloudsmithStatus !== "NOT_FOUND") { + continue; + } + + const key = getUncoveredDependencyKey(dependency); + if (!key || patchMap.has(key)) { + continue; + } + + patchMap.set(key, classifyDependency(dependency, snapshots)); + } + + return patchMap; +} + +function applyGapPatch(dependencies, patchMap) { + return (Array.isArray(dependencies) ? dependencies : []).map((dependency) => { + const key = getUncoveredDependencyKey(dependency); + if (!key || !patchMap.has(key)) { + return dependency; + } + + const gap = patchMap.get(key); + return { + ...dependency, + upstreamStatus: gap.upstreamStatus, + upstreamDetail: gap.upstreamDetail, + }; + }); +} + +async function analyzeUpstreamGaps(uncoveredDependencies, workspace, repositories, options = {}) { + const onProgress = typeof options.onProgress === "function" ? options.onProgress : null; + const cancellationToken = options.cancellationToken || null; + const upstreamChecker = options.upstreamChecker || new UpstreamChecker(options.context); + const repositoriesToInspect = Array.isArray(repositories) + ? repositories.map((repo) => String(repo || "").trim()).filter(Boolean) + : []; + + if (repositoriesToInspect.length === 0) { + const emptyPatch = buildGapPatch(uncoveredDependencies, []); + if (onProgress && emptyPatch.size > 0) { + onProgress(new Map(emptyPatch), { + completed: 0, + total: 0, + workspace, + stage: "upstream", + }); + } + return applyGapPatch(uncoveredDependencies, emptyPatch); + } + + const repoUpstreamStates = new Map(); + let completed = 0; + + await runPromisePool(repositoriesToInspect, UPSTREAM_REPO_CONCURRENCY, async (repo) => { + if (cancellationToken && cancellationToken.isCancellationRequested) { + return; + } + + const state = await upstreamChecker.getRepositoryUpstreamState(workspace, repo); + repoUpstreamStates.set(repo, { + repo, + groupedUpstreams: state && state.groupedUpstreams instanceof Map + ? state.groupedUpstreams + : new Map(), + }); + + completed += 1; + if (onProgress) { + onProgress(new Map(), { + completed, + total: repositoriesToInspect.length, + workspace, + stage: "upstream", + }); + } + }); + + const snapshots = repositoriesToInspect + .filter((repo) => repoUpstreamStates.has(repo)) + .map((repo) => repoUpstreamStates.get(repo)); + + const patchMap = buildGapPatch(uncoveredDependencies, snapshots); + if (onProgress && patchMap.size > 0) { + onProgress(new Map(patchMap), { + completed, + total: repositoriesToInspect.length, + workspace, + stage: "upstream", + }); + } + return applyGapPatch(uncoveredDependencies, patchMap); +} + +async function runPromisePool(items, concurrency, worker) { + const workers = []; + let index = 0; + const size = Math.max(1, Math.min(concurrency, items.length || 1)); + + for (let workerIndex = 0; workerIndex < size; workerIndex += 1) { + workers.push((async () => { + while (index < items.length) { + const item = items[index]; + index += 1; + if (item === undefined) { + break; + } + await worker(item); + } + })()); + } + + await Promise.all(workers); +} + +module.exports = { + analyzeUpstreamGaps, + getUncoveredDependencyKey, +}; diff --git a/util/upstreamPullService.js b/util/upstreamPullService.js new file mode 100644 index 0000000..cac515f --- /dev/null +++ b/util/upstreamPullService.js @@ -0,0 +1,1231 @@ +// Copyright 2026 Cloudsmith Ltd. All rights reserved. +const vscode = require("vscode"); +const { CloudsmithAPI } = require("./cloudsmithAPI"); +const { CredentialManager } = require("./credentialManager"); +const { PaginatedFetch } = require("./paginatedFetch"); +const { + buildRegistryTriggerPlan, + findPythonDistributionUrl, + formatForDependency, + isPullUnsupportedFormat, + isTrustedRegistryUrl, + parseComposerDistUrl, + parseDartArchiveUrl, + resolveAndValidateRegistryUrl, +} = require("./registryEndpoints"); +const { canonicalFormat } = require("./packageNameNormalizer"); +const { UpstreamChecker } = require("./upstreamChecker"); +const { normalizeUpstreamFormat } = require("./upstreamFormats"); + +const MAX_CONCURRENT_PULLS = 5; +const INITIAL_AUTH_PROBE_CONCURRENCY = 3; +const MAX_REGISTRY_REDIRECTS = 5; +const REQUEST_TIMEOUT_MS = 30 * 1000; +const WORKSPACE_REPOSITORY_PAGE_SIZE = 500; + +const PULL_STATUS = Object.freeze({ + PENDING: "pending", + PULLING: "pulling", + CACHED: "cached", + ALREADY_EXISTS: "exists", + NOT_FOUND: "not_found", + AUTH_FAILED: "auth_failed", + FORMAT_MISMATCH: "mismatch", + ERROR: "error", + SKIPPED: "skipped", +}); + +const PULL_SKIP_REASON = Object.freeze({ + NO_ACTIVE_UPSTREAM: "no_active_upstream", + NO_PULL_SUPPORT: "no_pull_support", + NO_TRIGGER_URL: "no_trigger_url", +}); + +class UpstreamPullService { + constructor(context, options = {}) { + this.context = context; + this._api = options.api || new CloudsmithAPI(context); + this._credentialManager = options.credentialManager || new CredentialManager(context); + this._fetchImpl = options.fetchImpl || fetch; + this._fetchRepositories = options.fetchRepositories || this._fetchWorkspaceRepositories.bind(this); + this._showQuickPick = options.showQuickPick || vscode.window.showQuickPick.bind(vscode.window); + this._showErrorMessage = options.showErrorMessage || vscode.window.showErrorMessage.bind(vscode.window); + this._showInformationMessage = options.showInformationMessage || vscode.window.showInformationMessage.bind(vscode.window); + this._showWarningMessage = options.showWarningMessage || vscode.window.showWarningMessage.bind(vscode.window); + this._upstreamChecker = options.upstreamChecker || new UpstreamChecker(context); + } + + async run(options) { + const prepared = await this.prepare(options); + if (!prepared) { + return null; + } + + const execution = await this.execute(prepared, options); + if (!execution) { + return null; + } + + return { + ...prepared, + ...execution, + }; + } + + async prepare({ + workspace, + repositoryHint, + dependencies, + }) { + const uncoveredDependencies = dedupePullDependencies( + (Array.isArray(dependencies) ? dependencies : []) + .filter((dependency) => dependency && dependency.cloudsmithStatus !== "FOUND") + ); + + if (!workspace) { + await this._showErrorMessage("Run a dependency scan against a Cloudsmith workspace first."); + return null; + } + + if (uncoveredDependencies.length === 0) { + await this._showInformationMessage("No uncovered dependencies are available to pull."); + return null; + } + + const projectFormats = [...new Set( + uncoveredDependencies + .map((dependency) => normalizeUpstreamFormat(formatForDependency(dependency))) + .filter(Boolean) + )]; + + if (projectFormats.length === 0) { + await this._showInformationMessage( + "Pull-through caching is not available for the uncovered dependency formats in this project." + ); + return null; + } + + let repositories; + try { + repositories = await this._fetchRepositories(workspace); + } catch (error) { + const message = error && error.message ? error.message : "Could not fetch workspace repositories."; + await this._showErrorMessage(message); + return null; + } + + const repositoryMatches = await this._findMatchingRepositories(workspace, repositories, projectFormats); + if (repositoryMatches.length === 0) { + await this._showInformationMessage( + `No repositories have upstream proxies configured for the dependency formats in this project (${formatListLabel(projectFormats)}). Configure an upstream proxy in Cloudsmith to enable pull-through caching.` + ); + return null; + } + + const orderedMatches = sortRepositoryMatches(repositoryMatches, repositoryHint); + const selected = await this._showQuickPick( + orderedMatches.map((match) => ({ + label: match.repo.slug || match.repo.name, + description: match.repo.name && match.repo.name !== match.repo.slug ? match.repo.name : "", + detail: `${formatListLabel(match.activeFormats)} upstream${match.activeFormats.length === 1 ? "" : "s"} configured`, + _match: match, + })), + { + placeHolder: "Select a repository to pull dependencies through", + matchOnDescription: true, + matchOnDetail: true, + } + ); + + if (!selected || !selected._match) { + return null; + } + + const repository = selected._match.repo; + const plan = buildPullExecutionPlan( + workspace, + repository.slug, + uncoveredDependencies, + selected._match.activeFormats + ); + + if (plan.pullableDependencies.length === 0) { + await this._showInformationMessage(buildPullPlanErrorMessage(repository.slug, plan)); + return null; + } + + const confirmation = await this._showWarningMessage( + buildPullConfirmationMessage(plan, repository.slug), + { modal: true }, + "Pull dependencies" + ); + + if (confirmation !== "Pull dependencies") { + return null; + } + + return { + workspace, + repository, + plan, + }; + } + + async prepareSingle({ + workspace, + repositoryHint, + dependency, + }) { + const normalizedDependency = normalizeSingleDependency(dependency); + if (!workspace) { + await this._showErrorMessage("Run a dependency scan against a Cloudsmith workspace first."); + return null; + } + + if (!normalizedDependency) { + await this._showWarningMessage("Could not determine the dependency details to pull."); + return null; + } + + const dependencyFormat = normalizeUpstreamFormat(formatForDependency(normalizedDependency)); + if (!dependencyFormat) { + await this._showInformationMessage( + `Pull-through caching is not available for ${formatDisplayName(normalizedDependency.format)} dependencies.` + ); + return null; + } + + let repositories; + try { + repositories = await this._fetchRepositories(workspace); + } catch (error) { + const message = error && error.message ? error.message : "Could not fetch workspace repositories."; + await this._showErrorMessage(message); + return null; + } + + const repositoryMatches = await this._findMatchingRepositories(workspace, repositories, [dependencyFormat]); + if (repositoryMatches.length === 0) { + await this._showInformationMessage( + `No repositories have a ${formatDisplayName(dependencyFormat)} upstream configured. Add one in Cloudsmith to pull this dependency.` + ); + return null; + } + + const orderedMatches = sortRepositoryMatches(repositoryMatches, repositoryHint); + const selected = await this._showQuickPick( + orderedMatches.map((match) => ({ + label: match.repo.slug || match.repo.name, + description: match.repo.name && match.repo.name !== match.repo.slug ? match.repo.name : "", + detail: buildSingleDependencyRepositoryDetail(match, dependencyFormat), + _match: match, + })), + { + placeHolder: `Select a repository to pull ${buildDependencyLabel(normalizedDependency)} through`, + matchOnDescription: true, + matchOnDetail: true, + } + ); + + if (!selected || !selected._match) { + return null; + } + + const repository = selected._match.repo; + const plan = buildPullExecutionPlan( + workspace, + repository.slug, + [normalizedDependency], + selected._match.activeFormats + ); + + if (plan.pullableDependencies.length === 0) { + await this._showInformationMessage(buildPullPlanErrorMessage(repository.slug, plan)); + return null; + } + + return { + workspace, + repository, + plan, + dependency: normalizedDependency, + }; + } + + async execute(prepared, options = {}) { + const apiKey = await this._credentialManager.getApiKey(); + if (!apiKey) { + await this._showErrorMessage("Authentication failed. Check your API key in Cloudsmith settings."); + return null; + } + + const progress = options.progress || null; + const token = options.token || null; + const onStatus = typeof options.onStatus === "function" ? options.onStatus : null; + const queue = prepared.plan.pullableDependencies.slice(); + let nextDependencyIndex = 0; + const details = []; + const counts = createResultCounts(prepared.plan.pullableDependencies.length); + const state = { + authFailureCount: 0, + nonAuthOutcomeCount: 0, + stopForAuthFailure: false, + canceled: false, + allowedConcurrency: Math.min( + prepared.plan.pullableDependencies.length || 1, + INITIAL_AUTH_PROBE_CONCURRENCY + ), + expandedConcurrency: false, + }; + const pending = new Set(); + let activeCount = 0; + let launchedCount = 0; + + const takeNextDependency = () => { + if (nextDependencyIndex >= queue.length) { + return null; + } + + const dependency = queue[nextDependencyIndex]; + nextDependencyIndex += 1; + return dependency; + }; + + const processNext = async () => { + if (token && token.isCancellationRequested) { + state.canceled = true; + return; + } + + if (state.stopForAuthFailure) { + return; + } + + const dependency = takeNextDependency(); + if (!dependency) { + return; + } + + activeCount += 1; + + try { + const pullingDetail = { + dependency, + status: PULL_STATUS.PULLING, + errorMessage: null, + requestUrl: buildPullRequestUrl(prepared.workspace, prepared.repository.slug, dependency), + }; + if (onStatus) { + await onStatus(pullingDetail); + } + + const result = await this._pullDependency( + prepared.workspace, + prepared.repository.slug, + dependency, + apiKey, + token + ); + + if (result.canceled) { + state.canceled = true; + return; + } + + details.push(result); + updateResultCounts(counts, result); + + if (result.status === PULL_STATUS.AUTH_FAILED) { + state.authFailureCount += 1; + } else { + state.nonAuthOutcomeCount += 1; + } + + if ( + result.status === PULL_STATUS.AUTH_FAILED + && state.authFailureCount >= INITIAL_AUTH_PROBE_CONCURRENCY + && state.nonAuthOutcomeCount === 0 + ) { + state.stopForAuthFailure = true; + } + + if ( + !state.expandedConcurrency + && state.nonAuthOutcomeCount > 0 + && state.allowedConcurrency < MAX_CONCURRENT_PULLS + ) { + state.allowedConcurrency = Math.min(MAX_CONCURRENT_PULLS, counts.total); + state.expandedConcurrency = true; + } + + if (progress) { + progress.report({ + message: buildProgressMessage(counts), + increment: counts.total > 0 ? 100 / counts.total : 100, + }); + } + + if (onStatus) { + await onStatus(result); + } + } finally { + activeCount -= 1; + fillConcurrency(); + } + }; + + const fillConcurrency = () => { + while ( + activeCount < state.allowedConcurrency + && (state.expandedConcurrency || launchedCount < INITIAL_AUTH_PROBE_CONCURRENCY) + && nextDependencyIndex < queue.length + && !(token && token.isCancellationRequested) + && !state.stopForAuthFailure + ) { + launchedCount += 1; + const promise = processNext(); + pending.add(promise); + promise.finally(() => pending.delete(promise)); + } + }; + + fillConcurrency(); + + while (pending.size > 0) { + await Promise.race([...pending]); + } + + if (state.stopForAuthFailure) { + while (nextDependencyIndex < queue.length) { + const dependency = queue[nextDependencyIndex]; + nextDependencyIndex += 1; + details.push({ + dependency, + status: PULL_STATUS.AUTH_FAILED, + errorMessage: "Skipped after repeated authentication failures.", + requestUrl: buildPullRequestUrl(prepared.workspace, prepared.repository.slug, dependency), + networkError: false, + }); + } + recomputeResultCounts(counts, details); + await this._showErrorMessage("Authentication failed. Check your API key in Cloudsmith settings."); + } else if (state.canceled) { + return { + canceled: true, + }; + } else if ( + counts.completed > 0 + && counts.completed === counts.errors + && counts.networkErrors === counts.errors + ) { + await this._showErrorMessage("Cannot reach the Cloudsmith registry. Check your network connection."); + } + + return { + canceled: false, + pullResult: { + total: counts.total, + cached: counts.cached, + alreadyExisted: counts.alreadyExisted, + notFound: counts.notFound, + formatMismatched: counts.formatMismatched, + errors: counts.errors, + networkErrors: counts.networkErrors, + authFailed: counts.authFailed, + skipped: counts.skipped, + details, + }, + }; + } + + async _findMatchingRepositories(workspace, repositories, projectFormats) { + const matches = []; + + await runPromisePool(repositories, 5, async (repo) => { + const repoSlug = repo && repo.slug ? repo.slug : null; + if (!repoSlug) { + return; + } + + const state = await this._upstreamChecker.getRepositoryUpstreamState(workspace, repoSlug); + const activeUpstreamsByFormat = new Map(); + const activeFormats = projectFormats.filter((format) => { + const upstreams = state && state.groupedUpstreams instanceof Map + ? state.groupedUpstreams.get(format) + : []; + const activeUpstreams = Array.isArray(upstreams) + ? upstreams.filter((upstream) => upstream && upstream.is_active !== false) + : []; + if (activeUpstreams.length > 0) { + activeUpstreamsByFormat.set(format, activeUpstreams); + return true; + } + return false; + }); + + if (activeFormats.length === 0) { + return; + } + + matches.push({ + repo, + activeFormats, + activeUpstreamsByFormat, + }); + }); + + return matches.sort((left, right) => { + const leftSlug = String(left.repo.slug || left.repo.name || ""); + const rightSlug = String(right.repo.slug || right.repo.name || ""); + return leftSlug.localeCompare(rightSlug, undefined, { sensitivity: "base" }); + }); + } + + async _fetchWorkspaceRepositories(workspace) { + const paginatedFetch = new PaginatedFetch(this._api); + const endpoint = `repos/${workspace}/?sort=name`; + const repositories = []; + let page = 1; + + while (true) { + const result = await paginatedFetch.fetchPage(endpoint, page, WORKSPACE_REPOSITORY_PAGE_SIZE); + if (result.error) { + throw new Error(`Could not fetch workspace repositories. ${result.error}`); + } + + repositories.push(...(Array.isArray(result.data) ? result.data : [])); + + const pageTotal = result.pagination && result.pagination.pageTotal + ? result.pagination.pageTotal + : 1; + if (page >= pageTotal) { + break; + } + page += 1; + } + + return repositories; + } + + async _pullDependency(workspace, repo, dependency, apiKey, token) { + const plan = buildRegistryTriggerPlan(workspace, repo, dependency); + const format = formatForDependency(dependency); + + if (!plan) { + const errorMessage = isPullUnsupportedFormat(format) + ? `Pull-through caching is not supported for ${formatDisplayName(format)} dependencies.` + : `No registry trigger URL is available for ${formatDisplayName(format)} dependencies.`; + + return { + dependency, + status: PULL_STATUS.FORMAT_MISMATCH, + errorMessage, + requestUrl: null, + networkError: false, + }; + } + + const metadataAttempt = await this._requestRegistry(plan.request, apiKey, token); + if (metadataAttempt.canceled) { + return metadataAttempt; + } + + if (plan.strategy === "direct") { + return mapRegistryAttempt(dependency, metadataAttempt, plan.request.url, format); + } + + if (metadataAttempt.statusCode === 401 || metadataAttempt.statusCode === 403) { + return mapRegistryAttempt(dependency, metadataAttempt, plan.request.url, format); + } + + if (metadataAttempt.statusCode === 404) { + return mapRegistryAttempt(dependency, metadataAttempt, plan.request.url, format); + } + + if (metadataAttempt.statusCode < 200 || metadataAttempt.statusCode >= 300) { + return mapRegistryAttempt(dependency, metadataAttempt, plan.request.url, format); + } + + let artifactUrl = null; + if (plan.strategy === "python-simple-index") { + artifactUrl = findPythonDistributionUrl(metadataAttempt.body, dependency.version, plan.request.url); + } else if (plan.strategy === "dart-api") { + artifactUrl = parseDartArchiveUrl(metadataAttempt.body, dependency.version, plan.request.url); + } else if (plan.strategy === "composer-p2") { + artifactUrl = parseComposerDistUrl( + metadataAttempt.body, + plan.packageName || dependency.name, + dependency.version, + plan.request.url + ); + } + + if (!artifactUrl) { + return { + dependency, + status: PULL_STATUS.NOT_FOUND, + errorMessage: missingArtifactMessage(plan.strategy, dependency.version), + requestUrl: plan.request.url, + networkError: false, + }; + } + + const artifactAttempt = await this._requestRegistry( + { + method: "GET", + url: artifactUrl, + headers: {}, + }, + apiKey, + token + ); + + if (artifactAttempt.canceled) { + return artifactAttempt; + } + + return mapRegistryAttempt(dependency, artifactAttempt, artifactUrl, format); + } + + async _requestRegistry(request, apiKey, token) { + const controller = new AbortController(); + let didTimeout = false; + const timeoutHandle = setTimeout(() => { + didTimeout = true; + controller.abort(); + }, REQUEST_TIMEOUT_MS); + + const cancellationDisposable = token && typeof token.onCancellationRequested === "function" + ? token.onCancellationRequested(() => controller.abort()) + : null; + + try { + const response = await this._fetchRegistryResponse( + request, + apiKey, + controller.signal, + 0 + ); + + return { + statusCode: response.status, + body: await response.text(), + }; + } catch (error) { + if (token && token.isCancellationRequested) { + return { canceled: true }; + } + + return { + statusCode: 0, + body: "", + errorMessage: didTimeout + ? "Registry request timed out." + : buildRegistryErrorMessage(request.url, error), + networkError: isNetworkError(error) || didTimeout, + }; + } finally { + clearTimeout(timeoutHandle); + if (cancellationDisposable && typeof cancellationDisposable.dispose === "function") { + cancellationDisposable.dispose(); + } + } + } + + async _fetchRegistryResponse(request, apiKey, signal, redirectCount) { + if (!request || !isTrustedRegistryUrl(request.url)) { + throw new Error("Refused to send Cloudsmith credentials to an untrusted registry host."); + } + + const response = await this._fetchImpl(request.url, { + method: request.method || "GET", + headers: { + Authorization: buildBasicAuthHeader(apiKey), + ...(request.headers || {}), + }, + redirect: "manual", + signal, + }); + + if (!isRedirectStatus(response.status)) { + return response; + } + + if (redirectCount >= MAX_REGISTRY_REDIRECTS) { + throw new Error("Registry request exceeded the redirect limit."); + } + + const location = response.headers && typeof response.headers.get === "function" + ? response.headers.get("location") + : ""; + const redirectUrl = resolveAndValidateRegistryUrl(location, request.url); + if (!redirectUrl || !isTrustedRegistryUrl(redirectUrl)) { + throw new Error("Registry redirect target was rejected."); + } + + return this._fetchRegistryResponse( + { + ...request, + url: redirectUrl, + }, + apiKey, + signal, + redirectCount + 1 + ); + } +} + +function buildPullExecutionPlan(workspace, repo, dependencies, activeUpstreamFormats) { + const normalizedActiveFormats = [...new Set( + (Array.isArray(activeUpstreamFormats) ? activeUpstreamFormats : []) + .map((format) => normalizeUpstreamFormat(format)) + .filter(Boolean) + )]; + + const uniqueDependencies = dedupePullDependencies(dependencies); + const skippedDependencies = []; + const pullableDependencies = []; + + for (const dependency of uniqueDependencies) { + const format = canonicalFormat(formatForDependency(dependency) || dependency.ecosystem || ""); + const triggerPlan = buildRegistryTriggerPlan(workspace, repo, dependency); + + if (isPullUnsupportedFormat(format)) { + skippedDependencies.push({ + dependency, + format, + reason: PULL_SKIP_REASON.NO_PULL_SUPPORT, + message: `Pull-through caching is not supported for ${formatDisplayName(format)} dependencies.`, + }); + continue; + } + + if (!triggerPlan) { + skippedDependencies.push({ + dependency, + format, + reason: PULL_SKIP_REASON.NO_TRIGGER_URL, + message: `No registry trigger URL is available for ${formatDisplayName(format)} dependencies.`, + }); + continue; + } + + if (!normalizedActiveFormats.includes(normalizeUpstreamFormat(format))) { + skippedDependencies.push({ + dependency, + format, + reason: PULL_SKIP_REASON.NO_ACTIVE_UPSTREAM, + message: `No ${formatDisplayName(format)} upstream is configured on this repository.`, + }); + continue; + } + + pullableDependencies.push(dependency); + } + + return { + dependencies: uniqueDependencies, + pullableDependencies, + skippedDependencies, + activeUpstreamFormats: normalizedActiveFormats, + }; +} + +function buildPullConfirmationMessage(plan, repositoryLabel) { + const totalCount = plan.dependencies.length; + const pullableCount = plan.pullableDependencies.length; + const header = plan.skippedDependencies.length > 0 + ? `Pull ${pullableCount} of ${totalCount} dependencies through ${repositoryLabel}?` + : singleFormatPullHeader(plan.pullableDependencies, repositoryLabel); + const pulledLine = buildPullableSummary(plan.pullableDependencies, plan.skippedDependencies.length > 0); + const skippedLine = buildSkippedSummary(plan.skippedDependencies); + + return [ + header, + pulledLine, + skippedLine, + "Packages not already cached will be fetched from the upstream source.", + ].filter(Boolean).join("\n"); +} + +function buildPullPlanErrorMessage(repositoryLabel, plan) { + const noUpstreamFormats = [...new Set( + plan.skippedDependencies + .filter((entry) => entry.reason === PULL_SKIP_REASON.NO_ACTIVE_UPSTREAM) + .map((entry) => entry.format) + .filter(Boolean) + )]; + + if (plan.pullableDependencies.length === 0 && noUpstreamFormats.length > 0) { + return `No ${formatListLabel(noUpstreamFormats)} upstream${noUpstreamFormats.length === 1 ? "" : "s"} are configured on ${repositoryLabel}.`; + } + + return "Pull-through caching is not available for the uncovered dependencies in this project."; +} + +function buildPullSummaryMessage(result, skippedCount) { + const pulledCount = result.cached + result.alreadyExisted; + const parts = [ + `Pulled ${pulledCount} of ${result.total} dependencies.`, + `${result.cached} cached`, + `${result.alreadyExisted} already existed`, + `${result.notFound} not found upstream`, + ]; + + if (skippedCount > 0) { + parts.push(`${skippedCount} skipped`); + } + + if (result.errors > 0) { + parts.push(`${result.errors} errors`); + } + + return `${parts.shift()} ${parts.join(", ")}.`; +} + +function buildProgressMessage(counts) { + const parts = [`Pulling dependencies... ${counts.completed}/${counts.total}`]; + const detail = []; + + if (counts.cached > 0) { + detail.push(`${counts.cached} cached`); + } + if (counts.notFound > 0) { + detail.push(`${counts.notFound} not found`); + } + if (counts.errors > 0) { + detail.push(`${counts.errors} errors`); + } + + if (detail.length > 0) { + parts.push(`(${detail.join(", ")})`); + } + + return parts.join(" "); +} + +function createResultCounts(total) { + return { + total, + completed: 0, + cached: 0, + alreadyExisted: 0, + notFound: 0, + formatMismatched: 0, + errors: 0, + networkErrors: 0, + authFailed: 0, + skipped: 0, + }; +} + +function updateResultCounts(counts, result) { + counts.completed += 1; + switch (result.status) { + case PULL_STATUS.CACHED: + counts.cached += 1; + break; + case PULL_STATUS.ALREADY_EXISTS: + counts.alreadyExisted += 1; + break; + case PULL_STATUS.NOT_FOUND: + counts.notFound += 1; + break; + case PULL_STATUS.FORMAT_MISMATCH: + counts.formatMismatched += 1; + break; + case PULL_STATUS.AUTH_FAILED: + counts.authFailed += 1; + counts.errors += 1; + break; + case PULL_STATUS.SKIPPED: + counts.skipped += 1; + break; + case PULL_STATUS.ERROR: + counts.errors += 1; + if (result.networkError) { + counts.networkErrors += 1; + } + break; + default: + break; + } +} + +function recomputeResultCounts(counts, results) { + const next = createResultCounts(results.length); + for (const result of results) { + updateResultCounts(next, result); + } + + Object.assign(counts, next); +} + +function mapRegistryAttempt(dependency, attempt, requestUrl, format) { + if (attempt.statusCode >= 200 && attempt.statusCode < 300) { + return { + dependency, + status: PULL_STATUS.CACHED, + errorMessage: null, + requestUrl, + networkError: false, + }; + } + + if (attempt.statusCode === 304 || attempt.statusCode === 409) { + return { + dependency, + status: PULL_STATUS.ALREADY_EXISTS, + errorMessage: null, + requestUrl, + networkError: false, + }; + } + + if (attempt.statusCode === 401 || attempt.statusCode === 403) { + return { + dependency, + status: PULL_STATUS.AUTH_FAILED, + errorMessage: "Authentication failed.", + requestUrl, + networkError: false, + }; + } + + if (attempt.statusCode === 404) { + return { + dependency, + status: PULL_STATUS.NOT_FOUND, + errorMessage: defaultNotFoundMessage(format), + requestUrl, + networkError: false, + }; + } + + if (attempt.statusCode === 0) { + return { + dependency, + status: PULL_STATUS.ERROR, + errorMessage: attempt.errorMessage || "Registry request failed.", + requestUrl, + networkError: Boolean(attempt.networkError), + }; + } + + return { + dependency, + status: PULL_STATUS.ERROR, + errorMessage: `Registry request returned HTTP ${attempt.statusCode}.`, + requestUrl, + networkError: false, + }; +} + +function defaultNotFoundMessage(format) { + switch (format) { + case "docker": + return "Image manifest not found upstream."; + case "go": + return "Go module metadata not found upstream."; + case "cargo": + return "Cargo index entry not found upstream."; + case "helm": + return "Chart archive not found upstream."; + default: + return "Package not found upstream."; + } +} + +function missingArtifactMessage(strategy, version) { + switch (strategy) { + case "python-simple-index": + return `No distribution file was found for version ${version}.`; + case "dart-api": + return `No Dart archive URL was found for version ${version}.`; + case "composer-p2": + return `No Composer dist URL was found for version ${version}.`; + default: + return "No downloadable artifact was found."; + } +} + +function buildRegistryErrorMessage(url, error) { + if (isNetworkError(error)) { + return "Cannot reach the Cloudsmith registry. Check your network connection."; + } + + const host = safeHost(url); + const message = error && error.message ? error.message : "Registry request failed."; + return host ? `${message} (${host})` : message; +} + +function isNetworkError(error) { + const code = error && ( + error.code + || (error.cause && error.cause.code) + || (error.errno) + ); + + if (["ECONNREFUSED", "ECONNRESET", "ENOTFOUND", "EHOSTUNREACH", "ETIMEDOUT"].includes(code)) { + return true; + } + + const message = String(error && error.message || "").toLowerCase(); + return message.includes("fetch failed") + || message.includes("network") + || message.includes("timed out") + || message.includes("econnrefused") + || message.includes("enotfound"); +} + +function buildBasicAuthHeader(apiKey) { + return `Basic ${Buffer.from(`token:${apiKey}`).toString("base64")}`; +} + +function isRedirectStatus(statusCode) { + return Number.isInteger(statusCode) && statusCode >= 300 && statusCode < 400; +} + +function safeHost(url) { + try { + return new URL(url).host; + } catch { + return ""; + } +} + +function buildPullRequestUrl(workspace, repo, dependency) { + const plan = buildRegistryTriggerPlan(workspace, repo, dependency); + return plan && plan.request ? plan.request.url : null; +} + +function dedupePullDependencies(dependencies) { + const unique = new Map(); + + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + const key = pullDependencyKey(dependency); + if (!unique.has(key)) { + unique.set(key, dependency); + } + } + + return [...unique.values()]; +} + +function pullDependencyKey(dependency) { + return [ + String(canonicalFormat(dependency && (dependency.format || dependency.ecosystem)) || "").toLowerCase(), + String(dependency && dependency.name || "").toLowerCase(), + String(dependency && dependency.version || "").toLowerCase(), + ].join(":"); +} + +function singleFormatPullHeader(dependencies, repositoryLabel) { + const formats = [...new Set( + dependencies.map((dependency) => canonicalFormat(formatForDependency(dependency))).filter(Boolean) + )]; + + if (formats.length === 1) { + return `Pull ${dependencies.length} ${formatDisplayName(formats[0])} dependenc${dependencies.length === 1 ? "y" : "ies"} through ${repositoryLabel}?`; + } + + return `Pull ${dependencies.length} dependencies through ${repositoryLabel}?`; +} + +function buildPullableSummary(dependencies, forceSummary) { + const groups = groupCountsByFormat(dependencies); + if (groups.length === 0) { + return ""; + } + + if (!forceSummary && groups.length === 1) { + return ""; + } + + return `${groups.map(({ count, format }) => `${count} ${formatDisplayName(format)}`).join(" + ")} will be pulled.`; +} + +function buildSkippedSummary(skippedDependencies) { + const groups = groupCountsByFormat(skippedDependencies.map((entry) => ({ + format: entry.format, + }))); + + if (groups.length === 0) { + return ""; + } + + const reason = skippedDependencies.every((entry) => entry.reason === PULL_SKIP_REASON.NO_ACTIVE_UPSTREAM) + ? "no matching upstream is configured on this repository" + : "pull-through is not available for these formats"; + + return `${groups.map(({ count, format }) => `${count} ${formatDisplayName(format)}`).join(" + ")} will be skipped (${reason}).`; +} + +function groupCountsByFormat(dependencies) { + const counts = new Map(); + + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + const format = canonicalFormat( + dependency && (dependency.format || dependency.ecosystem || formatForDependency(dependency)) + ); + if (!format) { + continue; + } + counts.set(format, (counts.get(format) || 0) + 1); + } + + return [...counts.entries()] + .map(([format, count]) => ({ format, count })) + .sort((left, right) => { + if (right.count !== left.count) { + return right.count - left.count; + } + return formatDisplayName(left.format).localeCompare(formatDisplayName(right.format), undefined, { + sensitivity: "base", + }); + }); +} + +function formatDisplayName(format) { + const normalized = String(canonicalFormat(format) || format || "").trim().toLowerCase(); + switch (normalized) { + case "npm": + return "npm"; + case "python": + return "Python"; + case "go": + return "Go"; + case "nuget": + return "NuGet"; + default: + return normalized ? normalized.charAt(0).toUpperCase() + normalized.slice(1) : "Unknown"; + } +} + +function formatListLabel(formats) { + return [...new Set( + (Array.isArray(formats) ? formats : []) + .map((format) => formatDisplayName(format)) + .filter(Boolean) + )].join(", "); +} + +function normalizeSingleDependency(dependency) { + if (!dependency || typeof dependency !== "object") { + return null; + } + + const format = canonicalFormat(formatForDependency(dependency) || dependency.format || dependency.ecosystem); + const name = String(dependency.name || "").trim(); + if (!name || !format) { + return null; + } + + return { + ...dependency, + name, + version: dependency.version || dependency.declaredVersion || "", + format, + ecosystem: dependency.ecosystem || format, + }; +} + +function buildDependencyLabel(dependency) { + const name = String(dependency && dependency.name || "").trim() || "dependency"; + const version = String(dependency && dependency.version || "").trim(); + return version ? `${name}@${version}` : name; +} + +function buildSingleDependencyRepositoryDetail(match, format) { + const upstreams = match && match.activeUpstreamsByFormat instanceof Map + ? match.activeUpstreamsByFormat.get(format) + : []; + const activeUpstream = Array.isArray(upstreams) ? upstreams[0] : null; + const configuredName = String(activeUpstream && activeUpstream.name || "").trim(); + const sourceLabel = configuredName || defaultUpstreamSourceLabel(format); + if (!sourceLabel) { + return `${formatDisplayName(format)} upstream configured`; + } + return `${formatDisplayName(format)} upstream (${sourceLabel})`; +} + +function defaultUpstreamSourceLabel(format) { + switch (canonicalFormat(format)) { + case "cargo": + return "crates.io"; + case "composer": + return "Packagist"; + case "conda": + return "Conda"; + case "dart": + return "pub.dev"; + case "docker": + return "Docker"; + case "go": + return "Go"; + case "helm": + return "Helm"; + case "hex": + return "Hex"; + case "maven": + return "Maven"; + case "npm": + return "npm"; + case "nuget": + return "NuGet"; + case "python": + return "PyPI"; + case "ruby": + return "RubyGems"; + case "swift": + return "Swift"; + default: + return null; + } +} + +function sortRepositoryMatches(matches, repositoryHint) { + const hint = String(repositoryHint || "").trim().toLowerCase(); + if (!hint) { + return matches; + } + + return matches.slice().sort((left, right) => { + const leftSlug = String(left.repo.slug || "").toLowerCase(); + const rightSlug = String(right.repo.slug || "").toLowerCase(); + const leftPriority = leftSlug === hint ? 0 : 1; + const rightPriority = rightSlug === hint ? 0 : 1; + if (leftPriority !== rightPriority) { + return leftPriority - rightPriority; + } + return leftSlug.localeCompare(rightSlug, undefined, { sensitivity: "base" }); + }); +} + +async function runPromisePool(items, concurrency, worker) { + const workers = []; + let index = 0; + const size = Math.max(1, Math.min(concurrency, items.length || 1)); + + for (let workerIndex = 0; workerIndex < size; workerIndex += 1) { + workers.push((async () => { + while (index < items.length) { + const item = items[index]; + index += 1; + if (item === undefined) { + break; + } + await worker(item); + } + })()); + } + + await Promise.all(workers); +} + +module.exports = { + PULL_SKIP_REASON, + PULL_STATUS, + UpstreamPullService, + buildPullSummaryMessage, +}; diff --git a/views/dependencyHealthProvider.js b/views/dependencyHealthProvider.js index 5bfd331..7ad2753 100644 --- a/views/dependencyHealthProvider.js +++ b/views/dependencyHealthProvider.js @@ -1,21 +1,81 @@ -// Dependency Health tree data provider. -// Reads project manifests, cross-references dependencies against Cloudsmith, -// and surfaces a health dashboard in the sidebar. - -const vscode = require("vscode"); +// Copyright 2026 Cloudsmith Ltd. All rights reserved. const path = require("path"); +const vscode = require("vscode"); const { CloudsmithAPI } = require("../util/cloudsmithAPI"); +const { LockfileResolver } = require("../util/lockfileResolver"); const { ManifestParser } = require("../util/manifestParser"); -const { TransitiveResolver } = require("../util/transitiveResolver"); +const { PaginatedFetch } = require("../util/paginatedFetch"); +const { SearchQueryBuilder } = require("../util/searchQueryBuilder"); +const { LicenseClassifier } = require("../util/licenseClassifier"); +const { + canonicalFormat, + getCloudsmithPackageLookupKeys, + getPackageLookupKeys, + normalizePackageName, +} = require("../util/packageNameNormalizer"); +const { + enrichVulnerabilities, +} = require("../util/dependencyVulnEnricher"); +const { + enrichLicenses, + getFoundDependencyKey, +} = require("../util/dependencyLicenseEnricher"); +const { enrichPolicies } = require("../util/dependencyPolicyEnricher"); +const { + analyzeUpstreamGaps, + getUncoveredDependencyKey, +} = require("../util/upstreamGapAnalyzer"); +const { + PULL_STATUS, + UpstreamPullService, + buildPullSummaryMessage, +} = require("../util/upstreamPullService"); const DependencyHealthNode = require("../models/dependencyHealthNode"); +const DependencySourceGroupNode = require("../models/dependencySourceGroupNode"); +const DependencySummaryNode = require("../models/dependencySummaryNode"); const InfoNode = require("../models/infoNode"); -const BATCH_SIZE = 5; -const DEFAULT_MAX_DEPENDENCIES_TO_SCAN = 200; +const DEFAULT_MAX_DEPENDENCIES_TO_SCAN = 10000; +const PACKAGE_INDEX_TTL_MS = 10 * 60 * 1000; +const PACKAGE_INDEX_CACHE_MAX_SIZE = 5000; +const PACKAGE_INDEX_PAGE_SIZE = 500; +const PACKAGE_INDEX_FALLBACK_THRESHOLD = 10000; +const FALLBACK_QUERY_PAGE_SIZE = 25; +const FALLBACK_QUERY_CONCURRENCY = 8; +const WORKSPACE_REPOSITORY_PAGE_SIZE = 500; +const COVERAGE_MATCH_BATCH_SIZE = 50; +const ENRICHMENT_PROGRESS_DEBOUNCE_MS = 500; + +const FILTER_MODES = Object.freeze({ + VULNERABLE: "vulnerable", + UNCOVERED: "uncovered", + RESTRICTIVE_LICENSE: "restrictive_license", + POLICY_VIOLATION: "policy_violation", +}); + +const SORT_MODES = Object.freeze({ + ALPHABETICAL: "alphabetical", + SEVERITY: "severity", + COVERAGE: "coverage", +}); + +const VIEW_MODES = ["direct", "flat", "tree"]; class DependencyHealthProvider { - constructor(context, diagnosticsPublisher) { + constructor(context, diagnosticsPublisher, options = {}) { this.context = context; + this._diagnosticsPublisher = diagnosticsPublisher || null; + this._services = { + enrichVulnerabilities: options.enrichVulnerabilities || enrichVulnerabilities, + enrichLicenses: options.enrichLicenses || enrichLicenses, + enrichPolicies: options.enrichPolicies || enrichPolicies, + analyzeUpstreamGaps: options.analyzeUpstreamGaps || analyzeUpstreamGaps, + fetchRepositories: options.fetchRepositories || this._fetchWorkspaceRepositories.bind(this), + upstreamPullService: options.upstreamPullService || new UpstreamPullService(context), + }; + this._reportDateFactory = typeof options.reportDateFactory === "function" + ? options.reportDateFactory + : () => new Date(); this._onDidChangeTreeData = new vscode.EventEmitter(); this.onDidChangeTreeData = this._onDidChangeTreeData.event; this.dependencies = []; @@ -23,50 +83,143 @@ class DependencyHealthProvider { this.lastRepo = null; this._scanning = false; this._statusMessage = null; - this._diagnosticsPublisher = diagnosticsPublisher || null; + this._failureMessage = null; + this._warnings = []; this._lastManifests = []; - this._projectFolderPath = null; // manually selected folder, persists across scans + this._projectFolderPath = null; this._hasScannedOnce = false; - // Auto-refresh when connection status changes in secrets store. - // This ensures the welcome/connected state updates without external refresh calls. - this.context.secrets.onDidChange(e => { - if (e.key === "cloudsmith-vsc.isConnected") { - this.refresh(); - } - }); + this._noManifestsFolder = null; + this._fullTrees = []; + this._displayTrees = []; + this._summary = emptySummary(); + this._viewMode = this._getInitialViewMode(); + this._sortMode = SORT_MODES.ALPHABETICAL; + this._filterMode = null; + this._reportData = null; + this._lastScanTimestamp = null; + + if (this.context && this.context.secrets && typeof this.context.secrets.onDidChange === "function") { + this.context.secrets.onDidChange((event) => { + if (event.key === "cloudsmith-vsc.isConnected") { + this.refresh(); + } + }); + } + + this._updateContexts(); + } + + _getInitialViewMode() { + const config = vscode.workspace.getConfiguration("cloudsmith-vsc"); + const configuredDefault = String(config.get("dependencyTreeDefaultView") || "flat"); + const storedView = this.context && this.context.workspaceState + ? this.context.workspaceState.get("cloudsmith-vsc.dependencyTreeView") + : null; + const candidate = String(storedView || configuredDefault || "flat"); + return ["direct", "flat", "tree"].includes(candidate) ? candidate : "flat"; + } + + async _updateContexts() { + await vscode.commands.executeCommand("setContext", "cloudsmith.depView", this._viewMode); + await vscode.commands.executeCommand("setContext", "cloudsmith.depViewMode", this._viewMode); + await vscode.commands.executeCommand("setContext", "cloudsmith.depFilterActive", Boolean(this._filterMode)); + await vscode.commands.executeCommand("setContext", "cloudsmith.depScanComplete", Boolean(this._reportData)); + await vscode.commands.executeCommand("setContext", "cloudsmith.depRepoSelected", Boolean(this.lastRepo)); + } + + async setViewMode(mode) { + if (!VIEW_MODES.includes(mode)) { + return; + } + + this._viewMode = mode; + if (this.context && this.context.workspaceState && typeof this.context.workspaceState.update === "function") { + await this.context.workspaceState.update("cloudsmith-vsc.dependencyTreeView", mode); + } + await this._updateContexts(); + this._rebuildSummary(); + this.refresh(); + } + + getViewMode() { + return this._viewMode; + } + + async cycleViewMode() { + const currentIndex = VIEW_MODES.indexOf(this._viewMode); + const nextMode = VIEW_MODES[(currentIndex + 1) % VIEW_MODES.length]; + await this.setViewMode(nextMode); + return nextMode; + } + + async setFilterMode(mode) { + this._filterMode = mode || null; + await this._updateContexts(); + this._rebuildSummary(); + this.refresh(); + } + + getFilterMode() { + return this._filterMode; + } + + async clearFilter() { + await this.setFilterMode(null); + } + + setSortMode(mode) { + if (!Object.values(SORT_MODES).includes(mode)) { + return; + } + + this._sortMode = mode; + this._rebuildSummary(); + this.refresh(); + } + + getSortMode() { + return this._sortMode; + } + + getReportData() { + return this._reportData; + } + + async _storeReportData(scanDate) { + this._lastScanTimestamp = normalizeReportTimestamp(scanDate); + this._reportData = buildComplianceReportData( + path.basename(this.getProjectFolder() || "workspace"), + this._fullTrees.flatMap((tree) => tree.dependencies), + { scanDate: this._lastScanTimestamp } + ); + await this._updateContexts(); } - /** - * Get the project folder to scan. Returns a path string. - * Priority: manually selected folder > first VS Code workspace folder > null. - */ getProjectFolder() { if (this._projectFolderPath) { return this._projectFolderPath; } const folders = vscode.workspace.workspaceFolders; - if (folders && folders.length > 0) { - return folders[0].uri.fsPath; - } - return null; + return folders && folders[0] ? folders[0].uri.fsPath : null; } - /** - * Set a manually picked project folder. - */ setProjectFolder(folderPath) { this._projectFolderPath = folderPath; } - /** - * Prompt the user to pick a folder when no workspace is open. - * Returns the selected path or null if cancelled. - */ async promptForFolder() { const choice = await vscode.window.showQuickPick( [ - { label: "$(folder-opened) Select a folder to scan", description: "Browse for a project folder", _action: "pick" }, - { label: "$(folder) Open a project folder", description: "Open a folder in VS Code", _action: "open" }, + { + label: "$(folder-opened) Select a folder to scan", + description: "Browse for a project folder", + _action: "pick", + }, + { + label: "$(folder) Open a project folder", + description: "Open a folder in VS Code", + _action: "open", + }, ], { placeHolder: "No workspace folder is open. Select a project folder to scan." } ); @@ -77,45 +230,35 @@ class DependencyHealthProvider { if (choice._action === "open") { await vscode.commands.executeCommand("vscode.openFolder"); - return null; // VS Code will reload, scan can happen after + return null; } - // Folder picker dialog const selected = await vscode.window.showOpenDialog({ canSelectFolders: true, canSelectFiles: false, canSelectMany: false, - openLabel: "Scan for dependencies", + openLabel: "Scan dependencies", }); if (!selected || selected.length === 0) { return null; } - const folderPath = selected[0].fsPath; - this._projectFolderPath = folderPath; - return folderPath; + this._projectFolderPath = selected[0].fsPath; + return this._projectFolderPath; } - /** - * Scan project manifests and cross-reference against Cloudsmith. - * - * @param {string} cloudsmithWorkspace Workspace/owner slug. - * @param {string|null} cloudsmithRepo Optional repo slug for scoped scan. - * @param {string|null} projectFolder Optional project folder path override. - */ async scan(cloudsmithWorkspace, cloudsmithRepo, projectFolder) { if (this._scanning) { vscode.window.showWarningMessage("A dependency scan is already in progress."); return; } - // Resolve project folder let folderPath = projectFolder || this.getProjectFolder(); if (!folderPath) { folderPath = await this.promptForFolder(); if (!folderPath) { - return; // User cancelled + return; } } @@ -123,24 +266,28 @@ class DependencyHealthProvider { this._hasScannedOnce = true; this.lastWorkspace = cloudsmithWorkspace; this.lastRepo = cloudsmithRepo; - this.dependencies = []; - this._statusMessage = "Scanning project manifests..."; + this._reportData = null; + this._failureMessage = null; + this._warnings = []; + this._noManifestsFolder = null; + this._statusMessage = "Parsing lockfiles..."; + this._displayTrees = []; + this._fullTrees = []; + this._summary = emptySummary(); + await this._updateContexts(); this.refresh(); const cancellationSource = new vscode.CancellationTokenSource(); try { - const scanResult = await vscode.window.withProgress( + const result = await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, - title: "Scanning dependency health", + title: "Scanning dependencies", cancellable: true, }, async (progress, token) => { - const tokenSubscription = token.onCancellationRequested(() => { - cancellationSource.cancel(); - }); - + const subscription = token.onCancellationRequested(() => cancellationSource.cancel()); try { return await this._performScan( cloudsmithWorkspace, @@ -150,23 +297,23 @@ class DependencyHealthProvider { cancellationSource.token ); } finally { - tokenSubscription.dispose(); + subscription.dispose(); } } ); - if (scanResult && scanResult.canceled) { + if (result && result.canceled) { this._statusMessage = null; if (this._diagnosticsPublisher) { this._diagnosticsPublisher.clear(); } vscode.window.showInformationMessage("Dependency scan canceled."); } - } catch (e) { - const reason = e && e.message - ? e.message - : "Check the Cloudsmith connection."; - this.dependencies = []; + } catch (error) { + const reason = error && error.message ? error.message : "Check the Cloudsmith connection."; + this._displayTrees = []; + this._fullTrees = []; + this._summary = emptySummary(); if (this._diagnosticsPublisher) { this._diagnosticsPublisher.clear(); } @@ -176,374 +323,2509 @@ class DependencyHealthProvider { } finally { cancellationSource.dispose(); this._scanning = false; + await this._updateContexts(); this.refresh(); } } _getMaxDependenciesToScan() { - const config = vscode.workspace.getConfiguration("cloudsmith-vsc"); - const configuredValue = Number(config.get("maxDependenciesToScan")); + const configuredValue = Number(vscode.workspace.getConfiguration("cloudsmith-vsc").get("maxDependenciesToScan")); if (!Number.isFinite(configuredValue) || configuredValue < 1) { return DEFAULT_MAX_DEPENDENCIES_TO_SCAN; } return Math.floor(configuredValue); } - async _waitForCancellationOrTimeout(token, ms) { - if (token && token.isCancellationRequested) { - return true; - } + async _performScan(cloudsmithWorkspace, cloudsmithRepo, folderPath, progress, token) { + progress.report({ message: "Parsing lockfiles..." }); + this._lastManifests = await ManifestParser.detectManifests(folderPath); - return new Promise(resolve => { - let subscription = null; - const timer = setTimeout(() => { - if (subscription) { - subscription.dispose(); - } - resolve(false); - }, ms); + const resolveTransitives = vscode.workspace.getConfiguration("cloudsmith-vsc").get("resolveTransitiveDependencies") !== false; + const trees = []; + const warnings = []; - if (token && typeof token.onCancellationRequested === "function") { - subscription = token.onCancellationRequested(() => { - clearTimeout(timer); - if (subscription) { - subscription.dispose(); + if (resolveTransitives) { + const detections = await LockfileResolver.detectResolvers(folderPath); + for (const detection of detections) { + if (token.isCancellationRequested) { + return { canceled: true }; + } + try { + const tree = await LockfileResolver.resolve( + detection.resolverName, + detection.lockfilePath, + detection.manifestPath, + { + workspaceFolder: folderPath, + maxDependenciesToScan: this._getMaxDependenciesToScan(), + } + ); + if (tree) { + trees.push(tree); + if (Array.isArray(tree.warnings) && tree.warnings.length > 0) { + warnings.push(...tree.warnings); + } } - resolve(true); - }); + } catch (error) { + warnings.push(error && error.message ? error.message : "A lockfile parser failed."); + } } - }); - } - - _isExactDependencyMatch(pkg, expected) { - if (!pkg || typeof pkg !== "object" || !expected) { - return false; } - if (String(pkg.name || "") !== String(expected.name || "")) { - return false; - } - if (String(pkg.format || "") !== String(expected.format || "")) { - return false; + if (trees.length === 0) { + const fallbackTrees = await this._buildManifestFallbackTrees(this._lastManifests); + trees.push(...fallbackTrees); } - // When the declared version is empty/null/undefined, treat as a name-only - // match so we select the newest package from the query results. - if (expected.version) { - if (String(pkg.version || "") !== String(expected.version)) { - return false; + + if (trees.length === 0) { + this._displayTrees = []; + this._fullTrees = []; + this._summary = emptySummary(); + this._statusMessage = null; + if (this._lastManifests.length === 0) { + this._noManifestsFolder = path.basename(folderPath); } + await this._storeReportData(this._reportDateFactory()); + return { canceled: false }; } - return true; - } - _exactDependencyMatch(pkg, deps) { - if (!pkg || !Array.isArray(deps)) { - return null; + const normalizedTrees = trees + .map(normalizeTree) + .filter((tree) => Array.isArray(tree.dependencies) && tree.dependencies.length > 0); + + if (normalizedTrees.length === 0) { + this._displayTrees = []; + this._fullTrees = []; + this._summary = emptySummary(); + this._statusMessage = null; + await this._storeReportData(this._reportDateFactory()); + return { canceled: false }; } - return deps.find(dep => this._isExactDependencyMatch(pkg, dep)) || null; - } + this._noManifestsFolder = null; + this._fullTrees = markTreesAsChecking(normalizedTrees); + + const limited = limitDisplayTrees(this._fullTrees, this._getMaxDependenciesToScan()); + this._displayTrees = limited.trees; + this._warnings = warnings.slice(); + if (limited.truncated) { + const warning = `Dependency display is capped at ${this._getMaxDependenciesToScan()} items ` + + `out of ${limited.totalDependencies} resolved dependencies.`; + this._warnings.push(warning); + vscode.window.showWarningMessage(warning); + } + this._statusMessage = null; + this._rebuildSummary(); + this.refresh(); - async _performScan(cloudsmithWorkspace, cloudsmithRepo, folderPath, progress, token) { - progress.report({ message: "Detecting manifests", increment: 10 }); + const totalCoverageDependencies = countCoverageDependencies(this._fullTrees); + progress.report({ + message: `Found ${limited.totalDependencies} dependencies. Fetching package index...`, + }); - let allDeps = []; - this._lastManifests = []; + await this._runCoverageChecks( + cloudsmithWorkspace, + cloudsmithRepo, + totalCoverageDependencies, + progress, + token + ); - // Scan the single resolved folder path (not workspace folders) if (token.isCancellationRequested) { return { canceled: true }; } - const manifests = await ManifestParser.detectManifests(folderPath); + progress.report({ + message: "Enriching vulnerabilities, licenses, policy, and upstream availability...", + }); + + await this._runEnrichmentPasses(cloudsmithWorkspace, cloudsmithRepo, progress, token); + if (token.isCancellationRequested) { return { canceled: true }; } - this._lastManifests = manifests; + await this._publishDiagnostics(); + this._rebuildSummary(); + await this._storeReportData(this._reportDateFactory()); + return { canceled: false }; + } - if (manifests.length === 0) { - // No manifests found — set a descriptive message - const folderName = path.basename(folderPath); - this.dependencies = []; - this._statusMessage = null; - this._noManifestsFolder = folderName; - return { canceled: false }; + async _buildManifestFallbackTrees(manifests) { + const trees = []; + for (const manifest of manifests) { + const parsed = await ManifestParser.parseManifest(manifest); + if (!Array.isArray(parsed) || parsed.length === 0) { + continue; + } + trees.push({ + ecosystem: manifest.format, + sourceFile: path.basename(manifest.filePath), + dependencies: parsed.map((dependency) => ({ + name: dependency.name, + version: dependency.version, + ecosystem: manifest.format, + format: canonicalFormat(manifest.format), + isDirect: true, + parent: null, + parentChain: [], + transitives: [], + cloudsmithStatus: "CHECKING", + cloudsmithPackage: null, + sourceFile: path.basename(manifest.filePath), + devDependency: Boolean(dependency.devDependency), + isDevelopmentDependency: Boolean(dependency.devDependency), + })), + }); } + return trees; + } - for (const manifest of manifests) { - if (token.isCancellationRequested) { - return { canceled: true }; + async _runCoverageChecks(cloudsmithWorkspace, cloudsmithRepo, totalDependencies, progress, token) { + const dependenciesByFormat = groupDependenciesByFormat(this._fullTrees); + await this._runCoverageResolution( + cloudsmithWorkspace, + cloudsmithRepo, + dependenciesByFormat, + totalDependencies, + progress, + token, + { + packageIndexFailureVerb: "fetch", + progressLabel: "Matching coverage", } + ); + } - const parsed = await ManifestParser.parseManifest(manifest); - allDeps = allDeps.concat(parsed); + async _runCoverageResolution( + cloudsmithWorkspace, + cloudsmithRepo, + dependenciesByFormat, + totalDependencies, + progress, + token, + options = {} + ) { + const formats = Object.keys(dependenciesByFormat); + const progressLabel = options.progressLabel || "Matching coverage"; + const packageIndexFailureVerb = options.packageIndexFailureVerb || "fetch"; + + if (formats.length === 0 || totalDependencies === 0) { + return 0; } - if (allDeps.length === 0) { - this._statusMessage = null; - this._noManifestsFolder = path.basename(folderPath); - return { canceled: false }; - } + const indexEntries = await Promise.all( + formats.map(async (format) => ({ + format, + dependencies: uniqueDependenciesForCoverage(dependenciesByFormat[format]), + packageIndex: await this._fetchPackageIndex(cloudsmithWorkspace, cloudsmithRepo, format), + })) + ); - // Clear the no-manifests flag since we found deps - this._noManifestsFolder = null; + let completed = 0; + for (const { format, dependencies, packageIndex } of indexEntries) { + if (token.isCancellationRequested) { + return completed; + } - progress.report({ message: "Resolving manifests", increment: 15 }); + if (packageIndex.error) { + this._warnings.push(`Could not ${packageIndexFailureVerb} the ${format} package index. ${packageIndex.error}`); + } - const resolveConfig = vscode.workspace.getConfiguration("cloudsmith-vsc"); - if (resolveConfig.get("resolveTransitiveDependencies")) { - this._statusMessage = "Resolving transitive dependencies via CLI..."; - this.refresh(); + if (packageIndex.tooLarge || packageIndex.error) { + completed = await this._resolveCoverageWithFallbackQueries( + cloudsmithWorkspace, + cloudsmithRepo, + format, + dependencies, + completed, + totalDependencies, + progress, + token, + progressLabel + ); + continue; + } - const directNames = new Set(allDeps.map(d => d.name)); - const formatsResolved = new Set(); - const formats = [...new Set(this._lastManifests.map(m => m.format))]; + completed = await this._matchCoverageBatch( + dependencies, + packageIndex.index, + completed, + totalDependencies, + progress, + token, + progressLabel + ); + } - for (const format of formats) { - if (token.isCancellationRequested) { - return { canceled: true }; - } - if (formatsResolved.has(format)) { - continue; - } - try { - const transitiveDeps = await TransitiveResolver.resolve(folderPath, format); - if (token.isCancellationRequested) { - return { canceled: true }; - } - if (transitiveDeps && transitiveDeps.length > 0) { - for (const dep of transitiveDeps) { - dep.isDirect = directNames.has(dep.name); - } - allDeps = allDeps.filter(d => d.format !== format); - allDeps = allDeps.concat(transitiveDeps); - formatsResolved.add(format); - } - } catch (e) { - vscode.window.showWarningMessage( - `Could not resolve transitive dependencies for ${format}. Using direct dependencies only. ${e.message}` - ); - } + return completed; + } + + async _matchCoverageBatch(dependencies, packageIndex, completed, totalDependencies, progress, token, progressLabel) { + const pendingMatches = []; + + for (let index = 0; index < dependencies.length; index += 1) { + if (token.isCancellationRequested) { + return completed; } - } - const seen = new Set(); - const uniqueDeps = []; - for (const dep of allDeps) { - const key = `${dep.format}:${dep.name}`; - if (!seen.has(key)) { - seen.add(key); - uniqueDeps.push(dep); + const dependency = dependencies[index]; + pendingMatches.push({ + dependency, + match: findCoverageMatch(packageIndex, dependency), + }); + + if (pendingMatches.length < COVERAGE_MATCH_BATCH_SIZE && index < dependencies.length - 1) { + continue; } - } - const maxDependenciesToScan = this._getMaxDependenciesToScan(); - const depsToScan = uniqueDeps.slice(0, maxDependenciesToScan); - if (uniqueDeps.length > depsToScan.length) { - vscode.window.showWarningMessage( - `Dependency scan truncated to ${depsToScan.length} dependencies out of ${uniqueDeps.length}. Increase cloudsmith-vsc.maxDependenciesToScan to scan more.` + completed = await this._flushCoverageMatchBatch( + pendingMatches, + completed, + totalDependencies, + progress, + progressLabel ); } - this._statusMessage = `Found ${uniqueDeps.length} dependencies. Checking ${depsToScan.length} against Cloudsmith...`; + return completed; + } + + async _flushCoverageMatchBatch(pendingMatches, completed, totalDependencies, progress, progressLabel) { + if (pendingMatches.length === 0) { + return completed; + } + + this._applyCoverageMatchBatch(pendingMatches); + + const batchSize = pendingMatches.length; + pendingMatches.length = 0; + completed += batchSize; + + this._rebuildSummary(); + progress.report({ + message: `${progressLabel}... ${completed}/${totalDependencies}`, + increment: totalDependencies > 0 ? (batchSize * 100) / totalDependencies : 100, + }); this.refresh(); + await yieldToEventLoop(); + + return completed; + } - progress.report({ message: "Checking Cloudsmith", increment: 20 }); + _applyCoverageMatchBatch(matches) { + if (!Array.isArray(matches) || matches.length === 0) { + return; + } - const byFormat = {}; - for (const dep of depsToScan) { - if (!byFormat[dep.format]) { - byFormat[dep.format] = []; - } - byFormat[dep.format].push(dep); + const matchMap = new Map(); + for (const { dependency, match } of matches) { + matchMap.set(coverageLookupKey(dependency), { + cloudsmithStatus: match ? "FOUND" : "NOT_FOUND", + cloudsmithPackage: match || null, + ...(match ? { upstreamStatus: null, upstreamDetail: null } : {}), + }); } - const cloudsmithAPI = new CloudsmithAPI(this.context); - const allResults = new Map(); + this._fullTrees = applyCoverageMatchBatchToTrees(this._fullTrees, matchMap); + this._displayTrees = applyCoverageMatchBatchToTrees(this._displayTrees, matchMap); + } - for (const [format, deps] of Object.entries(byFormat)) { - for (let i = 0; i < deps.length; i += BATCH_SIZE) { - if (token.isCancellationRequested) { - return { canceled: true }; + _createDebouncedEnrichmentHandler(patchApplier) { + let pendingPatchMaps = []; + let flushTimeout = null; + + const flush = () => { + if (flushTimeout) { + clearTimeout(flushTimeout); + flushTimeout = null; + } + + if (pendingPatchMaps.length === 0) { + return; + } + + const mergedPatchMap = mergePatchMaps(pendingPatchMaps); + pendingPatchMaps = []; + + patchApplier(mergedPatchMap); + this._rebuildSummary(); + this.refresh(); + }; + + return { + onProgress: (patchMap) => { + if (!(patchMap instanceof Map) || patchMap.size === 0) { + return; + } + + pendingPatchMaps.push(patchMap); + if (!flushTimeout) { + flushTimeout = setTimeout(() => { + flushTimeout = null; + flush(); + }, ENRICHMENT_PROGRESS_DEBOUNCE_MS); } + }, + flush, + }; + } - const batch = deps.slice(i, i + BATCH_SIZE); - const nameTerms = batch.map(d => `name:^${d.name}$`).join(" OR "); - const query = `(${nameTerms}) AND format:${format}`; - const baseEndpoint = cloudsmithRepo - ? `packages/${cloudsmithWorkspace}/${cloudsmithRepo}/` - : `packages/${cloudsmithWorkspace}/`; - const endpoint = `${baseEndpoint}?query=${encodeURIComponent(query)}&sort=-version&page_size=${BATCH_SIZE * 3}`; + async _resolveCoverageWithFallbackQueries( + cloudsmithWorkspace, + cloudsmithRepo, + format, + dependencies, + completed, + totalDependencies, + progress, + token, + progressLabel = "Matching coverage" + ) { + const api = new CloudsmithAPI(this.context); + const endpoint = cloudsmithRepo + ? `packages/${cloudsmithWorkspace}/${cloudsmithRepo}/` + : `packages/${cloudsmithWorkspace}/`; + const uniqueDependencies = dependencies.slice(); + + for (let index = 0; index < uniqueDependencies.length; index += COVERAGE_MATCH_BATCH_SIZE) { + if (token.isCancellationRequested) { + return completed; + } - const result = await cloudsmithAPI.get(endpoint); + const dependencyBatch = uniqueDependencies.slice(index, index + COVERAGE_MATCH_BATCH_SIZE); + const pendingMatches = []; + await runPromisePool(dependencyBatch, FALLBACK_QUERY_CONCURRENCY, async (dependency) => { if (token.isCancellationRequested) { - return { canceled: true }; + return; } - if (typeof result === "string" && result.includes("429")) { - this._statusMessage = "Rate limited. Pausing scan for 30 seconds..."; - this.refresh(); - const cancelledDuringBackoff = await this._waitForCancellationOrTimeout(token, 30000); - if (cancelledDuringBackoff) { - return { canceled: true }; - } - - const retry = await cloudsmithAPI.get(endpoint); - if (token.isCancellationRequested) { - return { canceled: true }; + let match = null; + for (const lookupName of getPackageLookupKeys(dependency.name, dependency.format)) { + const query = new SearchQueryBuilder() + .format(dependency.format) + .name(lookupName) + .build(); + const result = await api.get(`${endpoint}?query=${encodeURIComponent(query)}&page_size=${FALLBACK_QUERY_PAGE_SIZE}`); + if (typeof result === "string") { + this._warnings.push(`Coverage lookup failed for ${dependency.name}. ${result}`); + continue; } - if (typeof retry === "string") { - throw new Error( - `Dependency lookup failed after retry for ${format}: ${retry}` - ); - } - if (Array.isArray(retry)) { - for (const pkg of retry) { - const matchingDep = this._exactDependencyMatch(pkg, batch); - if (matchingDep) { - const mapKey = `${matchingDep.format}:${matchingDep.name}`; - if (!allResults.has(mapKey) || pkg.version > allResults.get(mapKey).version) { - allResults.set(mapKey, pkg); - } - } - } - } - } else if (typeof result === "string") { - throw new Error(`Dependency lookup failed for ${format}: ${result}`); - } else if (Array.isArray(result)) { - for (const pkg of result) { - const matchingDep = this._exactDependencyMatch(pkg, batch); - if (matchingDep) { - const mapKey = `${matchingDep.format}:${matchingDep.name}`; - if (!allResults.has(mapKey) || pkg.version > allResults.get(mapKey).version) { - allResults.set(mapKey, pkg); - } + if (Array.isArray(result) && result.length > 0) { + match = matchCoverageCandidates(result, dependency); + if (match) { + break; } } } - for (const dep of batch) { - const match = allResults.get(`${dep.format}:${dep.name}`) || null; - this.dependencies.push( - new DependencyHealthNode(dep, match, this.context) - ); - } + pendingMatches.push({ dependency, match }); + }); - this.dependencies.sort((a, b) => a.sortOrder - b.sortOrder); + completed = await this._flushCoverageMatchBatch( + pendingMatches, + completed, + totalDependencies, + progress, + progressLabel + ); - this._statusMessage = `Checked ${Math.min(i + BATCH_SIZE, deps.length)} of ${deps.length} ${format} dependencies...`; - progress.report({ message: `Checked ${Math.min(i + BATCH_SIZE, deps.length)} of ${deps.length} ${format} dependencies...` }); - this.refresh(); + if (token.isCancellationRequested) { + return completed; } } - this.dependencies.sort((a, b) => a.sortOrder - b.sortOrder); + return completed; + } - if (this._diagnosticsPublisher) { - await this._diagnosticsPublisher.publish(this._lastManifests, this.dependencies); + async _fetchPackageIndex(cloudsmithWorkspace, cloudsmithRepo, format) { + const cacheKey = `${String(cloudsmithWorkspace || "").toLowerCase()}:${String(cloudsmithRepo || "").toLowerCase()}:${format}`; + const cachedValue = getCachedPackageIndexValue(cacheKey); + if (cachedValue) { + return cachedValue; } - this._statusMessage = null; - return { canceled: false }; + const firstPage = await this._fetchSinglePage( + cloudsmithWorkspace, + cloudsmithRepo, + format, + 1, + PACKAGE_INDEX_PAGE_SIZE + ); + if (firstPage.error) { + const value = { error: firstPage.error, tooLarge: false, index: new Map() }; + setCachedPackageIndexValue(cacheKey, value); + return value; + } + + const totalCount = firstPage.pagination.count || firstPage.data.length; + if (totalCount > PACKAGE_INDEX_FALLBACK_THRESHOLD) { + const value = { error: null, tooLarge: true, index: new Map(), totalCount }; + setCachedPackageIndexValue(cacheKey, value); + return value; + } + + const packages = [...firstPage.data]; + const pageTotal = firstPage.pagination && firstPage.pagination.pageTotal + ? firstPage.pagination.pageTotal + : Math.ceil(totalCount / PACKAGE_INDEX_PAGE_SIZE) || 1; + + if (pageTotal > 1) { + const remainingPages = await Promise.all( + Array.from({ length: pageTotal - 1 }, (_, index) => this._fetchSinglePage( + cloudsmithWorkspace, + cloudsmithRepo, + format, + index + 2, + PACKAGE_INDEX_PAGE_SIZE + )) + ); + + for (const nextPage of remainingPages) { + if (nextPage.error) { + const value = { error: nextPage.error, tooLarge: false, index: new Map() }; + setCachedPackageIndexValue(cacheKey, value); + return value; + } + packages.push(...nextPage.data); + } + } + + const value = { + error: null, + tooLarge: false, + index: buildPackageIndex(packages, format), + totalCount, + }; + setCachedPackageIndexValue(cacheKey, value); + return value; } - /** Re-run the last scan with same settings. */ - async rescan() { - if (this.lastWorkspace) { - await this.scan(this.lastWorkspace, this.lastRepo); - } else { - vscode.window.showInformationMessage('No previous scan. Run "Scan dependencies" first.'); + async _fetchSinglePage(cloudsmithWorkspace, cloudsmithRepo, format, page, pageSize) { + const api = new CloudsmithAPI(this.context); + const paginatedFetch = new PaginatedFetch(api); + const endpoint = cloudsmithRepo + ? `packages/${cloudsmithWorkspace}/${cloudsmithRepo}/` + : `packages/${cloudsmithWorkspace}/`; + + return paginatedFetch.fetchPage(endpoint, page, pageSize, `format:${format}`); + } + + async _runEnrichmentPasses(cloudsmithWorkspace, cloudsmithRepo, progress, token) { + const dependencies = this._fullTrees.flatMap((tree) => tree.dependencies); + const tasks = [ + this._runVulnerabilityEnrichment(dependencies, cloudsmithWorkspace, progress, token), + this._runLicenseEnrichment(dependencies, token), + this._runPolicyEnrichment(dependencies, token), + ]; + + const uncoveredDependencies = dependencies.filter((dependency) => dependency.cloudsmithStatus === "NOT_FOUND"); + if (uncoveredDependencies.length > 0) { + tasks.push(this._runUpstreamGapAnalysis(uncoveredDependencies, cloudsmithWorkspace, cloudsmithRepo, progress, token)); + } + + const results = await Promise.allSettled(tasks); + for (const result of results) { + if (result.status !== "rejected") { + continue; + } + + const message = result.reason && result.reason.message + ? result.reason.message + : String(result.reason || "An enrichment step failed."); + this._warnings.push(message); } } - getTreeItem(element) { - return element.getTreeItem(); + async _runVulnerabilityEnrichment(dependencies, workspace, progress, token) { + const handler = this._createDebouncedEnrichmentHandler((patchMap) => { + this._fullTrees = applyFoundOverlayPatch(this._fullTrees, patchMap, (dependency, vulnerabilities) => ({ + ...dependency, + vulnerabilities, + })); + this._displayTrees = applyFoundOverlayPatch(this._displayTrees, patchMap, (dependency, vulnerabilities) => ({ + ...dependency, + vulnerabilities, + })); + }); + + try { + await this._services.enrichVulnerabilities(dependencies, workspace, { + context: this.context, + cancellationToken: token, + onProgress: (patchMap, meta = {}) => { + if (meta.total > 0) { + progress.report({ + message: `Loading vulnerability details... ${meta.completed}/${meta.total}`, + }); + } + handler.onProgress(patchMap); + }, + }); + } finally { + handler.flush(); + } } - // IMPORTANT: Connection status is checked live from context.secrets every render. - // Do NOT cache this value or rely on external refresh calls to set a connection flag. - // This pattern was adopted after three regressions caused by refresh wiring changes. - async getChildren(element) { - if (element) { - return element.getChildren(); + async _runLicenseEnrichment(dependencies, token) { + const handler = this._createDebouncedEnrichmentHandler((patchMap) => { + this._fullTrees = applyFoundOverlayPatch(this._fullTrees, patchMap, (dependency, license) => ({ + ...dependency, + license, + })); + this._displayTrees = applyFoundOverlayPatch(this._displayTrees, patchMap, (dependency, license) => ({ + ...dependency, + license, + })); + }); + + try { + await this._services.enrichLicenses(dependencies, { + cancellationToken: token, + onProgress: (patchMap) => { + handler.onProgress(patchMap); + }, + }); + } finally { + handler.flush(); } + } - // Show progress message while scanning - if (this._statusMessage) { - return [new InfoNode( - this._statusMessage, - "", - this._statusMessage, - "sync~spin", - "statusMessage" - )]; + async _runPolicyEnrichment(dependencies, token) { + const handler = this._createDebouncedEnrichmentHandler((patchMap) => { + this._fullTrees = applyFoundOverlayPatch(this._fullTrees, patchMap, (dependency, policy) => ({ + ...dependency, + policy, + })); + this._displayTrees = applyFoundOverlayPatch(this._displayTrees, patchMap, (dependency, policy) => ({ + ...dependency, + policy, + })); + }); + + try { + await this._services.enrichPolicies(dependencies, { + cancellationToken: token, + onProgress: (patchMap) => { + handler.onProgress(patchMap); + }, + }); + } finally { + handler.flush(); } + } - // Show failure message if last scan failed - if (this._failureMessage) { - return [new InfoNode( - this._failureMessage, - "", - this._failureMessage, - "error", - "statusMessage" - )]; + async _runUpstreamGapAnalysis(uncoveredDependencies, workspace, repo, progress, token) { + const repositories = repo + ? [repo] + : await this._services.fetchRepositories(workspace, token); + + const handler = this._createDebouncedEnrichmentHandler((patchMap) => { + this._fullTrees = applyUncoveredOverlayPatch(this._fullTrees, patchMap, (dependency, gap) => ({ + ...dependency, + upstreamStatus: gap.upstreamStatus, + upstreamDetail: gap.upstreamDetail, + })); + this._displayTrees = applyUncoveredOverlayPatch(this._displayTrees, patchMap, (dependency, gap) => ({ + ...dependency, + upstreamStatus: gap.upstreamStatus, + upstreamDetail: gap.upstreamDetail, + })); + }); + + try { + await this._services.analyzeUpstreamGaps(uncoveredDependencies, workspace, repositories, { + context: this.context, + cancellationToken: token, + onProgress: (patchMap, meta = {}) => { + if (meta.total > 0) { + progress.report({ + message: `Checking upstream coverage... ${meta.completed}/${meta.total}`, + }); + } + handler.onProgress(patchMap); + }, + }); + } finally { + handler.flush(); } + } - // Show "no manifests found" state - if (this._noManifestsFolder) { - return [new InfoNode( - "No dependency manifests found", - this._noManifestsFolder, - "Supported formats: package.json, requirements.txt, pyproject.toml, pom.xml, go.mod, Cargo.toml", - "warning", - "infoNode" - )]; + async _fetchWorkspaceRepositories(workspace, token) { + const api = new CloudsmithAPI(this.context); + const paginatedFetch = new PaginatedFetch(api); + const endpoint = `repos/${workspace}/?sort=name`; + const repositories = []; + let page = 1; + + while (!token || !token.isCancellationRequested) { + const result = await paginatedFetch.fetchPage(endpoint, page, WORKSPACE_REPOSITORY_PAGE_SIZE); + if (result.error) { + this._warnings.push(`Could not load repositories for upstream analysis. ${result.error}`); + break; + } + + for (const repository of Array.isArray(result.data) ? result.data : []) { + if (repository && repository.slug) { + repositories.push(repository.slug); + } + } + + const pageTotal = result.pagination && result.pagination.pageTotal + ? result.pagination.pageTotal + : 1; + if (page >= pageTotal) { + break; + } + page += 1; } - // Show results if we have them - if (this.dependencies.length > 0) { - return this.dependencies; + return [...new Set(repositories)]; + } + + async _publishDiagnostics() { + if (!this._diagnosticsPublisher) { + return; } - // Welcome state — no scan has been run yet - if (!this._hasScannedOnce) { - const isConnected = await this.context.secrets.get("cloudsmith-vsc.isConnected"); - if (isConnected !== "true") { - return [new InfoNode( - "Connect to Cloudsmith", - "Use the key icon above to set up a personal or service account API key, CLI import, or SSO.", - "Set up Cloudsmith authentication to get started.", - "plug", - undefined, - { command: "cloudsmith-vsc.configureCredentials", title: "Set up authentication" } - )]; - } - // Connected but no scan run yet - return [new InfoNode( - "Scan project dependencies", - "Select the play button above to start.", - "Reads local manifest files (package.json, requirements.txt, pyproject.toml, pom.xml, go.mod, Cargo.toml) and checks each dependency against the selected Cloudsmith workspace.", - "folder", - "dependencyHealthWelcome" - )]; - } - - // Scan completed but no dependencies were found in the manifests - return [new InfoNode( - "No dependencies found", - "", - "The manifest files were parsed but contained no dependency entries.", - "info", - "infoNode" - )]; + const diagnosticNodes = this._fullTrees + .flatMap((tree) => tree.dependencies) + .filter((dependency) => dependency.isDirect) + .map((dependency) => new DependencyHealthNode(dependency, null, this.context)); + + await this._diagnosticsPublisher.publish(this._lastManifests, diagnosticNodes); + } + + buildDependencyNodesForTree(tree) { + if (this._viewMode === "tree") { + return this._buildTreeModeNodes(tree); + } + + return this._buildListModeNodes(tree); + } + + _buildListModeNodes(tree) { + const visibleDependencies = this._viewMode === "direct" + ? tree.dependencies.filter((dependency) => dependency.isDirect) + : tree.dependencies.slice(); + + return visibleDependencies + .filter((dependency) => matchesFilter(dependency, this._filterMode)) + .sort((left, right) => compareDependencies(left, right, this._sortMode, true)) + .map((dependency) => new DependencyHealthNode( + dependency, + null, + this.context, + { childMode: "details" } + )); + } + + _buildTreeModeNodes(tree) { + const roots = getTreeRootDependencies(tree) + .sort((left, right) => compareDependencies(left, right, this._sortMode, false)); + + const filteredRoots = roots + .map((dependency) => buildFilteredTreeWrapper(dependency, this._filterMode, this._sortMode)) + .filter(Boolean); + + const duplicateAwareRoots = annotateDuplicateWrappers(filteredRoots, new Map(), []); + return duplicateAwareRoots.map((wrapper) => this._createTreeDependencyNode(wrapper)); + } + + _createTreeDependencyNode(wrapper) { + return new DependencyHealthNode( + wrapper.dependency, + null, + this.context, + { + childMode: "tree", + treeChildren: wrapper.children, + duplicateReference: wrapper.duplicate, + firstOccurrencePath: wrapper.firstOccurrencePath, + dimmedForFilter: wrapper.dimmedForFilter, + treeChildFactory: (children) => children.map((child) => this._createTreeDependencyNode(child)), + } + ); + } + + async buildReport() { + if (this._fullTrees.length === 0) { + return null; + } + + const dependencies = this._fullTrees.flatMap((tree) => tree.dependencies); + const projectName = path.basename(this.getProjectFolder() || "workspace"); + return buildDependencyHealthReport( + projectName, + dependencies, + this._summary, + formatReportDate(this._reportDateFactory()) + ); + } + + async pullDependencies() { + if (this._scanning) { + vscode.window.showWarningMessage("Wait for the current dependency operation to finish."); + return; + } + + if (!this.lastWorkspace) { + vscode.window.showInformationMessage("Run a dependency scan before pulling dependencies."); + return; + } + + const dependencies = this._fullTrees.flatMap((tree) => tree.dependencies); + if (dependencies.length === 0) { + vscode.window.showInformationMessage("Run a dependency scan before pulling dependencies."); + return; + } + + const cancellationSource = new vscode.CancellationTokenSource(); + this._scanning = true; + this._failureMessage = null; + await this._updateContexts(); + + try { + const result = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Pulling dependencies", + cancellable: true, + }, + async (progress, token) => { + const subscription = token.onCancellationRequested(() => cancellationSource.cancel()); + try { + progress.report({ message: "Preparing pull-through request..." }); + const execution = await this._services.upstreamPullService.run({ + workspace: this.lastWorkspace, + repositoryHint: this.lastRepo, + dependencies, + progress, + token: cancellationSource.token, + }); + + if (!execution || execution.canceled) { + return execution || { canceled: true }; + } + + this.lastRepo = execution.repository.slug; + await this._updateContexts(); + + progress.report({ message: "Refreshing Cloudsmith coverage..." }); + await this._refreshCoverageAfterPull( + execution.workspace, + execution.repository.slug, + progress, + cancellationSource.token + ); + + if (cancellationSource.token.isCancellationRequested) { + return { canceled: true }; + } + + return execution; + } finally { + subscription.dispose(); + } + } + ); + + if (!result) { + return; + } + + if (result.canceled) { + vscode.window.showInformationMessage("Dependency pull canceled."); + return; + } + + if (result.pullResult) { + vscode.window.showInformationMessage( + buildPullSummaryMessage(result.pullResult, result.plan.skippedDependencies.length) + ); + } + } finally { + cancellationSource.dispose(); + this._scanning = false; + await this._updateContexts(); + this.refresh(); + } + } + + async pullSingleDependency(item) { + if (this._scanning) { + vscode.window.showWarningMessage("Wait for the current dependency operation to finish."); + return; + } + + if (!this.lastWorkspace) { + vscode.window.showInformationMessage("Run a dependency scan before pulling dependencies."); + return; + } + + const dependency = createSingleDependencyPullTarget(item); + if (!dependency) { + vscode.window.showWarningMessage("Could not determine the dependency details."); + return; + } + + const prepared = await this._services.upstreamPullService.prepareSingle({ + workspace: this.lastWorkspace, + repositoryHint: this.lastRepo, + dependency, + }); + if (!prepared) { + return; + } + + const cancellationSource = new vscode.CancellationTokenSource(); + this._scanning = true; + this._failureMessage = null; + await this._updateContexts(); + + try { + const result = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Pulling ${formatSingleDependencyLabel(prepared.dependency)} through ${prepared.repository.slug}...`, + cancellable: true, + }, + async (progress, token) => { + const subscription = token.onCancellationRequested(() => cancellationSource.cancel()); + try { + progress.report({ message: "Triggering upstream pull..." }); + const execution = await this._services.upstreamPullService.execute(prepared, { + progress, + token: cancellationSource.token, + }); + + if (!execution || execution.canceled) { + return execution || { canceled: true }; + } + + this.lastRepo = prepared.repository.slug; + await this._updateContexts(); + + const pullDetail = getSingleDependencyPullDetail(execution.pullResult); + if (isSuccessfulSingleDependencyPull(pullDetail)) { + progress.report({ message: "Refreshing Cloudsmith coverage..." }); + await this._refreshSingleDependencyAfterPull( + prepared.workspace, + prepared.repository.slug, + prepared.dependency, + progress, + cancellationSource.token + ); + } + + return { + ...prepared, + ...execution, + }; + } finally { + subscription.dispose(); + } + } + ); + + if (!result) { + return; + } + + if (result.canceled) { + vscode.window.showInformationMessage("Dependency pull canceled."); + return; + } + + const notification = buildSingleDependencyPullNotification( + prepared.dependency, + prepared.repository.slug, + getSingleDependencyPullDetail(result.pullResult) + ); + if (notification.level === "error") { + vscode.window.showErrorMessage(notification.message); + } else { + vscode.window.showInformationMessage(notification.message); + } + } finally { + cancellationSource.dispose(); + this._scanning = false; + await this._updateContexts(); + this.refresh(); + } + } + + async _refreshCoverageAfterPull(cloudsmithWorkspace, cloudsmithRepo, progress, token) { + clearPackageIndexCache(cloudsmithWorkspace, cloudsmithRepo); + await this._refreshCoverageForDependencies( + cloudsmithWorkspace, + cloudsmithRepo, + null, + progress, + token, + { refreshRemainingUpstream: true } + ); + } + + async _refreshSingleDependencyAfterPull(cloudsmithWorkspace, cloudsmithRepo, dependency, progress, token) { + clearPackageIndexCache(cloudsmithWorkspace, cloudsmithRepo, dependency.format || dependency.ecosystem); + await this._refreshCoverageForDependencies( + cloudsmithWorkspace, + cloudsmithRepo, + [dependency], + progress, + token + ); + } + + async _refreshCoverageForDependencies( + cloudsmithWorkspace, + cloudsmithRepo, + targetDependencies, + progress, + token, + options = {} + ) { + const targetKeys = new Set( + (Array.isArray(targetDependencies) ? targetDependencies : []) + .map((dependency) => coverageLookupKey(dependency)) + .filter(Boolean) + ); + const unresolvedDependencies = uniqueDependenciesForCoverage( + this._fullTrees + .flatMap((tree) => tree.dependencies) + .filter((dependency) => ( + dependency.cloudsmithStatus !== "FOUND" + && (targetKeys.size === 0 || targetKeys.has(coverageLookupKey(dependency))) + )) + ); + const totalDependencies = unresolvedDependencies.length; + + if (totalDependencies === 0) { + await this._publishDiagnostics(); + this._rebuildSummary(); + await this._storeReportData(this._reportDateFactory()); + return []; + } + + const previousFoundKeys = new Set( + this._fullTrees + .flatMap((tree) => tree.dependencies) + .filter((dependency) => ( + dependency.cloudsmithStatus === "FOUND" + && (targetKeys.size === 0 || targetKeys.has(coverageLookupKey(dependency))) + )) + .map((dependency) => coverageLookupKey(dependency)) + .filter(Boolean) + ); + + const dependenciesByFormat = groupDependenciesByFormat([{ dependencies: unresolvedDependencies }]); + await this._runCoverageResolution( + cloudsmithWorkspace, + cloudsmithRepo, + dependenciesByFormat, + totalDependencies, + progress, + token, + { + packageIndexFailureVerb: "refresh", + progressLabel: "Refreshing Cloudsmith coverage", + } + ); + + const newlyFoundDependencies = uniqueDependenciesForCoverage( + this._fullTrees + .flatMap((tree) => tree.dependencies) + .filter((dependency) => { + const key = coverageLookupKey(dependency); + return dependency.cloudsmithStatus === "FOUND" + && Boolean(key) + && !previousFoundKeys.has(key) + && (targetKeys.size === 0 || targetKeys.has(key)); + }) + ); + + if (newlyFoundDependencies.length > 0) { + progress.report({ + message: targetKeys.size > 0 + ? "Enriching pulled dependency..." + : "Enriching newly covered dependencies...", + }); + await Promise.all([ + this._runVulnerabilityEnrichment(newlyFoundDependencies, cloudsmithWorkspace, progress, token), + this._runLicenseEnrichment(newlyFoundDependencies, token), + this._runPolicyEnrichment(newlyFoundDependencies, token), + ]); + } + + if (options.refreshRemainingUpstream) { + const remainingUncovered = this._fullTrees + .flatMap((tree) => tree.dependencies) + .filter((dependency) => dependency.cloudsmithStatus === "NOT_FOUND"); + if (remainingUncovered.length > 0) { + progress.report({ message: "Refreshing upstream availability..." }); + await this._runUpstreamGapAnalysis( + remainingUncovered, + cloudsmithWorkspace, + cloudsmithRepo, + progress, + token + ); + } + } + + await this._publishDiagnostics(); + this._rebuildSummary(); + await this._storeReportData(this._reportDateFactory()); + this.refresh(); + + return newlyFoundDependencies; + } + + async rescan() { + if (!this.lastWorkspace) { + vscode.window.showInformationMessage('No previous scan. Run "Scan dependencies" first.'); + return; + } + await this.scan(this.lastWorkspace, this.lastRepo); + } + + getTreeItem(element) { + return element.getTreeItem(); + } + + async getChildren(element) { + if (element) { + return element.getChildren(); + } + + if (this._statusMessage) { + return [ + new InfoNode( + this._statusMessage, + "", + this._statusMessage, + "loading~spin", + "statusMessage" + ), + ]; + } + + if (this._failureMessage) { + return [ + new InfoNode( + this._failureMessage, + "", + this._failureMessage, + "error", + "statusMessage" + ), + ]; + } + + if (this._noManifestsFolder) { + return [ + new InfoNode( + "No dependency manifests or lockfiles found", + this._noManifestsFolder, + "Supported formats include npm, Python, Maven, Gradle, Go, Cargo, Ruby, Docker, NuGet, Dart, Composer, Helm, Swift, and Hex.", + "warning", + "infoNode" + ), + ]; + } + + if (this._displayTrees.length > 0) { + const nodes = [new DependencySummaryNode(this._summary)]; + if (this._warnings.length > 0) { + nodes.push(new InfoNode( + this._warnings[0], + "", + this._warnings.join("\n"), + "warning", + "statusMessage" + )); + } + nodes.push(...this._displayTrees.map((tree) => new DependencySourceGroupNode(tree, this))); + return nodes; + } + + if (!this._hasScannedOnce) { + const isConnected = this.context && this.context.secrets + ? await this.context.secrets.get("cloudsmith-vsc.isConnected") + : "false"; + if (isConnected !== "true") { + return [ + new InfoNode( + "Connect to Cloudsmith", + "Use the key icon above to set up authentication.", + "Set up Cloudsmith authentication to get started.", + "plug", + undefined, + { command: "cloudsmith-vsc.configureCredentials", title: "Set up authentication" } + ), + ]; + } + + return [ + new InfoNode( + "Scan dependencies", + "Select the play button above to start.", + "Scans lockfiles and manifests, resolves direct and transitive dependencies, and checks each one against Cloudsmith.", + "folder", + "dependencyHealthWelcome" + ), + ]; + } + + return [ + new InfoNode( + "No dependencies found", + "", + "The detected dependency files did not contain any dependencies to scan.", + "info", + "infoNode" + ), + ]; } refresh() { this._onDidChangeTreeData.fire(); } + + _rebuildSummary() { + this._summary = buildDependencySummary(this._fullTrees, this._displayTrees, { + filterMode: this._filterMode, + }); + this.dependencies = this._displayTrees.flatMap((tree) => tree.dependencies); + } +} + +DependencyHealthProvider.packageIndexCache = new Map(); + +function pruneExpiredPackageIndexCache(now = Date.now()) { + for (const [cacheKey, cacheEntry] of DependencyHealthProvider.packageIndexCache.entries()) { + if (!cacheEntry || cacheEntry.expiresAt <= now) { + DependencyHealthProvider.packageIndexCache.delete(cacheKey); + } + } +} + +function getCachedPackageIndexValue(cacheKey) { + const cached = DependencyHealthProvider.packageIndexCache.get(cacheKey); + if (!cached) { + return null; + } + + if (cached.expiresAt > Date.now()) { + return cached.value; + } + + DependencyHealthProvider.packageIndexCache.delete(cacheKey); + return null; +} + +function setCachedPackageIndexValue(cacheKey, value) { + if (DependencyHealthProvider.packageIndexCache.size >= PACKAGE_INDEX_CACHE_MAX_SIZE) { + pruneExpiredPackageIndexCache(); + } + + DependencyHealthProvider.packageIndexCache.set(cacheKey, { + expiresAt: Date.now() + PACKAGE_INDEX_TTL_MS, + value, + }); +} + +function normalizeTree(tree) { + return { + ecosystem: tree.ecosystem, + sourceFile: tree.sourceFile, + dependencies: deduplicateDependenciesWithStatus( + (tree.dependencies || []).map((dependency) => normalizeDependency(dependency, tree)) + ), + }; +} + +function normalizeDependency(dependency, tree) { + const ecosystem = dependency.ecosystem || tree.ecosystem; + const format = dependency.format || canonicalFormat(ecosystem); + return { + ...dependency, + ecosystem, + format, + sourceFile: dependency.sourceFile || tree.sourceFile, + parent: dependency.parent || null, + parentChain: Array.isArray(dependency.parentChain) ? dependency.parentChain.slice() : [], + transitives: Array.isArray(dependency.transitives) + ? dependency.transitives.map((child) => normalizeDependency(child, tree)) + : [], + cloudsmithStatus: dependency.cloudsmithStatus || null, + cloudsmithPackage: dependency.cloudsmithPackage || null, + devDependency: Boolean(dependency.devDependency || dependency.isDevelopmentDependency), + isDevelopmentDependency: Boolean(dependency.isDevelopmentDependency || dependency.devDependency), + vulnerabilities: dependency.vulnerabilities || null, + license: dependency.license || null, + policy: dependency.policy || null, + upstreamStatus: dependency.upstreamStatus || null, + upstreamDetail: dependency.upstreamDetail || null, + }; +} + +function deduplicateDependenciesWithStatus(dependencies) { + const seen = new Map(); + const results = []; + + for (const dependency of dependencies) { + const key = displayDependencyKey(dependency); + const existing = seen.get(key); + if (!existing) { + seen.set(key, dependency); + results.push(dependency); + continue; + } + + if (!existing.isDirect && dependency.isDirect) { + const index = results.indexOf(existing); + if (index !== -1) { + results[index] = dependency; + } + seen.set(key, dependency); + } + } + + return results; +} + +function displayDependencyKey(dependency) { + return [ + dependency.sourceFile || "", + dependency.format || "", + dependency.name || "", + dependency.version || "", + dependency.isDirect ? "direct" : "transitive", + (dependency.parentChain || []).join(">"), + ].join(":").toLowerCase(); +} + +function coverageLookupKey(dependency) { + return [ + canonicalFormat(dependency.format || dependency.ecosystem), + normalizePackageName(dependency.name, dependency.format || dependency.ecosystem), + String(dependency.version || "").toLowerCase(), + ].join(":"); +} + +function groupDependenciesByFormat(trees) { + const byFormat = {}; + for (const tree of trees) { + for (const dependency of tree.dependencies) { + if (!byFormat[dependency.format]) { + byFormat[dependency.format] = []; + } + byFormat[dependency.format].push(dependency); + } + } + return byFormat; +} + +function uniqueDependenciesForCoverage(dependencies) { + const seen = new Set(); + const unique = []; + for (const dependency of dependencies) { + const key = coverageLookupKey(dependency); + if (seen.has(key)) { + continue; + } + seen.add(key); + unique.push(dependency); + } + return unique; +} + +function countCoverageDependencies(trees) { + return Object.values(groupDependenciesByFormat(trees)) + .reduce((count, dependencies) => count + uniqueDependenciesForCoverage(dependencies).length, 0); +} + +function clearPackageIndexCache(workspace, repo, format) { + const workspaceKey = String(workspace || "").toLowerCase(); + const repoKey = String(repo || "").toLowerCase(); + const formatKey = format ? String(canonicalFormat(format) || format).toLowerCase() : null; + + for (const cacheKey of DependencyHealthProvider.packageIndexCache.keys()) { + if (!cacheKey.startsWith(`${workspaceKey}:${repoKey}:`)) { + continue; + } + + if (formatKey && !cacheKey.endsWith(`:${formatKey}`)) { + continue; + } + + DependencyHealthProvider.packageIndexCache.delete(cacheKey); + } +} + +function buildPackageIndex(packages, format) { + const index = new Map(); + for (const pkg of packages) { + const versionKey = String(pkg.version || "").toLowerCase(); + for (const nameKey of getCloudsmithPackageLookupKeys(pkg, format)) { + if (!index.has(nameKey)) { + index.set(nameKey, new Map()); + } + const versionMap = index.get(nameKey); + if (!versionMap.has(versionKey)) { + versionMap.set(versionKey, []); + } + versionMap.get(versionKey).push(pkg); + } + } + return index; +} + +function findCoverageMatch(packageIndex, dependency) { + for (const lookupKey of getPackageLookupKeys(dependency.name, dependency.format)) { + const versions = packageIndex.get(lookupKey); + if (!versions) { + continue; + } + const versionKey = String(dependency.version || "").toLowerCase(); + if (versionKey && versions.has(versionKey)) { + return versions.get(versionKey)[0] || null; + } + const firstMatch = [...versions.values()][0]; + if (firstMatch && firstMatch[0]) { + return firstMatch[0]; + } + } + return null; +} + +function matchCoverageCandidates(candidates, dependency) { + const dependencyKeys = getPackageLookupKeys(dependency.name, dependency.format); + let nameMatch = null; + + for (const candidate of candidates) { + const candidateKeys = new Set(getCloudsmithPackageLookupKeys(candidate, dependency.format)); + const nameMatches = dependencyKeys.some((key) => candidateKeys.has(key)); + if (!nameMatches) { + continue; + } + if (!dependency.version || candidate.version === dependency.version) { + return candidate; + } + if (!nameMatch) { + nameMatch = candidate; + } + } + return nameMatch; +} + +function applyCoverageMatchBatchToTrees(trees, matchMap) { + return applyPatchMapToTrees(trees, coverageLookupKey, matchMap, (dependency, patch) => ({ + ...dependency, + cloudsmithStatus: patch.cloudsmithStatus, + cloudsmithPackage: patch.cloudsmithPackage, + upstreamStatus: Object.prototype.hasOwnProperty.call(patch, "upstreamStatus") + ? patch.upstreamStatus + : dependency.upstreamStatus, + upstreamDetail: Object.prototype.hasOwnProperty.call(patch, "upstreamDetail") + ? patch.upstreamDetail + : dependency.upstreamDetail, + })); +} + +function applyFoundOverlayPatch(trees, patchMap, mergeFn) { + return applyPatchMapToTrees(trees, getFoundDependencyKey, patchMap, mergeFn); +} + +function applyUncoveredOverlayPatch(trees, patchMap, mergeFn) { + return applyPatchMapToTrees(trees, getUncoveredDependencyKey, patchMap, mergeFn); +} + +function applyPatchMapToTrees(trees, getKey, patchMap, mergeFn) { + if (!(patchMap instanceof Map) || patchMap.size === 0) { + return trees; + } + + return trees.map((tree) => ({ + ...tree, + dependencies: tree.dependencies.map((dependency) => applyRecursiveDependencyPatch( + dependency, + getKey, + patchMap, + mergeFn + )), + })); +} + +function applyRecursiveDependencyPatch(dependency, getKey, patchMap, mergeFn) { + const key = getKey(dependency); + const hasPatch = Boolean(key) && patchMap.has(key); + const mergedDependency = hasPatch ? mergeFn(dependency, patchMap.get(key), key) : dependency; + const originalChildren = Array.isArray(mergedDependency.transitives) ? mergedDependency.transitives : []; + const nextChildren = originalChildren.map((child) => applyRecursiveDependencyPatch(child, getKey, patchMap, mergeFn)); + if (originalChildren === nextChildren || arraysEqualByReference(originalChildren, nextChildren)) { + return mergedDependency; + } + return { + ...mergedDependency, + transitives: nextChildren, + }; +} + +function applyRecursiveDependencyUpdate(dependency, predicate, mergeFn) { + const mergedDependency = predicate(dependency) ? mergeFn(dependency) : dependency; + const originalChildren = Array.isArray(mergedDependency.transitives) ? mergedDependency.transitives : []; + const nextChildren = originalChildren.map((child) => applyRecursiveDependencyUpdate(child, predicate, mergeFn)); + if (originalChildren === nextChildren || arraysEqualByReference(originalChildren, nextChildren)) { + return mergedDependency; + } + return { + ...mergedDependency, + transitives: nextChildren, + }; +} + +function arraysEqualByReference(left, right) { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} + +function markTreesAsChecking(trees) { + return trees.map((tree) => ({ + ...tree, + dependencies: tree.dependencies.map((dependency) => applyRecursiveDependencyUpdate( + dependency, + () => true, + (candidate) => ({ + ...candidate, + cloudsmithStatus: "CHECKING", + cloudsmithPackage: null, + vulnerabilities: null, + license: null, + policy: null, + upstreamStatus: null, + upstreamDetail: null, + }) + )), + })); +} + +function limitDisplayTrees(trees, maxDependencies) { + const allDependencies = []; + for (const tree of trees) { + for (const dependency of tree.dependencies) { + allDependencies.push(dependency); + } + } + + if (allDependencies.length <= maxDependencies) { + return { + trees: trees.map((tree) => ({ + ...tree, + dependencies: tree.dependencies.slice().sort((left, right) => compareDependencies(left, right, SORT_MODES.ALPHABETICAL, true)), + })), + truncated: false, + totalDependencies: allDependencies.length, + }; + } + + const allowedKeys = new Set( + allDependencies + .slice() + .sort(compareDependenciesForLimit) + .slice(0, maxDependencies) + .map(displayDependencyKey) + ); + + const limitedTrees = trees + .map((tree) => ({ + ...tree, + dependencies: tree.dependencies + .filter((dependency) => allowedKeys.has(displayDependencyKey(dependency))) + .map((dependency) => pruneDependencyTree(dependency, allowedKeys)) + .sort((left, right) => compareDependencies(left, right, SORT_MODES.ALPHABETICAL, true)), + })) + .filter((tree) => tree.dependencies.length > 0); + + return { + trees: limitedTrees, + truncated: true, + totalDependencies: allDependencies.length, + }; +} + +function pruneDependencyTree(dependency, allowedKeys) { + const transitives = Array.isArray(dependency.transitives) + ? dependency.transitives + .filter((child) => allowedKeys.has(displayDependencyKey(child))) + .map((child) => pruneDependencyTree(child, allowedKeys)) + : []; + + return { + ...dependency, + transitives, + }; +} + +function compareDependenciesForLimit(left, right) { + if (left.isDirect !== right.isDirect) { + return left.isDirect ? -1 : 1; + } + return compareDependencies(left, right, SORT_MODES.ALPHABETICAL, false); +} + +function compareDependencies(left, right, sortMode, preferDirect) { + if (preferDirect && left.isDirect !== right.isDirect) { + return left.isDirect ? -1 : 1; + } + + if (sortMode === SORT_MODES.SEVERITY) { + const severityDelta = dependencySeveritySortGroup(left) - dependencySeveritySortGroup(right); + if (severityDelta !== 0) { + return severityDelta; + } + } + + if (sortMode === SORT_MODES.COVERAGE) { + const coverageDelta = dependencyCoverageSortGroup(left) - dependencyCoverageSortGroup(right); + if (coverageDelta !== 0) { + return coverageDelta; + } + } + + const leftName = String(left.name || "").toLowerCase(); + const rightName = String(right.name || "").toLowerCase(); + if (leftName !== rightName) { + return leftName.localeCompare(rightName); + } + + return String(left.version || "").localeCompare(String(right.version || "")); +} + +function dependencyCoverageSortGroup(dependency) { + if (dependency.cloudsmithStatus === "NOT_FOUND") { + return 0; + } + if (dependency.cloudsmithStatus === "CHECKING") { + return 2; + } + return 1; +} + +function dependencySeveritySortGroup(dependency) { + if (dependency.cloudsmithStatus !== "FOUND") { + return dependency.cloudsmithStatus === "NOT_FOUND" ? 5 : 6; + } + + const policy = getDependencyPolicyData(dependency); + const vulnerabilities = getDependencyVulnerabilityData(dependency); + const licenseClassification = getDependencyLicenseClassification(dependency); + + if (policy && (policy.quarantined || policy.denied)) { + return 0; + } + + if (vulnerabilities && vulnerabilities.count > 0) { + if (vulnerabilities.maxSeverity === "Critical") { + return 1; + } + if (vulnerabilities.maxSeverity === "High") { + return 2; + } + return 3; + } + + if (licenseClassification === "restrictive") { + return 2; + } + + if (licenseClassification === "weak_copyleft" || (policy && policy.violated)) { + return 3; + } + + return 4; +} + +function getTreeRootDependencies(tree) { + return (tree.dependencies || []).filter((dependency) => { + const hasParentChain = Array.isArray(dependency.parentChain) && dependency.parentChain.length > 0; + return !dependency.parent && !hasParentChain; + }); +} + +function buildFilteredTreeWrapper(dependency, filterMode, sortMode) { + const children = Array.isArray(dependency.transitives) + ? dependency.transitives + .slice() + .sort((left, right) => compareDependencies(left, right, sortMode, false)) + .map((child) => buildFilteredTreeWrapper(child, filterMode, sortMode)) + .filter(Boolean) + : []; + const matches = matchesFilter(dependency, filterMode); + + if (filterMode && !matches && children.length === 0) { + return null; + } + + return { + dependency, + children, + duplicate: false, + firstOccurrencePath: null, + dimmedForFilter: Boolean(filterMode) && !matches, + }; +} + +function annotateDuplicateWrappers(wrappers, seen, ancestry) { + return wrappers.map((wrapper) => { + const pathLabel = ancestry.concat(wrapper.dependency.name).join(" > "); + const duplicateKey = buildDuplicateKey(wrapper.dependency); + if (duplicateKey && seen.has(duplicateKey)) { + return { + ...wrapper, + duplicate: true, + firstOccurrencePath: seen.get(duplicateKey), + children: [], + }; + } + + if (duplicateKey) { + seen.set(duplicateKey, pathLabel); + } + + return { + ...wrapper, + children: annotateDuplicateWrappers(wrapper.children, seen, ancestry.concat(wrapper.dependency.name)), + }; + }); +} + +function buildDuplicateKey(dependency) { + const name = String(dependency.name || "").trim().toLowerCase(); + const version = String(dependency.version || "").trim().toLowerCase(); + if (!name) { + return null; + } + return `${name}:${version}`; +} + +function matchesFilter(dependency, filterMode) { + const vulnerabilities = getDependencyVulnerabilityData(dependency); + const policy = getDependencyPolicyData(dependency); + const licenseClassification = getDependencyLicenseClassification(dependency); + + if (!filterMode) { + return true; + } + + switch (filterMode) { + case FILTER_MODES.VULNERABLE: + return Boolean(vulnerabilities && vulnerabilities.count > 0); + case FILTER_MODES.UNCOVERED: + return dependency.cloudsmithStatus === "NOT_FOUND"; + case FILTER_MODES.RESTRICTIVE_LICENSE: + return licenseClassification === "restrictive"; + case FILTER_MODES.POLICY_VIOLATION: + return Boolean(policy && policy.violated); + default: + return true; + } +} + +function getFilterLabel(filterMode) { + switch (filterMode) { + case FILTER_MODES.VULNERABLE: + return "vulnerable only"; + case FILTER_MODES.UNCOVERED: + return "not in Cloudsmith"; + case FILTER_MODES.RESTRICTIVE_LICENSE: + return "restrictive licenses"; + case FILTER_MODES.POLICY_VIOLATION: + return "policy violations"; + default: + return null; + } +} + +function buildDependencySummary(fullTrees, displayTrees, options = {}) { + const fullDependencies = fullTrees.flatMap((tree) => tree.dependencies); + const displayDependencies = displayTrees.flatMap((tree) => tree.dependencies); + const summaryDependencies = fullDependencies.length > 0 ? fullDependencies : displayDependencies; + const direct = fullDependencies.filter((dependency) => dependency.isDirect).length; + const ecosystems = {}; + + for (const tree of fullTrees) { + ecosystems[tree.ecosystem] = (ecosystems[tree.ecosystem] || 0) + tree.dependencies.length; + } + + const found = summaryDependencies.filter((dependency) => dependency.cloudsmithStatus === "FOUND").length; + const notFound = summaryDependencies.filter((dependency) => dependency.cloudsmithStatus === "NOT_FOUND").length; + const checking = summaryDependencies.filter((dependency) => dependency.cloudsmithStatus === "CHECKING").length; + const reachableViaUpstream = summaryDependencies.filter((dependency) => ( + dependency.cloudsmithStatus === "NOT_FOUND" && dependency.upstreamStatus === "reachable" + )).length; + const unreachableViaUpstream = summaryDependencies.filter((dependency) => ( + dependency.cloudsmithStatus === "NOT_FOUND" + && (dependency.upstreamStatus === "no_proxy" || dependency.upstreamStatus === "unreachable") + )).length; + const vulnerable = summaryDependencies.filter((dependency) => { + const vulnerabilities = getDependencyVulnerabilityData(dependency); + return dependency.cloudsmithStatus === "FOUND" && vulnerabilities && vulnerabilities.count > 0; + }).length; + const severityCounts = {}; + for (const dependency of summaryDependencies) { + const vulnerabilities = getDependencyVulnerabilityData(dependency); + if (dependency.cloudsmithStatus === "FOUND" && vulnerabilities && vulnerabilities.count > 0 && vulnerabilities.maxSeverity) { + severityCounts[vulnerabilities.maxSeverity] = (severityCounts[vulnerabilities.maxSeverity] || 0) + 1; + } + } + + const permissiveLicenses = summaryDependencies.filter((dependency) => ( + dependency.cloudsmithStatus === "FOUND" + && getDependencyLicenseClassification(dependency) === "permissive" + )).length; + const weakCopyleftLicenses = summaryDependencies.filter((dependency) => ( + dependency.cloudsmithStatus === "FOUND" + && getDependencyLicenseClassification(dependency) === "weak_copyleft" + )).length; + const restrictiveLicenses = summaryDependencies.filter((dependency) => ( + dependency.cloudsmithStatus === "FOUND" + && getDependencyLicenseClassification(dependency) === "restrictive" + )).length; + const unknownLicenses = summaryDependencies.filter((dependency) => ( + dependency.cloudsmithStatus === "FOUND" + && getDependencyLicenseClassification(dependency) === "unknown" + )).length; + const policyViolations = summaryDependencies.filter((dependency) => { + const policy = getDependencyPolicyData(dependency); + return dependency.cloudsmithStatus === "FOUND" && policy && policy.violated; + }).length; + const quarantined = summaryDependencies.filter((dependency) => { + const policy = getDependencyPolicyData(dependency); + return dependency.cloudsmithStatus === "FOUND" && policy && (policy.quarantined || policy.denied); + }).length; + + const filterMode = options.filterMode || null; + const filterLabel = getFilterLabel(filterMode); + const filteredCount = filterMode + ? summaryDependencies.filter((dependency) => matchesFilter(dependency, filterMode)).length + : 0; + + return { + total: fullDependencies.length, + direct, + transitive: fullDependencies.length - direct, + found, + notFound, + reachableViaUpstream, + unreachableViaUpstream, + ecosystems, + coveragePercent: summaryDependencies.length === 0 + ? 0 + : Math.round((found / summaryDependencies.length) * 100), + checking, + vulnerable, + severityCounts, + restrictiveLicenses, + weakCopyleftLicenses, + permissiveLicenses, + unknownLicenses, + policyViolations, + quarantined, + filterMode, + filterLabel, + filteredCount, + }; +} + +function emptySummary() { + return { + total: 0, + direct: 0, + transitive: 0, + found: 0, + notFound: 0, + reachableViaUpstream: 0, + unreachableViaUpstream: 0, + ecosystems: {}, + coveragePercent: 0, + checking: 0, + vulnerable: 0, + severityCounts: {}, + restrictiveLicenses: 0, + weakCopyleftLicenses: 0, + permissiveLicenses: 0, + unknownLicenses: 0, + policyViolations: 0, + quarantined: 0, + filterMode: null, + filterLabel: null, + filteredCount: 0, + }; +} + +async function runPromisePool(items, concurrency, worker) { + const workers = []; + let index = 0; + const size = Math.max(1, Math.min(concurrency, items.length || 1)); + + for (let workerIndex = 0; workerIndex < size; workerIndex += 1) { + workers.push((async () => { + while (index < items.length) { + const item = items[index]; + index += 1; + if (item === undefined) { + break; + } + await worker(item); + } + })()); + } + + await Promise.all(workers); +} + +function mergePatchMaps(patchMaps) { + const mergedPatchMap = new Map(); + + for (const patchMap of patchMaps) { + if (!(patchMap instanceof Map)) { + continue; + } + + for (const [key, value] of patchMap.entries()) { + mergedPatchMap.set(key, value); + } + } + + return mergedPatchMap; +} + +function yieldToEventLoop() { + return new Promise((resolve) => { + if (typeof setImmediate === "function") { + setImmediate(resolve); + return; + } + + setTimeout(resolve, 0); + }); +} + +function createSingleDependencyPullTarget(item) { + if (!item || typeof item !== "object") { + return null; + } + + const name = String(item.name || "").trim(); + const format = canonicalFormat(item.format || item.ecosystem); + if (!name || !format) { + return null; + } + + const versionValue = typeof item.declaredVersion === "string" + ? item.declaredVersion + : (typeof item.version === "string" ? item.version : ""); + + return { + ...item, + name, + version: versionValue || "", + format, + ecosystem: item.ecosystem || format, + }; +} + +function formatSingleDependencyLabel(dependency) { + const name = String(dependency && dependency.name || "").trim() || "dependency"; + const version = String(dependency && dependency.version || "").trim(); + return version ? `${name}@${version}` : name; +} + +function getSingleDependencyPullDetail(pullResult) { + return pullResult && Array.isArray(pullResult.details) ? (pullResult.details[0] || null) : null; +} + +function isSuccessfulSingleDependencyPull(detail) { + return Boolean( + detail + && (detail.status === PULL_STATUS.CACHED || detail.status === PULL_STATUS.ALREADY_EXISTS) + ); +} + +function buildSingleDependencyPullNotification(dependency, repositorySlug, detail) { + const dependencyLabel = formatSingleDependencyLabel(dependency); + if (!detail) { + return { + level: "error", + message: `Could not pull ${dependencyLabel}.`, + }; + } + + switch (detail.status) { + case PULL_STATUS.CACHED: + case PULL_STATUS.ALREADY_EXISTS: + return { + level: "info", + message: `${dependencyLabel} cached in ${repositorySlug}`, + }; + case PULL_STATUS.NOT_FOUND: + return { + level: "info", + message: `${dependencyLabel} not found on the upstream source.`, + }; + case PULL_STATUS.AUTH_FAILED: + return { + level: "error", + message: "Authentication failed. Check your API key in Cloudsmith settings.", + }; + default: + return { + level: "error", + message: detail.errorMessage + ? `Could not pull ${dependencyLabel}. ${detail.errorMessage}` + : `Could not pull ${dependencyLabel}.`, + }; + } +} + +function formatReportDate(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return new Date().toISOString().slice(0, 10); + } + return date.toISOString().slice(0, 10); +} + +function normalizeReportTimestamp(value) { + const date = value instanceof Date ? value : new Date(value); + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return new Date().toISOString(); + } + return date.toISOString(); +} + +function buildComplianceReportData(projectName, dependencies, options = {}) { + const uniqueDependencies = dedupeComplianceDependencies(dependencies); + const ecosystemBreakdown = {}; + + for (const dependency of uniqueDependencies) { + const ecosystem = String(dependency.format || dependency.ecosystem || "unknown").toLowerCase(); + ecosystemBreakdown[ecosystem] = (ecosystemBreakdown[ecosystem] || 0) + 1; + } + + const vulnerableDeps = uniqueDependencies + .filter((dependency) => dependency.cloudsmithStatus === "FOUND") + .map((dependency) => { + const vulnerabilities = getDependencyVulnerabilityData(dependency); + if (!vulnerabilities || vulnerabilities.count <= 0) { + return null; + } + + const fixEntry = Array.isArray(vulnerabilities.entries) + ? vulnerabilities.entries.find((entry) => entry && entry.fixVersion) + : null; + + return { + name: dependency.name, + version: dependency.version || "", + isDirect: Boolean(dependency.isDirect), + maxSeverity: vulnerabilities.maxSeverity || null, + cveCount: vulnerabilities.count || 0, + hasFixAvailable: Boolean(fixEntry || vulnerabilities.hasFixAvailable), + }; + }) + .filter(Boolean) + .sort(compareComplianceVulnerabilityRows); + + const severityCounts = {}; + for (const dependency of vulnerableDeps) { + const severity = dependency.maxSeverity || "Unknown"; + severityCounts[severity] = (severityCounts[severity] || 0) + 1; + } + + const restrictiveLicenseDeps = uniqueDependencies + .filter((dependency) => dependency.cloudsmithStatus === "FOUND") + .map((dependency) => { + const classification = getDependencyLicenseClassification(dependency); + if (!["restrictive", "weak_copyleft"].includes(classification)) { + return null; + } + + const licenseData = dependency.license || null; + const inspection = dependency.cloudsmithPackage + ? LicenseClassifier.inspect(dependency.cloudsmithPackage) + : LicenseClassifier.inspect(null); + const spdx = licenseData && licenseData.spdx + ? licenseData.spdx + : dependency.spdx_license + ? dependency.spdx_license + : inspection.spdxLicense || inspection.displayValue || ""; + + return { + name: dependency.name, + version: dependency.version || "", + spdx, + classification: humanizeLicenseClassification(classification), + }; + }) + .filter(Boolean) + .sort(compareNamedRows); + + const policyViolationDeps = uniqueDependencies + .filter((dependency) => dependency.cloudsmithStatus === "FOUND") + .map((dependency) => { + const policy = getDependencyPolicyData(dependency); + if (!policy || !policy.violated) { + return null; + } + + return { + name: dependency.name, + version: dependency.version || "", + status: humanizePolicyStatus(policy), + detail: policy.statusReason || defaultPolicyDetail(policy), + }; + }) + .filter(Boolean) + .sort(compareCompliancePolicyRows); + + const uncoveredDeps = uniqueDependencies + .filter((dependency) => dependency.cloudsmithStatus === "NOT_FOUND") + .map((dependency) => ({ + name: dependency.name, + version: dependency.version || "", + ecosystem: dependency.format || dependency.ecosystem || "", + upstreamStatus: dependency.upstreamStatus || "unknown", + upstreamDetail: dependency.upstreamDetail || defaultUpstreamDetail(dependency.upstreamStatus), + })) + .sort(compareComplianceUncoveredRows); + + const total = uniqueDependencies.length; + const direct = uniqueDependencies.filter((dependency) => dependency.isDirect).length; + const found = uniqueDependencies.filter((dependency) => dependency.cloudsmithStatus === "FOUND").length; + const notFound = uniqueDependencies.filter((dependency) => dependency.cloudsmithStatus === "NOT_FOUND").length; + const upstreamReachable = uncoveredDeps.filter((dependency) => dependency.upstreamStatus === "reachable").length; + const upstreamNoProxy = uncoveredDeps.filter((dependency) => dependency.upstreamStatus === "no_proxy").length; + const upstreamUnreachable = uncoveredDeps.filter((dependency) => dependency.upstreamStatus === "unreachable").length; + + return { + projectName: projectName || "workspace", + scanDate: normalizeReportTimestamp(options.scanDate), + summary: { + total, + direct, + transitive: Math.max(total - direct, 0), + found, + notFound, + coveragePct: total === 0 ? 0 : Math.round((found / total) * 100), + vulnCount: vulnerableDeps.length, + criticalCount: severityCounts.Critical || 0, + highCount: severityCounts.High || 0, + mediumCount: severityCounts.Medium || 0, + lowCount: severityCounts.Low || 0, + restrictiveLicenseCount: restrictiveLicenseDeps.length, + policyViolationCount: policyViolationDeps.length, + upstreamReachable, + upstreamNoProxy, + upstreamUnreachable, + }, + ecosystemBreakdown, + vulnerableDeps, + restrictiveLicenseDeps, + policyViolationDeps, + uncoveredDeps, + }; +} + +function dedupeComplianceDependencies(dependencies) { + const uniqueDependencies = new Map(); + + for (const dependency of Array.isArray(dependencies) ? dependencies : []) { + const key = complianceDependencyKey(dependency); + if (!uniqueDependencies.has(key)) { + uniqueDependencies.set(key, { ...dependency }); + continue; + } + + uniqueDependencies.set(key, mergeComplianceDependency(uniqueDependencies.get(key), dependency)); + } + + return [...uniqueDependencies.values()]; +} + +function complianceDependencyKey(dependency) { + return [ + String(dependency.format || dependency.ecosystem || "").toLowerCase(), + String(dependency.name || "").toLowerCase(), + String(dependency.version || "").toLowerCase(), + ].join(":"); +} + +function mergeComplianceDependency(existing, candidate) { + return { + ...existing, + isDirect: Boolean(existing.isDirect || candidate.isDirect), + cloudsmithStatus: pickBetterCoverageStatus(existing.cloudsmithStatus, candidate.cloudsmithStatus), + cloudsmithPackage: existing.cloudsmithPackage || candidate.cloudsmithPackage || null, + vulnerabilities: pickRicherVulnerabilityData(existing.vulnerabilities, candidate.vulnerabilities), + license: existing.license || candidate.license || null, + policy: pickRicherPolicyData(existing.policy, candidate.policy), + upstreamStatus: existing.upstreamStatus || candidate.upstreamStatus || null, + upstreamDetail: existing.upstreamDetail || candidate.upstreamDetail || null, + }; +} + +function pickBetterCoverageStatus(left, right) { + const priorities = { + FOUND: 3, + NOT_FOUND: 2, + CHECKING: 1, + }; + const leftPriority = priorities[left] || 0; + const rightPriority = priorities[right] || 0; + return rightPriority > leftPriority ? right : left; +} + +function pickRicherVulnerabilityData(left, right) { + if (!left) { + return right || null; + } + if (!right) { + return left; + } + if (Boolean(right.detailsLoaded) !== Boolean(left.detailsLoaded)) { + return right.detailsLoaded ? right : left; + } + return (right.count || 0) > (left.count || 0) ? right : left; +} + +function pickRicherPolicyData(left, right) { + if (!left) { + return right || null; + } + if (!right) { + return left; + } + if (Boolean(right.denied || right.quarantined) !== Boolean(left.denied || left.quarantined)) { + return right.denied || right.quarantined ? right : left; + } + if (Boolean(right.statusReason) !== Boolean(left.statusReason)) { + return right.statusReason ? right : left; + } + return right.violated ? right : left; +} + +function compareComplianceVulnerabilityRows(left, right) { + const severityDelta = severitySortWeight(left.maxSeverity) - severitySortWeight(right.maxSeverity); + if (severityDelta !== 0) { + return severityDelta; + } + + if (left.isDirect !== right.isDirect) { + return left.isDirect ? -1 : 1; + } + + return compareNamedRows(left, right); +} + +function compareCompliancePolicyRows(left, right) { + const statusDelta = policyStatusSortWeight(left.status) - policyStatusSortWeight(right.status); + if (statusDelta !== 0) { + return statusDelta; + } + return compareNamedRows(left, right); +} + +function compareComplianceUncoveredRows(left, right) { + const statusDelta = upstreamStatusSortWeight(left.upstreamStatus) - upstreamStatusSortWeight(right.upstreamStatus); + if (statusDelta !== 0) { + return statusDelta; + } + return compareNamedRows(left, right); +} + +function compareNamedRows(left, right) { + const nameDelta = String(left.name || "").localeCompare(String(right.name || ""), undefined, { sensitivity: "base" }); + if (nameDelta !== 0) { + return nameDelta; + } + return String(left.version || "").localeCompare(String(right.version || ""), undefined, { sensitivity: "base" }); +} + +function severitySortWeight(severity) { + switch (severity) { + case "Critical": + return 0; + case "High": + return 1; + case "Medium": + return 2; + case "Low": + return 3; + default: + return 4; + } +} + +function upstreamStatusSortWeight(status) { + switch (status) { + case "reachable": + return 0; + case "no_proxy": + return 1; + case "unreachable": + return 2; + default: + return 3; + } +} + +function policyStatusSortWeight(status) { + switch (status) { + case "Quarantined": + return 0; + case "Denied": + return 1; + case "Policy violation": + return 2; + default: + return 3; + } +} + +function humanizeLicenseClassification(classification) { + switch (classification) { + case "restrictive": + return "Restrictive"; + case "weak_copyleft": + return "Weak copyleft"; + default: + return "Unclassified"; + } +} + +function humanizePolicyStatus(policy) { + if (policy.quarantined) { + return "Quarantined"; + } + if (policy.denied) { + return "Denied"; + } + if (policy.status && policy.status !== "Completed") { + return policy.status; + } + return "Policy violation"; +} + +function defaultPolicyDetail(policy) { + if (policy.denied || policy.quarantined) { + return "Blocked by Cloudsmith policy."; + } + return "Policy requirements were not met."; +} + +function defaultUpstreamDetail(status) { + switch (status) { + case "reachable": + return "Available via an upstream proxy."; + case "no_proxy": + return "No upstream proxy is configured for this ecosystem."; + case "unreachable": + return "Configured upstreams could not serve this package."; + default: + return "Not found in Cloudsmith."; + } +} + +function buildDependencyHealthReport(projectName, dependencies, summary, generatedDate) { + const vulnerableDependencies = dependencies + .filter((dependency) => dependency.vulnerabilities && dependency.vulnerabilities.count > 0) + .sort((left, right) => compareDependencies(left, right, SORT_MODES.SEVERITY, false)); + const uncoveredDependencies = dependencies + .filter((dependency) => dependency.cloudsmithStatus === "NOT_FOUND") + .sort((left, right) => compareDependencies(left, right, SORT_MODES.COVERAGE, false)); + const policyViolations = dependencies + .filter((dependency) => dependency.policy && dependency.policy.violated) + .sort((left, right) => compareDependencies(left, right, SORT_MODES.SEVERITY, false)); + + const lines = [ + `# Dependency Health Report — ${projectName}`, + `Generated: ${generatedDate}`, + "", + "## Summary", + `- ${summary.total} total dependencies (${summary.direct} direct, ${summary.transitive} transitive)`, + `- ${summary.found} served by Cloudsmith (${summary.coveragePercent}% coverage)`, + ]; + + if (summary.notFound > 0) { + lines.push(`- ${summary.notFound} not found in Cloudsmith`); + } + + if (summary.vulnerable > 0) { + const severityParts = ["Critical", "High", "Medium", "Low"] + .filter((severity) => summary.severityCounts[severity] > 0) + .map((severity) => `${summary.severityCounts[severity]} ${severity}`); + lines.push(`- ${summary.vulnerable} with known vulnerabilities (${severityParts.join(", ")})`); + } + + lines.push(""); + lines.push("## Vulnerable Dependencies"); + if (vulnerableDependencies.length === 0) { + lines.push("None"); + } else { + lines.push("| Package | Version | Type | Severity | CVEs | Fix Available |"); + lines.push("|---------|---------|------|----------|------|---------------|"); + for (const dependency of vulnerableDependencies) { + const fixEntry = (dependency.vulnerabilities.entries || []).find((entry) => entry.fixVersion); + const fixCell = fixEntry + ? `Yes (${fixEntry.fixVersion})` + : dependency.vulnerabilities.hasFixAvailable + ? "Yes" + : "No"; + lines.push(`| ${dependency.name} | ${dependency.version || "—"} | ${dependency.isDirect ? "Direct" : "Transitive"} | ${dependency.vulnerabilities.maxSeverity || "Unknown"} | ${(dependency.vulnerabilities.cveIds || []).join(", ") || "—"} | ${fixCell} |`); + } + } + + const licenseTotals = summary.permissiveLicenses + summary.weakCopyleftLicenses + summary.restrictiveLicenses + summary.unknownLicenses; + if (licenseTotals > 0) { + lines.push(""); + lines.push("## License Summary"); + lines.push(`- ${summary.permissiveLicenses} permissive`); + lines.push(`- ${summary.weakCopyleftLicenses} weak copyleft`); + lines.push(`- ${summary.restrictiveLicenses} restrictive`); + lines.push(`- ${summary.unknownLicenses} unknown`); + } + + if (policyViolations.length > 0) { + lines.push(""); + lines.push("## Policy Compliance"); + for (const dependency of policyViolations) { + const reason = dependency.policy.denied ? "deny policy violated" : "policy violated"; + lines.push(`- ${dependency.name} ${dependency.version || ""} — ${reason}`.trim()); + } + } + + if (uncoveredDependencies.length > 0) { + lines.push(""); + lines.push("## Uncovered Dependencies"); + lines.push("| Package | Version | Ecosystem | Upstream Status | Detail |"); + lines.push("|---------|---------|-----------|-----------------|--------|"); + for (const dependency of uncoveredDependencies) { + lines.push(`| ${dependency.name} | ${dependency.version || "—"} | ${dependency.format || dependency.ecosystem || "—"} | ${formatUpstreamStatus(dependency.upstreamStatus)} | ${dependency.upstreamDetail || "—"} |`); + } + } + + return lines.join("\n"); +} + +function formatUpstreamStatus(status) { + switch (status) { + case "reachable": + return "Reachable"; + case "no_proxy": + return "No proxy"; + case "unreachable": + return "Unreachable"; + default: + return "Unknown"; + } +} + +function getDependencyVulnerabilityData(dependency) { + if (dependency.vulnerabilities) { + return dependency.vulnerabilities; + } + + const cloudsmithPackage = dependency.cloudsmithPackage; + if (!cloudsmithPackage) { + return null; + } + + const count = Number( + cloudsmithPackage.vulnerability_scan_results_count + || cloudsmithPackage.num_vulnerabilities + || 0 + ); + if (!Number.isFinite(count) || count <= 0) { + return null; + } + + return { + count, + maxSeverity: cloudsmithPackage.max_severity || null, + }; +} + +function getDependencyPolicyData(dependency) { + if (dependency.policy) { + return dependency.policy; + } + + const cloudsmithPackage = dependency.cloudsmithPackage; + if (!cloudsmithPackage) { + return null; + } + + const status = String(cloudsmithPackage.status_str || "").trim() || null; + const quarantined = status === "Quarantined"; + const denied = quarantined || Boolean(cloudsmithPackage.deny_policy_violated); + const violated = denied + || Boolean(cloudsmithPackage.policy_violated) + || Boolean(cloudsmithPackage.license_policy_violated) + || Boolean(cloudsmithPackage.vulnerability_policy_violated); + + return { + violated, + denied, + quarantined, + status, + statusReason: String(cloudsmithPackage.status_reason || "").trim() || null, + }; +} + +function getDependencyLicenseClassification(dependency) { + if (dependency.license && dependency.license.classification) { + return dependency.license.classification; + } + + if (!dependency.cloudsmithPackage) { + return "unknown"; + } + + const inspection = LicenseClassifier.inspect(dependency.cloudsmithPackage); + switch (inspection.tier) { + case "permissive": + return "permissive"; + case "cautious": + return "weak_copyleft"; + case "restrictive": + return "restrictive"; + default: + return "unknown"; + } } -module.exports = { DependencyHealthProvider }; +module.exports = { + DependencyHealthProvider, + FILTER_MODES, + SORT_MODES, + buildComplianceReportData, + buildDependencyHealthReport, + buildDependencySummary, + buildFilteredTreeWrapper, + buildPackageIndex, + findCoverageMatch, + getFilterLabel, + matchesFilter, + matchCoverageCandidates, +};