diff --git a/README.md b/README.md index 9ad71d3..0c1d7d4 100644 --- a/README.md +++ b/README.md @@ -38,17 +38,21 @@ -| Key | Description | Type | Default | -| ----------------------------------- | --------------------------------------------------------------------------------------- | --------- | ------------------- | -| `npmx.hover.enabled` | Enable hover information for packages | `boolean` | `true` | -| `npmx.completion.version` | Version completion behavior | `string` | `"provenance-only"` | -| `npmx.completion.excludePrerelease` | Exclude prerelease versions (alpha, beta, rc, canary, etc.) from completion suggestions | `boolean` | `true` | -| `npmx.diagnostics.upgrade` | Show hints when a newer version of a package is available | `boolean` | `true` | -| `npmx.diagnostics.deprecation` | Show warnings for deprecated packages | `boolean` | `true` | -| `npmx.diagnostics.replacement` | Show suggestions for package replacements | `boolean` | `true` | -| `npmx.diagnostics.vulnerability` | Show warnings for packages with known vulnerabilities | `boolean` | `true` | -| `npmx.diagnostics.distTag` | Show warnings when a dependency uses a dist tag | `boolean` | `true` | -| `npmx.diagnostics.engineMismatch` | Show warnings when dependency engines mismatch with the current package | `boolean` | `true` | +| Key | Description | Type | Default | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------- | ------------------- | +| `npmx.hover.enabled` | Enable hover information for packages | `boolean` | `true` | +| `npmx.completion.version` | Version completion behavior | `string` | `"provenance-only"` | +| `npmx.completion.excludePrerelease` | Exclude prerelease versions (alpha, beta, rc, canary, etc.) from completion suggestions | `boolean` | `true` | +| `npmx.diagnostics.upgrade` | Show hints when a newer version of a package is available | `boolean` | `true` | +| `npmx.diagnostics.deprecation` | Show warnings for deprecated packages | `boolean` | `true` | +| `npmx.diagnostics.replacement` | Show suggestions for package replacements | `boolean` | `true` | +| `npmx.diagnostics.vulnerability` | Show warnings for packages with known vulnerabilities | `boolean` | `true` | +| `npmx.diagnostics.distTag` | Show warnings when a dependency uses a dist tag | `boolean` | `true` | +| `npmx.diagnostics.engineMismatch` | Show warnings when dependency engines mismatch with the current package | `boolean` | `true` | +| `npmx.ignore.upgrade` | List of packages to ignore upgrade hints. Supports both "name" and "name@version" (e.g. ["request", "uuid@3.4.0"]) | `array` | `[]` | +| `npmx.ignore.deprecation` | List of packages to ignore deprecation warnings. Supports both "name" and "name@version" (e.g. ["request", "uuid@3.4.0"]) | `array` | `[]` | +| `npmx.ignore.replacement` | List of package names to ignore replacement suggestions. (e.g. ["find-up", "axios"]) | `array` | `[]` | +| `npmx.ignore.vulnerability` | List of packages to ignore vulnerability warnings. Supports both "name" and "name@version" (e.g. ["lodash", "express@4.18.0"]) | `array` | `[]` | diff --git a/package.json b/package.json index c3e86fa..9ecbe8a 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,42 @@ "type": "boolean", "default": true, "description": "Show warnings when dependency engines mismatch with the current package" + }, + "npmx.ignore.upgrade": { + "scope": "resource", + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of packages to ignore upgrade hints. Supports both \"name\" and \"name@version\" (e.g. [\"request\", \"uuid@3.4.0\"])" + }, + "npmx.ignore.deprecation": { + "scope": "resource", + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of packages to ignore deprecation warnings. Supports both \"name\" and \"name@version\" (e.g. [\"request\", \"uuid@3.4.0\"])" + }, + "npmx.ignore.replacement": { + "scope": "resource", + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of package names to ignore replacement suggestions. (e.g. [\"find-up\", \"axios\"])" + }, + "npmx.ignore.vulnerability": { + "scope": "resource", + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of packages to ignore vulnerability warnings. Supports both \"name\" and \"name@version\" (e.g. [\"lodash\", \"express@4.18.0\"])" } } }, diff --git a/src/providers/code-actions/index.ts b/src/providers/code-actions/index.ts index 1fa2bfa..ae7e708 100644 --- a/src/providers/code-actions/index.ts +++ b/src/providers/code-actions/index.ts @@ -1,11 +1,22 @@ +import type { ConfigurationTarget } from 'vscode' import { extractorEntries } from '#extractors' import { config } from '#state' -import { computed, watch } from 'reactive-vscode' -import { CodeActionKind, Disposable, languages } from 'vscode' +import { computed, useCommand, watch } from 'reactive-vscode' +import { CodeActionKind, Disposable, languages, workspace } from 'vscode' +import { scopedConfigs } from '../../generated-meta' import { QuickFixProvider } from './quick-fix' export function useCodeActions() { - const hasQuickFix = computed(() => config.diagnostics.upgrade || config.diagnostics.vulnerability) + useCommand('npmx.addToIgnore', async (scope: string, name: string, target: ConfigurationTarget) => { + scope = `ignore.${scope}` + const config = workspace.getConfiguration(scopedConfigs.scope) + const current = config.get(scope, []) + if (current.includes(name)) + return + await config.update(scope, [...current, name], target) + }) + + const hasQuickFix = computed(() => config.diagnostics.upgrade || config.diagnostics.deprecation || config.diagnostics.replacement || config.diagnostics.vulnerability) watch(hasQuickFix, (enabled, _, onCleanup) => { if (!enabled) diff --git a/src/providers/code-actions/quick-fix.ts b/src/providers/code-actions/quick-fix.ts index a6b0175..e4195e2 100644 --- a/src/providers/code-actions/quick-fix.ts +++ b/src/providers/code-actions/quick-fix.ts @@ -1,5 +1,5 @@ import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument } from 'vscode' -import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' +import { CodeAction, CodeActionKind, ConfigurationTarget, WorkspaceEdit } from 'vscode' interface QuickFixRule { pattern: RegExp @@ -7,7 +7,7 @@ interface QuickFixRule { isPreferred?: boolean } -const quickFixRules: Record = { +const quickFixRules: Partial> = { upgrade: { pattern: /^New version available: (?\S+)$/, title: (target) => `Update to ${target}`, @@ -19,6 +19,22 @@ const quickFixRules: Record = { }, } +interface AddIgnoreRule { + pattern: RegExp +} + +const addIgnoreRules: Partial> = { + deprecation: { + pattern: /^"(?\S+)" has been deprecated/, + }, + replacement: { + pattern: /^"(?\S+)"/, + }, + vulnerability: { + pattern: /^"(?\S+)" has .+ vulnerabilit/, + }, +} + function getDiagnosticCodeValue(diagnostic: Diagnostic): string | undefined { if (typeof diagnostic.code === 'string') return diagnostic.code @@ -34,20 +50,38 @@ export class QuickFixProvider implements CodeActionProvider { if (!code) return [] - const rule = quickFixRules[code] - if (!rule) - return [] + const actions: CodeAction[] = [] - const target = rule.pattern.exec(diagnostic.message)?.groups?.target - if (!target) - return [] + const quickFixRule = quickFixRules[code] + const target = quickFixRule?.pattern?.exec(diagnostic.message)?.groups?.target + if (target) { + const action = new CodeAction(quickFixRule.title(target), CodeActionKind.QuickFix) + action.isPreferred = quickFixRule.isPreferred ?? false + action.diagnostics = [diagnostic] + action.edit = new WorkspaceEdit() + action.edit.replace(document.uri, diagnostic.range, target) + actions.push(action) + } + + const addIgnoreRule = addIgnoreRules[code] + const ignoreTarget = addIgnoreRule?.pattern?.exec(diagnostic.message)?.groups?.target + if (ignoreTarget) { + for (const [title, configTarget] of [ + [`Ignore ${code} for "${ignoreTarget}" (Workspace)`, ConfigurationTarget.Workspace], + [`Ignore ${code} for "${ignoreTarget}" (User)`, ConfigurationTarget.Global], + ] as const) { + const action = new CodeAction(title, CodeActionKind.QuickFix) + action.diagnostics = [diagnostic] + action.command = { + title, + command: 'npmx.addToIgnore', + arguments: [code, ignoreTarget, configTarget], + } + actions.push(action) + } + } - const action = new CodeAction(rule.title(target), CodeActionKind.QuickFix) - action.isPreferred = rule.isPreferred ?? false - action.diagnostics = [diagnostic] - action.edit = new WorkspaceEdit() - action.edit.replace(document.uri, diagnostic.range, target) - return [action] + return actions }) } } diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index 5eaaa20..dfc7b54 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -1,4 +1,5 @@ import type { DiagnosticRule } from '..' +import { config } from '#state' import { npmxPackageUrl } from '#utils/links' import { formatPackageId } from '#utils/package' import { DiagnosticSeverity, DiagnosticTag, Uri } from 'vscode' @@ -12,6 +13,10 @@ export const checkDeprecation: DiagnosticRule = ({ dep, pkg, parsed, exactVersio if (!versionInfo.deprecated) return + const ignoreList = config.ignore.deprecation + if (ignoreList.includes(dep.name) || ignoreList.includes(formatPackageId(dep.name, exactVersion))) + return + return { node: dep.versionNode, message: `"${formatPackageId(dep.name, exactVersion)}" has been deprecated: ${versionInfo.deprecated}`, diff --git a/src/providers/diagnostics/rules/replacement.ts b/src/providers/diagnostics/rules/replacement.ts index be99efe..e3f7bb8 100644 --- a/src/providers/diagnostics/rules/replacement.ts +++ b/src/providers/diagnostics/rules/replacement.ts @@ -1,5 +1,6 @@ import type { ModuleReplacement } from 'module-replacements' import type { DiagnosticRule } from '..' +import { config } from '#state' import { getReplacement } from '#utils/api/replacement' import { DiagnosticSeverity, Uri } from 'vscode' @@ -20,26 +21,29 @@ function getReplacementInfo(replacement: ModuleReplacement) { switch (replacement.type) { case 'native': return { - message: `This can be replaced with ${replacement.replacement}, available since Node ${replacement.nodeVersion}.`, + message: `can be replaced with ${replacement.replacement}, available since Node ${replacement.nodeVersion}.`, link: getMdnUrl(replacement.mdnPath), } case 'simple': return { - message: `The community has flagged this package as redundant, with the advice:\n${replacement.replacement}.`, + message: `has been flagged as redundant, with the advice:\n${replacement.replacement}.`, } case 'documented': return { - message: 'The community has flagged this package as having more performant alternatives.', + message: 'has been flagged as having more performant alternatives.', link: getReplacementsDocUrl(replacement.docPath), } case 'none': return { - message: 'This package has been flagged as no longer needed, and its functionality is likely available natively in all engines.', + message: 'has been flagged as no longer needed, and its functionality is likely available natively in all engines.', } } } export const checkReplacement: DiagnosticRule = async ({ dep }) => { + if (config.ignore.replacement.includes(dep.name)) + return + const replacement = await getReplacement(dep.name) if (!replacement) return @@ -48,7 +52,7 @@ export const checkReplacement: DiagnosticRule = async ({ dep }) => { return { node: dep.nameNode, - message, + message: `"${dep.name}" ${message}`, severity: DiagnosticSeverity.Warning, code: link ? { value: 'replacement', target: Uri.parse(link) } : 'replacement', } diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index 17643b8..c543ee5 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -1,7 +1,9 @@ import type { DependencyInfo } from '#types/extractor' import type { ParsedVersion } from '#utils/version' import type { DiagnosticRule, NodeDiagnosticInfo } from '..' +import { config } from '#state' import { npmxPackageUrl } from '#utils/links' +import { formatPackageId } from '#utils/package' import { formatUpgradeVersion } from '#utils/version' import gt from 'semver/functions/gt' import lte from 'semver/functions/lte' @@ -24,6 +26,10 @@ export const checkUpgrade: DiagnosticRule = ({ dep, pkg, parsed, exactVersion }) if (!parsed || !exactVersion) return + const ignoreList = config.ignore.upgrade + if (ignoreList.includes(dep.name) || ignoreList.includes(formatPackageId(dep.name, exactVersion))) + return + if (Object.hasOwn(pkg.distTags, exactVersion)) return diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index 63ca465..3e4e25b 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -1,7 +1,9 @@ import type { OsvSeverityLevel, PackageVulnerabilityInfo } from '#utils/api/vulnerability' import type { DiagnosticRule } from '..' +import { config } from '#state' import { getVulnerability, SEVERITY_LEVELS } from '#utils/api/vulnerability' import { npmxPackageUrl } from '#utils/links' +import { formatPackageId } from '#utils/package' import { formatUpgradeVersion } from '#utils/version' import lt from 'semver/functions/lt' import { DiagnosticSeverity, Uri } from 'vscode' @@ -30,6 +32,10 @@ export const checkVulnerability: DiagnosticRule = async ({ dep, parsed, exactVer if (!parsed || !exactVersion) return + const ignoreList = config.ignore.vulnerability + if (ignoreList.includes(dep.name) || ignoreList.includes(formatPackageId(dep.name, exactVersion))) + return + const result = await getVulnerability({ name: dep.name, version: exactVersion }) if (!result) return @@ -60,7 +66,7 @@ export const checkVulnerability: DiagnosticRule = async ({ dep, parsed, exactVer return { node: dep.versionNode, - message: `This version has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`, + message: `"${formatPackageId(dep.name, exactVersion)}" has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`, severity: severity ?? DiagnosticSeverity.Error, code: { value: 'vulnerability', diff --git a/tests/__mocks__/vscode.ts b/tests/__mocks__/vscode.ts index 788f5ef..37ae376 100644 --- a/tests/__mocks__/vscode.ts +++ b/tests/__mocks__/vscode.ts @@ -20,6 +20,7 @@ export const { CompletionItemKind, CodeAction, CodeActionKind, + ConfigurationTarget, WorkspaceEdit, Diagnostic, DiagnosticSeverity, diff --git a/tests/__setup__/index.ts b/tests/__setup__/index.ts index cecb13e..eb022f0 100644 --- a/tests/__setup__/index.ts +++ b/tests/__setup__/index.ts @@ -4,4 +4,12 @@ import './msw' vi.mock('#state', () => ({ logger: { info: vi.fn(), warn: vi.fn() }, + config: { + ignore: { + upgrade: [], + deprecation: [], + replacement: [], + vulnerability: [], + }, + }, })) diff --git a/tests/code-actions/quick-fix.test.ts b/tests/code-actions/quick-fix.test.ts index cdd3014..f952b1b 100644 --- a/tests/code-actions/quick-fix.test.ts +++ b/tests/code-actions/quick-fix.test.ts @@ -32,15 +32,40 @@ describe('quick fix provider', () => { expect(actions[0]!.title).toMatchInlineSnapshot('"Update to ^2.0.0"') }) - it('vulnerability', () => { + it('vulnerability with fix', () => { const diagnostic = createDiagnostic( { value: 'vulnerability', target: Uri.parse('https://npmx.dev') }, - 'This version has 1 high vulnerability. Upgrade to ^1.2.3 to fix.', + '"lodash@4.17.20" has 1 high vulnerability. Upgrade to ^4.17.21 to fix.', ) const actions = provideCodeActions([diagnostic]) - expect(actions).toHaveLength(1) - expect(actions[0]!.title).toMatchInlineSnapshot('"Update to ^1.2.3 to fix vulnerabilities"') + expect(actions).toHaveLength(3) + expect(actions[0]!.title).toMatchInlineSnapshot('"Update to ^4.17.21 to fix vulnerabilities"') + expect(actions[1]!.title).toMatchInlineSnapshot('"Ignore vulnerability for "lodash@4.17.20" (Workspace)"') + expect(actions[2]!.title).toMatchInlineSnapshot('"Ignore vulnerability for "lodash@4.17.20" (User)"') + }) + + it('vulnerability without fix', () => { + const diagnostic = createDiagnostic( + { value: 'vulnerability', target: Uri.parse('https://npmx.dev') }, + '"express@4.18.0" has 1 moderate vulnerability.', + ) + const actions = provideCodeActions([diagnostic]) + + expect(actions).toHaveLength(2) + expect(actions[0]!.title).toMatchInlineSnapshot('"Ignore vulnerability for "express@4.18.0" (Workspace)"') + expect(actions[1]!.title).toMatchInlineSnapshot('"Ignore vulnerability for "express@4.18.0" (User)"') + }) + + it('vulnerability for scoped package', () => { + const diagnostic = createDiagnostic( + { value: 'vulnerability', target: Uri.parse('https://npmx.dev') }, + '"@babel/core@7.0.0" has 1 critical vulnerability. Upgrade to ^7.1.0 to fix.', + ) + const actions = provideCodeActions([diagnostic]) + + expect(actions).toHaveLength(3) + expect(actions[1]!.title).toMatchInlineSnapshot('"Ignore vulnerability for "@babel/core@7.0.0" (Workspace)"') }) it('mixed diagnostics', () => { @@ -48,13 +73,13 @@ describe('quick fix provider', () => { createDiagnostic('upgrade', 'New version available: ^2.0.0'), createDiagnostic( { value: 'vulnerability', target: Uri.parse('https://npmx.dev') }, - 'This version has 1 high vulnerability. Upgrade to ^1.2.3 to fix.', + '"lodash@4.17.20" has 1 high vulnerability. Upgrade to ^4.17.21 to fix.', ), ] const actions = provideCodeActions(diagnostics) - expect(actions).toHaveLength(2) + expect(actions).toHaveLength(4) expect(actions[0]!.title).toMatchInlineSnapshot('"Update to ^2.0.0"') - expect(actions[1]!.title).toMatchInlineSnapshot('"Update to ^1.2.3 to fix vulnerabilities"') + expect(actions[1]!.title).toMatchInlineSnapshot('"Update to ^4.17.21 to fix vulnerabilities"') }) })