From 07e7f3518d11451d9d7f639222f78c9de446bd90 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Thu, 5 Feb 2026 23:32:03 +0800 Subject: [PATCH 01/11] wip --- playground/package.json | 2 +- src/providers/diagnostics/index.ts | 3 ++- src/providers/diagnostics/rules/upgrade.ts | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 src/providers/diagnostics/rules/upgrade.ts diff --git a/playground/package.json b/playground/package.json index d633bb6..1c7a703 100644 --- a/playground/package.json +++ b/playground/package.json @@ -2,7 +2,7 @@ "dependencies": { "@deno/doc": "jsr:^0.189.1", "@prismicio/client": "~7.21.0-canary.147e3f2", - "nuxt": "npm:4.3.0" + "nuxt": "npm:4.2.0" }, "devDependencies": { "array-includes": "", diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 457270e..cc898e7 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -11,6 +11,7 @@ import { languages } from 'vscode' import { displayName } from '../../generated-meta' import { checkDeprecation } from './rules/deprecation' import { checkReplacement } from './rules/replacement' +import { checkUpgrade } from './rules/upgrade' import { checkVulnerability } from './rules/vulnerability' export interface NodeDiagnosticInfo extends Omit { @@ -19,7 +20,7 @@ export interface NodeDiagnosticInfo extends Omit export type DiagnosticRule = (dep: DependencyInfo, pkg: PackageInfo) => Awaitable const enabledRules = computed(() => { - const rules: DiagnosticRule[] = [] + const rules: DiagnosticRule[] = [checkUpgrade] if (config.diagnostics.deprecation) rules.push(checkDeprecation) if (config.diagnostics.replacement) diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts new file mode 100644 index 0000000..e248394 --- /dev/null +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -0,0 +1,19 @@ +import type { DiagnosticRule } from '..' +import { isSupportedProtocol, parseVersion } from '#utils/package' +import { DiagnosticSeverity } from 'vscode' + +export const checkUpgrade: DiagnosticRule = (dep, pkg) => { + const parsed = parseVersion(dep.version) + if (!parsed || !isSupportedProtocol(parsed.protocol)) + return + + const { semver } = parsed + if (pkg.distTags.latest === semver) + return + + return { + node: dep.versionNode, + severity: DiagnosticSeverity.Hint, + message: 'New version is avaliable', + } +} From 344cac07c5151cbc18ab8447d62ba9e440797392 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Feb 2026 22:56:03 +0800 Subject: [PATCH 02/11] wip --- README.md | 1 + package.json | 5 +++++ src/index.ts | 17 ++++++++++++++++- src/providers/code-actions/upgrade.ts | 21 +++++++++++++++++++++ src/providers/diagnostics/index.ts | 4 +++- src/providers/diagnostics/rules/upgrade.ts | 9 ++++++--- 6 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 src/providers/code-actions/upgrade.ts diff --git a/README.md b/README.md index 9456e4e..bd1889d 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ | `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` | diff --git a/package.json b/package.json index 6e50cfc..16cd5b9 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,11 @@ "default": true, "description": "Exclude prerelease versions (alpha, beta, rc, canary, etc.) from completion suggestions" }, + "npmx.diagnostics.upgrade": { + "type": "boolean", + "default": true, + "description": "Show hints when a newer version of a package is available" + }, "npmx.diagnostics.deprecation": { "type": "boolean", "default": true, diff --git a/src/index.ts b/src/index.ts index a6283e6..048a7d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,13 @@ import { VERSION_TRIGGER_CHARACTERS, } from '#constants' import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' -import { Disposable, languages } from 'vscode' +import { CodeActionKind, Disposable, languages } from 'vscode' import { openFileInNpmx } from './commands/open-file-in-npmx' import { openInBrowser } from './commands/open-in-browser' import { PackageJsonExtractor } from './extractors/package-json' import { PnpmWorkspaceYamlExtractor } from './extractors/pnpm-workspace-yaml' import { commands, displayName, version } from './generated-meta' +import { UpgradeProvider } from './providers/code-actions/upgrade' import { VersionCompletionItemProvider } from './providers/completion-item/version' import { registerDiagnosticCollection } from './providers/diagnostics' import { NpmxHoverProvider } from './providers/hover/npmx' @@ -61,6 +62,20 @@ export const { activate, deactivate } = defineExtension(() => { onCleanup(() => Disposable.from(...disposables).dispose()) }) + watchEffect((onCleanup) => { + if (!config.diagnostics.upgrade) + return + + const provider = new UpgradeProvider() + const options = { providedCodeActionKinds: [CodeActionKind.QuickFix] } + const disposable = Disposable.from( + languages.registerCodeActionsProvider({ pattern: PACKAGE_JSON_PATTERN }, provider, options), + languages.registerCodeActionsProvider({ pattern: PNPM_WORKSPACE_PATTERN }, provider, options), + ) + + onCleanup(() => disposable.dispose()) + }) + registerDiagnosticCollection({ [PACKAGE_JSON_BASENAME]: packageJsonExtractor, [PNPM_WORKSPACE_BASENAME]: pnpmWorkspaceYamlExtractor, diff --git a/src/providers/code-actions/upgrade.ts b/src/providers/code-actions/upgrade.ts new file mode 100644 index 0000000..c58b67d --- /dev/null +++ b/src/providers/code-actions/upgrade.ts @@ -0,0 +1,21 @@ +import type { CodeActionContext, CodeActionProvider, Command, ProviderResult, Range, Selection, TextDocument } from 'vscode' +import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' + +const UPGRADE_MESSAGE_RE = /^New version available: (.+)$/ + +export class UpgradeProvider implements CodeActionProvider { + provideCodeActions(document: TextDocument, _range: Range | Selection, context: CodeActionContext): ProviderResult<(CodeAction | Command)[]> { + return context.diagnostics.flatMap((d) => { + const match = d.message.match(UPGRADE_MESSAGE_RE) + if (!match) + return [] + + const target = match[1] + const fix = new CodeAction(`Update to ${target}`, CodeActionKind.QuickFix) + fix.edit = new WorkspaceEdit() + fix.edit.replace(document.uri, d.range, `"${target}"`) + fix.diagnostics = [d] + return [fix] + }) + } +} diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index cc898e7..2b146fd 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -20,7 +20,9 @@ export interface NodeDiagnosticInfo extends Omit export type DiagnosticRule = (dep: DependencyInfo, pkg: PackageInfo) => Awaitable const enabledRules = computed(() => { - const rules: DiagnosticRule[] = [checkUpgrade] + const rules: DiagnosticRule[] = [] + if (config.diagnostics.upgrade) + rules.push(checkUpgrade) if (config.diagnostics.deprecation) rules.push(checkDeprecation) if (config.diagnostics.replacement) diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index e248394..13634d8 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -1,5 +1,5 @@ import type { DiagnosticRule } from '..' -import { isSupportedProtocol, parseVersion } from '#utils/package' +import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/package' import { DiagnosticSeverity } from 'vscode' export const checkUpgrade: DiagnosticRule = (dep, pkg) => { @@ -8,12 +8,15 @@ export const checkUpgrade: DiagnosticRule = (dep, pkg) => { return const { semver } = parsed - if (pkg.distTags.latest === semver) + const latest = pkg.distTags.latest + if (latest === semver) return + const target = formatVersion({ ...parsed, semver: latest }) + return { node: dep.versionNode, severity: DiagnosticSeverity.Hint, - message: 'New version is avaliable', + message: `New version available: ${target}`, } } From 13eb6d2f23acaf5991c26dcfa975877bf50b09bb Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Feb 2026 22:58:12 +0800 Subject: [PATCH 03/11] wip: simplify upgrade --- src/constants.ts | 2 ++ src/providers/code-actions/upgrade.ts | 8 +++----- src/providers/diagnostics/rules/upgrade.ts | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index defcc10..555650c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,6 +7,8 @@ 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 UPGRADE_MESSAGE_PREFIX = 'New version available: ' + export const CACHE_TTL_ONE_DAY = 1000 * 60 * 60 * 24 export const NPMX_DEV = 'https://npmx.dev' diff --git a/src/providers/code-actions/upgrade.ts b/src/providers/code-actions/upgrade.ts index c58b67d..dd9df6e 100644 --- a/src/providers/code-actions/upgrade.ts +++ b/src/providers/code-actions/upgrade.ts @@ -1,16 +1,14 @@ import type { CodeActionContext, CodeActionProvider, Command, ProviderResult, Range, Selection, TextDocument } from 'vscode' +import { UPGRADE_MESSAGE_PREFIX } from '#constants' import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' -const UPGRADE_MESSAGE_RE = /^New version available: (.+)$/ - export class UpgradeProvider implements CodeActionProvider { provideCodeActions(document: TextDocument, _range: Range | Selection, context: CodeActionContext): ProviderResult<(CodeAction | Command)[]> { return context.diagnostics.flatMap((d) => { - const match = d.message.match(UPGRADE_MESSAGE_RE) - if (!match) + if (!d.message.startsWith(UPGRADE_MESSAGE_PREFIX)) return [] - const target = match[1] + const target = d.message.slice(UPGRADE_MESSAGE_PREFIX.length) const fix = new CodeAction(`Update to ${target}`, CodeActionKind.QuickFix) fix.edit = new WorkspaceEdit() fix.edit.replace(document.uri, d.range, `"${target}"`) diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index 13634d8..777e14b 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -1,4 +1,5 @@ import type { DiagnosticRule } from '..' +import { UPGRADE_MESSAGE_PREFIX } from '#constants' import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/package' import { DiagnosticSeverity } from 'vscode' @@ -17,6 +18,6 @@ export const checkUpgrade: DiagnosticRule = (dep, pkg) => { return { node: dep.versionNode, severity: DiagnosticSeverity.Hint, - message: `New version available: ${target}`, + message: `${UPGRADE_MESSAGE_PREFIX}${target}`, } } From e17e65c5e4f34310d79ca574fd286d1afb60ea34 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Feb 2026 23:00:09 +0800 Subject: [PATCH 04/11] fix: correct target string --- src/providers/code-actions/upgrade.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/code-actions/upgrade.ts b/src/providers/code-actions/upgrade.ts index dd9df6e..daeae8a 100644 --- a/src/providers/code-actions/upgrade.ts +++ b/src/providers/code-actions/upgrade.ts @@ -11,7 +11,7 @@ export class UpgradeProvider implements CodeActionProvider { const target = d.message.slice(UPGRADE_MESSAGE_PREFIX.length) const fix = new CodeAction(`Update to ${target}`, CodeActionKind.QuickFix) fix.edit = new WorkspaceEdit() - fix.edit.replace(document.uri, d.range, `"${target}"`) + fix.edit.replace(document.uri, d.range, `${target}`) fix.diagnostics = [d] return [fix] }) From 023dae7afcb67a0d5ca5e8227e16b6a0b60fe823 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Feb 2026 23:51:09 +0800 Subject: [PATCH 05/11] feat: add upgrade diagnostic for prerelease and outdated versions --- package.json | 2 + pnpm-lock.yaml | 47 +++++++++++++--------- src/providers/diagnostics/rules/upgrade.ts | 39 ++++++++++++++---- tsdown.config.ts | 1 + 4 files changed, 64 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 16cd5b9..4eebc16 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ }, "devDependencies": { "@types/node": "^25.2.2", + "@types/semver": "^7.7.1", "@types/vscode": "1.101.0", "@vida0905/eslint-config": "^2.10.0", "@vscode/vsce": "^3.7.1", @@ -163,6 +164,7 @@ "ofetch": "^2.0.0-alpha.3", "perfect-debounce": "^2.1.0", "reactive-vscode": "^1.0.0-beta.2", + "semver": "^7.7.4", "tsdown": "^0.20.3", "typescript": "^5.9.3", "vitest": "^4.0.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 297ce75..babea00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@types/node': specifier: ^25.2.2 version: 25.2.2 + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 '@types/vscode': specifier: 1.101.0 version: 1.101.0 @@ -50,6 +53,9 @@ importers: reactive-vscode: specifier: ^1.0.0-beta.2 version: 1.0.0-beta.2(@types/vscode@1.101.0) + semver: + specifier: ^7.7.4 + version: 7.7.4 tsdown: specifier: ^0.20.3 version: 0.20.3(typescript@5.9.3) @@ -883,6 +889,9 @@ packages: '@types/sarif@2.1.7': resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -2667,8 +2676,8 @@ packages: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true @@ -3896,6 +3905,8 @@ snapshots: '@types/sarif@2.1.7': {} + '@types/semver@7.7.1': {} + '@types/unist@3.0.3': {} '@types/vscode@1.101.0': {} @@ -3968,7 +3979,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.54.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -4143,7 +4154,7 @@ snapshots: parse-semver: 1.1.1 read: 1.0.7 secretlint: 10.2.2 - semver: 7.7.3 + semver: 7.7.4 tmp: 0.2.5 typed-rest-client: 1.8.11 url-join: 4.0.1 @@ -4586,12 +4597,12 @@ snapshots: eslint-compat-utils@0.5.1(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) - semver: 7.7.3 + semver: 7.7.4 eslint-compat-utils@0.6.5(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) - semver: 7.7.3 + semver: 7.7.4 eslint-config-flat-gitignore@2.1.0(eslint@9.39.2(jiti@2.6.1)): dependencies: @@ -4630,7 +4641,7 @@ snapshots: dependencies: empathic: 2.0.0 module-replacements: 2.11.0 - semver: 7.7.3 + semver: 7.7.4 eslint-plugin-es-x@7.8.0(eslint@9.39.2(jiti@2.6.1)): dependencies: @@ -4657,7 +4668,7 @@ snapshots: html-entities: 2.6.0 object-deep-merge: 2.0.0 parse-imports-exports: 0.2.4 - semver: 7.7.3 + semver: 7.7.4 spdx-expression-parse: 4.0.0 to-valid-identifier: 1.0.0 transitivePeerDependencies: @@ -4688,7 +4699,7 @@ snapshots: globals: 15.15.0 globrex: 0.1.2 ignore: 5.3.2 - semver: 7.7.3 + semver: 7.7.4 ts-declaration-location: 1.0.7(typescript@5.9.3) transitivePeerDependencies: - typescript @@ -4755,7 +4766,7 @@ snapshots: pluralize: 8.0.0 regexp-tree: 0.1.27 regjsparser: 0.13.0 - semver: 7.7.3 + semver: 7.7.4 strip-indent: 4.1.1 eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)): @@ -4771,7 +4782,7 @@ snapshots: natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 - semver: 7.7.3 + semver: 7.7.4 vue-eslint-parser: 10.2.0(eslint@9.39.2(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: @@ -5202,7 +5213,7 @@ snapshots: acorn: 8.15.0 eslint-visitor-keys: 3.4.3 espree: 9.6.1 - semver: 7.7.3 + semver: 7.7.4 jsonc-parser@3.3.1: {} @@ -5223,7 +5234,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.3 + semver: 7.7.4 jwa@1.4.2: dependencies: @@ -5693,7 +5704,7 @@ snapshots: node-abi@3.78.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 optional: true node-addon-api@4.3.0: @@ -5709,7 +5720,7 @@ snapshots: normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 - semver: 7.7.3 + semver: 7.7.4 validate-npm-package-license: 3.0.4 nth-check@2.1.1: @@ -6051,7 +6062,7 @@ snapshots: semver@5.7.2: {} - semver@7.7.3: {} + semver@7.7.4: {} shebang-command@2.0.0: dependencies: @@ -6274,7 +6285,7 @@ snapshots: picomatch: 4.0.3 rolldown: 1.0.0-rc.3 rolldown-plugin-dts: 0.22.1(rolldown@1.0.0-rc.3)(typescript@5.9.3) - semver: 7.7.3 + semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 @@ -6444,7 +6455,7 @@ snapshots: eslint-visitor-keys: 4.2.1 espree: 10.4.0 esquery: 1.7.0 - semver: 7.7.3 + semver: 7.7.4 transitivePeerDependencies: - supports-color diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index 777e14b..df6ee09 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -1,8 +1,25 @@ -import type { DiagnosticRule } from '..' +import type { DependencyInfo } from '#types/extractor' +import type { ParsedVersion } from '#utils/package' +import type { DiagnosticRule, NodeDiagnosticInfo } from '..' import { UPGRADE_MESSAGE_PREFIX } from '#constants' import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/package' +import { lt, prerelease } from 'semver' import { DiagnosticSeverity } from 'vscode' +function getPrereleaseId(version: string): string | null { + const pre = prerelease(version) + return (pre?.length && typeof pre[0] === 'string') ? pre[0] : null +} + +function createUpgradeDiagnostic(dep: DependencyInfo, parsed: ParsedVersion, upgradeVersion: string): NodeDiagnosticInfo { + const target = formatVersion({ ...parsed, semver: upgradeVersion }) + return { + node: dep.versionNode, + severity: DiagnosticSeverity.Hint, + message: `${UPGRADE_MESSAGE_PREFIX}${target}`, + } +} + export const checkUpgrade: DiagnosticRule = (dep, pkg) => { const parsed = parseVersion(dep.version) if (!parsed || !isSupportedProtocol(parsed.protocol)) @@ -10,14 +27,22 @@ export const checkUpgrade: DiagnosticRule = (dep, pkg) => { const { semver } = parsed const latest = pkg.distTags.latest - if (latest === semver) + + if (latest && lt(semver, latest)) + return createUpgradeDiagnostic(dep, parsed, latest) + + const currentPreId = getPrereleaseId(semver) + if (!currentPreId) return - const target = formatVersion({ ...parsed, semver: latest }) + for (const [tag, tagVersion] of Object.entries(pkg.distTags)) { + if (tag === 'latest') + continue + if (getPrereleaseId(tagVersion) !== currentPreId) + continue + if (!lt(semver, tagVersion)) + continue - return { - node: dep.versionNode, - severity: DiagnosticSeverity.Hint, - message: `${UPGRADE_MESSAGE_PREFIX}${target}`, + return createUpgradeDiagnostic(dep, parsed, tagVersion) } } diff --git a/tsdown.config.ts b/tsdown.config.ts index 8eeb7dd..605cac2 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ 'ofetch', 'fast-npm-meta', 'perfect-debounce', + 'semver', ], minify: 'dce-only', }) From a8364a64b957b162da3c56d5bafc5689a5950da6 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Feb 2026 00:12:12 +0800 Subject: [PATCH 06/11] refactor: remove `semver`, use custom version parse, compare --- package.json | 2 - pnpm-lock.yaml | 11 --- src/constants.ts | 4 +- src/providers/completion-item/version.ts | 2 +- .../diagnostics/rules/deprecation.ts | 2 +- src/providers/diagnostics/rules/upgrade.ts | 10 +- .../diagnostics/rules/vulnerability.ts | 2 +- src/providers/hover/npmx.ts | 2 +- src/utils/package.ts | 49 ---------- src/utils/version.ts | 92 +++++++++++++++++++ tests/package.test.ts | 74 +-------------- tests/version.test.ts | 74 +++++++++++++++ 12 files changed, 175 insertions(+), 149 deletions(-) create mode 100644 src/utils/version.ts create mode 100644 tests/version.test.ts diff --git a/package.json b/package.json index 4eebc16..16cd5b9 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,6 @@ }, "devDependencies": { "@types/node": "^25.2.2", - "@types/semver": "^7.7.1", "@types/vscode": "1.101.0", "@vida0905/eslint-config": "^2.10.0", "@vscode/vsce": "^3.7.1", @@ -164,7 +163,6 @@ "ofetch": "^2.0.0-alpha.3", "perfect-debounce": "^2.1.0", "reactive-vscode": "^1.0.0-beta.2", - "semver": "^7.7.4", "tsdown": "^0.20.3", "typescript": "^5.9.3", "vitest": "^4.0.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index babea00..8efc04f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@types/node': specifier: ^25.2.2 version: 25.2.2 - '@types/semver': - specifier: ^7.7.1 - version: 7.7.1 '@types/vscode': specifier: 1.101.0 version: 1.101.0 @@ -53,9 +50,6 @@ importers: reactive-vscode: specifier: ^1.0.0-beta.2 version: 1.0.0-beta.2(@types/vscode@1.101.0) - semver: - specifier: ^7.7.4 - version: 7.7.4 tsdown: specifier: ^0.20.3 version: 0.20.3(typescript@5.9.3) @@ -889,9 +883,6 @@ packages: '@types/sarif@2.1.7': resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -3905,8 +3896,6 @@ snapshots: '@types/sarif@2.1.7': {} - '@types/semver@7.7.1': {} - '@types/unist@3.0.3': {} '@types/vscode@1.101.0': {} diff --git a/src/constants.ts b/src/constants.ts index 555650c..345a2be 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,11 +7,11 @@ 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 UPGRADE_MESSAGE_PREFIX = 'New version available: ' - export const CACHE_TTL_ONE_DAY = 1000 * 60 * 60 * 24 export const NPMX_DEV = 'https://npmx.dev' export const NPMX_DEV_API = `${NPMX_DEV}/api` export const SPACER = ' ' + +export const UPGRADE_MESSAGE_PREFIX = 'New version available: ' diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 3db7d51..c10a3a7 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -3,7 +3,7 @@ import type { CompletionItemProvider, Position, TextDocument } from 'vscode' import { PRERELEASE_PATTERN } from '#constants' import { config } from '#state' import { getPackageInfo } from '#utils/api/package' -import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/package' +import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/version' import { CompletionItem, CompletionItemKind } from 'vscode' export class VersionCompletionItemProvider implements CompletionItemProvider { diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index a0b6a6a..4c7d1a3 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -1,6 +1,6 @@ import type { DiagnosticRule } from '..' import { npmxPackageUrl } from '#utils/links' -import { isSupportedProtocol, parseVersion } from '#utils/package' +import { isSupportedProtocol, parseVersion } from '#utils/version' import { DiagnosticSeverity, DiagnosticTag, Uri } from 'vscode' export const checkDeprecation: DiagnosticRule = (dep, pkg) => { diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index df6ee09..28b7d5d 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -1,16 +1,10 @@ import type { DependencyInfo } from '#types/extractor' -import type { ParsedVersion } from '#utils/package' +import type { ParsedVersion } from '#utils/version' import type { DiagnosticRule, NodeDiagnosticInfo } from '..' import { UPGRADE_MESSAGE_PREFIX } from '#constants' -import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/package' -import { lt, prerelease } from 'semver' +import { formatVersion, getPrereleaseId, isSupportedProtocol, lt, parseVersion } from '#utils/version' import { DiagnosticSeverity } from 'vscode' -function getPrereleaseId(version: string): string | null { - const pre = prerelease(version) - return (pre?.length && typeof pre[0] === 'string') ? pre[0] : null -} - function createUpgradeDiagnostic(dep: DependencyInfo, parsed: ParsedVersion, upgradeVersion: string): NodeDiagnosticInfo { const target = formatVersion({ ...parsed, semver: upgradeVersion }) return { diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index 01cbc08..b42a4bc 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -2,7 +2,7 @@ import type { OsvSeverityLevel } from '#utils/api/vulnerability' import type { DiagnosticRule } from '..' import { getVulnerability, SEVERITY_LEVELS } from '#utils/api/vulnerability' import { npmxPackageUrl } from '#utils/links' -import { isSupportedProtocol, parseVersion } from '#utils/package' +import { isSupportedProtocol, parseVersion } from '#utils/version' import { DiagnosticSeverity, Uri } from 'vscode' const DIAGNOSTIC_MAPPING: Record, DiagnosticSeverity> = { diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 3d5c757..78f028c 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -3,7 +3,7 @@ import type { HoverProvider, Position, TextDocument } from 'vscode' import { SPACER } from '#constants' import { getPackageInfo } from '#utils/api/package' import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' -import { isSupportedProtocol, parseVersion } from '#utils/package' +import { isSupportedProtocol, parseVersion } from '#utils/version' import { Hover, MarkdownString } from 'vscode' export class NpmxHoverProvider implements HoverProvider { diff --git a/src/utils/package.ts b/src/utils/package.ts index 487798e..b5429bd 100644 --- a/src/utils/package.ts +++ b/src/utils/package.ts @@ -8,52 +8,3 @@ export function encodePackageName(name: string): string { } return encodeURIComponent(name) } - -export type VersionProtocol = 'workspace' | 'catalog' | 'npm' | 'jsr' | null - -const KNOWN_PROTOCOLS = new Set(['workspace', 'catalog', 'npm', 'jsr']) -const URL_PREFIXES = ['http://', 'https://', 'git://', 'git+'] -const UNSUPPORTED_PROTOCOLS = new Set(['workspace', 'catalog', 'jsr']) - -export interface ParsedVersion { - protocol: VersionProtocol - prefix: '' | '^' | '~' - semver: string -} - -export function isSupportedProtocol(protocol: VersionProtocol): boolean { - return !UNSUPPORTED_PROTOCOLS.has(protocol) -} - -export function formatVersion(parsed: ParsedVersion): string { - const protocol = parsed.protocol ? `${parsed.protocol}:` : '' - return `${protocol}${parsed.prefix}${parsed.semver}` -} - -export function parseVersion(rawVersion: string): ParsedVersion | null { - rawVersion = rawVersion.trim() - // Skip URL-based versions - if (URL_PREFIXES.some((p) => rawVersion.startsWith(p))) - return null - - let protocol: VersionProtocol = null - let versionStr = rawVersion - - // Parse protocol if present (e.g., npm:^1.0.0 -> protocol: 'npm') - const colonIndex = rawVersion.indexOf(':') - if (colonIndex !== -1) { - protocol = rawVersion.slice(0, colonIndex) as VersionProtocol - - if (!KNOWN_PROTOCOLS.has(protocol)) - return null - - versionStr = rawVersion.slice(colonIndex + 1) - } - - const firstChar = versionStr[0] - const hasPrefix = firstChar === '^' || firstChar === '~' - const prefix = hasPrefix ? firstChar : '' - const semver = hasPrefix ? versionStr.slice(1) : versionStr - - return { protocol, prefix, semver } -} diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 0000000..f74d778 --- /dev/null +++ b/src/utils/version.ts @@ -0,0 +1,92 @@ +export type VersionProtocol = 'workspace' | 'catalog' | 'npm' | 'jsr' | null + +const KNOWN_PROTOCOLS = new Set(['workspace', 'catalog', 'npm', 'jsr']) +const URL_PREFIXES = ['http://', 'https://', 'git://', 'git+'] +const UNSUPPORTED_PROTOCOLS = new Set(['workspace', 'catalog', 'jsr']) + +export interface ParsedVersion { + protocol: VersionProtocol + prefix: '' | '^' | '~' + semver: string +} + +export function isSupportedProtocol(protocol: VersionProtocol): boolean { + return !UNSUPPORTED_PROTOCOLS.has(protocol) +} + +export function formatVersion(parsed: ParsedVersion): string { + const protocol = parsed.protocol ? `${parsed.protocol}:` : '' + return `${protocol}${parsed.prefix}${parsed.semver}` +} + +export function parseVersion(rawVersion: string): ParsedVersion | null { + rawVersion = rawVersion.trim() + if (URL_PREFIXES.some((p) => rawVersion.startsWith(p))) + return null + + let protocol: VersionProtocol = null + let versionStr = rawVersion + + const colonIndex = rawVersion.indexOf(':') + if (colonIndex !== -1) { + protocol = rawVersion.slice(0, colonIndex) as VersionProtocol + + if (!KNOWN_PROTOCOLS.has(protocol)) + return null + + versionStr = rawVersion.slice(colonIndex + 1) + } + + const firstChar = versionStr[0] + const hasPrefix = firstChar === '^' || firstChar === '~' + const prefix = hasPrefix ? firstChar : '' + const semver = hasPrefix ? versionStr.slice(1) : versionStr + + return { protocol, prefix, semver } +} + +export function getPrereleaseId(version: string): string | null { + const idx = version.indexOf('-') + if (idx === -1) + return null + const pre = version.slice(idx + 1).split('.')[0] + return pre || null +} + +function comparePrerelease(a: string, b: string): number { + const pa = a.split('.') + const pb = b.split('.') + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + if (i >= pa.length) + return -1 + if (i >= pb.length) + return 1 + const na = Number(pa[i]) + const nb = Number(pb[i]) + if (!Number.isNaN(na) && !Number.isNaN(nb)) { + if (na !== nb) + return na - nb + } + else if (pa[i] !== pb[i]) { + return pa[i] < pb[i] ? -1 : 1 + } + } + return 0 +} + +export function lt(a: string, b: string): boolean { + const [coreA, preA] = a.split('-', 2) + const [coreB, preB] = b.split('-', 2) + const partsA = coreA.split('.').map(Number) + const partsB = coreB.split('.').map(Number) + for (let i = 0; i < 3; i++) { + const diff = (partsA[i] || 0) - (partsB[i] || 0) + if (diff !== 0) + return diff < 0 + } + if (preA && !preB) + return true + if (!preA || !preB) + return false + return comparePrerelease(preA, preB) < 0 +} diff --git a/tests/package.test.ts b/tests/package.test.ts index 7eb5ecd..e3ab59d 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { encodePackageName, parseVersion } from '../src/utils/package' +import { encodePackageName } from '../src/utils/package' describe('encodePackageName', () => { it('should encode regular package name', () => { @@ -10,75 +10,3 @@ describe('encodePackageName', () => { expect(encodePackageName('@vue/core')).toBe('@vue%2Fcore') }) }) - -describe('parseVersion', () => { - it('should parse plain version', () => { - expect(parseVersion('1.0.0')).toEqual({ - protocol: null, - prefix: '', - semver: '1.0.0', - }) - }) - - it('should parse version with ^ prefix', () => { - expect(parseVersion('^1.2.3')).toEqual({ - protocol: null, - prefix: '^', - semver: '1.2.3', - }) - }) - - it('should parse version with ~ prefix', () => { - expect(parseVersion('~2.0.0')).toEqual({ - protocol: null, - prefix: '~', - semver: '2.0.0', - }) - }) - - it('should parse npm: protocol', () => { - expect(parseVersion('npm:1.0.0')).toEqual({ - protocol: 'npm', - prefix: '', - semver: '1.0.0', - }) - }) - - it('should parse npm: protocol with prefix', () => { - expect(parseVersion('npm:^1.0.0')).toEqual({ - protocol: 'npm', - prefix: '^', - semver: '1.0.0', - }) - }) - - it('should parse workspace: protocol', () => { - expect(parseVersion('workspace:*')).toEqual({ - protocol: 'workspace', - prefix: '', - semver: '*', - }) - }) - - it('should parse catalog: protocol', () => { - expect(parseVersion('catalog:default')).toEqual({ - protocol: 'catalog', - prefix: '', - semver: 'default', - }) - }) - - it('should parse jsr: protocol', () => { - expect(parseVersion('jsr:^1.1.4')).toEqual({ - protocol: 'jsr', - prefix: '^', - semver: '1.1.4', - }) - }) - - it('should return null for URL-based versions', () => { - expect(parseVersion('https://github.com/user/repo')).toBeNull() - expect(parseVersion('git://github.com/user/repo')).toBeNull() - expect(parseVersion('git+https://github.com/user/repo')).toBeNull() - }) -}) diff --git a/tests/version.test.ts b/tests/version.test.ts new file mode 100644 index 0000000..dfd73a6 --- /dev/null +++ b/tests/version.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { parseVersion } from '../src/utils/version' + +describe('parseVersion', () => { + it('should parse plain version', () => { + expect(parseVersion('1.0.0')).toEqual({ + protocol: null, + prefix: '', + semver: '1.0.0', + }) + }) + + it('should parse version with ^ prefix', () => { + expect(parseVersion('^1.2.3')).toEqual({ + protocol: null, + prefix: '^', + semver: '1.2.3', + }) + }) + + it('should parse version with ~ prefix', () => { + expect(parseVersion('~2.0.0')).toEqual({ + protocol: null, + prefix: '~', + semver: '2.0.0', + }) + }) + + it('should parse npm: protocol', () => { + expect(parseVersion('npm:1.0.0')).toEqual({ + protocol: 'npm', + prefix: '', + semver: '1.0.0', + }) + }) + + it('should parse npm: protocol with prefix', () => { + expect(parseVersion('npm:^1.0.0')).toEqual({ + protocol: 'npm', + prefix: '^', + semver: '1.0.0', + }) + }) + + it('should parse workspace: protocol', () => { + expect(parseVersion('workspace:*')).toEqual({ + protocol: 'workspace', + prefix: '', + semver: '*', + }) + }) + + it('should parse catalog: protocol', () => { + expect(parseVersion('catalog:default')).toEqual({ + protocol: 'catalog', + prefix: '', + semver: 'default', + }) + }) + + it('should parse jsr: protocol', () => { + expect(parseVersion('jsr:^1.1.4')).toEqual({ + protocol: 'jsr', + prefix: '^', + semver: '1.1.4', + }) + }) + + it('should return null for URL-based versions', () => { + expect(parseVersion('https://github.com/user/repo')).toBeNull() + expect(parseVersion('git://github.com/user/repo')).toBeNull() + expect(parseVersion('git+https://github.com/user/repo')).toBeNull() + }) +}) From 444baaceadbc581b12dd77a9c3139b3c8e66ef66 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Feb 2026 00:16:42 +0800 Subject: [PATCH 07/11] test: add basic test cases --- src/utils/version.ts | 3 +-- tests/version.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/utils/version.ts b/src/utils/version.ts index f74d778..9ed8130 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -66,8 +66,7 @@ function comparePrerelease(a: string, b: string): number { if (!Number.isNaN(na) && !Number.isNaN(nb)) { if (na !== nb) return na - nb - } - else if (pa[i] !== pb[i]) { + } else if (pa[i] !== pb[i]) { return pa[i] < pb[i] ? -1 : 1 } } diff --git a/tests/version.test.ts b/tests/version.test.ts index dfd73a6..4f26a5c 100644 --- a/tests/version.test.ts +++ b/tests/version.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseVersion } from '../src/utils/version' +import { getPrereleaseId, lt, parseVersion } from '../src/utils/version' describe('parseVersion', () => { it('should parse plain version', () => { @@ -72,3 +72,58 @@ describe('parseVersion', () => { expect(parseVersion('git+https://github.com/user/repo')).toBeNull() }) }) + +describe('getPrereleaseId', () => { + it('should return null for stable versions', () => { + expect(getPrereleaseId('1.0.0')).toBeNull() + }) + + it('should extract identifier', () => { + expect(getPrereleaseId('2.0.0-beta.1')).toBe('beta') + }) + + it('should handle prerelease without dots', () => { + expect(getPrereleaseId('1.0.0-canary')).toBe('canary') + }) +}) + +describe('lt', () => { + it('should compare major versions', () => { + expect(lt('1.0.0', '2.0.0')).toBe(true) + expect(lt('2.0.0', '1.0.0')).toBe(false) + }) + + it('should compare minor versions', () => { + expect(lt('1.0.0', '1.1.0')).toBe(true) + expect(lt('1.1.0', '1.0.0')).toBe(false) + }) + + it('should compare patch versions', () => { + expect(lt('1.0.0', '1.0.1')).toBe(true) + expect(lt('1.0.1', '1.0.0')).toBe(false) + }) + + it('should return false for equal versions', () => { + expect(lt('1.0.0', '1.0.0')).toBe(false) + }) + + it('should treat prerelease as less than release', () => { + expect(lt('1.0.0-beta.1', '1.0.0')).toBe(true) + expect(lt('1.0.0', '1.0.0-beta.1')).toBe(false) + }) + + it('should compare prerelease versions numerically', () => { + expect(lt('1.0.0-beta.1', '1.0.0-beta.2')).toBe(true) + expect(lt('1.0.0-beta.2', '1.0.0-beta.1')).toBe(false) + }) + + it('should compare different prerelease identifiers', () => { + expect(lt('1.0.0-alpha.1', '1.0.0-beta.1')).toBe(true) + expect(lt('1.0.0-beta.1', '1.0.0-alpha.1')).toBe(false) + }) + + it('should handle prerelease with fewer segments', () => { + expect(lt('1.0.0-beta', '1.0.0-beta.1')).toBe(true) + expect(lt('1.0.0-beta.1', '1.0.0-beta')).toBe(false) + }) +}) From 63febcbc5b4ab8a900c0397164fb3ac14bb906d1 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Feb 2026 00:43:22 +0800 Subject: [PATCH 08/11] chore: revert playground --- playground/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/package.json b/playground/package.json index 1c7a703..d633bb6 100644 --- a/playground/package.json +++ b/playground/package.json @@ -2,7 +2,7 @@ "dependencies": { "@deno/doc": "jsr:^0.189.1", "@prismicio/client": "~7.21.0-canary.147e3f2", - "nuxt": "npm:4.2.0" + "nuxt": "npm:4.3.0" }, "devDependencies": { "array-includes": "", From 9d8960140e4e688b2511cffba35ad5974c9b52e3 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Feb 2026 09:56:30 +0800 Subject: [PATCH 09/11] refactor: reduce type assertion --- src/utils/version.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/utils/version.ts b/src/utils/version.ts index 9ed8130..e77bf48 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -1,8 +1,8 @@ -export type VersionProtocol = 'workspace' | 'catalog' | 'npm' | 'jsr' | null +type VersionProtocol = 'workspace' | 'catalog' | 'npm' | 'jsr' | null -const KNOWN_PROTOCOLS = new Set(['workspace', 'catalog', 'npm', 'jsr']) const URL_PREFIXES = ['http://', 'https://', 'git://', 'git+'] -const UNSUPPORTED_PROTOCOLS = new Set(['workspace', 'catalog', 'jsr']) +const UNSUPPORTED_PROTOCOLS = new Set(['workspace', 'catalog', 'jsr']) +const KNOWN_PROTOCOLS = new Set([...UNSUPPORTED_PROTOCOLS, 'npm']) export interface ParsedVersion { protocol: VersionProtocol @@ -11,7 +11,7 @@ export interface ParsedVersion { } export function isSupportedProtocol(protocol: VersionProtocol): boolean { - return !UNSUPPORTED_PROTOCOLS.has(protocol) + return !protocol || !UNSUPPORTED_PROTOCOLS.has(protocol) } export function formatVersion(parsed: ParsedVersion): string { @@ -19,19 +19,23 @@ export function formatVersion(parsed: ParsedVersion): string { return `${protocol}${parsed.prefix}${parsed.semver}` } +function isKnownProtocol(protocol: string): protocol is NonNullable { + return KNOWN_PROTOCOLS.has(protocol) +} + export function parseVersion(rawVersion: string): ParsedVersion | null { rawVersion = rawVersion.trim() if (URL_PREFIXES.some((p) => rawVersion.startsWith(p))) return null - let protocol: VersionProtocol = null + let protocol: string | null = null let versionStr = rawVersion const colonIndex = rawVersion.indexOf(':') if (colonIndex !== -1) { - protocol = rawVersion.slice(0, colonIndex) as VersionProtocol + protocol = rawVersion.slice(0, colonIndex) - if (!KNOWN_PROTOCOLS.has(protocol)) + if (!isKnownProtocol(protocol)) return null versionStr = rawVersion.slice(colonIndex + 1) From 12a2c718e72af31c17e0899a376995d78d73bdc3 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Feb 2026 10:28:07 +0800 Subject: [PATCH 10/11] chore: improve readability of pre-release comparison logic --- src/utils/version.ts | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/utils/version.ts b/src/utils/version.ts index e77bf48..6b0a5b0 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -57,23 +57,31 @@ export function getPrereleaseId(version: string): string | null { return pre || null } -function comparePrerelease(a: string, b: string): number { - const pa = a.split('.') - const pb = b.split('.') - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - if (i >= pa.length) +/** + * Compare two pre-release strings part by part following SemVer precedence rules. + * + * Numeric parts are compared as numbers, string parts are compared lexicographically. + * A version with fewer parts is less than one with more parts when all preceding parts are equal. + */ +function comparePrereleasePrecedence(a: string, b: string): number { + const partsA = a.split('.') + const partsB = b.split('.') + + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { + if (i >= partsA.length) return -1 - if (i >= pb.length) + if (i >= partsB.length) return 1 - const na = Number(pa[i]) - const nb = Number(pb[i]) - if (!Number.isNaN(na) && !Number.isNaN(nb)) { - if (na !== nb) - return na - nb - } else if (pa[i] !== pb[i]) { - return pa[i] < pb[i] ? -1 : 1 + + const numA = Number(partsA[i]) + const numB = Number(partsB[i]) + if (!Number.isNaN(numA) && !Number.isNaN(numB)) { + return numA - numB + } else if (partsA[i] !== partsB[i]) { + return partsA[i] < partsB[i] ? -1 : 1 } } + return 0 } @@ -91,5 +99,5 @@ export function lt(a: string, b: string): boolean { return true if (!preA || !preB) return false - return comparePrerelease(preA, preB) < 0 + return comparePrereleasePrecedence(preA, preB) < 0 } From c56fd0703c4be2cf37db7a90ee7b03bf966a6751 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Feb 2026 11:30:27 +0800 Subject: [PATCH 11/11] chore: remove unused inlineOnly --- tsdown.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tsdown.config.ts b/tsdown.config.ts index 605cac2..8eeb7dd 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -17,7 +17,6 @@ export default defineConfig({ 'ofetch', 'fast-npm-meta', 'perfect-debounce', - 'semver', ], minify: 'dce-only', })