From abbb343abc812e132ca631005e6e32d1c8c6a222 Mon Sep 17 00:00:00 2001 From: nitodeco Date: Tue, 10 Feb 2026 00:23:03 +0100 Subject: [PATCH 1/3] feat: add vulnerability quick-fix + hint --- src/constants.ts | 2 + src/index.ts | 17 ++++- src/providers/code-actions/vulnerability.ts | 56 +++++++++++++++ .../diagnostics/rules/vulnerability.ts | 70 +++++++++++++++++-- src/utils/api/vulnerability.ts | 1 + 5 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 src/providers/code-actions/vulnerability.ts diff --git a/src/constants.ts b/src/constants.ts index defcc10..39f3636 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,3 +13,5 @@ export const NPMX_DEV = 'https://npmx.dev' export const NPMX_DEV_API = `${NPMX_DEV}/api` export const SPACER = ' ' + +export const VULNERABILITY_FETCH_TIMEOUT_MS = 3_000 diff --git a/src/index.ts b/src/index.ts index a6283e6..9c694af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,13 @@ import { VERSION_TRIGGER_CHARACTERS, } from '#constants' import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' -import { Disposable, languages } from 'vscode' +import { CodeActionKind, Disposable, languages } from 'vscode' import { openFileInNpmx } from './commands/open-file-in-npmx' import { openInBrowser } from './commands/open-in-browser' import { PackageJsonExtractor } from './extractors/package-json' import { PnpmWorkspaceYamlExtractor } from './extractors/pnpm-workspace-yaml' import { commands, displayName, version } from './generated-meta' +import { VulnerabilityCodeActionProvider } from './providers/code-actions/vulnerability' import { VersionCompletionItemProvider } from './providers/completion-item/version' import { registerDiagnosticCollection } from './providers/diagnostics' import { NpmxHoverProvider } from './providers/hover/npmx' @@ -61,6 +62,20 @@ export const { activate, deactivate } = defineExtension(() => { onCleanup(() => Disposable.from(...disposables).dispose()) }) + watchEffect((onCleanup) => { + if (!config.diagnostics.vulnerability) + return + + const provider = new VulnerabilityCodeActionProvider() + const options = { providedCodeActionKinds: [CodeActionKind.QuickFix] } + const disposable = Disposable.from( + languages.registerCodeActionsProvider({ pattern: PACKAGE_JSON_PATTERN }, provider, options), + languages.registerCodeActionsProvider({ pattern: PNPM_WORKSPACE_PATTERN }, provider, options), + ) + + onCleanup(() => disposable.dispose()) + }) + registerDiagnosticCollection({ [PACKAGE_JSON_BASENAME]: packageJsonExtractor, [PNPM_WORKSPACE_BASENAME]: pnpmWorkspaceYamlExtractor, diff --git a/src/providers/code-actions/vulnerability.ts b/src/providers/code-actions/vulnerability.ts new file mode 100644 index 0000000..a0d31bf --- /dev/null +++ b/src/providers/code-actions/vulnerability.ts @@ -0,0 +1,56 @@ +import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument } from 'vscode' +import { formatVersion, parseVersion } from '#utils/package' +import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' + +function getVulnerabilityCodeValue(diagnostic: Diagnostic): string | null { + if (typeof diagnostic.code === 'string') + return diagnostic.code + + if (typeof diagnostic.code === 'object' && typeof diagnostic.code.value === 'string') + return diagnostic.code.value + + return null +} + +function getFixedInVersion(diagnostic: Diagnostic): string | null { + const vulnerabilityCodeValue = getVulnerabilityCodeValue(diagnostic) + if (!vulnerabilityCodeValue || !vulnerabilityCodeValue.startsWith('vulnerability|')) + return null + + const fixedInVersion = vulnerabilityCodeValue.slice('vulnerability|'.length) + return fixedInVersion.length > 0 ? fixedInVersion : null +} + +function createUpdateVersionAction(document: TextDocument, range: Range, fixedInVersion: string): CodeAction { + const currentVersion = document.getText(range) + const parsedCurrentVersion = parseVersion(currentVersion) + const formattedFixedVersion = parsedCurrentVersion + ? formatVersion({ ...parsedCurrentVersion, semver: fixedInVersion }) + : fixedInVersion + + const codeAction = new CodeAction(`Update to ${formattedFixedVersion} to fix vulnerabilities`, CodeActionKind.QuickFix) + codeAction.isPreferred = true + const workspaceEdit = new WorkspaceEdit() + workspaceEdit.replace(document.uri, range, formattedFixedVersion) + codeAction.edit = workspaceEdit + + return codeAction +} + +export class VulnerabilityCodeActionProvider implements CodeActionProvider { + provideCodeActions(document: TextDocument, _range: Range, context: CodeActionContext): CodeAction[] { + return context.diagnostics.flatMap((diagnostic) => { + const fixedInVersion = getFixedInVersion(diagnostic) + if (!fixedInVersion) + return [] + + const currentVersion = document.getText(diagnostic.range) + const currentSemver = parseVersion(currentVersion)?.semver + const fixedSemver = parseVersion(fixedInVersion)?.semver ?? fixedInVersion + if (currentSemver && currentSemver === fixedSemver) + return [] + + return [createUpdateVersionAction(document, diagnostic.range, fixedInVersion)] + }) + } +} diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index 01cbc08..268b415 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -12,6 +12,52 @@ const DIAGNOSTIC_MAPPING: Record, Diagnosti low: DiagnosticSeverity.Hint, } +// TODO: remove and import once #36 is merged +function comparePrerelease(a: string, b: string): number { + const pa = a.split('.') + const pb = b.split('.') + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + if (i >= pa.length) + return -1 + if (i >= pb.length) + return 1 + const na = Number(pa[i]) + const nb = Number(pb[i]) + if (!Number.isNaN(na) && !Number.isNaN(nb)) { + if (na !== nb) + return na - nb + } else if (pa[i] !== pb[i]) { + return pa[i] < pb[i] ? -1 : 1 + } + } + return 0 +} + +// TODO: remove and import once #36 is merged +function lt(a: string, b: string): boolean { + const [coreA, preA] = a.split('-', 2) + const [coreB, preB] = b.split('-', 2) + const partsA = coreA.split('.').map(Number) + const partsB = coreB.split('.').map(Number) + for (let i = 0; i < 3; i++) { + const diff = (partsA[i] || 0) - (partsB[i] || 0) + if (diff !== 0) + return diff < 0 + } + if (preA && !preB) + return true + if (!preA || !preB) + return false + return comparePrerelease(preA, preB) < 0 +} + +function getBestFixedInVersion(fixedInVersions: string[]): string | undefined { + if (!fixedInVersions.length) + return + + return fixedInVersions.reduce((best, current) => lt(best, current) ? current : best) +} + export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { const parsed = parseVersion(dep.version) if (!parsed || !isSupportedProtocol(parsed.protocol)) @@ -26,7 +72,7 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { if (!result) return - const { totalCounts } = result + const { totalCounts, vulnerablePackages } = result const message: string[] = [] let severity: DiagnosticSeverity | null = null @@ -45,13 +91,27 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { if (!message.length) return + const rootVulnerabilitiesFixedIn = vulnerablePackages + .filter((vulnerablePackage) => vulnerablePackage.depth === 'root') + .flatMap((vulnerablePackage) => vulnerablePackage.vulnerabilities) + .map((vulnerability) => vulnerability.fixedIn) + .filter((fixedIn): fixedIn is string => Boolean(fixedIn)) + const fixedInVersion = getBestFixedInVersion(rootVulnerabilitiesFixedIn) + const messageSuffix = fixedInVersion + ? ` Upgrade to ${parsed.prefix}${fixedInVersion} to fix.` + : '' + const vulnerabilityCode = fixedInVersion + ? `vulnerability|${fixedInVersion}` + : 'vulnerability' + const targetVersion = fixedInVersion ?? semver + return { node: dep.versionNode, - message: `This version has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}`, - severity: DiagnosticSeverity.Error, + message: `This version has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`, + severity: severity ?? DiagnosticSeverity.Error, code: { - value: 'vulnerability', - target: Uri.parse(npmxPackageUrl(dep.name, semver)), + value: vulnerabilityCode, + target: Uri.parse(npmxPackageUrl(dep.name, targetVersion)), }, } } diff --git a/src/utils/api/vulnerability.ts b/src/utils/api/vulnerability.ts index c9ab5fd..7f34fc9 100644 --- a/src/utils/api/vulnerability.ts +++ b/src/utils/api/vulnerability.ts @@ -23,6 +23,7 @@ export interface VulnerabilitySummary { severity: OsvSeverityLevel aliases: string[] url: string + fixedIn?: string } /** Depth in dependency tree */ From 3afa90f3367e834d0ca63871c58e6183558b0dd2 Mon Sep 17 00:00:00 2001 From: nitodeco Date: Tue, 10 Feb 2026 00:34:24 +0100 Subject: [PATCH 2/3] chore: remove unused variable --- src/constants.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 39f3636..defcc10 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,5 +13,3 @@ export const NPMX_DEV = 'https://npmx.dev' export const NPMX_DEV_API = `${NPMX_DEV}/api` export const SPACER = ' ' - -export const VULNERABILITY_FETCH_TIMEOUT_MS = 3_000 From b451e10daab531e081d58042621c8a39b232bb26 Mon Sep 17 00:00:00 2001 From: nitodeco Date: Thu, 12 Feb 2026 14:02:19 +0100 Subject: [PATCH 3/3] refactor: improve vulnerability diagnostic messaging --- src/providers/code-actions/vulnerability.ts | 16 ++- .../diagnostics/rules/vulnerability.ts | 5 +- tests/__mocks__/vscode.ts | 6 + tests/vulnerability-code-actions.test.ts | 106 ++++++++++++++++++ vitest.config.ts | 1 + 5 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 tests/vulnerability-code-actions.test.ts diff --git a/src/providers/code-actions/vulnerability.ts b/src/providers/code-actions/vulnerability.ts index a0d31bf..82889b6 100644 --- a/src/providers/code-actions/vulnerability.ts +++ b/src/providers/code-actions/vulnerability.ts @@ -2,7 +2,9 @@ import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocu import { formatVersion, parseVersion } from '#utils/package' import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' -function getVulnerabilityCodeValue(diagnostic: Diagnostic): string | null { +const FIXED_VERSION_MESSAGE_PATTERN = / Upgrade to (?\S+) to fix\.$/ + +function getDiagnosticCodeValue(diagnostic: Diagnostic): string | null { if (typeof diagnostic.code === 'string') return diagnostic.code @@ -12,13 +14,17 @@ function getVulnerabilityCodeValue(diagnostic: Diagnostic): string | null { return null } +function isVulnerabilityDiagnostic(diagnostic: Diagnostic): boolean { + return getDiagnosticCodeValue(diagnostic) === 'vulnerability' +} + function getFixedInVersion(diagnostic: Diagnostic): string | null { - const vulnerabilityCodeValue = getVulnerabilityCodeValue(diagnostic) - if (!vulnerabilityCodeValue || !vulnerabilityCodeValue.startsWith('vulnerability|')) + if (!isVulnerabilityDiagnostic(diagnostic)) return null - const fixedInVersion = vulnerabilityCodeValue.slice('vulnerability|'.length) - return fixedInVersion.length > 0 ? fixedInVersion : null + const fixedInVersionMatch = FIXED_VERSION_MESSAGE_PATTERN.exec(diagnostic.message) + const fixedInVersion = fixedInVersionMatch?.groups?.fixedInVersion + return fixedInVersion && fixedInVersion.length > 0 ? fixedInVersion : null } function createUpdateVersionAction(document: TextDocument, range: Range, fixedInVersion: string): CodeAction { diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index 268b415..4cee7d3 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -100,9 +100,6 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { const messageSuffix = fixedInVersion ? ` Upgrade to ${parsed.prefix}${fixedInVersion} to fix.` : '' - const vulnerabilityCode = fixedInVersion - ? `vulnerability|${fixedInVersion}` - : 'vulnerability' const targetVersion = fixedInVersion ?? semver return { @@ -110,7 +107,7 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { message: `This version has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`, severity: severity ?? DiagnosticSeverity.Error, code: { - value: vulnerabilityCode, + value: 'vulnerability', target: Uri.parse(npmxPackageUrl(dep.name, targetVersion)), }, } diff --git a/tests/__mocks__/vscode.ts b/tests/__mocks__/vscode.ts index 4049317..a1ce867 100644 --- a/tests/__mocks__/vscode.ts +++ b/tests/__mocks__/vscode.ts @@ -5,10 +5,16 @@ const vscode = createVSCodeMock(vi) export const Uri = vscode.Uri export const workspace = vscode.workspace +export const languages = vscode.languages export const Range = vscode.Range export const Position = vscode.Position export const Location = vscode.Location export const Selection = vscode.Selection +export const CodeAction = vscode.CodeAction +export const CodeActionKind = vscode.CodeActionKind +export const CodeActionTriggerKind = vscode.CodeActionTriggerKind +export const WorkspaceEdit = vscode.WorkspaceEdit +export const DiagnosticSeverity = vscode.DiagnosticSeverity export const ThemeColor = vscode.ThemeColor export const ThemeIcon = vscode.ThemeIcon export const TreeItem = vscode.TreeItem diff --git a/tests/vulnerability-code-actions.test.ts b/tests/vulnerability-code-actions.test.ts new file mode 100644 index 0000000..c337727 --- /dev/null +++ b/tests/vulnerability-code-actions.test.ts @@ -0,0 +1,106 @@ +import type { CodeActionContext, Diagnostic, TextDocument } from 'vscode' +import { describe, expect, it, vi } from 'vitest' +import { Range, Uri } from 'vscode' +import { VulnerabilityCodeActionProvider } from '../src/providers/code-actions/vulnerability' + +function createDiagnostic(options: { code: string | { value: string }, message: string }): Diagnostic { + return { + code: options.code, + message: options.message, + range: new Range(0, 0, 0, 6), + } as Diagnostic +} + +function createTextDocument(versionText: string): TextDocument { + return { + uri: Uri.parse('file:///package.json'), + getText: vi.fn(() => versionText), + } as unknown as TextDocument +} + +function createCodeActionContext(diagnostics: Diagnostic[]): CodeActionContext { + return { + diagnostics, + triggerKind: 1 as CodeActionContext['triggerKind'], + only: undefined, + } +} + +describe('vulnerability code action provider', () => { + it('provides a quick fix when vulnerability message includes upgrade version', () => { + const provider = new VulnerabilityCodeActionProvider() + const textDocument = createTextDocument('^1.0.0') + + const diagnostic = createDiagnostic({ + code: { value: 'vulnerability' }, + message: 'This version has 1 high vulnerability. Upgrade to 1.2.3 to fix.', + }) + + const codeActions = provider.provideCodeActions( + textDocument, + diagnostic.range, + createCodeActionContext([diagnostic]), + ) + + expect(codeActions).toEqual([ + expect.objectContaining({ + title: 'Update to ^1.2.3 to fix vulnerabilities', + isPreferred: true, + }), + ]) + }) + + it('does not provide a quick fix when vulnerability message has no upgrade target', () => { + const provider = new VulnerabilityCodeActionProvider() + const textDocument = createTextDocument('^1.0.0') + + const diagnostic = createDiagnostic({ + code: { value: 'vulnerability' }, + message: 'This version has 1 high vulnerability.', + }) + + const codeActions = provider.provideCodeActions( + textDocument, + diagnostic.range, + createCodeActionContext([diagnostic]), + ) + + expect(codeActions).toHaveLength(0) + }) + + it('does not provide a quick fix when current version already matches fixed version', () => { + const provider = new VulnerabilityCodeActionProvider() + const textDocument = createTextDocument('~1.2.3') + + const diagnostic = createDiagnostic({ + code: { value: 'vulnerability' }, + message: 'This version has 1 high vulnerability. Upgrade to 1.2.3 to fix.', + }) + + const codeActions = provider.provideCodeActions( + textDocument, + diagnostic.range, + createCodeActionContext([diagnostic]), + ) + + expect(codeActions).toHaveLength(0) + }) + + it('does not rely on encoded vulnerability code values', () => { + const provider = new VulnerabilityCodeActionProvider() + const textDocument = createTextDocument('^1.0.0') + + const diagnostic = createDiagnostic({ + code: { value: 'vulnerability|1.2.3' }, + message: 'This version has 1 high vulnerability. Upgrade to 1.2.3 to fix.', + }) + + const codeActions = provider.provideCodeActions( + textDocument, + diagnostic.range, + createCodeActionContext([diagnostic]), + ) + + expect(codeActions).toHaveLength(0) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index dd2fd01..ef30460 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ alias: { '#constants': join(rootDir, '/src/constants.ts'), '#state': join(rootDir, '/src/state.ts'), + '#utils': join(rootDir, '/src/utils'), '#types/*': join(rootDir, '/src/types/*'), '#utils/*': join(rootDir, '/src/utils/*'), 'vscode': join(rootDir, '/tests/__mocks__/vscode.ts'),