diff --git a/README.md b/README.md index d26866b..ffd9130 100644 --- a/README.md +++ b/README.md @@ -39,20 +39,49 @@ -| 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` | `[]` | +## 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) – A fast, modern browser for the npm registry diff --git a/package.json b/package.json index 1f311ff..dd612ac 100644 --- a/package.json +++ b/package.json @@ -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)" } } }, diff --git a/src/commands/add-to-ignore.ts b/src/commands/add-to-ignore.ts new file mode 100644 index 0000000..8bd943c --- /dev/null +++ b/src/commands/add-to-ignore.ts @@ -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(ignoreScope, []) + + if (checkIgnored({ ignoreList: current, name })) + return + + await extensionConfig.update(ignoreScope, [...current, name], target) +} diff --git a/src/providers/code-actions/index.ts b/src/providers/code-actions/index.ts index 1fa2bfa..a1f21c5 100644 --- a/src/providers/code-actions/index.ts +++ b/src/providers/code-actions/index.ts @@ -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) diff --git a/src/providers/code-actions/quick-fix.ts b/src/providers/code-actions/quick-fix.ts index a6b0175..075b0f3 100644 --- a/src/providers/code-actions/quick-fix.ts +++ b/src/providers/code-actions/quick-fix.ts @@ -1,5 +1,6 @@ import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument } from 'vscode' -import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' +import { internalCommands } from '#state' +import { CodeAction, CodeActionKind, ConfigurationTarget, WorkspaceEdit } from 'vscode' interface QuickFixRule { pattern: RegExp @@ -7,7 +8,7 @@ interface QuickFixRule { isPreferred?: boolean } -const quickFixRules: Record = { +const quickFixRules: Partial> = { upgrade: { pattern: /^New version available: (?\S+)$/, title: (target) => `Update to ${target}`, @@ -19,6 +20,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 +51,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: internalCommands.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..911db8d 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -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' @@ -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}`, diff --git a/src/providers/diagnostics/rules/replacement.ts b/src/providers/diagnostics/rules/replacement.ts index be99efe..ee3da2e 100644 --- a/src/providers/diagnostics/rules/replacement.ts +++ b/src/providers/diagnostics/rules/replacement.ts @@ -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 { @@ -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 @@ -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', } diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index 17643b8..ec6b4be 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -1,6 +1,8 @@ import type { DependencyInfo } from '#types/extractor' import type { ParsedVersion } from '#utils/version' import type { DiagnosticRule, NodeDiagnosticInfo } from '..' +import { config } from '#state' +import { checkIgnored } from '#utils/ignore' import { npmxPackageUrl } from '#utils/links' import { formatUpgradeVersion } from '#utils/version' import gt from 'semver/functions/gt' @@ -24,6 +26,9 @@ export const checkUpgrade: DiagnosticRule = ({ dep, pkg, parsed, exactVersion }) if (!parsed || !exactVersion) return + if (checkIgnored({ ignoreList: config.ignore.upgrade, name: dep.name, version: 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..931bc3d 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -1,7 +1,10 @@ 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 { checkIgnored } from '#utils/ignore' 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 +33,9 @@ export const checkVulnerability: DiagnosticRule = async ({ dep, parsed, exactVer if (!parsed || !exactVersion) return + if (checkIgnored({ ignoreList: config.ignore.vulnerability, name: dep.name, version: 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/src/state.ts b/src/state.ts index 99e6068..6123f1d 100644 --- a/src/state.ts +++ b/src/state.ts @@ -5,3 +5,7 @@ import { displayName, scopedConfigs } from './generated-meta' export const config = defineConfig(scopedConfigs.scope) export const logger = defineLogger(displayName) + +export const internalCommands = { + addToIgnore: `${displayName}.addToIgnore`, +} diff --git a/src/utils/ignore.ts b/src/utils/ignore.ts new file mode 100644 index 0000000..7777ebf --- /dev/null +++ b/src/utils/ignore.ts @@ -0,0 +1,21 @@ +import { formatPackageId, parsePackageId } from './package' + +export function checkIgnored(options: { + ignoreList: string[] + name: string + version?: string | null +}): boolean { + const { ignoreList, name, version } = options + + if (ignoreList.includes(name)) + return true + + if (version) + return ignoreList.includes(formatPackageId(name, version)) + + const parsed = parsePackageId(name) + if (!parsed.version) + return false + + return ignoreList.includes(parsed.name) +} diff --git a/src/utils/package.ts b/src/utils/package.ts index b5b4ced..e83e477 100644 --- a/src/utils/package.ts +++ b/src/utils/package.ts @@ -16,6 +16,26 @@ export function formatPackageId(name: string, version: string): string { return `${name}@${version}` } +interface ParsedPackageId { + name: string + version: string | null +} + +export function parsePackageId(id: string): ParsedPackageId { + const separatorIndex = id.lastIndexOf('@') + if (separatorIndex <= 0) { + return { + name: id, + version: null, + } + } + + return { + name: id.slice(0, separatorIndex), + version: id.slice(separatorIndex + 1), + } +} + export function resolveExactVersion(pkg: PackageInfo, version: string) { if (Object.hasOwn(pkg.distTags, version)) return pkg.distTags[version] 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..0d80747 100644 --- a/tests/__setup__/index.ts +++ b/tests/__setup__/index.ts @@ -4,4 +4,13 @@ import './msw' vi.mock('#state', () => ({ logger: { info: vi.fn(), warn: vi.fn() }, + config: { + ignore: { + upgrade: [], + deprecation: [], + replacement: [], + vulnerability: [], + }, + }, + internalCommands: {}, })) 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"') }) }) diff --git a/tests/utils/ignore.test.ts b/tests/utils/ignore.test.ts new file mode 100644 index 0000000..45f8547 --- /dev/null +++ b/tests/utils/ignore.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { checkIgnored } from '../../src/utils/ignore' + +describe('checkIgnored', () => { + it('should match exact package name', () => { + expect(checkIgnored({ + ignoreList: ['lodash'], + name: 'lodash', + })).toBe(true) + }) + + it('should match package name and version pair', () => { + expect(checkIgnored({ + ignoreList: ['lodash@4.17.21'], + name: 'lodash', + version: '4.17.21', + })).toBe(true) + }) + + it('should match package-level ignore for package id input', () => { + expect(checkIgnored({ + ignoreList: ['@babel/core'], + name: '@babel/core@7.0.0', + })).toBe(true) + }) + + it('should return false when item is not ignored', () => { + expect(checkIgnored({ + ignoreList: ['lodash'], + name: 'express', + })).toBe(false) + }) +}) diff --git a/tests/utils/package.test.ts b/tests/utils/package.test.ts index 31fc969..facae01 100644 --- a/tests/utils/package.test.ts +++ b/tests/utils/package.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { encodePackageName } from '../../src/utils/package' +import { encodePackageName, parsePackageId } from '../../src/utils/package' describe('encodePackageName', () => { it('should encode regular package name', () => { @@ -10,3 +10,26 @@ describe('encodePackageName', () => { expect(encodePackageName('@vue/core')).toBe('@vue%2Fcore') }) }) + +describe('parsePackageId', () => { + it('should parse package id with version', () => { + expect(parsePackageId('lodash@4.17.21')).toEqual({ + name: 'lodash', + version: '4.17.21', + }) + }) + + it('should parse scoped package id with version', () => { + expect(parsePackageId('@babel/core@7.0.0')).toEqual({ + name: '@babel/core', + version: '7.0.0', + }) + }) + + it('should keep package name when version is missing', () => { + expect(parsePackageId('@babel/core')).toEqual({ + name: '@babel/core', + version: null, + }) + }) +})