Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
51 changes: 40 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,49 @@

<!-- 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` | Ignore list for upgrade diagnostics ("name" or "name@version"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics) | `array` | `[]` |
| `npmx.ignore.deprecation` | Ignore list for deprecation diagnostics ("name" or "name@version"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics) | `array` | `[]` |
| `npmx.ignore.replacement` | Ignore list for replacement diagnostics ("name" only). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics) | `array` | `[]` |
| `npmx.ignore.vulnerability` | Ignore list for vulnerability diagnostics ("name" or "name@version"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics) | `array` | `[]` |

<!-- configs -->

## Ignore Diagnostics

`npmx` supports ignore lists for selected diagnostics.

Matching rules:

- `npmx.ignore.upgrade`, `npmx.ignore.deprecation`, and `npmx.ignore.vulnerability` support `name` and `name@version`.
- `npmx.ignore.replacement` supports `name` only.

When a diagnostic supports ignore actions, quick fixes can add entries directly:

- `Ignore ... (Workspace)` updates workspace settings.
- `Ignore ... (User)` updates user settings.

### Example

```json
{
"npmx.ignore.upgrade": ["lodash", "@babel/core@7.0.0"],
"npmx.ignore.deprecation": ["request"],
"npmx.ignore.replacement": ["find-up"],
"npmx.ignore.vulnerability": ["express@4.18.0"]
}
```

## Related

- [npmx.dev](https://npmx.dev) &ndash; A fast, modern browser for the npm registry
Expand Down
36 changes: 36 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,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": [],
"markdownDescription": "Ignore list for upgrade diagnostics (\"name\" or \"name@version\"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics)"
},
"npmx.ignore.deprecation": {
"scope": "resource",
"type": "array",
"items": {
"type": "string"
},
"default": [],
"markdownDescription": "Ignore list for deprecation diagnostics (\"name\" or \"name@version\"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics)"
},
"npmx.ignore.replacement": {
"scope": "resource",
"type": "array",
"items": {
"type": "string"
},
"default": [],
"markdownDescription": "Ignore list for replacement diagnostics (\"name\" only). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics)"
},
"npmx.ignore.vulnerability": {
"scope": "resource",
"type": "array",
"items": {
"type": "string"
},
"default": [],
"markdownDescription": "Ignore list for vulnerability diagnostics (\"name\" or \"name@version\"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics)"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
},
Expand Down
15 changes: 15 additions & 0 deletions src/commands/add-to-ignore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { ConfigurationTarget } from 'vscode'
import { checkIgnored } from '#utils/ignore'
import { workspace } from 'vscode'
import { scopedConfigs } from '../generated-meta'

export async function addToIgnore(scope: string, name: string, target: ConfigurationTarget) {
const ignoreScope = `ignore.${scope}`
const extensionConfig = workspace.getConfiguration(scopedConfigs.scope)
const current = extensionConfig.get<string[]>(ignoreScope, [])

if (checkIgnored({ ignoreList: current, name }))
return

await extensionConfig.update(ignoreScope, [...current, name], target)
}
9 changes: 6 additions & 3 deletions src/providers/code-actions/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { extractorEntries } from '#extractors'
import { config } from '#state'
import { computed, watch } from 'reactive-vscode'
import { config, internalCommands } from '#state'
import { computed, useCommand, watch } from 'reactive-vscode'
import { CodeActionKind, Disposable, languages } from 'vscode'
import { addToIgnore } from '../../commands/add-to-ignore'
import { QuickFixProvider } from './quick-fix'

export function useCodeActions() {
const hasQuickFix = computed(() => config.diagnostics.upgrade || config.diagnostics.vulnerability)
useCommand(internalCommands.addToIgnore, addToIgnore)

const hasQuickFix = computed(() => config.diagnostics.upgrade || config.diagnostics.deprecation || config.diagnostics.replacement || config.diagnostics.vulnerability)

watch(hasQuickFix, (enabled, _, onCleanup) => {
if (!enabled)
Expand Down
126 changes: 103 additions & 23 deletions src/providers/code-actions/quick-fix.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,113 @@
import type { DiagnosticsCode } from '#types/meta'
import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument } from 'vscode'
import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode'
import { internalCommands } from '#state'
import { parsePackageId } from '#utils/package'
import { CodeAction, CodeActionKind, ConfigurationTarget, WorkspaceEdit } from 'vscode'
Comment thread
9romise marked this conversation as resolved.

interface QuickFixRule {
type MatchGroups = NonNullable<RegExpExecArray['groups']>

interface DiagnosticContext {
code: DiagnosticsCode
document: TextDocument
diagnostic: Diagnostic
groups: MatchGroups
}

type ActionBuilder = (context: DiagnosticContext) => CodeAction[]

interface DiagnosticStrategy {
pattern: RegExp
title: (target: string) => string
isPreferred?: boolean
actionBuilders: ActionBuilder[]
}

const ignoreScopes = [
{ label: 'Workspace', target: ConfigurationTarget.Workspace },
{ label: 'User', target: ConfigurationTarget.Global },
]
Comment on lines +22 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add a project-scoped ignore action to meet the default-scope requirement.

The current ignore actions only target Workspace/User. That does not satisfy the “default to current project/package.json” goal and can over-suppress diagnostics in multi-project workspaces. Please add a project-level ignore path and pass enough context in the command arguments to persist it at project scope.

Also applies to: 58-62


function quickFix(
resolveReplacement: (groups: MatchGroups) => string | undefined,
formatTitle: (replacement: string) => string,
isPreferred = false,
): ActionBuilder {
return (context) => {
const replacement = resolveReplacement(context.groups)
if (!replacement)
return []

const action = new CodeAction(formatTitle(replacement), CodeActionKind.QuickFix)
action.diagnostics = [context.diagnostic]
action.isPreferred = isPreferred
action.edit = new WorkspaceEdit()
action.edit.replace(context.document.uri, context.diagnostic.range, replacement)

return [action]
}
}

const quickFixRules: Record<string, QuickFixRule> = {
function ignore(resolvePackageId: (groups: MatchGroups) => string | undefined): ActionBuilder {
return (context) => {
const packageId = resolvePackageId(context.groups)
if (!packageId)
return []

return ignoreScopes.map(({ label, target }) => {
const title = `Ignore ${context.code} for "${packageId}" (${label})`
const action = new CodeAction(title, CodeActionKind.QuickFix)
action.diagnostics = [context.diagnostic]
action.command = {
title,
command: internalCommands.addToIgnore,
arguments: [context.code, packageId, target],
}

return action
})
}
}

const strategies: Partial<Record<DiagnosticsCode, DiagnosticStrategy>> = {
upgrade: {
pattern: /^New version available: (?<target>\S+)$/,
title: (target) => `Update to ${target}`,
pattern: /^"(?<current>[^"]+)" can be upgraded to (?<targetVersion>[^"\s]+)\.$/,
actionBuilders: [
quickFix((g) => g.targetVersion, (replacement) => `Update to ${replacement}`),
ignore((g) => {
const targetVersion = g.targetVersion
if (!targetVersion)
return

const parsed = parsePackageId(g.current)
return `${parsed.name}@${targetVersion}`
}),
],
},
vulnerability: {
pattern: / Upgrade to (?<target>\S+) to fix\.$/,
title: (target) => `Update to ${target} to fix vulnerabilities`,
isPreferred: true,
pattern: /^"(?<packageId>\S+)" has .+ vulnerabilit(?:y|ies)\.(?: Upgrade to (?<targetVersion>\S+) to fix\.)?$/,
actionBuilders: [
quickFix((g) => g.targetVersion, (replacement) => `Update to ${replacement} to fix vulnerabilities`, true),
ignore((g) => g.packageId),
],
},
deprecation: {
pattern: /^"(?<packageId>\S+)" has been deprecated/,
actionBuilders: [
ignore((g) => g.packageId),
],
},
replacement: {
pattern: /^"(?<packageName>\S+)"/,
actionBuilders: [
ignore((g) => g.packageName),
],
},
}

function getDiagnosticCodeValue(diagnostic: Diagnostic): string | undefined {
function getDiagnosticCodeValue(diagnostic: Diagnostic): DiagnosticsCode | undefined {
if (typeof diagnostic.code === 'string')
return diagnostic.code
return diagnostic.code as DiagnosticsCode

if (typeof diagnostic.code === 'object' && typeof diagnostic.code.value === 'string')
return diagnostic.code.value
return diagnostic.code.value as DiagnosticsCode
}

export class QuickFixProvider implements CodeActionProvider {
Expand All @@ -34,20 +117,17 @@ export class QuickFixProvider implements CodeActionProvider {
if (!code)
return []

const rule = quickFixRules[code]
if (!rule)
const strategy = strategies[code]
if (!strategy)
return []

const target = rule.pattern.exec(diagnostic.message)?.groups?.target
if (!target)
const groups = strategy.pattern.exec(diagnostic.message)?.groups
if (!groups)
return []

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]
const diagnosticContext: DiagnosticContext = { code, document, diagnostic, groups }

return strategy.actionBuilders.flatMap((build) => build(diagnosticContext))
})
}
}
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,6 @@
import type { DiagnosticRule } from '..'
import { config } from '#state'
import { checkIgnored } from '#utils/ignore'
import { npmxPackageUrl } from '#utils/links'
import { formatPackageId } from '#utils/package'
import { DiagnosticSeverity, DiagnosticTag, Uri } from 'vscode'
Expand All @@ -12,6 +14,9 @@ export const checkDeprecation: DiagnosticRule = ({ dep, pkg, parsed, exactVersio
if (!versionInfo.deprecated)
return

if (checkIgnored({ ignoreList: config.ignore.deprecation, name: dep.name, version: exactVersion }))
return

return {
node: dep.versionNode,
message: `"${formatPackageId(dep.name, exactVersion)}" has been deprecated: ${versionInfo.deprecated}`,
Expand Down
15 changes: 10 additions & 5 deletions src/providers/diagnostics/rules/replacement.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ModuleReplacement } from 'module-replacements'
import type { DiagnosticRule } from '..'
import { config } from '#state'
import { getReplacement } from '#utils/api/replacement'
import { checkIgnored } from '#utils/ignore'
import { DiagnosticSeverity, Uri } from 'vscode'

function getMdnUrl(path: string): string {
Expand All @@ -20,26 +22,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 (checkIgnored({ ignoreList: config.ignore.replacement, name: dep.name }))
return

const replacement = await getReplacement(dep.name)
if (!replacement)
return
Expand All @@ -48,7 +53,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
Loading