Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,21 @@

<!-- configs -->

| 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` | `[]` |

<!-- configs -->

Expand Down
36 changes: 36 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"])"
}
}
},
Expand Down
17 changes: 14 additions & 3 deletions src/providers/code-actions/index.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>(scope, [])
if (current.includes(name))
return
await config.update(scope, [...current, name], target)
})
Comment on lines +10 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate command arguments before mutating configuration.

npmx.addToIgnore currently trusts runtime arguments. A malformed invocation can write unexpected keys or values into settings.

Suggested patch
   useCommand('npmx.addToIgnore', async (scope: string, name: string, target: ConfigurationTarget) => {
+    if (!scope || !name)
+      return
+    if (!['deprecation', 'replacement', 'vulnerability'].includes(scope))
+      return
+
     scope = `ignore.${scope}`
     const config = workspace.getConfiguration(scopedConfigs.scope)
     const current = config.get<string[]>(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)
Expand Down
62 changes: 48 additions & 14 deletions src/providers/code-actions/quick-fix.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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
title: (target: string) => string
isPreferred?: boolean
}

const quickFixRules: Record<string, QuickFixRule> = {
const quickFixRules: Partial<Record<string, QuickFixRule>> = {
upgrade: {
pattern: /^New version available: (?<target>\S+)$/,
title: (target) => `Update to ${target}`,
Expand All @@ -19,6 +19,22 @@ const quickFixRules: Record<string, QuickFixRule> = {
},
}

interface AddIgnoreRule {
pattern: RegExp
}

const addIgnoreRules: Partial<Record<string, AddIgnoreRule>> = {
deprecation: {
pattern: /^"(?<target>\S+)" has been deprecated/,
},
replacement: {
pattern: /^"(?<target>\S+)"/,
},
vulnerability: {
pattern: /^"(?<target>\S+)" has .+ vulnerabilit/,
},
}

function getDiagnosticCodeValue(diagnostic: Diagnostic): string | undefined {
if (typeof diagnostic.code === 'string')
return diagnostic.code
Expand All @@ -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
})
}
}
5 changes: 5 additions & 0 deletions src/providers/diagnostics/rules/deprecation.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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}`,
Expand Down
14 changes: 9 additions & 5 deletions src/providers/diagnostics/rules/replacement.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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
Expand All @@ -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',
}
Expand Down
6 changes: 6 additions & 0 deletions src/providers/diagnostics/rules/upgrade.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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

Expand Down
8 changes: 7 additions & 1 deletion src/providers/diagnostics/rules/vulnerability.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions tests/__mocks__/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const {
CompletionItemKind,
CodeAction,
CodeActionKind,
ConfigurationTarget,
WorkspaceEdit,
Diagnostic,
DiagnosticSeverity,
Expand Down
8 changes: 8 additions & 0 deletions tests/__setup__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,12 @@ import './msw'

vi.mock('#state', () => ({
logger: { info: vi.fn(), warn: vi.fn() },
config: {
ignore: {
upgrade: [],
deprecation: [],
replacement: [],
vulnerability: [],
},
},
}))
39 changes: 32 additions & 7 deletions tests/code-actions/quick-fix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,29 +32,54 @@ 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', () => {
const diagnostics = [
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"')
})
})