From 8e7b3959f6f20ab349c2b8ad7f013e5d875c4cef Mon Sep 17 00:00:00 2001 From: Devon Lawler Date: Fri, 10 Apr 2026 10:04:12 -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/lockfileResolver.test.js | 204 ++ test/treeVisualization.test.js | 198 ++ util/formatIcons.js | 82 + views/dependencyHealthProvider.js | 2948 ++++++++++++++++++++++--- 8 files changed, 4191 insertions(+), 517 deletions(-) create mode 100644 models/dependencySourceGroupNode.js create mode 100644 models/dependencySummaryNode.js create mode 100644 test/lockfileResolver.test.js create mode 100644 test/treeVisualization.test.js create mode 100644 util/formatIcons.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/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/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/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, +};