diff --git a/README.md b/README.md index 2740c30..9e662f9 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ | `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 (e.g. latest, next, beta) | `boolean` | `true` | diff --git a/package.json b/package.json index f312372..076f924 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,11 @@ "type": "boolean", "default": true, "description": "Show warnings for packages with known vulnerabilities" + }, + "npmx.diagnostics.distTag": { + "type": "boolean", + "default": true, + "description": "Show warnings when a dependency uses a dist tag (e.g. latest, next, beta)" } } }, diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 2b146fd..57160ec 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -10,6 +10,7 @@ import { computed, useActiveTextEditor, useDocumentText, watch } from 'reactive- import { languages } from 'vscode' import { displayName } from '../../generated-meta' import { checkDeprecation } from './rules/deprecation' +import { checkDistTag } from './rules/dist-tag' import { checkReplacement } from './rules/replacement' import { checkUpgrade } from './rules/upgrade' import { checkVulnerability } from './rules/vulnerability' @@ -25,6 +26,8 @@ const enabledRules = computed(() => { rules.push(checkUpgrade) if (config.diagnostics.deprecation) rules.push(checkDeprecation) + if (config.diagnostics.distTag) + rules.push(checkDistTag) if (config.diagnostics.replacement) rules.push(checkReplacement) if (config.diagnostics.vulnerability) diff --git a/src/providers/diagnostics/rules/dist-tag.ts b/src/providers/diagnostics/rules/dist-tag.ts new file mode 100644 index 0000000..609dc74 --- /dev/null +++ b/src/providers/diagnostics/rules/dist-tag.ts @@ -0,0 +1,26 @@ +import type { DiagnosticRule } from '..' +import { npmxPackageUrl } from '#utils/links' +import { isDistTagLike, isSupportedProtocol, parseVersion } from '#utils/version' +import { DiagnosticSeverity, Uri } from 'vscode' + +export const checkDistTag: DiagnosticRule = (dep, pkg) => { + const parsed = parseVersion(dep.version) + if (!parsed || !isSupportedProtocol(parsed.protocol)) + return + + const tag = parsed.semver + const isPublishedDistTag = tag in (pkg.distTags ?? {}) + const isDistTag = isPublishedDistTag || isDistTagLike(tag) + if (!isDistTag) + return + + return { + node: dep.versionNode, + message: `"${dep.name}" uses the "${tag}" version tag. This may lead to unexpected breaking changes. Consider pinning to a specific version.`, + severity: DiagnosticSeverity.Warning, + code: { + value: 'dist-tag', + target: Uri.parse(npmxPackageUrl(dep.name)), + }, + } +} diff --git a/src/utils/version.ts b/src/utils/version.ts index 6b0a5b0..9fb1bb6 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -3,6 +3,8 @@ type VersionProtocol = 'workspace' | 'catalog' | 'npm' | 'jsr' | null const URL_PREFIXES = ['http://', 'https://', 'git://', 'git+'] const UNSUPPORTED_PROTOCOLS = new Set(['workspace', 'catalog', 'jsr']) const KNOWN_PROTOCOLS = new Set([...UNSUPPORTED_PROTOCOLS, 'npm']) +const DIST_TAG_PATTERN = /^[a-z][\w.-]*$/i +const V_PREFIXED_SEMVER_PATTERN = /^v(?:0|[1-9]\d*)(?:\.(?:0|[1-9]\d*)){2}(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ export interface ParsedVersion { protocol: VersionProtocol @@ -14,6 +16,13 @@ export function isSupportedProtocol(protocol: VersionProtocol): boolean { return !protocol || !UNSUPPORTED_PROTOCOLS.has(protocol) } +export function isDistTagLike(version: string): boolean { + if (V_PREFIXED_SEMVER_PATTERN.test(version)) + return false + + return DIST_TAG_PATTERN.test(version) +} + export function formatVersion(parsed: ParsedVersion): string { const protocol = parsed.protocol ? `${parsed.protocol}:` : '' return `${protocol}${parsed.prefix}${parsed.semver}` diff --git a/tests/__mocks__/vscode.ts b/tests/__mocks__/vscode.ts index 4049317..222551e 100644 --- a/tests/__mocks__/vscode.ts +++ b/tests/__mocks__/vscode.ts @@ -3,6 +3,7 @@ import { vi } from 'vitest' const vscode = createVSCodeMock(vi) +export const DiagnosticSeverity = vscode.DiagnosticSeverity export const Uri = vscode.Uri export const workspace = vscode.workspace export const Range = vscode.Range diff --git a/tests/dist-tag.test.ts b/tests/dist-tag.test.ts new file mode 100644 index 0000000..f4e15f1 --- /dev/null +++ b/tests/dist-tag.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { checkDistTag } from '../src/providers/diagnostics/rules/dist-tag' + +type DistTagDependency = Parameters[0] +type DistTagPackageInfo = Parameters[1] + +function createDependency(name: string, version: string): DistTagDependency { + return { + name, + version, + nameNode: {}, + versionNode: {}, + } +} + +function createPackageInfo(distTags: Record): DistTagPackageInfo { + return { distTags } as DistTagPackageInfo +} + +describe('checkDistTag', () => { + it('should flag "latest" as a dist tag', async () => { + const dependency = createDependency('lodash', 'latest') + const packageInfo = createPackageInfo({ latest: '2.0.0' }) + + const result = await checkDistTag(dependency, packageInfo) + + expect(result).toBeDefined() + }) + + it('should flag "next" as a dist tag', async () => { + const dependency = createDependency('vue', 'next') + const packageInfo = createPackageInfo({ latest: '2.0.0', next: '3.0.0-beta' }) + + const result = await checkDistTag(dependency, packageInfo) + + expect(result).toBeDefined() + }) + + it('should flag common dist tags even when metadata does not include them', async () => { + const distTagNames = ['next', 'beta', 'canary', 'stable'] + + for (const distTagName of distTagNames) { + const dependency = createDependency('lodash', distTagName) + const packageInfo = createPackageInfo({}) + const result = await checkDistTag(dependency, packageInfo) + + expect(result).toBeDefined() + } + }) + + it('should flag "npm:latest" as a dist tag', async () => { + const dependency = createDependency('lodash', 'npm:latest') + const packageInfo = createPackageInfo({ latest: '2.0.0' }) + + const result = await checkDistTag(dependency, packageInfo) + + expect(result).toBeDefined() + }) + + it('should not flag pinned semver', async () => { + const dependency = createDependency('lodash', '1.0.0') + const packageInfo = createPackageInfo({ latest: '2.0.0' }) + + const result = await checkDistTag(dependency, packageInfo) + + expect(result).toBeUndefined() + }) + + it('should not flag pinned semver with v prefix', async () => { + const dependency = createDependency('lodash', 'v1.2.3') + const packageInfo = createPackageInfo({ latest: '2.0.0' }) + + const result = await checkDistTag(dependency, packageInfo) + + expect(result).toBeUndefined() + }) + + it('should not flag npm protocol pinned semver with v prefix', async () => { + const dependency = createDependency('lodash', 'npm:v1.2.3') + const packageInfo = createPackageInfo({ latest: '2.0.0' }) + + const result = await checkDistTag(dependency, packageInfo) + + expect(result).toBeUndefined() + }) + + it('should flag unknown tag-like versions', async () => { + const dependency = createDependency('lodash', 'edge-channel') + const packageInfo = createPackageInfo({}) + + const result = await checkDistTag(dependency, packageInfo) + + expect(result).toBeDefined() + }) + + it('should flag uncommon tags when package metadata does not include them', async () => { + const dependency = createDependency('lodash', 'preview') + const packageInfo = createPackageInfo({ latest: '1.0.0' }) + + const result = await checkDistTag(dependency, packageInfo) + + expect(result).toBeDefined() + }) + + it('should not flag wildcard ranges', async () => { + const dependency = createDependency('lodash', '*') + const packageInfo = createPackageInfo({ latest: '1.0.0' }) + + const result = await checkDistTag(dependency, packageInfo) + + expect(result).toBeUndefined() + }) + + it('should not flag workspace packages', async () => { + const dependency = createDependency('lodash', 'workspace:*') + const packageInfo = createPackageInfo({ latest: '1.0.0' }) + + const result = await checkDistTag(dependency, packageInfo) + + expect(result).toBeUndefined() + }) + + it('should not flag URL-based version', async () => { + const dependency = createDependency('lodash', 'https://github.com/user/repo') + const packageInfo = createPackageInfo({ latest: '1.0.0' }) + + const result = await checkDistTag(dependency, packageInfo) + + expect(result).toBeUndefined() + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index dd2fd01..5a7fe4f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,14 +5,18 @@ import { defineConfig } from 'vitest/config' const rootDir = fileURLToPath(new URL('.', import.meta.url)) export default defineConfig({ - test: { + resolve: { alias: { '#constants': join(rootDir, '/src/constants.ts'), '#state': join(rootDir, '/src/state.ts'), + '#types': join(rootDir, '/src/types'), '#types/*': join(rootDir, '/src/types/*'), + '#utils': join(rootDir, '/src/utils'), '#utils/*': join(rootDir, '/src/utils/*'), 'vscode': join(rootDir, '/tests/__mocks__/vscode.ts'), }, + }, + test: { include: ['tests/**/*.test.ts'], }, })