Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
68 changes: 67 additions & 1 deletion 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 @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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
4 changes: 4 additions & 0 deletions test/nuxt/components/compare/FacetSelector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
},
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 @@ -232,7 +236,7 @@
})

describe('category all/none buttons', () => {
function findCategoryActionButton(

Check warning on line 239 in test/nuxt/components/compare/FacetSelector.spec.ts

View workflow job for this annotation

GitHub Actions / πŸ€– Autofix code

eslint-plugin-unicorn(consistent-function-scoping)

Function `findCategoryActionButton` does not capture any variables from its parent scope
component: Awaited<ReturnType<typeof mountSuspended>>,
category: string,
action: 'all' | 'none',
Expand Down
68 changes: 68 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 @@ -127,6 +128,73 @@
})
})

describe('computeHealthScore', () => {
function makeData(overrides: Partial<PackageComparisonData> = {}): PackageComparisonData {

Check warning on line 132 in test/nuxt/composables/use-package-comparison.spec.ts

View workflow job for this annotation

GitHub Actions / πŸ€– Autofix code

eslint-plugin-unicorn(consistent-function-scoping)

Function `makeData` does not capture any variables from its parent scope
return {
package: { name: 'test', version: '1.0.0' },
directDeps: 2,
...overrides,
}
}

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({
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)
})
})

describe('staleness detection', () => {
it('marks packages not published in 2+ years as stale', async () => {
vi.stubGlobal(
Expand Down
Loading