From 904a75dfc9b5ac3f26342165dcc7bff7f2d53e27 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 31 Jan 2026 21:58:20 +0800 Subject: [PATCH 1/6] feat: show deprecation diagnostics --- src/constants.ts | 7 ++- src/index.ts | 15 ++++- src/providers/diagnostics/deprecation.ts | 72 ++++++++++++++++++++++++ src/utils/npm.ts | 2 + 4 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 src/providers/diagnostics/deprecation.ts diff --git a/src/constants.ts b/src/constants.ts index 70dbe4b..5e8d534 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,8 @@ -export const PACKAGE_JSON_PATTERN = '**/package.json' -export const PNPM_WORKSPACE_PATTERN = '**/pnpm-workspace.yaml' +export const PACKAGE_JSON_BASENAME = 'package.json' +export const PNPM_WORKSPACE_BASENAME = 'pnpm-workspace.yaml' + +export const PACKAGE_JSON_PATTERN = `**/${PACKAGE_JSON_BASENAME}` +export const PNPM_WORKSPACE_PATTERN = `**/${PNPM_WORKSPACE_BASENAME}` export const VERSION_TRIGGER_CHARACTERS = ['.', '^', '~', ...Array.from({ length: 10 }).map((_, i) => `${i}`)] diff --git a/src/index.ts b/src/index.ts index 5b6a0a4..d2aed7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,17 @@ -import { PACKAGE_JSON_PATTERN, PNPM_WORKSPACE_PATTERN, VERSION_TRIGGER_CHARACTERS } from '#constants' +import { + PACKAGE_JSON_BASENAME, + PACKAGE_JSON_PATTERN, + PNPM_WORKSPACE_BASENAME, + PNPM_WORKSPACE_PATTERN, + VERSION_TRIGGER_CHARACTERS, +} from '#constants' import { defineExtension } from 'reactive-vscode' import { languages } from 'vscode' import { JsonExtractor } from './extractors/json' import { YamlExtractor } from './extractors/yaml' import { displayName, version } from './generated-meta' import { VersionCompletionItemProvider } from './providers/completion-item/version' +import { useDeprecationDiagnostics } from './providers/diagnostics/deprecation' import { NpmxDocumentLinkProvider } from './providers/document-link/npmx' import { config, logger } from './state' @@ -39,4 +46,10 @@ export const { activate, deactivate } = defineExtension((ctx) => { ), ) } + + // Deprecation diagnostics with reactive-vscode + useDeprecationDiagnostics({ + [PACKAGE_JSON_BASENAME]: jsonExtractor, + [PNPM_WORKSPACE_BASENAME]: yamlExtractor, + }) }) diff --git a/src/providers/diagnostics/deprecation.ts b/src/providers/diagnostics/deprecation.ts new file mode 100644 index 0000000..04e78bf --- /dev/null +++ b/src/providers/diagnostics/deprecation.ts @@ -0,0 +1,72 @@ +import type { Extractor } from '#types/extractor' +import type { TextDocument } from 'vscode' +import { basename } from 'node:path' +import { logger } from '#state' +import { getPackageInfo } from '#utils/npm' +import { useActiveTextEditor, useDocumentText, watch } from 'reactive-vscode' +import { Diagnostic, DiagnosticSeverity, languages } from 'vscode' + +function extractVersion(versionRange: string): string { + return versionRange.replace(/^[\^~]/, '') +} + +export function useDeprecationDiagnostics(mapping: Record) { + const diagnosticCollection = languages.createDiagnosticCollection('npmx') + + const activeEditor = useActiveTextEditor() + const activeDocumentText = useDocumentText(() => activeEditor.value?.document) + + async function checkDeprecations(document: TextDocument, extractor: Extractor) { + diagnosticCollection.delete(document.uri) + + const root = extractor.parse(document) + if (!root) { + return + } + + const dependencies = extractor.getDependenciesInfo(root) + const diagnostics: Diagnostic[] = [] + + await Promise.all( + dependencies.map(async ({ versionNode, name, version }) => { + try { + const pkg = await getPackageInfo(name) + const exactVersion = extractVersion(version) + const versionInfo = pkg.versions[exactVersion] + + if (!versionInfo?.deprecated) + return + + const range = extractor.getNodeRange(document, versionNode) + const diagnostic = new Diagnostic( + range, + `${name} v${exactVersion} has been deprecated: ${versionInfo.deprecated}`, + DiagnosticSeverity.Error, + ) + diagnostic.source = 'npmx' + diagnostics.push(diagnostic) + } catch (err) { + logger.warn(`Failed to check ${name}: ${err}`) + } + }), + ) + + if (diagnostics.length > 0) + logger.info(`Found ${diagnostics.length} deprecated packages in ${basename(document.fileName)}`) + + diagnosticCollection.set(document.uri, diagnostics) + } + + watch(activeDocumentText, async () => { + const editor = activeEditor.value + if (!editor) + return + + const document = editor.document + const filename = basename(document.fileName) + const extractor = mapping[filename] + + if (extractor) + await checkDeprecations(document, extractor) + }, { immediate: true }) +} diff --git a/src/utils/npm.ts b/src/utils/npm.ts index 35ca05a..90eb5ae 100644 --- a/src/utils/npm.ts +++ b/src/utils/npm.ts @@ -6,6 +6,7 @@ import { ofetch } from 'ofetch' interface ResolvedPackumentVersion extends Pick { tag?: string hasProvenance: boolean + deprecated?: string } interface ResolvedPackument { @@ -42,6 +43,7 @@ const cache = new LRUCache({ version: v, // @ts-expect-error present if published with provenance hasProvenance: !!pkg.versions[v].dist.attestations, + deprecated: pkg.versions[v].deprecated, }, ]), ) From 9e04b84b87bc36b6bb23b456487c81dbfb70089b Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 31 Jan 2026 21:58:47 +0800 Subject: [PATCH 2/6] update --- src/providers/completion-item/version.ts | 2 +- src/providers/document-link/npmx.ts | 2 +- src/types/extractor.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 51924b5..4b1b0e4 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -15,7 +15,7 @@ function extractVersionPrefix(v: string) { return valid ? firstChar : '' } -export class VersionCompletionItemProvider> implements CompletionItemProvider { +export class VersionCompletionItemProvider implements CompletionItemProvider { extractor: T constructor(extractor: T) { diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index 8ef9274..4f7187f 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -2,7 +2,7 @@ import type { Extractor } from '#types/extractor' import type { DocumentLinkProvider, TextDocument } from 'vscode' import { DocumentLink, Uri } from 'vscode' -export class NpmxDocumentLinkProvider> implements DocumentLinkProvider { +export class NpmxDocumentLinkProvider implements DocumentLinkProvider { extractor: T constructor(extractor: T) { diff --git a/src/types/extractor.ts b/src/types/extractor.ts index 2af10bd..0c0f91b 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -7,7 +7,7 @@ export interface DependencyInfo { version: string } -export interface Extractor { +export interface Extractor { parse: (document: TextDocument) => T | null | undefined getNodeRange: (document: TextDocument, node: T) => Range From 253350b1580644bd9216a43d589c9fb7369159b4 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 31 Jan 2026 21:58:56 +0800 Subject: [PATCH 3/6] log --- src/utils/npm.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/npm.ts b/src/utils/npm.ts index 90eb5ae..a561142 100644 --- a/src/utils/npm.ts +++ b/src/utils/npm.ts @@ -1,5 +1,6 @@ import type { Packument, PackumentVersion } from '@npm/types' import { NPM_REGISTRY } from '#constants' +import { logger } from '#state' import { LRUCache } from 'lru-cache' import { ofetch } from 'ofetch' @@ -32,6 +33,7 @@ const cache = new LRUCache({ fetchMethod: async (name, staleValue, { signal }) => { const encodedName = encodePackageName(name) + logger.info(`fetching ${name}...`) const pkg = await ofetch(`${NPM_REGISTRY}/${encodedName}`, { signal }) const resolvedVersions = Object.fromEntries( From f57b9d6d05b512f27add13803a3ece5e86021c1d Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 31 Jan 2026 22:01:25 +0800 Subject: [PATCH 4/6] #utils/version --- src/providers/completion-item/version.ts | 12 +----------- src/providers/diagnostics/deprecation.ts | 5 +---- src/utils/version.ts | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 15 deletions(-) create mode 100644 src/utils/version.ts diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 4b1b0e4..e8ad917 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -2,19 +2,9 @@ import type { Extractor } from '#types/extractor' import type { CompletionItemProvider, Position, TextDocument } from 'vscode' import { config } from '#state' import { getPackageInfo } from '#utils/npm' +import { extractVersionPrefix } from '#utils/version' import { CompletionItem, CompletionItemKind } from 'vscode' -function isVersionPrefix(c: string) { - return c === '^' || c === '~' -} - -function extractVersionPrefix(v: string) { - const firstChar = v[0] - const valid = isVersionPrefix(firstChar) - - return valid ? firstChar : '' -} - export class VersionCompletionItemProvider implements CompletionItemProvider { extractor: T diff --git a/src/providers/diagnostics/deprecation.ts b/src/providers/diagnostics/deprecation.ts index 04e78bf..b602680 100644 --- a/src/providers/diagnostics/deprecation.ts +++ b/src/providers/diagnostics/deprecation.ts @@ -3,13 +3,10 @@ import type { TextDocument } from 'vscode' import { basename } from 'node:path' import { logger } from '#state' import { getPackageInfo } from '#utils/npm' +import { extractVersion } from '#utils/version' import { useActiveTextEditor, useDocumentText, watch } from 'reactive-vscode' import { Diagnostic, DiagnosticSeverity, languages } from 'vscode' -function extractVersion(versionRange: string): string { - return versionRange.replace(/^[\^~]/, '') -} - export function useDeprecationDiagnostics(mapping: Record) { const diagnosticCollection = languages.createDiagnosticCollection('npmx') diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 0000000..091300a --- /dev/null +++ b/src/utils/version.ts @@ -0,0 +1,14 @@ +export function isValidPrefix(c: string) { + return c === '^' || c === '~' +} + +export function extractVersionPrefix(v: string) { + const firstChar = v[0] + const valid = isValidPrefix(firstChar) + + return valid ? firstChar : '' +} + +export function extractVersion(versionRange: string): string { + return versionRange.replace(/^[\^~]/, '') +} From 3725150b7ad31f2dc27f72e7c1d2c49d78bcfd48 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 31 Jan 2026 22:32:01 +0800 Subject: [PATCH 5/6] refactor: more extendable --- src/index.ts | 5 +- src/providers/diagnostics/deprecation.ts | 69 ------------------ src/providers/diagnostics/index.ts | 73 +++++++++++++++++++ .../diagnostics/rules/deprecation.ts | 17 +++++ src/state.ts | 4 +- src/types/extractor.ts | 8 +- src/utils/data.ts | 4 +- src/utils/npm.ts | 2 +- 8 files changed, 103 insertions(+), 79 deletions(-) delete mode 100644 src/providers/diagnostics/deprecation.ts create mode 100644 src/providers/diagnostics/index.ts create mode 100644 src/providers/diagnostics/rules/deprecation.ts diff --git a/src/index.ts b/src/index.ts index d2aed7f..d96faca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import { JsonExtractor } from './extractors/json' import { YamlExtractor } from './extractors/yaml' import { displayName, version } from './generated-meta' import { VersionCompletionItemProvider } from './providers/completion-item/version' -import { useDeprecationDiagnostics } from './providers/diagnostics/deprecation' +import { registerDiagnosticCollection } from './providers/diagnostics' import { NpmxDocumentLinkProvider } from './providers/document-link/npmx' import { config, logger } from './state' @@ -47,8 +47,7 @@ export const { activate, deactivate } = defineExtension((ctx) => { ) } - // Deprecation diagnostics with reactive-vscode - useDeprecationDiagnostics({ + registerDiagnosticCollection({ [PACKAGE_JSON_BASENAME]: jsonExtractor, [PNPM_WORKSPACE_BASENAME]: yamlExtractor, }) diff --git a/src/providers/diagnostics/deprecation.ts b/src/providers/diagnostics/deprecation.ts deleted file mode 100644 index b602680..0000000 --- a/src/providers/diagnostics/deprecation.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Extractor } from '#types/extractor' -import type { TextDocument } from 'vscode' -import { basename } from 'node:path' -import { logger } from '#state' -import { getPackageInfo } from '#utils/npm' -import { extractVersion } from '#utils/version' -import { useActiveTextEditor, useDocumentText, watch } from 'reactive-vscode' -import { Diagnostic, DiagnosticSeverity, languages } from 'vscode' - -export function useDeprecationDiagnostics(mapping: Record) { - const diagnosticCollection = languages.createDiagnosticCollection('npmx') - - const activeEditor = useActiveTextEditor() - const activeDocumentText = useDocumentText(() => activeEditor.value?.document) - - async function checkDeprecations(document: TextDocument, extractor: Extractor) { - diagnosticCollection.delete(document.uri) - - const root = extractor.parse(document) - if (!root) { - return - } - - const dependencies = extractor.getDependenciesInfo(root) - const diagnostics: Diagnostic[] = [] - - await Promise.all( - dependencies.map(async ({ versionNode, name, version }) => { - try { - const pkg = await getPackageInfo(name) - const exactVersion = extractVersion(version) - const versionInfo = pkg.versions[exactVersion] - - if (!versionInfo?.deprecated) - return - - const range = extractor.getNodeRange(document, versionNode) - const diagnostic = new Diagnostic( - range, - `${name} v${exactVersion} has been deprecated: ${versionInfo.deprecated}`, - DiagnosticSeverity.Error, - ) - diagnostic.source = 'npmx' - diagnostics.push(diagnostic) - } catch (err) { - logger.warn(`Failed to check ${name}: ${err}`) - } - }), - ) - - if (diagnostics.length > 0) - logger.info(`Found ${diagnostics.length} deprecated packages in ${basename(document.fileName)}`) - - diagnosticCollection.set(document.uri, diagnostics) - } - - watch(activeDocumentText, async () => { - const editor = activeEditor.value - if (!editor) - return - - const document = editor.document - const filename = basename(document.fileName) - const extractor = mapping[filename] - - if (extractor) - await checkDeprecations(document, extractor) - }, { immediate: true }) -} diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts new file mode 100644 index 0000000..9a94997 --- /dev/null +++ b/src/providers/diagnostics/index.ts @@ -0,0 +1,73 @@ +import type { DependencyInfo, Extractor, ValidNode } from '#types/extractor' +import type { ResolvedPackument } from '#utils/npm' +import type { Diagnostic, TextDocument } from 'vscode' +import { basename } from 'node:path' +import { logger } from '#state' +import { getPackageInfo } from '#utils/npm' +import { useActiveTextEditor, useDocumentText, watch } from 'reactive-vscode' +import { languages } from 'vscode' +import { displayName } from '../../generated-meta' +import { checkDeprecations } from './rules/deprecation' + +export interface DiagnosticRuleDetails extends Pick { + node: ValidNode +} +export type DiagnosticRule = (dep: DependencyInfo, pkg: ResolvedPackument) => DiagnosticRuleDetails | undefined + +const rules: DiagnosticRule[] = [ + checkDeprecations, +] + +export function registerDiagnosticCollection(mapping: Record) { + const diagnosticCollection = languages.createDiagnosticCollection(displayName) + + const activeEditor = useActiveTextEditor() + const activeDocumentText = useDocumentText(() => activeEditor.value?.document) + + async function collectDiagnostics(document: TextDocument, extractor: Extractor) { + const root = extractor.parse(document) + if (!root) + return + + const dependencies = extractor.getDependenciesInfo(root) + const diagnostics: Diagnostic[] = [] + + await Promise.all( + dependencies.map(async (dep) => { + try { + const pkg = await getPackageInfo(dep.name) + + for (const rule of rules) { + const diagnostic = rule(dep, pkg) + + if (diagnostic) { + diagnostics.push({ + source: displayName, + message: diagnostic.message, + severity: diagnostic.severity, + range: extractor.getNodeRange(document, diagnostic.node), + }) + } + } + } catch (err) { + logger.warn(`Failed to check ${dep.name}: ${err}`) + } + }), + ) + + diagnosticCollection.set(document.uri, diagnostics) + } + + watch(activeDocumentText, async () => { + const editor = activeEditor.value + if (!editor) + return + + const document = editor.document + const filename = basename(document.fileName) + const extractor = mapping[filename] + + if (extractor) + await collectDiagnostics(document, extractor) + }, { immediate: true }) +} diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts new file mode 100644 index 0000000..57e912f --- /dev/null +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -0,0 +1,17 @@ +import type { DiagnosticRule } from '..' +import { extractVersion } from '#utils/version' +import { DiagnosticSeverity } from 'vscode' + +export const checkDeprecations: DiagnosticRule = (dep, pkg) => { + const exactVersion = extractVersion(dep.version) + const versionInfo = pkg.versions[exactVersion] + + if (!versionInfo?.deprecated) + return + + return { + node: dep.versionNode, + message: `${dep.name} v${exactVersion} has been deprecated: ${versionInfo.deprecated}`, + severity: DiagnosticSeverity.Error, + } +} diff --git a/src/state.ts b/src/state.ts index 7025bbd..ee3e1a6 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,10 +1,10 @@ import type { NestedScopedConfigs } from './generated-meta' import { defineConfigObject, defineLogger } from 'reactive-vscode' -import { scopedConfigs } from './generated-meta' +import { displayName, scopedConfigs } from './generated-meta' export const config = defineConfigObject( scopedConfigs.scope, scopedConfigs.defaults, ) -export const logger = defineLogger('npmx') +export const logger = defineLogger(displayName) diff --git a/src/types/extractor.ts b/src/types/extractor.ts index 0c0f91b..c66c635 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -1,13 +1,17 @@ +import type { Node as JsonNode } from 'jsonc-parser' import type { Range, TextDocument } from 'vscode' +import type { Node as YamlNode } from 'yaml' -export interface DependencyInfo { +export type ValidNode = JsonNode | YamlNode + +export interface DependencyInfo { nameNode: T versionNode: T name: string version: string } -export interface Extractor { +export interface Extractor { parse: (document: TextDocument) => T | null | undefined getNodeRange: (document: TextDocument, node: T) => Range diff --git a/src/utils/data.ts b/src/utils/data.ts index 0113fd4..ddd2670 100644 --- a/src/utils/data.ts +++ b/src/utils/data.ts @@ -1,4 +1,4 @@ -import type { Extractor } from '#types/extractor' +import type { Extractor, ValidNode } from '#types/extractor' import type { TextDocument } from 'vscode' import { createHash } from 'node:crypto' import { logger } from '#state' @@ -17,7 +17,7 @@ function computeHash(text: string) { return createHash('sha1').update(text).digest('hex') } -export function createCachedParse( +export function createCachedParse( parse: (text: string) => ReturnType['parse']>, ): Extractor['parse'] { return function (doc: TextDocument) { diff --git a/src/utils/npm.ts b/src/utils/npm.ts index a561142..5fd892e 100644 --- a/src/utils/npm.ts +++ b/src/utils/npm.ts @@ -10,7 +10,7 @@ interface ResolvedPackumentVersion extends Pick { deprecated?: string } -interface ResolvedPackument { +export interface ResolvedPackument { versions: Record } From ea9402cb07af345073a7fe34f50ee464ead01178 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 31 Jan 2026 23:25:50 +0800 Subject: [PATCH 6/6] rename --- src/providers/diagnostics/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 9a94997..87f294c 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -9,10 +9,10 @@ import { languages } from 'vscode' import { displayName } from '../../generated-meta' import { checkDeprecations } from './rules/deprecation' -export interface DiagnosticRuleDetails extends Pick { +export interface NodeDiagnosticInfo extends Pick { node: ValidNode } -export type DiagnosticRule = (dep: DependencyInfo, pkg: ResolvedPackument) => DiagnosticRuleDetails | undefined +export type DiagnosticRule = (dep: DependencyInfo, pkg: ResolvedPackument) => NodeDiagnosticInfo | undefined const rules: DiagnosticRule[] = [ checkDeprecations,