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
2 changes: 2 additions & 0 deletions extensions/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@
| `npmx.diagnostics.vulnerability` | Show warnings for packages with known vulnerabilities | `boolean` | `true` |
| `npmx.diagnostics.distTag` | Show warnings when a dependency uses a dist tag | `boolean` | `true` |
| `npmx.diagnostics.engineMismatch` | Show warnings when dependency engines mismatch with the current package | `boolean` | `true` |
| `npmx.versionLens.enabled` | Show version lens (CodeLens) for package dependencies | `boolean` | `true` |
| `npmx.versionLens.hideWhenLatest` | Hide version lens when the dependency is already at the latest version | `boolean` | `false` |
| `npmx.packageLinks` | Enable clickable links for package names | `string` | `"declared"` |
| `npmx.ignore.upgrade` | Ignore list for upgrade diagnostics ("name" or "name@version"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics) | `array` | `[]` |
| `npmx.ignore.deprecation` | Ignore list for deprecation diagnostics ("name" or "name@version"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics) | `array` | `[]` |
Expand Down
10 changes: 10 additions & 0 deletions extensions/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@
"default": true,
"description": "Show warnings when dependency engines mismatch with the current package"
},
"npmx.versionLens.enabled": {
"type": "boolean",
"default": true,
"description": "Show version lens (CodeLens) for package dependencies"
},
"npmx.versionLens.hideWhenLatest": {
"type": "boolean",
"default": false,
"description": "Hide version lens when the dependency is already at the latest version"
},
"npmx.packageLinks": {
"type": "string",
"enum": [
Expand Down
15 changes: 15 additions & 0 deletions extensions/vscode/src/commands/replace-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Range as LspRange } from '@volar/vscode'
import { Position, Range, Uri, workspace, WorkspaceEdit } from 'vscode'

export async function replaceText(uri: string, range: LspRange, newText: string) {
const edit = new WorkspaceEdit()
edit.replace(
Uri.parse(uri),
new Range(
new Position(range.start.line, range.start.character),
new Position(range.end.line, range.end.character),
),
newText,
)
await workspace.applyEdit(edit)
}
9 changes: 5 additions & 4 deletions extensions/vscode/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { createLabsInfo } from '@volar/vscode'
import { ADD_TO_IGNORE_COMMAND } from 'npmx-shared/commands'
import { ADD_TO_IGNORE_COMMAND, REPLACE_TEXT_COMMAND } from 'npmx-shared/commands'
import { commands, displayName, version } from 'npmx-shared/meta'
import { defineExtension, useCommand, useCommands } from 'reactive-vscode'
import { defineExtension, useCommands } from 'reactive-vscode'
import { Uri } from 'vscode'
import { launch } from './client'
import { addToIgnore } from './commands/add-to-ignore'
import { openFileInNpmx } from './commands/open-file-in-npmx'
import { openInBrowser } from './commands/open-in-browser'
import { replaceText } from './commands/replace-text'
import { useDecorators } from './providers/decorators'
import { logger } from './state'

Expand All @@ -19,11 +20,11 @@ export const { activate, deactivate } = defineExtension((ctx) => {

useDecorators(client)

useCommand(ADD_TO_IGNORE_COMMAND, addToIgnore)

useCommands({
[commands.openInBrowser]: openInBrowser,
[commands.openFileInNpmx]: openFileInNpmx,
[ADD_TO_IGNORE_COMMAND]: addToIgnore,
[REPLACE_TEXT_COMMAND]: replaceText,
})

logger.info(`${displayName} Activated, v${version}`)
Expand Down
2 changes: 2 additions & 0 deletions packages/language-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { create as createNpmxDiagnosticsService } from './plugins/diagnostics'
import { create as createNpmxDocumentLinkService } from './plugins/document-link'
import { create as createNpmxHoverService } from './plugins/hover'
import { create as createNpmxVersionCompletionService } from './plugins/version-completion'
import { create as createNpmxVersionLensService } from './plugins/version-lens'

export function createNpmxLanguageServicePlugins(workspace: IWorkspaceState): LanguageServicePlugin[] {
return [
Expand All @@ -13,5 +14,6 @@ export function createNpmxLanguageServicePlugins(workspace: IWorkspaceState): La
createNpmxDocumentLinkService(workspace),
createNpmxHoverService(workspace),
createNpmxVersionCompletionService(workspace),
createNpmxVersionLensService(workspace),
]
}
124 changes: 124 additions & 0 deletions packages/language-service/src/plugins/version-lens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { CodeLens, LanguageServicePlugin, LanguageServicePluginInstance } from '@volar/language-service'
import type { OffsetRange } from 'npmx-language-core/types'
import type { IWorkspaceState } from '../types'
import type { UpgradeTier } from '../utils/version'
import { isDependencyFile } from 'npmx-language-core/utils'
import { REPLACE_TEXT_COMMAND } from 'npmx-shared/commands'
import { URI } from 'vscode-uri'
import { getConfig } from '../config'
import { formatUpgradeVersion, resolveUpgradeTiers } from '../utils/version'
import { resolveUpgrade } from './diagnostics/rules/upgrade'

interface LenData {
uri: string
specRange: OffsetRange
tier?: UpgradeTier
}

export function create(workspaceState: IWorkspaceState): LanguageServicePlugin {
const UNKNOWN_COMMAND: CodeLens['command'] = { title: '$(question) unknown', command: '' }

return {
name: 'npmx-version-lens',
capabilities: {
codeLensProvider: {
resolveProvider: true,
},
},
create(context): LanguageServicePluginInstance {
async function resolveVersionLensCommand({ uri, specRange, tier }: LenData, range: CodeLens['range']): Promise<CodeLens['command']> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n packages/language-service/src/plugins/version-lens.ts | head -150

Repository: npmx-dev/vscode-npmx

Length of output: 5201


Remove the as LenData cast and validate lens.data before destructuring.

Line 118 uses an unchecked type cast that bypasses validation. If lens.data is missing or malformed, the destructuring at line 29 will throw instead of degrading gracefully. Implement a validation function and guard the call:

Suggested implementation
         async resolveCodeLens(lens): Promise<CodeLens> {
-          const command = await resolveVersionLensCommand(lens.data as LenData, lens.range)
+          if (!isLenData(lens.data))
+            return { ...lens, command: UNKNOWN_COMMAND }
+          const command = await resolveVersionLensCommand(lens.data, lens.range)
           return { ...lens, command }
         },
function isLenData(value: unknown): value is LenData {
  if (typeof value !== 'object' || value === null)
    return false
  if (!('uri' in value) || typeof value.uri !== 'string')
    return false
  if (!('specRange' in value) || !Array.isArray(value.specRange) || value.specRange.length !== 2)
    return false
  if (typeof value.specRange[0] !== 'number' || typeof value.specRange[1] !== 'number')
    return false
  if (!('tier' in value) || value.tier === undefined)
    return true
  return typeof value.tier === 'object'
    && value.tier !== null
    && 'type' in value.tier
    && typeof value.tier.type === 'string'
    && 'version' in value.tier
    && typeof value.tier.version === 'string'
}

This aligns with the coding guideline: "Avoid as type casts—validate instead in TypeScript".

const dependencies = await workspaceState.getResolvedDependencies(uri)
const dep = dependencies?.find(
(d) => d.specRange[0] === specRange[0] && d.specRange[1] === specRange[1],
)
if (!dep)
return UNKNOWN_COMMAND

const pkg = await dep.packageInfo()
if (!pkg)
return UNKNOWN_COMMAND

const resolvedVersion = await dep.resolvedVersion()
if (!resolvedVersion)
return UNKNOWN_COMMAND

if (tier) {
const formatted = formatUpgradeVersion(dep, tier.version)
return {
title: `$(arrow-up) ${formatted} (${tier.type})`,
command: REPLACE_TEXT_COMMAND,
arguments: [uri, range, formatted],
}
}

const ignoreList = await getConfig(context, 'npmx.ignore.upgrade')
const targetVersion = resolveUpgrade(dep, pkg, resolvedVersion, ignoreList)
if (!targetVersion)
return { title: '$(check) latest', command: '' }

return {
title: `$(arrow-up) ${targetVersion}`,
command: REPLACE_TEXT_COMMAND,
arguments: [uri, range, targetVersion],
}
Comment on lines +54 to +63
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't short-circuit the fallback path with hideWhenLatest.

Line 107 hides every dependency that reaches the non-tiered branch, so the fallback logic on Lines 54-63 never runs for those cases. That suppresses the fallback lens entirely and also hides the unknown state when metadata is still missing; hideWhenLatest should only hide confirmed latest cases.

Suggested fix
           const dependencies = await workspaceState.getResolvedDependencies(document.uri)
           if (!dependencies)
             return []

           const lenses: CodeLens[] = []
+          const hideWhenLatest = await getConfig(context, 'npmx.versionLens.hideWhenLatest')
+          const ignoreList = await getConfig(context, 'npmx.ignore.upgrade')

           for (const dep of dependencies) {
             if (dep.resolvedProtocol !== 'npm' || dep.category === 'peerDependencies')
               continue
@@
-            const hideWhenLatest = await getConfig(context, 'npmx.versionLens.hideWhenLatest')
-            if (hideWhenLatest)
+            if (pkg && resolvedVersion && hideWhenLatest && !resolveUpgrade(dep, pkg, resolvedVersion, ignoreList))
               continue

             lenses.push({ range, data: baseData })
           }

Also applies to: 79-109

}

return {
async provideCodeLenses(document): Promise<CodeLens[]> {
if (!await getConfig(context, 'npmx.versionLens.enabled'))
return []

const uri = URI.parse(document.uri)
if (uri.scheme !== 'file' || !isDependencyFile(uri.path))
return []

const dependencies = await workspaceState.getResolvedDependencies(document.uri)
if (!dependencies)
return []

const lenses: CodeLens[] = []
const hideWhenLatest = await getConfig(context, 'npmx.versionLens.hideWhenLatest')

for (const dep of dependencies) {
if (dep.resolvedProtocol !== 'npm' || dep.category === 'peerDependencies')
continue

const range = {
start: document.positionAt(dep.specRange[0]),
end: document.positionAt(dep.specRange[1]),
}
const baseData: LenData = { uri: document.uri, specRange: dep.specRange }

const pkg = await dep.packageInfo()
const resolvedVersion = await dep.resolvedVersion()

if (pkg && resolvedVersion) {
const tiers = resolveUpgradeTiers(pkg, resolvedVersion)
if (tiers.length > 0) {
for (const tier of tiers) {
lenses.push({
range,
data: { ...baseData, tier } satisfies LenData,
})
}
continue
}
}

if (hideWhenLatest)
continue

lenses.push({ range, data: baseData })
}

return lenses
},

async resolveCodeLens(lens): Promise<CodeLens> {
const command = await resolveVersionLensCommand(lens.data as LenData, lens.range)
return { ...lens, command }
},
}
},
}
}
41 changes: 40 additions & 1 deletion packages/language-service/src/utils/version.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PackageInfo } from 'npmx-language-core/api/package'
import type { DependencyInfo } from 'npmx-language-core/workspace'
import { describe, expect, it } from 'vitest'
import { formatUpgradeVersion } from './version'
import { formatUpgradeVersion, resolveUpgradeTiers } from './version'

describe('formatUpgradeVersion', () => {
it.each([
Expand All @@ -23,3 +24,41 @@ describe('formatUpgradeVersion', () => {
).toBe(expected)
})
})

function createPkg(versions: string[]): PackageInfo {
const versionsMeta: Record<string, object> = {}
for (const v of versions)
versionsMeta[v] = {}
return { versionsMeta, distTags: { latest: versions.at(-1)! } } as PackageInfo
}

describe('resolveUpgradeTiers', () => {
it('returns all three tiers', () => {
const pkg = createPkg(['1.0.0', '1.0.1', '1.0.2', '1.1.0', '1.2.0', '2.0.0', '3.0.0'])
expect(resolveUpgradeTiers(pkg, '1.0.0')).toEqual([
{ type: 'patch', version: '1.0.2' },
{ type: 'minor', version: '1.2.0' },
{ type: 'major', version: '3.0.0' },
])
})

it('returns only patch and minor when no major upgrade exists', () => {
const pkg = createPkg(['1.0.0', '1.0.3', '1.1.0'])
expect(resolveUpgradeTiers(pkg, '1.0.0')).toEqual([
{ type: 'patch', version: '1.0.3' },
{ type: 'minor', version: '1.1.0' },
])
})

it('returns empty when already on latest', () => {
const pkg = createPkg(['1.0.0', '1.0.1'])
expect(resolveUpgradeTiers(pkg, '1.0.1')).toEqual([])
})

it('skips prerelease versions', () => {
const pkg = createPkg(['1.0.0', '1.0.1', '2.0.0-beta.1'])
expect(resolveUpgradeTiers(pkg, '1.0.0')).toEqual([
{ type: 'patch', version: '1.0.1' },
])
})
})
46 changes: 46 additions & 0 deletions packages/language-service/src/utils/version.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { PackageInfo } from 'npmx-language-core/api/package'
import type { DependencyInfo } from 'npmx-language-core/workspace'
import { formatPackageId } from 'npmx-language-core/utils'
import SemVer from 'semver/classes/semver'
import gt from 'semver/functions/gt'

const RANGE_PREFIXES = ['>=', '<=', '=', '>', '<']

Expand Down Expand Up @@ -45,3 +48,46 @@ export function formatUpgradeVersion(dep: DependencyInfo, target: string): strin

return `${declaredProtocol}:${formatPackageId(resolvedName, result)}`
}

export type UpgradeType = 'major' | 'minor' | 'patch'

export interface UpgradeTier {
type: UpgradeType
version: string
}

export function resolveUpgradeTiers(pkg: PackageInfo, resolvedVersion: string): UpgradeTier[] {
const current = new SemVer(resolvedVersion)
const currentMajor = current.major
const currentMinor = current.minor

let maxPatch: SemVer | undefined
let maxMinor: SemVer | undefined
let maxMajor: SemVer | undefined

for (const v of Object.keys(pkg.versionsMeta)) {
const parsed = new SemVer(v, { loose: true })
if (parsed.prerelease.length > 0 || !gt(parsed, current))
continue

if (parsed.major === currentMajor && parsed.minor === currentMinor) {
if (!maxPatch || gt(parsed, maxPatch))
maxPatch = parsed
} else if (parsed.major === currentMajor) {
if (!maxMinor || gt(parsed, maxMinor))
maxMinor = parsed
} else {
if (!maxMajor || gt(parsed, maxMajor))
maxMajor = parsed
}
}

const tiers: UpgradeTier[] = []
if (maxPatch)
tiers.push({ type: 'patch', version: maxPatch.version })
if (maxMinor)
tiers.push({ type: 'minor', version: maxMinor.version })
if (maxMajor)
tiers.push({ type: 'major', version: maxMajor.version })
return tiers
}
1 change: 1 addition & 0 deletions packages/shared/src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { displayName } from './meta'

export const ADD_TO_IGNORE_COMMAND = `${displayName}.addToIgnore`
export const REPLACE_TEXT_COMMAND = `${displayName}.replaceText`
Loading