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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

<!-- configs -->

Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
}
},
Expand Down
3 changes: 3 additions & 0 deletions src/providers/diagnostics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,6 +26,8 @@ const enabledRules = computed<DiagnosticRule[]>(() => {
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)
Expand Down
26 changes: 26 additions & 0 deletions src/providers/diagnostics/rules/dist-tag.ts
Original file line number Diff line number Diff line change
@@ -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)),
},
}
}
9 changes: 9 additions & 0 deletions src/utils/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}`
Expand Down
1 change: 1 addition & 0 deletions tests/__mocks__/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 131 additions & 0 deletions tests/dist-tag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import { checkDistTag } from '../src/providers/diagnostics/rules/dist-tag'

type DistTagDependency = Parameters<typeof checkDistTag>[0]
type DistTagPackageInfo = Parameters<typeof checkDistTag>[1]

function createDependency(name: string, version: string): DistTagDependency {
return {
name,
version,
nameNode: {},
versionNode: {},
}
}

function createPackageInfo(distTags: Record<string, string>): 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()
})
})
6 changes: 5 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
})