Skip to content

Commit 1ffb1a6

Browse files
feat(compare): add health score facet
1 parent a54def4 commit 1ffb1a6

File tree

9 files changed

+184
-2
lines changed

9 files changed

+184
-2
lines changed

app/composables/useFacetSelection.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ export function useFacetSelection(queryParam = 'facets') {
8989
description: t(`compare.facets.items.deprecated.description`),
9090
chartable: false,
9191
},
92+
healthScore: {
93+
label: t(`compare.facets.items.healthScore.label`),
94+
description: t(`compare.facets.items.healthScore.description`),
95+
chartable: true,
96+
},
9297
}),
9398
)
9499

app/composables/usePackageComparison.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ export interface PackageComparisonData {
4949
}
5050
/** Whether this is a binary-only package (CLI without library entry points) */
5151
isBinaryOnly?: boolean
52+
/** Computed health score (0-100) */
53+
healthScore?: number
5254
/** Marks this as the "no dependency" column for special display */
5355
isNoDependency?: boolean
5456
}
@@ -191,11 +193,12 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
191193
}),
192194
)
193195

194-
// Add results to cache
196+
// Add results to cache (with computed health score)
195197
const newCache = new Map(cache.value)
196198
for (const [i, name] of namesToFetch.entries()) {
197199
const data = results[i]
198200
if (data) {
201+
data.healthScore = computeHealthScore(data)
199202
newCache.set(name, data)
200203
}
201204
}
@@ -338,6 +341,60 @@ function resolveNoDependencyDisplay(
338341
}
339342
}
340343

344+
/**
345+
* Compute a health score (0-100) from already-fetched package data.
346+
* Dimensions: Maintenance (35%), Quality (30%), Security (20%), Popularity (15%)
347+
*/
348+
export function computeHealthScore(data: PackageComparisonData): number {
349+
// Deprecated packages get score 0 regardless
350+
if (data.metadata?.deprecated) return 0
351+
352+
// MAINTENANCE (35%) — based on lastUpdated
353+
let maintenance = 0
354+
if (data.metadata?.lastUpdated) {
355+
const daysSince = Math.floor(
356+
(Date.now() - new Date(data.metadata.lastUpdated).getTime()) / 86400000,
357+
)
358+
if (daysSince < 30) maintenance = 100
359+
else if (daysSince < 90) maintenance = 80
360+
else if (daysSince < 180) maintenance = 60
361+
else if (daysSince < 365) maintenance = 40
362+
else maintenance = 10
363+
}
364+
365+
// QUALITY (30%) — based on analysis data
366+
let quality = 0
367+
if (data.analysis) {
368+
if (data.analysis.types?.kind === 'included' || data.analysis.types?.kind === '@types')
369+
quality += 40
370+
if (data.analysis.moduleFormat === 'esm' || data.analysis.moduleFormat === 'dual')
371+
quality += 30
372+
if (data.metadata?.license) quality += 20
373+
if (data.package.description) quality += 10
374+
}
375+
376+
// SECURITY (20%) — based on vulnerability severity
377+
let security = 100
378+
if (data.vulnerabilities) {
379+
if (data.vulnerabilities.severity.critical > 0) security = 0
380+
else if (data.vulnerabilities.severity.high > 0) security = 25
381+
else if (data.vulnerabilities.severity.moderate > 0) security = 50
382+
else if (data.vulnerabilities.count > 0) security = 75
383+
}
384+
385+
// POPULARITY (15%) — based on weekly downloads
386+
let popularity = 0
387+
const dl = data.downloads ?? 0
388+
if (dl > 1_000_000) popularity = 100
389+
else if (dl > 100_000) popularity = 80
390+
else if (dl > 10_000) popularity = 60
391+
else if (dl > 1_000) popularity = 40
392+
else if (dl > 100) popularity = 20
393+
else popularity = 5
394+
395+
return Math.round(maintenance * 0.35 + quality * 0.3 + security * 0.2 + popularity * 0.15)
396+
}
397+
341398
function computeFacetValue(
342399
facet: ComparisonFacet,
343400
data: PackageComparisonData,
@@ -538,6 +595,16 @@ function computeFacetValue(
538595
status: totalDepCount > 50 ? 'warning' : 'neutral',
539596
}
540597
}
598+
case 'healthScore': {
599+
const score = data.healthScore
600+
if (score === undefined) return null
601+
return {
602+
raw: score,
603+
display: `${score}/100`,
604+
status: score >= 80 ? 'good' : score >= 60 ? 'warning' : 'bad',
605+
tooltip: t('compare.facets.items.healthScore.tooltip'),
606+
}
607+
}
541608
default: {
542609
return null
543610
}

i18n/locales/ar-EG.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,14 @@
277277
"select_all_category_facets": "تحديد جميع أوجه الفئة",
278278
"deselect_all_category_facets": "إلغاء تحديد جميع أوجه الفئة",
279279
"selected_all_category_facets": "تم تحديد جميع أوجه الفئة",
280-
"deselected_all_category_facets": "تم إلغاء تحديد جميع أوجه الفئة"
280+
"deselected_all_category_facets": "تم إلغاء تحديد جميع أوجه الفئة",
281+
"items": {
282+
"healthScore": {
283+
"label": "درجة الصحة",
284+
"description": "الصحة الإجمالية للحزمة بناءً على الصيانة والجودة والأمان والشعبية",
285+
"tooltip": "درجة من 0 إلى 100. الحزم المهملة تحصل على 0."
286+
}
287+
}
281288
},
282289
"file_changes": "تغييرات الملفات",
283290
"files_count": "{count} ملفات | ملف واحد | ملفان | {count} ملفات | {count} ملفًا | {count} ملف",

i18n/locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,6 +1200,11 @@
12001200
"vulnerabilities": {
12011201
"label": "Vulnerabilities",
12021202
"description": "Known security vulnerabilities"
1203+
},
1204+
"healthScore": {
1205+
"label": "Health Score",
1206+
"description": "Overall package health based on maintenance, quality, security and popularity",
1207+
"tooltip": "Score 0–100. Deprecated packages score 0."
12031208
}
12041209
},
12051210
"values": {

i18n/locales/fr-FR.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,6 +1196,11 @@
11961196
"vulnerabilities": {
11971197
"label": "Vulnérabilités",
11981198
"description": "Vulnérabilités de sécurité connues"
1199+
},
1200+
"healthScore": {
1201+
"label": "Score de santé",
1202+
"description": "Santé globale du paquet basée sur la maintenance, la qualité, la sécurité et la popularité",
1203+
"tooltip": "Score 0–100. Les paquets dépréciés obtiennent 0."
11991204
}
12001205
},
12011206
"values": {

i18n/schema.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3606,6 +3606,21 @@
36063606
}
36073607
},
36083608
"additionalProperties": false
3609+
},
3610+
"healthScore": {
3611+
"type": "object",
3612+
"properties": {
3613+
"label": {
3614+
"type": "string"
3615+
},
3616+
"description": {
3617+
"type": "string"
3618+
},
3619+
"tooltip": {
3620+
"type": "string"
3621+
}
3622+
},
3623+
"additionalProperties": false
36093624
}
36103625
},
36113626
"additionalProperties": false

shared/types/comparison.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type ComparisonFacet =
1717
| 'totalDependencies'
1818
| 'deprecated'
1919
| 'totalLikes'
20+
| 'healthScore'
2021

2122
/** Facet metadata for UI display */
2223
export interface FacetInfo {
@@ -56,6 +57,9 @@ export const FACET_INFO: Record<ComparisonFacet, Omit<FacetInfo, 'id'>> = {
5657
deprecated: {
5758
category: 'health',
5859
},
60+
healthScore: {
61+
category: 'health',
62+
},
5963
// Compatibility
6064
engines: {
6165
category: 'compatibility',

test/nuxt/components/compare/FacetSelector.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ const facetLabels: Record<ComparisonFacet, { label: string; description: string
3232
},
3333
deprecated: { label: 'Deprecated?', description: 'Whether the package is deprecated' },
3434
totalLikes: { label: 'Likes', description: 'Number of likes' },
35+
healthScore: {
36+
label: 'Health Score',
37+
description: 'Overall package health based on maintenance, quality, security and popularity',
38+
},
3539
}
3640

3741
const categoryLabels: Record<string, string> = {

test/nuxt/composables/use-package-comparison.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, describe, expect, it, vi } from 'vitest'
22
import { mountSuspended } from '@nuxt/test-utils/runtime'
33
import type { PackageComparisonData } from '~/composables/usePackageComparison'
4+
import { computeHealthScore } from '~/composables/usePackageComparison'
45

56
/**
67
* Helper to test usePackageComparison by wrapping it in a component.
@@ -127,6 +128,75 @@ describe('usePackageComparison', () => {
127128
})
128129
})
129130

131+
describe('computeHealthScore', () => {
132+
function makeData(overrides: Partial<PackageComparisonData> = {}): PackageComparisonData {
133+
return {
134+
package: { name: 'test', version: '1.0.0' },
135+
directDeps: 2,
136+
...overrides,
137+
}
138+
}
139+
140+
it('returns score 0 for deprecated packages', () => {
141+
const score = computeHealthScore(
142+
makeData({ metadata: { deprecated: 'Use something else' } }),
143+
)
144+
expect(score).toBe(0)
145+
})
146+
147+
it('returns high score for a perfect package', () => {
148+
const score = computeHealthScore(
149+
makeData({
150+
metadata: {
151+
lastUpdated: new Date().toISOString(),
152+
license: 'MIT',
153+
},
154+
downloads: 2_000_000,
155+
directDeps: 1,
156+
analysis: {
157+
package: 'test',
158+
version: '1.0.0',
159+
moduleFormat: 'esm',
160+
types: { kind: 'included' },
161+
devDependencySuggestion: { recommended: false },
162+
} as PackageComparisonData['analysis'],
163+
vulnerabilities: {
164+
count: 0,
165+
severity: { critical: 0, high: 0, moderate: 0, low: 0 },
166+
},
167+
}),
168+
)
169+
expect(score).toBeGreaterThanOrEqual(85)
170+
})
171+
172+
it('sets security to 0 for critical vulnerabilities', () => {
173+
const safe = computeHealthScore(
174+
makeData({
175+
vulnerabilities: {
176+
count: 0,
177+
severity: { critical: 0, high: 0, moderate: 0, low: 0 },
178+
},
179+
}),
180+
)
181+
const critical = computeHealthScore(
182+
makeData({
183+
vulnerabilities: {
184+
count: 1,
185+
severity: { critical: 1, high: 0, moderate: 0, low: 0 },
186+
},
187+
}),
188+
)
189+
expect(safe).toBeGreaterThan(critical)
190+
})
191+
192+
it('handles missing/undefined fields gracefully', () => {
193+
const score = computeHealthScore(makeData())
194+
expect(typeof score).toBe('number')
195+
expect(score).toBeGreaterThanOrEqual(0)
196+
expect(score).toBeLessThanOrEqual(100)
197+
})
198+
})
199+
130200
describe('staleness detection', () => {
131201
it('marks packages not published in 2+ years as stale', async () => {
132202
vi.stubGlobal(

0 commit comments

Comments
 (0)