Skip to content
Closed
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
5 changes: 5 additions & 0 deletions app/composables/useFacetSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}),
)

Expand Down
70 changes: 68 additions & 2 deletions app/composables/usePackageComparison.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -160,7 +162,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
package: {
name: pkgData.name,
version: latestVersion,
description: undefined,
description: pkgData.description,
},
downloads: downloads?.downloads,
packageSize,
Expand Down Expand Up @@ -191,11 +193,12 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
}),
)

// 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)
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
9 changes: 8 additions & 1 deletion i18n/locales/ar-EG.json
Original file line number Diff line number Diff line change
Expand Up @@ -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} ملف",
Expand Down
5 changes: 5 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions i18n/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
15 changes: 15 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3606,6 +3606,21 @@
}
},
"additionalProperties": false
},
"healthScore": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"description": {
"type": "string"
},
"tooltip": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
Expand Down
4 changes: 4 additions & 0 deletions shared/types/comparison.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type ComparisonFacet =
| 'totalDependencies'
| 'deprecated'
| 'totalLikes'
| 'healthScore'

/** Facet metadata for UI display */
export interface FacetInfo {
Expand Down Expand Up @@ -56,6 +57,9 @@ export const FACET_INFO: Record<ComparisonFacet, Omit<FacetInfo, 'id'>> = {
deprecated: {
category: 'health',
},
healthScore: {
category: 'health',
},
// Compatibility
engines: {
category: 'compatibility',
Expand Down
24 changes: 14 additions & 10 deletions test/nuxt/components/compare/FacetSelector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const facetLabels: Record<ComparisonFacet, { label: string; description: string
},
deprecated: { label: 'Deprecated?', description: 'Whether the package is deprecated' },
totalLikes: { label: 'Likes', description: 'Number of likes' },
healthScore: {
label: 'Health Score',
description: 'Overall package health based on maintenance, quality, security and popularity',
},
}

const categoryLabels: Record<string, string> = {
Expand Down Expand Up @@ -100,6 +104,16 @@ vi.mock('@vueuse/router', () => ({
useRouteQuery: () => ref(''),
}))

function findCategoryActionButton(
component: Awaited<ReturnType<typeof mountSuspended>>,
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']
Expand Down Expand Up @@ -232,16 +246,6 @@ describe('FacetSelector', () => {
})

describe('category all/none buttons', () => {
function findCategoryActionButton(
component: Awaited<ReturnType<typeof mountSuspended>>,
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)

Expand Down
97 changes: 97 additions & 0 deletions test/nuxt/composables/use-package-comparison.spec.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -41,6 +42,14 @@ async function usePackageComparisonInComponent(packageNames: string[]) {
}
}

function makeData(overrides: Partial<PackageComparisonData> = {}): PackageComparisonData {
return {
package: { name: 'test', version: '1.0.0' },
directDeps: 2,
...overrides,
}
}

describe('usePackageComparison', () => {
afterEach(() => {
vi.unstubAllGlobals()
Expand Down Expand Up @@ -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(
Expand Down
Loading