diff --git a/app/composables/useFacetSelection.ts b/app/composables/useFacetSelection.ts index 92875037e9..3d77293fde 100644 --- a/app/composables/useFacetSelection.ts +++ b/app/composables/useFacetSelection.ts @@ -89,6 +89,11 @@ export function useFacetSelection(queryParam = 'facets') { description: t(`compare.facets.items.deprecated.description`), chartable: false, }, + healthScore: { + label: t(`compare.facets.items.healthScore.label`), + description: t(`compare.facets.items.healthScore.description`), + chartable: true, + }, }), ) diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts index 760f7ce08c..8c1a9b6df1 100644 --- a/app/composables/usePackageComparison.ts +++ b/app/composables/usePackageComparison.ts @@ -49,6 +49,8 @@ export interface PackageComparisonData { } /** Whether this is a binary-only package (CLI without library entry points) */ isBinaryOnly?: boolean + /** Computed health score (0-100) */ + healthScore?: number /** Marks this as the "no dependency" column for special display */ isNoDependency?: boolean } @@ -160,7 +162,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { package: { name: pkgData.name, version: latestVersion, - description: undefined, + description: pkgData.description, }, downloads: downloads?.downloads, packageSize, @@ -191,11 +193,12 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { }), ) - // Add results to cache + // Add results to cache (with computed health score) const newCache = new Map(cache.value) for (const [i, name] of namesToFetch.entries()) { const data = results[i] if (data) { + data.healthScore = computeHealthScore(data) newCache.set(name, data) } } @@ -338,6 +341,59 @@ function resolveNoDependencyDisplay( } } +/** + * Compute a health score (0-100) from already-fetched package data. + * Dimensions: Maintenance (35%), Quality (30%), Security (20%), Popularity (15%) + */ +export function computeHealthScore(data: PackageComparisonData): number { + // Deprecated packages get score 0 regardless + if (data.metadata?.deprecated) return 0 + + // MAINTENANCE (35%) — based on lastUpdated + let maintenance = 0 + if (data.metadata?.lastUpdated) { + const daysSince = Math.floor( + (Date.now() - new Date(data.metadata.lastUpdated).getTime()) / 86400000, + ) + if (daysSince < 30) maintenance = 100 + else if (daysSince < 90) maintenance = 80 + else if (daysSince < 180) maintenance = 60 + else if (daysSince < 365) maintenance = 40 + else maintenance = 10 + } + + // QUALITY (30%) — based on analysis data + let quality = 0 + if (data.analysis) { + if (data.analysis.types?.kind === 'included' || data.analysis.types?.kind === '@types') + quality += 40 + if (data.analysis.moduleFormat === 'esm' || data.analysis.moduleFormat === 'dual') quality += 30 + } + if (data.metadata?.license) quality += 20 + if (data.package.description) quality += 10 + + // SECURITY (20%) — based on vulnerability severity + let security = 100 + if (data.vulnerabilities) { + if (data.vulnerabilities.severity.critical > 0) security = 0 + else if (data.vulnerabilities.severity.high > 0) security = 25 + else if (data.vulnerabilities.severity.moderate > 0) security = 50 + else if (data.vulnerabilities.count > 0) security = 75 + } + + // POPULARITY (15%) — based on weekly downloads + let popularity = 0 + const dl = data.downloads ?? 0 + if (dl > 1_000_000) popularity = 100 + else if (dl > 100_000) popularity = 80 + else if (dl > 10_000) popularity = 60 + else if (dl > 1_000) popularity = 40 + else if (dl > 100) popularity = 20 + else popularity = 5 + + return Math.round(maintenance * 0.35 + quality * 0.3 + security * 0.2 + popularity * 0.15) +} + function computeFacetValue( facet: ComparisonFacet, data: PackageComparisonData, @@ -538,6 +594,16 @@ function computeFacetValue( status: totalDepCount > 50 ? 'warning' : 'neutral', } } + case 'healthScore': { + const score = data.healthScore + if (score === undefined) return null + return { + raw: score, + display: `${score}/100`, + status: score >= 80 ? 'good' : score >= 60 ? 'warning' : 'bad', + tooltip: t('compare.facets.items.healthScore.tooltip'), + } + } default: { return null } diff --git a/i18n/locales/ar-EG.json b/i18n/locales/ar-EG.json index 17d1cb6a35..7ee65aafce 100644 --- a/i18n/locales/ar-EG.json +++ b/i18n/locales/ar-EG.json @@ -277,7 +277,14 @@ "select_all_category_facets": "تحديد جميع أوجه الفئة", "deselect_all_category_facets": "إلغاء تحديد جميع أوجه الفئة", "selected_all_category_facets": "تم تحديد جميع أوجه الفئة", - "deselected_all_category_facets": "تم إلغاء تحديد جميع أوجه الفئة" + "deselected_all_category_facets": "تم إلغاء تحديد جميع أوجه الفئة", + "items": { + "healthScore": { + "label": "درجة الصحة", + "description": "الصحة الإجمالية للحزمة بناءً على الصيانة والجودة والأمان والشعبية", + "tooltip": "درجة من 0 إلى 100. الحزم المهملة تحصل على 0." + } + } }, "file_changes": "تغييرات الملفات", "files_count": "{count} ملفات | ملف واحد | ملفان | {count} ملفات | {count} ملفًا | {count} ملف", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 0dd3ea035e..bea641bd2f 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1200,6 +1200,11 @@ "vulnerabilities": { "label": "Vulnerabilities", "description": "Known security vulnerabilities" + }, + "healthScore": { + "label": "Health Score", + "description": "Overall package health based on maintenance, quality, security and popularity", + "tooltip": "Score 0–100. Deprecated packages score 0." } }, "values": { diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index f6c17d5b9c..d7ee1f856c 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -1196,6 +1196,11 @@ "vulnerabilities": { "label": "Vulnérabilités", "description": "Vulnérabilités de sécurité connues" + }, + "healthScore": { + "label": "Score de santé", + "description": "Santé globale du paquet basée sur la maintenance, la qualité, la sécurité et la popularité", + "tooltip": "Score 0–100. Les paquets dépréciés obtiennent 0." } }, "values": { diff --git a/i18n/schema.json b/i18n/schema.json index 9c831eccde..9deaa2a44a 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -3606,6 +3606,21 @@ } }, "additionalProperties": false + }, + "healthScore": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "tooltip": { + "type": "string" + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/shared/types/comparison.ts b/shared/types/comparison.ts index 9dfe229eb9..b9f3900357 100644 --- a/shared/types/comparison.ts +++ b/shared/types/comparison.ts @@ -17,6 +17,7 @@ export type ComparisonFacet = | 'totalDependencies' | 'deprecated' | 'totalLikes' + | 'healthScore' /** Facet metadata for UI display */ export interface FacetInfo { @@ -56,6 +57,9 @@ export const FACET_INFO: Record> = { deprecated: { category: 'health', }, + healthScore: { + category: 'health', + }, // Compatibility engines: { category: 'compatibility', diff --git a/test/nuxt/components/compare/FacetSelector.spec.ts b/test/nuxt/components/compare/FacetSelector.spec.ts index 623857f07c..53ba23ec1b 100644 --- a/test/nuxt/components/compare/FacetSelector.spec.ts +++ b/test/nuxt/components/compare/FacetSelector.spec.ts @@ -32,6 +32,10 @@ const facetLabels: Record = { @@ -100,6 +104,16 @@ vi.mock('@vueuse/router', () => ({ useRouteQuery: () => ref(''), })) +function findCategoryActionButton( + component: Awaited>, + category: string, + action: 'all' | 'none', +) { + return component.find( + `button[data-facet-category="${category}"][data-facet-category-action="${action}"]`, + ) +} + describe('FacetSelector', () => { beforeEach(() => { mockSelectedFacets.value = ['downloads', 'types'] @@ -232,16 +246,6 @@ describe('FacetSelector', () => { }) describe('category all/none buttons', () => { - function findCategoryActionButton( - component: Awaited>, - category: string, - action: 'all' | 'none', - ) { - return component.find( - `button[data-facet-category="${category}"][data-facet-category-action="${action}"]`, - ) - } - it('calls selectCategory when all button is clicked', async () => { const component = await mountSuspended(FacetSelector) diff --git a/test/nuxt/composables/use-package-comparison.spec.ts b/test/nuxt/composables/use-package-comparison.spec.ts index bf3710c9b1..5b1eafd2f6 100644 --- a/test/nuxt/composables/use-package-comparison.spec.ts +++ b/test/nuxt/composables/use-package-comparison.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mountSuspended } from '@nuxt/test-utils/runtime' import type { PackageComparisonData } from '~/composables/usePackageComparison' +import { computeHealthScore } from '~/composables/usePackageComparison' /** * Helper to test usePackageComparison by wrapping it in a component. @@ -41,6 +42,14 @@ async function usePackageComparisonInComponent(packageNames: string[]) { } } +function makeData(overrides: Partial = {}): PackageComparisonData { + return { + package: { name: 'test', version: '1.0.0' }, + directDeps: 2, + ...overrides, + } +} + describe('usePackageComparison', () => { afterEach(() => { vi.unstubAllGlobals() @@ -127,6 +136,94 @@ describe('usePackageComparison', () => { }) }) + describe('computeHealthScore', () => { + it('returns score 0 for deprecated packages', () => { + const score = computeHealthScore(makeData({ metadata: { deprecated: 'Use something else' } })) + expect(score).toBe(0) + }) + + it('returns high score for a perfect package', () => { + const score = computeHealthScore( + makeData({ + package: { name: 'test', version: '1.0.0', description: 'A test package' }, + metadata: { + lastUpdated: new Date().toISOString(), + license: 'MIT', + }, + downloads: 2_000_000, + directDeps: 1, + analysis: { + package: 'test', + version: '1.0.0', + moduleFormat: 'esm', + types: { kind: 'included' }, + devDependencySuggestion: { recommended: false }, + } as PackageComparisonData['analysis'], + vulnerabilities: { + count: 0, + severity: { critical: 0, high: 0, moderate: 0, low: 0 }, + }, + }), + ) + expect(score).toBeGreaterThanOrEqual(85) + }) + + it('sets security to 0 for critical vulnerabilities', () => { + const safe = computeHealthScore( + makeData({ + vulnerabilities: { + count: 0, + severity: { critical: 0, high: 0, moderate: 0, low: 0 }, + }, + }), + ) + const critical = computeHealthScore( + makeData({ + vulnerabilities: { + count: 1, + severity: { critical: 1, high: 0, moderate: 0, low: 0 }, + }, + }), + ) + expect(safe).toBeGreaterThan(critical) + }) + + it('handles missing/undefined fields gracefully', () => { + const score = computeHealthScore(makeData()) + expect(typeof score).toBe('number') + expect(score).toBeGreaterThanOrEqual(0) + expect(score).toBeLessThanOrEqual(100) + }) + + it('applies lower maintenance score for packages older than 1 year', () => { + const old = new Date() + old.setFullYear(old.getFullYear() - 2) + const score = computeHealthScore(makeData({ metadata: { lastUpdated: old.toISOString() } })) + expect(score).toBeGreaterThanOrEqual(0) + expect(score).toBeLessThanOrEqual(100) + }) + + it('applies partial security score for high/moderate vulnerabilities', () => { + const high = computeHealthScore( + makeData({ + vulnerabilities: { count: 1, severity: { critical: 0, high: 1, moderate: 0, low: 0 } }, + }), + ) + const moderate = computeHealthScore( + makeData({ + vulnerabilities: { count: 1, severity: { critical: 0, high: 0, moderate: 1, low: 0 } }, + }), + ) + const low = computeHealthScore( + makeData({ + vulnerabilities: { count: 1, severity: { critical: 0, high: 0, moderate: 0, low: 0 } }, + }), + ) + expect(high).toBeLessThan(low) + expect(moderate).toBeGreaterThan(high) + }) + }) + describe('staleness detection', () => { it('marks packages not published in 2+ years as stale', async () => { vi.stubGlobal(