Skip to content
Merged
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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"husky": "^9.1.7",
"jsonc-parser": "^3.3.1",
"lru-cache": "^11.2.5",
"module-replacements": "^2.11.0",
"nano-staged": "^0.9.0",
"ofetch": "^2.0.0-alpha.3",
"reactive-vscode": "^0.4.1",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

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

1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export const PNPM_WORKSPACE_PATTERN = `**/${PNPM_WORKSPACE_BASENAME}`
export const VERSION_TRIGGER_CHARACTERS = ['.', '^', '~', ...Array.from({ length: 10 }).map((_, i) => `${i}`)]

export const NPM_REGISTRY = 'https://registry.npmjs.org'
export const NPMX_DEV_API = 'https://npmx.dev/api'
9 changes: 7 additions & 2 deletions src/providers/diagnostics/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DependencyInfo, Extractor, ValidNode } from '#types/extractor'
import type { ResolvedPackument } from '#utils/npm'
import type { Awaitable } from 'reactive-vscode'
import type { Diagnostic, TextDocument } from 'vscode'
import { basename } from 'node:path'
import { logger } from '#state'
Expand All @@ -8,14 +9,16 @@ import { useActiveTextEditor, useDocumentText, watch } from 'reactive-vscode'
import { languages } from 'vscode'
import { displayName } from '../../generated-meta'
import { checkDeprecation } from './rules/deprecation'
import { checkReplacement } from './rules/replacement'

export interface NodeDiagnosticInfo extends Pick<Diagnostic, 'message' | 'severity'> {
node: ValidNode
}
export type DiagnosticRule = (dep: DependencyInfo, pkg: ResolvedPackument) => NodeDiagnosticInfo | undefined
export type DiagnosticRule = (dep: DependencyInfo, pkg: ResolvedPackument) => Awaitable<NodeDiagnosticInfo | undefined>

const rules: DiagnosticRule[] = [
checkDeprecation,
checkReplacement,
]

export function registerDiagnosticCollection(mapping: Record<string, Extractor | undefined>) {
Expand All @@ -38,7 +41,7 @@ export function registerDiagnosticCollection(mapping: Record<string, Extractor |
const pkg = await getPackageInfo(dep.name)

for (const rule of rules) {
const diagnostic = rule(dep, pkg)
const diagnostic = await rule(dep, pkg)

if (diagnostic) {
diagnostics.push({
Expand All @@ -55,6 +58,8 @@ export function registerDiagnosticCollection(mapping: Record<string, Extractor |
}),
)

if (diagnostics.length > 0)
logger.info(`${diagnostics.length} diagnostic collected in ${document.fileName}.`)
diagnosticCollection.set(document.uri, diagnostics)
}

Expand Down
31 changes: 31 additions & 0 deletions src/providers/diagnostics/rules/replacement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ModuleReplacement } from 'module-replacements'
import type { DiagnosticRule } from '..'
import { getReplacement } from '#utils/replacement'
import { DiagnosticSeverity } from 'vscode'

// https://github.com/npmx-dev/npmx.dev/blob/main/app/components/PackageReplacement.vue#L8-L30
function generateMessage(replacement: ModuleReplacement) {
switch (replacement.type) {
case 'native':
return `This can be replaced with ${replacement.replacement}, available since Node ${replacement.nodeVersion}.`
case 'simple':
return `The community has flagged this package as redundant, with the advice: ${replacement.replacement}.`
case 'documented':
return 'The community has flagged this package as having more performant alternatives.'
case 'none':
return 'This package has been flagged as no longer needed, and its functionality is likely available natively in all engines.'
}
}

export const checkReplacement: DiagnosticRule = async (dep) => {
const replacement = await getReplacement(dep.name)
// Fallback for cache compatibility (LRUCache rejects null/undefined)
if (!replacement || !('type' in replacement))
return

return {
node: dep.nameNode,
message: generateMessage(replacement),
severity: DiagnosticSeverity.Warning,
}
}
4 changes: 2 additions & 2 deletions src/utils/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface ResolvedPackument {
* Encode a package name for use in npm registry URLs.
* Handles scoped packages (e.g., @scope/name -> @scope%2Fname).
*/
function encodePackageName(name: string): string {
export function encodePackageName(name: string): string {
if (name.startsWith('@')) {
return `@${encodeURIComponent(name.slice(1))}`
}
Expand All @@ -33,7 +33,7 @@ const cache = new LRUCache<string, ResolvedPackument>({
fetchMethod: async (name, staleValue, { signal }) => {
const encodedName = encodePackageName(name)

logger.info(`fetching ${name}...`)
logger.info(`fetching package info for ${name}...`)
const pkg = await ofetch<Packument>(`${NPM_REGISTRY}/${encodedName}`, { signal })

const resolvedVersions = Object.fromEntries(
Expand Down
29 changes: 29 additions & 0 deletions src/utils/replacement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ModuleReplacement } from 'module-replacements'
import { NPMX_DEV_API } from '#constants'
import { logger } from '#state'
import { LRUCache } from 'lru-cache'
import { ofetch } from 'ofetch'
import { encodePackageName } from './npm'

const cache = new LRUCache<string, ModuleReplacement>({
max: 500,
ttl: 60 * 60 * 1000,
updateAgeOnGet: true,
allowStale: true,
fetchMethod: async (name, staleValue, { signal }) => {
const encodedName = encodePackageName(name)

logger.info(`fetching replacement for ${name}...`)
try {
return await ofetch<ModuleReplacement>(`${NPMX_DEV_API}/replacements/${encodedName}`, { signal })
// Fallback for cache compatibility (LRUCache rejects null/undefined)
?? {}
} catch (err) {
logger.warn('fetching replacement error: ', err)
}
},
})

export async function getReplacement(name: string) {
return (await cache.fetch(name))!
}