diff --git a/README.md b/README.md index 9456e4e..491fb5e 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/package.json b/package.json index 6e50cfc..c4e0ddb 100644 --- a/package.json +++ b/package.json @@ -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)" } } }, @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 297ce75..dde2231 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vite-tsconfig-paths: + specifier: ^6.1.0 + version: 6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(yaml@2.8.2)) vitest: specifier: ^4.0.18 version: 4.0.18(@types/node@25.2.2)(jiti@2.6.1)(yaml@2.8.2) @@ -2866,6 +2869,16 @@ packages: peerDependencies: typescript: '>=4.0.0' + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tsdown@0.20.3: resolution: {integrity: sha512-qWOUXSbe4jN8JZEgrkc/uhJpC8VN2QpNu3eZkBWwNuTEjc/Ik1kcc54ycfcQ5QPRHeu9OQXaLfCI3o7pEJgB2w==} engines: {node: '>=20.19.0'} @@ -2996,6 +3009,11 @@ packages: resolution: {integrity: sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==} engines: {node: '>=4'} + vite-tsconfig-paths@6.1.0: + resolution: {integrity: sha512-kpd3sY9glHIDaq4V/Tlc1Y8WaKtutoc3B525GHxEVKWX42FKfQsXvjFOemu1I8VIN8pNbrMLWVTbW79JaRUxKg==} + peerDependencies: + vite: '*' + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6262,6 +6280,10 @@ snapshots: picomatch: 4.0.3 typescript: 5.9.3 + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + tsdown@0.20.3(typescript@5.9.3): dependencies: ansis: 4.2.0 @@ -6379,6 +6401,16 @@ snapshots: version-range@4.15.0: {} + vite-tsconfig-paths@6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(yaml@2.8.2)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + - typescript + vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(yaml@2.8.2): dependencies: esbuild: 0.27.2 diff --git a/src/constants.ts b/src/constants.ts index defcc10..362ecf1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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 diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 457270e..61d02db 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 { checkVulnerability } from './rules/vulnerability' @@ -22,6 +23,8 @@ const enabledRules = computed(() => { 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) diff --git a/src/providers/diagnostics/rules/dist-tag.ts b/src/providers/diagnostics/rules/dist-tag.ts new file mode 100644 index 0000000..be451ab --- /dev/null +++ b/src/providers/diagnostics/rules/dist-tag.ts @@ -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()) + 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)), + }, + } +} 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..4fb0ff9 --- /dev/null +++ b/tests/dist-tag.test.ts @@ -0,0 +1,104 @@ +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 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() + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index dd2fd01..ec37776 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,18 +1,18 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import tsconfigPaths from 'vite-tsconfig-paths' 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'], }, })