From 72c669df2ae09afd0ff093a64552147153e62291 Mon Sep 17 00:00:00 2001 From: Devon Lawler Date: Sun, 5 Apr 2026 15:17:19 -0400 Subject: [PATCH] feat: transitive dependency visibility in Dependency Health view (v2.2.0) - Lockfile-based transitive resolution for 15+ ecosystems - Vulnerability, license, and policy overlays on resolved dependencies - Upstream proxy gap analysis with pull-through caching - Tree visualization with diamond dependency collapsing and filtered views - Compliance report WebView - Single-dependency and bulk pull-through with format-specific registry endpoints - Inverted index coverage strategy, parallel pagination, batched tree updates - Security hardening: redirect validation, workspace confinement, input sanitization --- CHANGELOG.md | 37 + README.md | 75 +- extension.js | 416 ++- models/dependencyHealthNode.js | 667 +++-- models/dependencySourceGroupNode.js | 35 + models/dependencySummaryNode.js | 175 ++ models/packageNode.js | 5 +- package.json | 236 +- test/complianceReportProvider.test.js | 150 ++ test/dependencyHealthProvider.test.js | 399 ++- test/dependencyLicenseEnricher.test.js | 68 + test/dependencyPolicyEnricher.test.js | 51 + test/dependencyVulnEnricher.test.js | 183 ++ test/fixtures/cargo/Cargo.lock | 25 + test/fixtures/cargo/Cargo.toml | 9 + test/fixtures/composer/composer.json | 8 + test/fixtures/composer/composer.lock | 28 + test/fixtures/dart/pubspec.lock | 15 + test/fixtures/dart/pubspec.yaml | 7 + test/fixtures/docker/Dockerfile | 5 + test/fixtures/docker/docker-compose.yml | 7 + test/fixtures/go/go.mod | 8 + test/fixtures/gradle/build.gradle | 8 + test/fixtures/gradle/gradle.lockfile | 2 + test/fixtures/helm/Chart.lock | 5 + test/fixtures/helm/Chart.yaml | 6 + test/fixtures/hex/mix.exs | 16 + test/fixtures/hex/mix.lock | 3 + test/fixtures/maven/dependency-tree.txt | 3 + test/fixtures/maven/pom.xml | 14 + test/fixtures/npm/package-lock.json | 29 + test/fixtures/npm/package.json | 8 + test/fixtures/npm/pnpm-lock.yaml | 20 + test/fixtures/npm/yarn.lock | 10 + test/fixtures/nuget/Fixture.csproj | 5 + test/fixtures/nuget/packages.lock.json | 18 + test/fixtures/python/Pipfile.lock | 12 + test/fixtures/python/poetry.lock | 14 + test/fixtures/python/pyproject.toml | 15 + test/fixtures/python/requirements.txt | 2 + test/fixtures/python/uv.lock | 18 + test/fixtures/ruby/Gemfile | 3 + test/fixtures/ruby/Gemfile.lock | 9 + test/fixtures/swift/Package.resolved | 14 + test/fixtures/swift/Package.swift | 9 + test/helpers/fixtureWorkspace.js | 46 + test/lockfileParsers/cargoParser.test.js | 119 + test/lockfileParsers/dockerParser.test.js | 71 + test/lockfileParsers/mavenParser.test.js | 81 + test/lockfileParsers/npmParser.test.js | 219 ++ test/lockfileParsers/pythonParser.test.js | 93 + test/lockfileResolver.test.js | 204 ++ test/packageMetadataFlow.test.js | 114 + test/treeVisualization.test.js | 198 ++ test/upstreamGapAnalyzer.test.js | 136 + test/upstreamPullService.test.js | 312 +++ util/dependencyLicenseEnricher.js | 76 + util/dependencyPolicyEnricher.js | 67 + util/dependencyVulnEnricher.js | 463 ++++ util/formatIcons.js | 82 + util/foundDependencyKey.js | 21 + util/lockfileParsers/cargoParser.js | 283 ++ util/lockfileParsers/composerParser.js | 174 ++ util/lockfileParsers/dartParser.js | 124 + util/lockfileParsers/dockerParser.js | 295 +++ util/lockfileParsers/goParser.js | 82 + util/lockfileParsers/gradleParser.js | 124 + util/lockfileParsers/helmParser.js | 62 + util/lockfileParsers/hexParser.js | 86 + util/lockfileParsers/manifestHelpers.js | 650 +++++ util/lockfileParsers/mavenParser.js | 181 ++ util/lockfileParsers/npmParser.js | 836 ++++++ util/lockfileParsers/nugetParser.js | 185 ++ util/lockfileParsers/pythonParser.js | 380 +++ util/lockfileParsers/rubyParser.js | 217 ++ util/lockfileParsers/shared.js | 412 +++ util/lockfileParsers/swiftParser.js | 89 + util/lockfileResolver.js | 140 + util/manifestParser.js | 120 +- util/packageNameNormalizer.js | 143 + util/registryEndpoints.js | 600 +++++ util/upstreamChecker.js | 114 +- util/upstreamGapAnalyzer.js | 213 ++ util/upstreamPullService.js | 1231 +++++++++ views/complianceReportProvider.js | 677 +++++ views/dependencyHealthProvider.js | 2948 ++++++++++++++++++--- 86 files changed, 14680 insertions(+), 840 deletions(-) create mode 100644 models/dependencySourceGroupNode.js create mode 100644 models/dependencySummaryNode.js create mode 100644 test/complianceReportProvider.test.js create mode 100644 test/dependencyLicenseEnricher.test.js create mode 100644 test/dependencyPolicyEnricher.test.js create mode 100644 test/dependencyVulnEnricher.test.js create mode 100644 test/fixtures/cargo/Cargo.lock create mode 100644 test/fixtures/cargo/Cargo.toml create mode 100644 test/fixtures/composer/composer.json create mode 100644 test/fixtures/composer/composer.lock create mode 100644 test/fixtures/dart/pubspec.lock create mode 100644 test/fixtures/dart/pubspec.yaml create mode 100644 test/fixtures/docker/Dockerfile create mode 100644 test/fixtures/docker/docker-compose.yml create mode 100644 test/fixtures/go/go.mod create mode 100644 test/fixtures/gradle/build.gradle create mode 100644 test/fixtures/gradle/gradle.lockfile create mode 100644 test/fixtures/helm/Chart.lock create mode 100644 test/fixtures/helm/Chart.yaml create mode 100644 test/fixtures/hex/mix.exs create mode 100644 test/fixtures/hex/mix.lock create mode 100644 test/fixtures/maven/dependency-tree.txt create mode 100644 test/fixtures/maven/pom.xml create mode 100644 test/fixtures/npm/package-lock.json create mode 100644 test/fixtures/npm/package.json create mode 100644 test/fixtures/npm/pnpm-lock.yaml create mode 100644 test/fixtures/npm/yarn.lock create mode 100644 test/fixtures/nuget/Fixture.csproj create mode 100644 test/fixtures/nuget/packages.lock.json create mode 100644 test/fixtures/python/Pipfile.lock create mode 100644 test/fixtures/python/poetry.lock create mode 100644 test/fixtures/python/pyproject.toml create mode 100644 test/fixtures/python/requirements.txt create mode 100644 test/fixtures/python/uv.lock create mode 100644 test/fixtures/ruby/Gemfile create mode 100644 test/fixtures/ruby/Gemfile.lock create mode 100644 test/fixtures/swift/Package.resolved create mode 100644 test/fixtures/swift/Package.swift create mode 100644 test/helpers/fixtureWorkspace.js create mode 100644 test/lockfileParsers/cargoParser.test.js create mode 100644 test/lockfileParsers/dockerParser.test.js create mode 100644 test/lockfileParsers/mavenParser.test.js create mode 100644 test/lockfileParsers/npmParser.test.js create mode 100644 test/lockfileParsers/pythonParser.test.js create mode 100644 test/lockfileResolver.test.js create mode 100644 test/treeVisualization.test.js create mode 100644 test/upstreamGapAnalyzer.test.js create mode 100644 test/upstreamPullService.test.js create mode 100644 util/dependencyLicenseEnricher.js create mode 100644 util/dependencyPolicyEnricher.js create mode 100644 util/dependencyVulnEnricher.js create mode 100644 util/formatIcons.js create mode 100644 util/foundDependencyKey.js create mode 100644 util/lockfileParsers/cargoParser.js create mode 100644 util/lockfileParsers/composerParser.js create mode 100644 util/lockfileParsers/dartParser.js create mode 100644 util/lockfileParsers/dockerParser.js create mode 100644 util/lockfileParsers/goParser.js create mode 100644 util/lockfileParsers/gradleParser.js create mode 100644 util/lockfileParsers/helmParser.js create mode 100644 util/lockfileParsers/hexParser.js create mode 100644 util/lockfileParsers/manifestHelpers.js create mode 100644 util/lockfileParsers/mavenParser.js create mode 100644 util/lockfileParsers/npmParser.js create mode 100644 util/lockfileParsers/nugetParser.js create mode 100644 util/lockfileParsers/pythonParser.js create mode 100644 util/lockfileParsers/rubyParser.js create mode 100644 util/lockfileParsers/shared.js create mode 100644 util/lockfileParsers/swiftParser.js create mode 100644 util/lockfileResolver.js create mode 100644 util/packageNameNormalizer.js create mode 100644 util/registryEndpoints.js create mode 100644 util/upstreamGapAnalyzer.js create mode 100644 util/upstreamPullService.js create mode 100644 views/complianceReportProvider.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f4e4df0..1c91ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,40 @@ +## 2.2.0 - April 2026 +### Transitive Dependency Visibility + +#### Transitive Dependency Resolution +- Dependency Health view now resolves the complete dependency set (direct and transitive) by parsing lockfiles and manifests directly. +- Ecosystems with lockfiles (npm, Yarn, pnpm, Python/Poetry/uv, Rust, Ruby, Go, NuGet, Dart, PHP, Helm, Swift, Hex) resolve the full transitive tree automatically. +- Maven and Gradle resolve direct dependencies from pom.xml and build.gradle. The extension prompts when a tree file is not found. +- Docker and Helm resolve direct dependencies (base images and chart dependencies). +- Summary bar shows total dependency count with direct/transitive breakdown and per-ecosystem composition for multi-ecosystem projects. + +#### Vulnerability, License, and Policy Overlays +- Resolved dependencies found in Cloudsmith are checked for known vulnerabilities with severity indicators displayed inline. +- License classification (permissive, weak copyleft, restrictive) and policy compliance status shown for all covered dependencies. +- Summary bar aggregates vulnerability counts by severity, restrictive license count, and policy violation count. + +#### Upstream Proxy Gap Analysis +- Dependencies not found in Cloudsmith are checked against configured upstream proxies across all repositories. +- Each uncovered dependency shows whether it's reachable via an existing upstream or requires a new proxy to be configured. + +#### Tree Visualization +- New tree view mode displays the full dependency hierarchy with collapsible parent-child relationships. +- Diamond dependencies collapsed on subsequent occurrences to prevent exponential tree growth. +- Filters prune the tree to show only vulnerable, uncovered, or restrictive-license dependency paths. +- Three-way view toggle: direct only, all (flat), or all (tree). + +#### Pull Dependencies Through Upstream +- New pull action caches uncovered dependencies through a selected repository's upstream proxy directly from the editor. +- Repository selector shows only repositories with upstream proxies matching the project's dependency formats. +- Right-click any individual uncovered dependency to pull just that package through an upstream. + +#### Compliance Report +- New report view opens a dependency health summary in a dedicated editor panel with coverage, vulnerability, license, policy, and upstream gap analysis. + +#### UX Improvements +- Toolbar consolidated to five inline actions: scan, pull, view mode cycle, sort and filter, and compliance report. +- Format-specific icons displayed on uncovered dependencies for at-a-glance ecosystem identification. + ## 2.1.1 - April 2026 ### Fixed - Fixed erroneous error banner displaying in the Upstream Webview when all upstream data loaded successfully. diff --git a/README.md b/README.md index 8d05fbe..f008842 100644 --- a/README.md +++ b/README.md @@ -80,12 +80,12 @@ The right-click menu provides access to the following commands, varying dependin - **Inspect package** — View the full raw JSON API response for the package. - **Copy Install Command** — Copy the installation command for the package to the clipboard. -- **Show Install Command** - Show the installation command for the package. -- **Show vulnerabilities** - Open a webview showing the vulnerabilities report for a package. -- **View package in Cloudsmith** - Open the package page in the Cloudsmith web UI for the configured workspace. -- **Promote Package** - Promote the package between configured repositories. -- **Show Promotion Status** - Show the current status of the package promotion request. -- **Find safe version** - Show possible safe versions of the package within Cloudsmith for quick remediation. +- **Show Install Command** - Show the installation command for the package. +- **Show vulnerabilities** - Open a webview showing the vulnerabilities report for a package. +- **View package in Cloudsmith** - Open the package page in the Cloudsmith web UI for the configured workspace. +- **Promote Package** - Promote the package between configured repositories. +- **Show Promotion Status** - Show the current status of the package promotion request. +- **Find safe version** - Show possible safe versions of the package within Cloudsmith for quick remediation. contextMenu @@ -109,6 +109,54 @@ If you have access to multiple workspaces, the explorer lets you switch between View vulnerability data associated with packages directly in the explorer, including security scan results when available. +### Dependency Health + +The Dependency Health view scans your project's manifest and lockfiles, cross-references every declared and transitive dependency against your Cloudsmith workspace, and shows coverage, vulnerability, license, and policy status at a glance. + +#### Transitive Resolution + +The extension parses lockfiles and manifests directly. Most ecosystems resolve the full dependency tree from an existing lockfile. For ecosystems without a standard lockfile, the extension parses the manifest for direct dependencies and can optionally parse a generated dependency tree for transitives. + +| Ecosystem | Automatic (lockfile) | Direct only (manifest) | Notes | +|-----------|---------------------|----------------------|-------| +| npm / Yarn / pnpm | package-lock.json, yarn.lock, pnpm-lock.yaml | package.json | | +| Python | poetry.lock, uv.lock, Pipfile.lock | pyproject.toml, requirements.txt | requirements.txt provides direct deps only | +| Maven | | pom.xml | Run `mvn dependency:tree -DoutputFile=dependency-tree.txt` once to enable transitive resolution | +| Gradle | gradle.lockfile | build.gradle, build.gradle.kts | Run `gradle dependencies` once if dependency locking is not enabled | +| Go | go.mod | | go.mod marks direct vs indirect natively | +| Rust | Cargo.lock | Cargo.toml | | +| Ruby | Gemfile.lock | Gemfile | | +| Docker | | Dockerfile, docker-compose.yml | All dependencies are direct (base images) | +| NuGet | packages.lock.json | | | +| Dart | pubspec.lock | | | +| PHP | composer.lock | | | +| Helm | Chart.lock | | Helm dependencies are all direct | +| Swift | Package.resolved | | | +| Elixir | mix.lock | | | + +#### View Modes + +- **Direct only** — shows only top-level manifest dependencies. +- **All (flat)** — shows every resolved dependency in a flat list with direct/transitive labels. +- **All (tree)** — shows the full dependency hierarchy. Diamond dependencies are collapsed to keep the tree manageable. + +#### Overlays + +Each dependency found in Cloudsmith is enriched with: +- **Vulnerability status** — severity count and max severity inline, with click-through to CVE details. +- **License classification** — permissive, weak copyleft, or restrictive, with configurable flagging. +- **Policy compliance** — quarantine and policy violation indicators. + +Dependencies not found in Cloudsmith show upstream proxy reachability — whether a configured upstream could serve them. + +#### Pull Through Upstream + +Click "Pull dependencies" to cache uncovered dependencies through a repository's upstream proxy. The extension shows only repositories with matching upstream formats, pulls in parallel, and automatically rescans after completion. You can also right-click any individual dependency to pull just that one package. + +#### Compliance Report + +The report view opens a styled summary panel with coverage percentage, vulnerability breakdown by severity, license risk summary, policy compliance, and upstream gap analysis. + ### Configuration & Settings The extension exposes several settings under `cloudsmith-vsc.*`: @@ -121,13 +169,16 @@ The extension exposes several settings under `cloudsmith-vsc.*`: | `cloudsmith-vsc.defaultWorkspace` | Cloudsmith workspace slug to load by default. Leave empty to show all accessible workspaces. | | `cloudsmith-vsc.showPermissibilityIndicators` | Show visual indicators for quarantined packages and policy violations. Default: `true`. | | `cloudsmith-vsc.showLicenseIndicators` | Show license risk classification on packages. Default: `true`. | +| `cloudsmith-vsc.flagRestrictiveLicenses` | Color-code restrictive licenses in the Dependency Health view. Default: `true`. | +| `cloudsmith-vsc.restrictiveLicenses` | List of SPDX license identifiers flagged as restrictive. Default: `["AGPL-3.0", "GPL-2.0", "GPL-3.0", "SSPL-1.0"]`. | | `cloudsmith-vsc.showDockerDigestCommand` | Show an additional "Pull by digest" option for Docker install commands. Default: `false`. | | `cloudsmith-vsc.experimentalSSOBrowser` | Enable experimental browser-based SSO authentication. Default: `false`. | -| `cloudsmith-vsc.useLegacyWebApp` | Use the legacy `cloudsmith.io` webapp for platform links. Default: `false`. | | `cloudsmith-vsc.autoScanOnOpen` | Automatically scan project dependencies against Cloudsmith when a workspace is opened. Default: `false`. | | `cloudsmith-vsc.dependencyScanWorkspace` | Cloudsmith workspace slug to use for dependency health scanning. | | `cloudsmith-vsc.dependencyScanRepo` | Cloudsmith repository slug to use for dependency health scanning. | -| `cloudsmith-vsc.resolveTransitiveDependencies` | Resolve transitive (indirect) dependencies using the package manager CLI. Default: `false`. | +| `cloudsmith-vsc.resolveTransitiveDependencies` | Parse lockfiles to resolve transitive dependencies. When disabled, only direct manifest dependencies are shown. Default: `true`. | +| `cloudsmith-vsc.dependencyTreeDefaultView` | Default view mode for the Dependency Health panel: `direct`, `flat`, or `tree`. Default: `flat`. | +| `cloudsmith-vsc.maxDependenciesToScan` | Maximum number of dependencies to display. Pull operations always process all dependencies regardless of this limit. Default: `10000`. | | `cloudsmith-vsc.searchPageSize` | Number of results per page when searching packages (10–100). Default: `50`. | | `cloudsmith-vsc.recentSearches` | Number of recent searches to remember (0–50). Default: `10`. | @@ -145,8 +196,14 @@ All commands are available via the Command Palette (`Cmd+Shift+P`): | `Cloudsmith: Copy to Clipboard` | Copy a package detail value to the clipboard. | | `Cloudsmith: Refresh Packages` | Refresh the Cloudsmith explorer tree. | | `Cloudsmith: Search Packages` | Search for packages within a repository. | +| `Cloudsmith: Scan Dependencies` | Scan project lockfiles and check dependency coverage against Cloudsmith. | +| `Cloudsmith: Pull Dependencies` | Pull uncovered dependencies through a repository's upstream proxy. | +| `Cloudsmith: Pull Dependency` | Pull a single dependency through an upstream proxy (right-click context menu). | +| `Cloudsmith: View Compliance Report` | Open the dependency health compliance report in an editor panel. | +| `Cloudsmith: Cycle Dependency View` | Switch between direct, flat, and tree view modes. | +| `Cloudsmith: Sort and Filter Dependencies` | Open sort and filter options for the Dependency Health view. | ## License -Apache 2.0 +Apache 2.0 \ No newline at end of file diff --git a/extension.js b/extension.js index 3d7cb23..0e08aac 100644 --- a/extension.js +++ b/extension.js @@ -6,9 +6,14 @@ const { CloudsmithAPI } = require("./util/cloudsmithAPI"); const { CredentialManager } = require("./util/credentialManager"); const { RecentSearches } = require("./util/recentSearches"); const { RemediationHelper } = require("./util/remediationHelper"); -const { DependencyHealthProvider } = require("./views/dependencyHealthProvider"); +const { + DependencyHealthProvider, + FILTER_MODES, + SORT_MODES, +} = require("./views/dependencyHealthProvider"); const { InstallCommandBuilder } = require("./util/installCommandBuilder"); const { VulnerabilityProvider } = require("./views/vulnerabilityProvider"); +const { ComplianceReportProvider } = require("./views/complianceReportProvider"); const { QuarantineExplainProvider } = require("./views/quarantineExplainProvider"); const { DiagnosticsPublisher } = require("./util/diagnosticsPublisher"); const { SSOAuthManager } = require("./util/ssoAuthManager"); @@ -322,6 +327,223 @@ function buildPresetQuery(preset, customQuery) { return builder.build(); } +async function resolveDependencyScanTarget(context) { + const config = vscode.workspace.getConfiguration("cloudsmith-vsc"); + let scanWorkspace = config.get("dependencyScanWorkspace"); + let scanRepo = config.get("dependencyScanRepo") || null; + + if (!scanWorkspace) { + scanWorkspace = getDefaultWorkspace(); + } + + if (scanWorkspace) { + return { + scanWorkspace, + scanRepo, + }; + } + + const workspaces = await getWorkspaces(context); + if (!workspaces) { + return null; + } + if (workspaces.length === 0) { + vscode.window.showErrorMessage("No workspaces found. Connect to Cloudsmith first."); + return null; + } + + const selectedWorkspace = await vscode.window.showQuickPick( + workspaces.map((workspace) => ({ + label: workspace.name, + description: workspace.slug, + })), + { + placeHolder: "Select a Cloudsmith workspace for the scan", + } + ); + + if (!selectedWorkspace) { + return null; + } + + scanWorkspace = selectedWorkspace.description; + + const selectedScope = await vscode.window.showQuickPick( + [ + { + label: "All repositories", + description: "Search across the entire workspace", + _all: true, + }, + { + label: "Select a specific repository", + description: "Search one repository", + _all: false, + }, + ], + { + placeHolder: "Select a scan scope", + } + ); + + if (!selectedScope) { + return null; + } + + if (!selectedScope._all) { + const cloudsmithAPI = new CloudsmithAPI(context); + const repos = await cloudsmithAPI.get(`repos/${scanWorkspace}/?sort=name`); + if (typeof repos !== "string" && Array.isArray(repos) && repos.length > 0) { + const selectedRepo = await vscode.window.showQuickPick( + repos.map((repository) => ({ + label: repository.name, + description: repository.slug, + })), + { + placeHolder: "Select a repository", + } + ); + + if (selectedRepo) { + scanRepo = selectedRepo.description; + } + } + } + + return { + scanWorkspace, + scanRepo, + }; +} + +function buildDependencySortFilterItems(provider) { + const currentSort = provider.getSortMode(); + const currentFilter = provider.getFilterMode(); + return [ + { + label: "Sort", + kind: vscode.QuickPickItemKind.Separator, + }, + createDependencyPickerItem( + "Alphabetical", + "Default ordering", + "sort", + SORT_MODES.ALPHABETICAL, + currentSort === SORT_MODES.ALPHABETICAL + ), + createDependencyPickerItem( + "Severity", + "Most severe first", + "sort", + SORT_MODES.SEVERITY, + currentSort === SORT_MODES.SEVERITY + ), + createDependencyPickerItem( + "Coverage", + "Not found first", + "sort", + SORT_MODES.COVERAGE, + currentSort === SORT_MODES.COVERAGE + ), + { + label: "Filters", + kind: vscode.QuickPickItemKind.Separator, + }, + createDependencyPickerItem( + "Vulnerable only", + "Toggle vulnerable dependencies", + "filter", + FILTER_MODES.VULNERABLE, + currentFilter === FILTER_MODES.VULNERABLE + ), + createDependencyPickerItem( + "Not in Cloudsmith", + "Toggle uncovered dependencies", + "filter", + FILTER_MODES.UNCOVERED, + currentFilter === FILTER_MODES.UNCOVERED + ), + createDependencyPickerItem( + "Restrictive licenses", + "Toggle restrictive or weak copyleft results", + "filter", + FILTER_MODES.RESTRICTIVE_LICENSE, + currentFilter === FILTER_MODES.RESTRICTIVE_LICENSE + ), + createDependencyPickerItem( + "Policy violations", + "Toggle policy failures", + "filter", + FILTER_MODES.POLICY_VIOLATION, + currentFilter === FILTER_MODES.POLICY_VIOLATION + ), + createDependencyPickerItem( + "Show all dependencies", + "Clear active dependency filters", + "filter", + null, + currentFilter === null + ), + ]; +} + +function createDependencyPickerItem(label, description, action, value, active) { + return { + label: `${active ? "$(check)" : "$(circle-large-outline)"} ${label}`, + description, + _action: action, + _value: value, + }; +} + +async function showDependencySortFilterPicker(provider) { + await new Promise((resolve) => { + const quickPick = vscode.window.createQuickPick(); + const disposables = []; + + const refreshItems = () => { + quickPick.items = buildDependencySortFilterItems(provider); + }; + + quickPick.title = "Sort & filter dependencies"; + quickPick.matchOnDescription = true; + quickPick.ignoreFocusOut = true; + refreshItems(); + + disposables.push(quickPick.onDidAccept(async () => { + const selected = quickPick.selectedItems[0]; + if (!selected || !selected._action) { + quickPick.hide(); + return; + } + + quickPick.busy = true; + try { + if (selected._action === "sort") { + provider.setSortMode(selected._value); + } else if (selected._value === null || provider.getFilterMode() === selected._value) { + await provider.clearFilter(); + } else { + await provider.setFilterMode(selected._value); + } + refreshItems(); + } finally { + quickPick.busy = false; + } + })); + + disposables.push(quickPick.onDidHide(() => { + quickPick.dispose(); + for (const disposable of disposables) { + disposable.dispose(); + } + resolve(); + })); + + quickPick.show(); + }); +} + /** * @param {vscode.ExtensionContext} context @@ -386,7 +608,7 @@ async function activate(context) { const dependencyHealthProvider = new DependencyHealthProvider(context, diagnosticsPublisher); vscode.window.createTreeView("cloudsmithDependencyHealthView", { treeDataProvider: dependencyHealthProvider, - showCollapseAll: true, + showCollapseAll: false, }); context.subscriptions.push( @@ -413,6 +635,10 @@ async function activate(context) { const vulnerabilityProvider = new VulnerabilityProvider(context); context.subscriptions.push({ dispose: () => vulnerabilityProvider.dispose() }); + // Create compliance report WebView provider + const complianceReportProvider = new ComplianceReportProvider(context); + context.subscriptions.push({ dispose: () => complianceReportProvider.dispose() }); + // Create quarantine explanation WebView provider const quarantineExplainProvider = new QuarantineExplainProvider(context); context.subscriptions.push({ dispose: () => quarantineExplainProvider.dispose() }); @@ -447,28 +673,6 @@ async function activate(context) { void initializeConnectionContext(); - // Auto-scan dependencies on open if configured - const autoScanConfig = vscode.workspace.getConfiguration("cloudsmith-vsc"); - if (autoScanConfig.get("autoScanOnOpen")) { - const scanWorkspace = autoScanConfig.get("dependencyScanWorkspace"); - if (scanWorkspace) { - const scanRepo = autoScanConfig.get("dependencyScanRepo") || null; - // Delay to avoid blocking VS Code startup - setTimeout(() => { - dependencyHealthProvider.scan(scanWorkspace, scanRepo); - }, 2000); - } else { - vscode.window.showInformationMessage( - "Auto-scan is enabled but no Cloudsmith workspace is configured.", - "Configure" - ).then((selection) => { - if (selection === "Configure") { - vscode.commands.executeCommand("workbench.action.openSettings", "cloudsmith-vsc.dependencyScanWorkspace"); - } - }); - } - } - // Shared post-authentication handler: connect, refresh all views, and prompt // to set default workspace if only one workspace is available. @@ -1331,6 +1535,14 @@ async function activate(context) { await vulnerabilityProvider.show(item); }), + vscode.commands.registerCommand("cloudsmith-vsc.showDepVulnerabilities", async (item) => { + await vscode.commands.executeCommand("cloudsmith-vsc.showVulnerabilities", item); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.findDepSafeVersion", async (item) => { + await vscode.commands.executeCommand("cloudsmith-vsc.findSafeVersion", item); + }), + // Register vulnerability filter command — updates a summary node in-place vscode.commands.registerCommand("cloudsmith-vsc.filterVulnerabilities", async (vulnSummaryNode) => { if (!vulnSummaryNode || @@ -1423,99 +1635,99 @@ async function activate(context) { // Register scan dependencies command vscode.commands.registerCommand("cloudsmith-vsc.scanDependencies", async () => { - const config = vscode.workspace.getConfiguration("cloudsmith-vsc"); - let scanWorkspace = config.get("dependencyScanWorkspace"); - let scanRepo = config.get("dependencyScanRepo") || null; + if (dependencyHealthProvider.lastWorkspace) { + await dependencyHealthProvider.rescan(); + return; + } - // If no dedicated scan workspace, try the default workspace setting - if (!scanWorkspace) { - scanWorkspace = getDefaultWorkspace(); + const scanTarget = await resolveDependencyScanTarget(context); + if (!scanTarget) { + return; } - // If still no workspace, prompt user - if (!scanWorkspace) { - const workspaces = await getWorkspaces(context); - if (!workspaces) { - return; - } - if (workspaces.length === 0) { - vscode.window.showErrorMessage("No workspaces found. Connect to Cloudsmith first."); - return; - } + await dependencyHealthProvider.scan(scanTarget.scanWorkspace, scanTarget.scanRepo); + }), - const wsItems = workspaces.map(ws => ({ label: ws.name, description: ws.slug })); - const selectedWs = await vscode.window.showQuickPick(wsItems, { - placeHolder: "Select a Cloudsmith workspace for the scan", - }); - if (!selectedWs) { - return; - } - scanWorkspace = selectedWs.description; + vscode.commands.registerCommand("cloudsmith-vsc.scanDependenciesPending", async () => { + await vscode.commands.executeCommand("cloudsmith-vsc.scanDependencies"); + }), - // Optionally select a repo - const scopeItems = [ - { label: "All repositories", description: "Search across the entire workspace", _all: true }, - { label: "Select a specific repository", description: "Search one repository" }, - ]; - const selectedScope = await vscode.window.showQuickPick(scopeItems, { - placeHolder: "Select a scan scope", - }); - if (!selectedScope) { - return; - } + vscode.commands.registerCommand("cloudsmith-vsc.scanDependenciesComplete", async () => { + await vscode.commands.executeCommand("cloudsmith-vsc.scanDependencies"); + }), - if (!selectedScope._all) { - const cloudsmithAPI = new CloudsmithAPI(context); - const repos = await cloudsmithAPI.get(`repos/${scanWorkspace}/?sort=name`); - if (typeof repos !== "string" && Array.isArray(repos) && repos.length > 0) { - const repoItems = repos.map(r => ({ label: r.name, description: r.slug })); - const selectedRepo = await vscode.window.showQuickPick(repoItems, { - placeHolder: "Select a repository", - }); - if (selectedRepo) { - scanRepo = selectedRepo.description; - } - } - } - } + vscode.commands.registerCommand("cloudsmith-vsc.pullDependencies", async () => { + await dependencyHealthProvider.pullDependencies(); + }), - // Resolve project folder: stored path > workspace folder > prompt - // The provider handles the prompt internally if no folder is available - await dependencyHealthProvider.scan(scanWorkspace, scanRepo); + vscode.commands.registerCommand("cloudsmith-vsc.pullSingleDependency", async (item) => { + await dependencyHealthProvider.pullSingleDependency(item); }), - // Register rescan dependencies command - vscode.commands.registerCommand("cloudsmith-vsc.rescanDependencies", async () => { - await dependencyHealthProvider.rescan(); + vscode.commands.registerCommand("cloudsmith-vsc.cycleDepView", async () => { + await dependencyHealthProvider.cycleViewMode(); }), - // Register change dependency folder command - vscode.commands.registerCommand("cloudsmith-vsc.changeDependencyFolder", async () => { - const selected = await vscode.window.showOpenDialog({ - canSelectFolders: true, - canSelectFiles: false, - canSelectMany: false, - openLabel: "Select project folder to scan", - }); + vscode.commands.registerCommand("cloudsmith-vsc.cycleDepViewDirect", async () => { + await vscode.commands.executeCommand("cloudsmith-vsc.cycleDepView"); + }), - if (!selected || selected.length === 0) { - return; - } + vscode.commands.registerCommand("cloudsmith-vsc.cycleDepViewFlat", async () => { + await vscode.commands.executeCommand("cloudsmith-vsc.cycleDepView"); + }), - dependencyHealthProvider.setProjectFolder(selected[0].fsPath); + vscode.commands.registerCommand("cloudsmith-vsc.cycleDepViewTree", async () => { + await vscode.commands.executeCommand("cloudsmith-vsc.cycleDepView"); + }), - // Re-run scan if we have a previous workspace context - if (dependencyHealthProvider.lastWorkspace) { - await dependencyHealthProvider.scan( - dependencyHealthProvider.lastWorkspace, - dependencyHealthProvider.lastRepo - ); - } else { - vscode.window.showInformationMessage( - `Project folder set to ${selected[0].fsPath}. Run "Scan dependencies" to check against Cloudsmith.` - ); - dependencyHealthProvider.refresh(); + vscode.commands.registerCommand("cloudsmith-vsc.depViewDirect", async () => { + await dependencyHealthProvider.setViewMode("direct"); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depViewFlat", async () => { + await dependencyHealthProvider.setViewMode("flat"); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depViewTree", async () => { + await dependencyHealthProvider.setViewMode("tree"); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depFilterVulnerable", async () => { + await dependencyHealthProvider.setFilterMode(FILTER_MODES.VULNERABLE); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depFilterUncovered", async () => { + await dependencyHealthProvider.setFilterMode(FILTER_MODES.UNCOVERED); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depFilterRestrictiveLicense", async () => { + await dependencyHealthProvider.setFilterMode(FILTER_MODES.RESTRICTIVE_LICENSE); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depFilterPolicyViolation", async () => { + await dependencyHealthProvider.setFilterMode(FILTER_MODES.POLICY_VIOLATION); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depFilterClear", async () => { + await dependencyHealthProvider.clearFilter(); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depSortFilter", async () => { + await showDependencySortFilterPicker(dependencyHealthProvider); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depSortFilterActive", async () => { + await vscode.commands.executeCommand("cloudsmith-vsc.depSortFilter"); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.viewComplianceReport", async () => { + const reportData = dependencyHealthProvider.getReportData(); + if (!reportData) { + vscode.window.showInformationMessage("Run a dependency scan before opening the report."); + return; } + + complianceReportProvider.show(reportData); }), // Register copy install command 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/models/packageNode.js b/models/packageNode.js index b378815..6d44ce6 100644 --- a/models/packageNode.js +++ b/models/packageNode.js @@ -1,8 +1,8 @@ // Package node treeview const vscode = require("vscode"); -const path = require("path"); const { LicenseClassifier } = require("../util/licenseClassifier"); +const { getFormatIconPath } = require("../util/formatIcons"); class PackageNode { constructor(pkg, context) { @@ -117,8 +117,7 @@ class PackageNode { if (format === "raw") { return new vscode.ThemeIcon("file-binary"); } - const iconURI = "file_type_" + format + ".svg"; - return path.join(__filename, "..", "..", "media", "vscode_icons", iconURI); + return getFormatIconPath(format, this.context && this.context.extensionPath); } _buildTooltip() { diff --git a/package.json b/package.json index 834a64d..d1cbbfd 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publisher": "Cloudsmith", "displayName": "Cloudsmith VS Code", "description": "Access packages from a Cloudsmith instance.", - "version": "2.1.1", + "version": "2.2.0", "license": "SEE LICENSE IN LICENSE", "homepage": "https://github.com/cloudsmith-io/cloudsmith-vscode-extension/blob/main/README.md", "bugs": { @@ -172,6 +172,16 @@ "title": "Show vulnerabilities", "category": "Cloudsmith" }, + { + "command": "cloudsmith-vsc.showDepVulnerabilities", + "title": "Show vulnerabilities", + "category": "Cloudsmith" + }, + { + "command": "cloudsmith-vsc.findDepSafeVersion", + "title": "Find safe version", + "category": "Cloudsmith" + }, { "command": "cloudsmith-vsc.filterVulnerabilities", "title": "Filter vulnerabilities", @@ -190,16 +200,118 @@ "icon": "$(play)" }, { - "command": "cloudsmith-vsc.rescanDependencies", + "command": "cloudsmith-vsc.scanDependenciesPending", + "title": "Scan dependencies", + "category": "Cloudsmith", + "icon": "$(play)" + }, + { + "command": "cloudsmith-vsc.scanDependenciesComplete", "title": "Rescan dependencies", "category": "Cloudsmith", - "icon": "$(refresh)" + "icon": "$(play)" }, { - "command": "cloudsmith-vsc.changeDependencyFolder", - "title": "Change project folder", + "command": "cloudsmith-vsc.pullDependencies", + "title": "Pull dependencies", "category": "Cloudsmith", - "icon": "$(folder-opened)" + "icon": "$(cloud-download)" + }, + { + "command": "cloudsmith-vsc.pullSingleDependency", + "title": "Pull Dependency", + "category": "Cloudsmith", + "icon": "$(cloud-download)" + }, + { + "command": "cloudsmith-vsc.cycleDepView", + "title": "Cycle dependency view", + "category": "Cloudsmith", + "icon": "$(symbol-enum)" + }, + { + "command": "cloudsmith-vsc.cycleDepViewDirect", + "title": "View: Direct only (click to switch)", + "category": "Cloudsmith", + "icon": "$(list-selection)" + }, + { + "command": "cloudsmith-vsc.cycleDepViewFlat", + "title": "View: Flat list (click to switch)", + "category": "Cloudsmith", + "icon": "$(list-unordered)" + }, + { + "command": "cloudsmith-vsc.cycleDepViewTree", + "title": "View: Dependency tree (click to switch)", + "category": "Cloudsmith", + "icon": "$(list-tree)" + }, + { + "command": "cloudsmith-vsc.depViewDirect", + "title": "Show direct dependencies", + "category": "Cloudsmith", + "icon": "$(list-selection)" + }, + { + "command": "cloudsmith-vsc.depViewFlat", + "title": "Show all dependencies (flat)", + "category": "Cloudsmith", + "icon": "$(list-unordered)" + }, + { + "command": "cloudsmith-vsc.depViewTree", + "title": "Show dependency tree", + "category": "Cloudsmith", + "icon": "$(list-tree)" + }, + { + "command": "cloudsmith-vsc.depFilterVulnerable", + "title": "Show only vulnerable", + "category": "Cloudsmith", + "icon": "$(warning)" + }, + { + "command": "cloudsmith-vsc.depFilterUncovered", + "title": "Show only not in Cloudsmith", + "category": "Cloudsmith", + "icon": "$(package)" + }, + { + "command": "cloudsmith-vsc.depFilterRestrictiveLicense", + "title": "Show only restrictive licenses", + "category": "Cloudsmith", + "icon": "$(law)" + }, + { + "command": "cloudsmith-vsc.depFilterPolicyViolation", + "title": "Show only policy violations", + "category": "Cloudsmith", + "icon": "$(shield)" + }, + { + "command": "cloudsmith-vsc.depFilterClear", + "title": "Clear dependency filters", + "category": "Cloudsmith", + "icon": "$(clear-all)" + }, + { + "command": "cloudsmith-vsc.depSortFilter", + "title": "Sort & filter dependencies", + "category": "Cloudsmith", + "icon": "$(filter)" + }, + { + "command": "cloudsmith-vsc.depSortFilterActive", + "title": "Sort & filter dependencies", + "category": "Cloudsmith", + "icon": "$(filter-filled)" + }, + { + "command": "cloudsmith-vsc.viewComplianceReport", + "title": "View compliance report", + "category": "Cloudsmith", + "icon": "$(graph)" }, { "command": "cloudsmith-vsc.copyInstallCommand", @@ -347,12 +459,12 @@ }, { "command": "cloudsmith-vsc.inspectPackage", - "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealth|dependencyHealthBlocked|dependencyHealthViolated|dependencyHealthSyncing)$/", + "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealthFound|dependencyHealthVulnerable|dependencyHealthQuarantined|dependencyHealthSyncing)$/", "group": "navigation" }, { "command": "cloudsmith-vsc.openPackage", - "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealth|dependencyHealthBlocked|dependencyHealthViolated|dependencyHealthSyncing)$/", + "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealthFound|dependencyHealthVulnerable|dependencyHealthQuarantined|dependencyHealthSyncing)$/", "group": "navigation" }, { @@ -417,7 +529,7 @@ }, { "command": "cloudsmith-vsc.findSafeVersion", - "when": "viewItem =~ /^(package|packageQuarantined|dependencyHealthBlocked|dependencyHealthViolated)$/", + "when": "viewItem =~ /^(package|packageQuarantined)$/", "group": "navigation" }, { @@ -427,7 +539,17 @@ }, { "command": "cloudsmith-vsc.showVulnerabilities", - "when": "viewItem =~ /^(package|packageQuarantined|dependencyHealthBlocked|dependencyHealthViolated)$/", + "when": "viewItem =~ /^(package|packageQuarantined)$/", + "group": "navigation" + }, + { + "command": "cloudsmith-vsc.showDepVulnerabilities", + "when": "view == cloudsmithDependencyHealthView && viewItem == dependencyHealthVulnerable", + "group": "navigation" + }, + { + "command": "cloudsmith-vsc.findDepSafeVersion", + "when": "view == cloudsmithDependencyHealthView && viewItem == dependencyHealthVulnerable", "group": "navigation" }, { @@ -442,17 +564,17 @@ }, { "command": "cloudsmith-vsc.explainQuarantine", - "when": "viewItem =~ /^(packageQuarantined|dependencyHealthBlocked)$/", + "when": "viewItem =~ /^(packageQuarantined|dependencyHealthQuarantined)$/", "group": "navigation" }, { "command": "cloudsmith-vsc.copyInstallCommand", - "when": "viewItem =~ /^(package|dependencyHealth|dependencyHealthViolated)$/", + "when": "viewItem =~ /^(package|dependencyHealthFound|dependencyHealthVulnerable)$/", "group": "navigation" }, { "command": "cloudsmith-vsc.showInstallCommand", - "when": "viewItem =~ /^(package|dependencyHealth|dependencyHealthViolated)$/", + "when": "viewItem =~ /^(package|dependencyHealthFound|dependencyHealthVulnerable)$/", "group": "navigation" }, { @@ -467,9 +589,19 @@ }, { "command": "cloudsmith-vsc.previewUpstreamResolution", - "when": "view == cloudsmithDependencyHealthView && viewItem == dependencyHealthNotFound", + "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealthMissing|dependencyHealthUpstreamReachable|dependencyHealthUpstreamUnreachable)$/", "group": "navigation" }, + { + "command": "cloudsmith-vsc.pullSingleDependency", + "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealthMissing|dependencyHealthUpstreamReachable)$/", + "group": "inline" + }, + { + "command": "cloudsmith-vsc.pullSingleDependency", + "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealthMissing|dependencyHealthUpstreamReachable)$/", + "group": "1_pull" + }, { "command": "cloudsmith-vsc.showPromotionStatus", "when": "view == cloudsmithView && viewItem == package", @@ -563,18 +695,63 @@ "when": "view == cloudsmithSearchView" }, { - "command": "cloudsmith-vsc.scanDependencies", - "group": "navigation", + "command": "cloudsmith-vsc.scanDependenciesPending", + "group": "navigation@1", + "when": "view == cloudsmithDependencyHealthView && !cloudsmith.depScanComplete" + }, + { + "command": "cloudsmith-vsc.scanDependenciesComplete", + "group": "navigation@1", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depScanComplete" + }, + { + "command": "cloudsmith-vsc.pullDependencies", + "group": "navigation@2", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depScanComplete" + }, + { + "command": "cloudsmith-vsc.cycleDepViewDirect", + "group": "navigation@3", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depViewMode == 'direct'" + }, + { + "command": "cloudsmith-vsc.cycleDepViewFlat", + "group": "navigation@3", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depViewMode == 'flat'" + }, + { + "command": "cloudsmith-vsc.cycleDepViewTree", + "group": "navigation@3", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depViewMode == 'tree'" + }, + { + "command": "cloudsmith-vsc.depSortFilter", + "group": "navigation@4", + "when": "view == cloudsmithDependencyHealthView && !cloudsmith.depFilterActive" + }, + { + "command": "cloudsmith-vsc.depSortFilterActive", + "group": "navigation@4", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depFilterActive" + }, + { + "command": "cloudsmith-vsc.viewComplianceReport", + "group": "navigation@5", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depScanComplete" + }, + { + "command": "cloudsmith-vsc.depViewDirect", + "group": "view@1", "when": "view == cloudsmithDependencyHealthView" }, { - "command": "cloudsmith-vsc.rescanDependencies", - "group": "navigation", + "command": "cloudsmith-vsc.depViewFlat", + "group": "view@2", "when": "view == cloudsmithDependencyHealthView" }, { - "command": "cloudsmith-vsc.changeDependencyFolder", - "group": "navigation", + "command": "cloudsmith-vsc.depViewTree", + "group": "view@3", "when": "view == cloudsmithDependencyHealthView" } ] @@ -645,15 +822,20 @@ }, "cloudsmith-vsc.maxDependenciesToScan": { "type": "integer", - "default": 200, + "default": 10000, "minimum": 1, - "description": "Maximum number of dependencies to scan before truncating a dependency health run." + "description": "Maximum number of dependencies to display in the Dependency Health view. Pull operations always process all resolved dependencies." }, "cloudsmith-vsc.showLicenseIndicators": { "type": "boolean", "default": true, "description": "Show license risk classification on packages. When disabled, license nodes are hidden." }, + "cloudsmith-vsc.flagRestrictiveLicenses": { + "type": "boolean", + "default": true, + "description": "Highlight restrictive licenses inline in the Dependency Health view." + }, "cloudsmith-vsc.showDockerDigestCommand": { "type": "boolean", "default": false, @@ -666,8 +848,14 @@ }, "cloudsmith-vsc.resolveTransitiveDependencies": { "type": "boolean", - "default": false, - "description": "Use the package manager CLI to resolve transitive dependencies. Requires the package manager and installed project dependencies." + "default": true, + "description": "Parse lockfiles to resolve transitive dependencies. When disabled, only direct manifest dependencies are shown." + }, + "cloudsmith-vsc.dependencyTreeDefaultView": { + "type": "string", + "enum": ["direct", "flat", "tree"], + "default": "flat", + "description": "Default view mode for the Dependency Health panel." }, "cloudsmith-vsc.showLegacyPolicies": { "type": "boolean", diff --git a/test/complianceReportProvider.test.js b/test/complianceReportProvider.test.js new file mode 100644 index 0000000..583c2f3 --- /dev/null +++ b/test/complianceReportProvider.test.js @@ -0,0 +1,150 @@ +const assert = require("assert"); +const vscode = require("vscode"); +const { ComplianceReportProvider } = require("../views/complianceReportProvider"); +const { buildComplianceReportData } = require("../views/dependencyHealthProvider"); + +suite("ComplianceReportProvider", () => { + test("report data and HTML escape dynamic content", () => { + const dependencies = [ + { + name: "evil'\"", + version: "1.0.0'\"", + format: "npm", + ecosystem: "npm", + isDirect: true, + cloudsmithStatus: "FOUND", + cloudsmithPackage: { + repository: "prod", + status_str: "Completed", + license: "MIT", + }, + vulnerabilities: { + count: 2, + maxSeverity: "High", + severityCounts: { High: 2 }, + hasFixAvailable: true, + entries: [{ fixVersion: "1.0.1" }], + detailsLoaded: true, + }, + }, + { + name: "license-risk", + version: "2.0.0", + format: "npm", + ecosystem: "npm", + isDirect: false, + cloudsmithStatus: "FOUND", + cloudsmithPackage: { + repository: "prod", + status_str: "Completed", + license: "GPL-3.0", + }, + license: { + display: "GPL-3.0", + spdx: "GPL-3.0", + classification: "restrictive", + }, + }, + { + name: "policy-fail", + version: "3.0.0", + format: "pypi", + ecosystem: "pypi", + isDirect: true, + cloudsmithStatus: "FOUND", + cloudsmithPackage: { + repository: "prod", + status_str: "Quarantined", + }, + policy: { + violated: true, + denied: true, + quarantined: true, + status: "Quarantined", + statusReason: "Blocked by policy ", + }, + }, + { + name: "missing-lib", + version: "0.1.0", + format: "npm", + ecosystem: "npm", + isDirect: true, + cloudsmithStatus: "NOT_FOUND", + upstreamStatus: "reachable", + upstreamDetail: "proxy ", + }, + { + name: "missing-lib", + version: "0.1.0", + format: "npm", + ecosystem: "npm", + isDirect: false, + cloudsmithStatus: "NOT_FOUND", + upstreamStatus: "reachable", + upstreamDetail: "proxy ", + }, + ]; + + const reportData = buildComplianceReportData("fixture ", dependencies, { + scanDate: "2026-04-05T12:30:00Z", + }); + + assert.strictEqual(reportData.summary.total, 4); + assert.strictEqual(reportData.summary.found, 3); + assert.strictEqual(reportData.summary.notFound, 1); + assert.strictEqual(reportData.summary.coveragePct, 75); + assert.strictEqual(reportData.summary.vulnCount, 1); + assert.strictEqual(reportData.summary.restrictiveLicenseCount, 1); + assert.strictEqual(reportData.summary.policyViolationCount, 1); + + const provider = new ComplianceReportProvider({}); + const html = provider._getHtml(reportData); + + assert.match(html, /fixture <app>/); + assert.match(html, /evil<script>alert\(1\)<\/script>'"/); + assert.match(html, /1\.0\.0'"/); + assert.match(html, /proxy <prod>/); + assert.match(html, /Blocked by policy <rule>/); + assert.doesNotMatch(html, /