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 @@ -40,6 +40,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
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,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 Expand Up @@ -160,6 +165,7 @@
"reactive-vscode": "^1.0.0-beta.2",
"tsdown": "^0.20.3",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^6.1.0",
"vitest": "^4.0.18",
"vscode-ext-gen": "1.3.0",
"yaml": "^2.8.2"
Expand Down
32 changes: 32 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ export const PNPM_WORKSPACE_PATTERN = `**/${PNPM_WORKSPACE_BASENAME}`

export const VERSION_TRIGGER_CHARACTERS = [':', '^', '~', '.', ...Array.from({ length: 10 }).map((_, i) => `${i}`)]
export const PRERELEASE_PATTERN = /-.+/
export const COMMON_DIST_TAGS = new Set([
'latest',
'next',
'beta',
'alpha',
'rc',
'canary',
'stable',
'experimental',
'nightly',
'snapshot',
'dev',
])

export const CACHE_TTL_ONE_DAY = 1000 * 60 * 60 * 24

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 { checkVulnerability } from './rules/vulnerability'

Expand All @@ -22,6 +23,8 @@ const enabledRules = computed<DiagnosticRule[]>(() => {
const rules: DiagnosticRule[] = []
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
27 changes: 27 additions & 0 deletions src/providers/diagnostics/rules/dist-tag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { DiagnosticRule } from '..'
import { COMMON_DIST_TAGS } from '#constants'
import { npmxPackageUrl } from '#utils/links'
import { isSupportedProtocol, parseVersion } from '#utils/package'
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 isCommonDistTag = COMMON_DIST_TAGS.has(tag.toLowerCase())
Copy link
Member

Choose a reason for hiding this comment

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

Why do we check for COMMON_DIST_TAGS? I might be missing some specific use cases here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

In pretty much every project I've worked on before, using a dist tag for a version was considered bad practice because it can lead to all sorts of problems (stale installations, keeping track of changes in deps, package updates breaking apps, etc.). It would be nice to get a warning about this, because a lot of boilerplate/starter apps use dist tags in their package.json (for example when setting up a project with pnpm create).

This might not be the general expectation or experience shared by other developers, and I'm curious what your opinion on this is. I think having the option for a warning would be nice, and I'm fine with it being off by default :)

Copy link
Member

Choose a reason for hiding this comment

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

I totally agree that using dist‑tags for version pinning is not a good practice.
Just curious why do we check for isCommonDistTag rather than warning on any dist‑tag?

if (!isPublishedDistTag && !isCommonDistTag)
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)),
},
}
}
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
104 changes: 104 additions & 0 deletions tests/dist-tag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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 unknown tag-like versions', async () => {
const dependency = createDependency('lodash', 'edge-channel')
const packageInfo = createPackageInfo({})

const result = await checkDistTag(dependency, packageInfo)

expect(result).toBeUndefined()
})

it('should not flag non-common 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).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()
})
})
12 changes: 6 additions & 6 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import tsconfigPaths from 'vite-tsconfig-paths'
Copy link
Member

Choose a reason for hiding this comment

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

We could put the vite-tsconfig-paths change in its own PR.

import { defineConfig } from 'vitest/config'

const rootDir = fileURLToPath(new URL('.', import.meta.url))

export default defineConfig({
test: {
plugins: [tsconfigPaths()],
resolve: {
alias: {
'#constants': join(rootDir, '/src/constants.ts'),
'#state': join(rootDir, '/src/state.ts'),
'#types/*': join(rootDir, '/src/types/*'),
'#utils/*': join(rootDir, '/src/utils/*'),
'vscode': join(rootDir, '/tests/__mocks__/vscode.ts'),
vscode: join(rootDir, '/tests/__mocks__/vscode.ts'),
},
},
test: {
include: ['tests/**/*.test.ts'],
},
})