From abbb343abc812e132ca631005e6e32d1c8c6a222 Mon Sep 17 00:00:00 2001 From: nitodeco Date: Tue, 10 Feb 2026 00:23:03 +0100 Subject: [PATCH 1/9] 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/9] 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/9] 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'), From e17e8431d3fda3771eb3b04f5344c29f2d18cb17 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 16 Feb 2026 15:46:25 +0800 Subject: [PATCH 4/9] cleanup --- src/providers/code-actions/vulnerability.ts | 2 +- .../diagnostics/rules/vulnerability.ts | 42 +------------------ tests/__mocks__/vscode.ts | 3 -- 3 files changed, 3 insertions(+), 44 deletions(-) diff --git a/src/providers/code-actions/vulnerability.ts b/src/providers/code-actions/vulnerability.ts index 82889b6..4de396a 100644 --- a/src/providers/code-actions/vulnerability.ts +++ b/src/providers/code-actions/vulnerability.ts @@ -1,5 +1,5 @@ import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument } from 'vscode' -import { formatVersion, parseVersion } from '#utils/package' +import { formatVersion, parseVersion } from '#utils/version' import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' const FIXED_VERSION_MESSAGE_PATTERN = / Upgrade to (?\S+) to fix\.$/ diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index 4a68023..f890214 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -2,7 +2,7 @@ import type { OsvSeverityLevel } from '#utils/api/vulnerability' import type { DiagnosticRule } from '..' import { getVulnerability, SEVERITY_LEVELS } from '#utils/api/vulnerability' import { npmxPackageUrl } from '#utils/links' -import { isSupportedProtocol, parseVersion } from '#utils/version' +import { isSupportedProtocol, lt, parseVersion } from '#utils/version' import { DiagnosticSeverity, Uri } from 'vscode' const DIAGNOSTIC_MAPPING: Record, DiagnosticSeverity> = { @@ -12,45 +12,6 @@ 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 @@ -96,6 +57,7 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { .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.` diff --git a/tests/__mocks__/vscode.ts b/tests/__mocks__/vscode.ts index a1ce867..da24b46 100644 --- a/tests/__mocks__/vscode.ts +++ b/tests/__mocks__/vscode.ts @@ -5,16 +5,13 @@ 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 From 0f13ef2007ceea96fa3c48fd225f5cbbc8f1d396 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 16 Feb 2026 21:51:40 +0800 Subject: [PATCH 5/9] refactor: simplify --- src/providers/code-actions/vulnerability.ts | 23 ++++--------------- .../diagnostics/rules/vulnerability.ts | 14 ++++++----- 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/src/providers/code-actions/vulnerability.ts b/src/providers/code-actions/vulnerability.ts index 4de396a..4d9fd1f 100644 --- a/src/providers/code-actions/vulnerability.ts +++ b/src/providers/code-actions/vulnerability.ts @@ -1,5 +1,4 @@ import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument } from 'vscode' -import { formatVersion, parseVersion } from '#utils/version' import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' const FIXED_VERSION_MESSAGE_PATTERN = / Upgrade to (?\S+) to fix\.$/ @@ -19,25 +18,16 @@ function isVulnerabilityDiagnostic(diagnostic: Diagnostic): boolean { } function getFixedInVersion(diagnostic: Diagnostic): string | null { - if (!isVulnerabilityDiagnostic(diagnostic)) - return 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 { - 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) + const codeAction = new CodeAction(`Update to ${fixedInVersion} to fix vulnerabilities`, CodeActionKind.QuickFix) codeAction.isPreferred = true const workspaceEdit = new WorkspaceEdit() - workspaceEdit.replace(document.uri, range, formattedFixedVersion) + workspaceEdit.replace(document.uri, range, fixedInVersion) codeAction.edit = workspaceEdit return codeAction @@ -46,14 +36,11 @@ function createUpdateVersionAction(document: TextDocument, range: Range, fixedIn export class VulnerabilityCodeActionProvider implements CodeActionProvider { provideCodeActions(document: TextDocument, _range: Range, context: CodeActionContext): CodeAction[] { return context.diagnostics.flatMap((diagnostic) => { - const fixedInVersion = getFixedInVersion(diagnostic) - if (!fixedInVersion) + if (!isVulnerabilityDiagnostic(diagnostic)) return [] - const currentVersion = document.getText(diagnostic.range) - const currentSemver = parseVersion(currentVersion)?.semver - const fixedSemver = parseVersion(fixedInVersion)?.semver ?? fixedInVersion - if (currentSemver && currentSemver === fixedSemver) + const fixedInVersion = getFixedInVersion(diagnostic) + if (!fixedInVersion) return [] return [createUpdateVersionAction(document, diagnostic.range, fixedInVersion)] diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index f890214..f43ed3d 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -2,7 +2,7 @@ import type { OsvSeverityLevel } from '#utils/api/vulnerability' import type { DiagnosticRule } from '..' import { getVulnerability, SEVERITY_LEVELS } from '#utils/api/vulnerability' import { npmxPackageUrl } from '#utils/links' -import { isSupportedProtocol, lt, parseVersion } from '#utils/version' +import { formatVersion, isSupportedProtocol, lt, parseVersion } from '#utils/version' import { DiagnosticSeverity, Uri } from 'vscode' const DIAGNOSTIC_MAPPING: Record, DiagnosticSeverity> = { @@ -53,14 +53,16 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { return const rootVulnerabilitiesFixedIn = vulnerablePackages - .filter((vulnerablePackage) => vulnerablePackage.depth === 'root') - .flatMap((vulnerablePackage) => vulnerablePackage.vulnerabilities) - .map((vulnerability) => vulnerability.fixedIn) - .filter((fixedIn): fixedIn is string => Boolean(fixedIn)) + .flatMap(({ depth, vulnerabilities }) => { + if (depth !== 'root') + return [] + + return vulnerabilities.flatMap(({ fixedIn }) => fixedIn ? [fixedIn] : []) + }) const fixedInVersion = getBestFixedInVersion(rootVulnerabilitiesFixedIn) const messageSuffix = fixedInVersion - ? ` Upgrade to ${parsed.prefix}${fixedInVersion} to fix.` + ? ` Upgrade to ${formatVersion({ ...parsed, semver: fixedInVersion })} to fix.` : '' const targetVersion = fixedInVersion ?? semver From dfdbef58365e90d7d864786ff5a37e5dda9f50ca Mon Sep 17 00:00:00 2001 From: Nico <98180436+nitodeco@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:16:31 +0100 Subject: [PATCH 6/9] Update src/providers/diagnostics/rules/vulnerability.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/providers/diagnostics/rules/vulnerability.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index f43ed3d..63196a9 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -16,7 +16,7 @@ function getBestFixedInVersion(fixedInVersions: string[]): string | undefined { if (!fixedInVersions.length) return - return fixedInVersions.reduce((best, current) => lt(best, current) ? current : best) + return fixedInVersions.reduce((best, current) => lt(current, best) ? current : best) } export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { From 7df5add63f797edfc5f2c07bfd182dd78420b213 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 18 Feb 2026 12:43:56 +0800 Subject: [PATCH 7/9] refactor: move test to code-actions --- .../vulnerability.test.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/{vulnerability-code-actions.test.ts => code-actions/vulnerability.test.ts} (97%) diff --git a/tests/vulnerability-code-actions.test.ts b/tests/code-actions/vulnerability.test.ts similarity index 97% rename from tests/vulnerability-code-actions.test.ts rename to tests/code-actions/vulnerability.test.ts index c337727..1db6c1c 100644 --- a/tests/vulnerability-code-actions.test.ts +++ b/tests/code-actions/vulnerability.test.ts @@ -1,7 +1,7 @@ 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' +import { VulnerabilityCodeActionProvider } from '../../src/providers/code-actions/vulnerability' function createDiagnostic(options: { code: string | { value: string }, message: string }): Diagnostic { return { From 50e0a321ff8f9d6c413a39317acb8d9a2c8d7ed8 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 18 Feb 2026 12:47:44 +0800 Subject: [PATCH 8/9] test: update --- tests/code-actions/vulnerability.test.ts | 38 +----------------------- 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/tests/code-actions/vulnerability.test.ts b/tests/code-actions/vulnerability.test.ts index 1db6c1c..c7a9376 100644 --- a/tests/code-actions/vulnerability.test.ts +++ b/tests/code-actions/vulnerability.test.ts @@ -33,7 +33,7 @@ describe('vulnerability code action provider', () => { const diagnostic = createDiagnostic({ code: { value: 'vulnerability' }, - message: 'This version has 1 high vulnerability. Upgrade to 1.2.3 to fix.', + message: 'This version has 1 high vulnerability. Upgrade to ^1.2.3 to fix.', }) const codeActions = provider.provideCodeActions( @@ -67,40 +67,4 @@ describe('vulnerability code action provider', () => { 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) - }) }) From ff072e3e5e64e61f96bc359ab3d9742c68e9bdb7 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 18 Feb 2026 13:10:04 +0800 Subject: [PATCH 9/9] refactor: simplify `getBestFixedInVersion` --- .../diagnostics/rules/vulnerability.ts | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index 63196a9..7d11f76 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -1,4 +1,4 @@ -import type { OsvSeverityLevel } from '#utils/api/vulnerability' +import type { OsvSeverityLevel, PackageVulnerabilityInfo } from '#utils/api/vulnerability' import type { DiagnosticRule } from '..' import { getVulnerability, SEVERITY_LEVELS } from '#utils/api/vulnerability' import { npmxPackageUrl } from '#utils/links' @@ -12,11 +12,17 @@ const DIAGNOSTIC_MAPPING: Record, Diagnosti low: DiagnosticSeverity.Hint, } -function getBestFixedInVersion(fixedInVersions: string[]): string | undefined { - if (!fixedInVersions.length) - return - - return fixedInVersions.reduce((best, current) => lt(current, best) ? current : best) +function getBigestFixedInVersion(vulnerablePackages: PackageVulnerabilityInfo[]): string | undefined { + let bigest: string | undefined + for (const { depth, vulnerabilities } of vulnerablePackages) { + if (depth !== 'root') + continue + for (const { fixedIn } of vulnerabilities) { + if (fixedIn && (!bigest || lt(bigest, fixedIn))) + bigest = fixedIn + } + } + return bigest } export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { @@ -52,19 +58,10 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { if (!message.length) return - const rootVulnerabilitiesFixedIn = vulnerablePackages - .flatMap(({ depth, vulnerabilities }) => { - if (depth !== 'root') - return [] - - return vulnerabilities.flatMap(({ fixedIn }) => fixedIn ? [fixedIn] : []) - }) - - const fixedInVersion = getBestFixedInVersion(rootVulnerabilitiesFixedIn) + const fixedInVersion = getBigestFixedInVersion(vulnerablePackages) const messageSuffix = fixedInVersion ? ` Upgrade to ${formatVersion({ ...parsed, semver: fixedInVersion })} to fix.` : '' - const targetVersion = fixedInVersion ?? semver return { node: dep.versionNode, @@ -72,7 +69,7 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => { severity: severity ?? DiagnosticSeverity.Error, code: { value: 'vulnerability', - target: Uri.parse(npmxPackageUrl(dep.name, targetVersion)), + target: Uri.parse(npmxPackageUrl(dep.name, semver)), }, } }