Skip to content
Open
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
128 changes: 128 additions & 0 deletions app/components/Package/QualityScore.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<script setup lang="ts">
const props = defineProps<{
score: PackageScore
}>()

const categoryLabels: Record<ScoreCategory, string> = {
documentation: $t('package.score.categories.documentation'),
maintenance: $t('package.score.categories.maintenance'),
types: $t('package.score.categories.types'),
bestPractices: $t('package.score.categories.bestPractices'),
security: $t('package.score.categories.security'),
}

const checkLabels: Record<string, string> = {
'has-readme': $t('package.score.checks.has-readme'),
'has-description': $t('package.score.checks.has-description'),
'update-frequency': $t('package.score.checks.update-frequency'),
'has-types': $t('package.score.checks.has-types'),
'has-license': $t('package.score.checks.has-license'),
'has-repository': $t('package.score.checks.has-repository'),
'has-provenance': $t('package.score.checks.has-provenance'),
'has-esm': $t('package.score.checks.has-esm'),
'no-vulnerabilities': $t('package.score.checks.no-vulnerabilities'),
'not-deprecated': $t('package.score.checks.not-deprecated'),
}

const categories = computed(() => {
const order: ScoreCategory[] = [
'documentation',
'maintenance',
'types',
'bestPractices',
'security',
]
return order.map(cat => ({
key: cat,
label: categoryLabels[cat],
checks: props.score.checks.filter(c => c.category === cat),
}))
})

function scoreColor(pct: number): string {
if (pct >= 80) return 'text-green-600 dark:text-green-400'
if (pct >= 50) return 'text-amber-600 dark:text-amber-400'
return 'text-red-600 dark:text-red-400'
}

function ringColor(pct: number): string {
if (pct >= 80) return 'stroke-green-600 dark:stroke-green-400'
if (pct >= 50) return 'stroke-amber-600 dark:stroke-amber-400'
return 'stroke-red-600 dark:stroke-red-400'
}

const circumference = 2 * Math.PI * 16
const dashOffset = computed(() => circumference - (props.score.percentage / 100) * circumference)

const scoreLabel = computed(() =>
$t('package.score.subtitle', { earned: props.score.totalPoints, total: props.score.maxPoints }),
)
</script>

<template>
<CollapsibleSection :title="$t('package.score.title')" id="quality-score">
<template #actions>
<div
class="relative w-7 h-7 shrink-0"
role="img"
:aria-label="`${score.percentage}% — ${scoreLabel}`"
>
<svg viewBox="0 0 36 36" class="w-full h-full -rotate-90" aria-hidden="true">
<circle cx="18" cy="18" r="16" fill="none" class="stroke-border" stroke-width="3" />
<circle
cx="18"
cy="18"
r="16"
fill="none"
:class="[ringColor(score.percentage), 'transition-[stroke-dashoffset] duration-500']"
stroke-width="3"
stroke-linecap="round"
:stroke-dasharray="circumference"
:stroke-dashoffset="dashOffset"
/>
</svg>
<span
class="absolute inset-0 flex items-center justify-center text-3xs font-mono font-medium"
:class="scoreColor(score.percentage)"
aria-hidden="true"
>
{{ score.percentage }}
</span>
</div>
</template>

<!-- Checks by category -->
<div class="space-y-3">
<div v-for="cat in categories" :key="cat.key">
<h3 class="text-2xs text-fg-subtle uppercase tracking-wider mb-1.5">
{{ cat.label }}
</h3>
<ul class="space-y-1 list-none m-0 p-0">
<li
v-for="check in cat.checks"
:key="check.id"
class="flex items-start gap-2 text-sm py-0.5"
>
<span
class="w-4 h-4 shrink-0 mt-0.5"
:class="
check.points === check.maxPoints
? 'i-lucide:check text-green-600 dark:text-green-400'
: check.points > 0
? 'i-lucide:minus text-amber-600 dark:text-amber-400'
: 'i-lucide:x text-fg-subtle'
"
aria-hidden="true"
/>
<span class="flex-1 text-fg-muted" :class="{ 'text-fg-subtle': check.points === 0 }">
{{ checkLabels[check.id] }}
</span>
<span class="font-mono text-2xs text-fg-subtle shrink-0">
{{ check.points }}/{{ check.maxPoints }}
</span>
</li>
</ul>
</div>
</div>
</CollapsibleSection>
</template>
153 changes: 153 additions & 0 deletions app/composables/npm/usePackageScore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
export type ScoreCategory = 'documentation' | 'maintenance' | 'types' | 'bestPractices' | 'security'

export interface ScoreCheck {
id: string
category: ScoreCategory
points: number
maxPoints: number
}

export interface PackageScore {
percentage: number
totalPoints: number
maxPoints: number
checks: ScoreCheck[]
}

interface ScoreInput {
pkg: SlimPackument | null | undefined
resolvedVersion: string | null | undefined
readmeHtml: string
analysis: PackageAnalysisResponse | null | undefined
vulnCounts: { total: number; critical: number; high: number } | null | undefined
vulnStatus: string
hasProvenance: boolean
}

function computeScore(input: ScoreInput): PackageScore {
const { pkg, resolvedVersion, readmeHtml, analysis, vulnCounts, vulnStatus, hasProvenance } =
input
const checks: ScoreCheck[] = []

// --- Documentation (3 pts) ---
// README: 0 = none, 1 = exists but short, 2 = substantial (> 500 chars of content)
const hasReadme = !!readmeHtml
const readmePoints = !hasReadme ? 0 : readmeHtml.length > 500 ? 2 : 1
checks.push({ id: 'has-readme', category: 'documentation', points: readmePoints, maxPoints: 2 })

const hasDescription = !!pkg?.description?.trim()
checks.push({
id: 'has-description',
category: 'documentation',
points: hasDescription ? 1 : 0,
maxPoints: 1,
})

// --- Maintenance (2 pts) ---
// 0 = older than 2 years, 1 = within 2 years, 2 = within 1 year
const publishTime = resolvedVersion && pkg?.time?.[resolvedVersion]
const msSincePublish = publishTime ? Date.now() - new Date(publishTime).getTime() : null
const oneYear = 365 * 24 * 60 * 60 * 1000
const twoYears = 2 * oneYear

const maintenancePoints =
msSincePublish !== null && msSincePublish < oneYear
? 2
: msSincePublish !== null && msSincePublish < twoYears
? 1
: 0
checks.push({
id: 'update-frequency',
category: 'maintenance',
points: maintenancePoints,
maxPoints: 2,
})

// --- Types (2 pts) ---
// 0 = none, 1 = @types available, 2 = bundled in package
const typesKind = analysis?.types?.kind
const typesPoints = typesKind === 'included' ? 2 : typesKind === '@types' ? 1 : 0
checks.push({ id: 'has-types', category: 'types', points: typesPoints, maxPoints: 2 })

// --- Best Practices (4 pts, each 1) ---
checks.push({
id: 'has-license',
category: 'bestPractices',
points: pkg?.license ? 1 : 0,
maxPoints: 1,
})
checks.push({
id: 'has-repository',
category: 'bestPractices',
points: pkg?.repository ? 1 : 0,
maxPoints: 1,
})
checks.push({
id: 'has-provenance',
category: 'bestPractices',
points: hasProvenance ? 1 : 0,
maxPoints: 1,
})

const hasEsm = analysis?.moduleFormat === 'esm' || analysis?.moduleFormat === 'dual'
checks.push({ id: 'has-esm', category: 'bestPractices', points: hasEsm ? 1 : 0, maxPoints: 1 })

// --- Security (3 pts) ---
// Vulnerabilities are excluded from the score until loaded to avoid score jumps.
// 0 = has critical/high, 1 = only moderate/low, 2 = none
const vulnsLoaded = vulnStatus === 'success'
if (vulnsLoaded) {
const vulnPoints =
!vulnCounts || vulnCounts.total === 0
? 2
: vulnCounts.critical === 0 && vulnCounts.high === 0
? 1
: 0
checks.push({
id: 'no-vulnerabilities',
category: 'security',
points: vulnPoints,
maxPoints: 2,
})
}

const latestTag = pkg?.['dist-tags']?.latest
const latestVersion = latestTag ? pkg?.versions[latestTag] : null
const isNotDeprecated = !latestVersion?.deprecated
checks.push({
id: 'not-deprecated',
category: 'security',
points: isNotDeprecated ? 1 : 0,
maxPoints: 1,
})

const totalPoints = checks.reduce((sum, c) => sum + c.points, 0)
const maxPoints = checks.reduce((sum, c) => sum + c.maxPoints, 0)
const percentage = maxPoints > 0 ? Math.round((totalPoints / maxPoints) * 100) : 0

return { percentage, totalPoints, maxPoints, checks }
}

type MaybeRefLike<T> = Ref<T> | ComputedRef<T>

export function usePackageScore(input: {
pkg: MaybeRefLike<SlimPackument | null | undefined>
resolvedVersion: MaybeRefLike<string | null | undefined>
readmeHtml: MaybeRefLike<string>
analysis: MaybeRefLike<PackageAnalysisResponse | null | undefined>
vulnCounts: MaybeRefLike<{ total: number; critical: number; high: number } | null | undefined>
vulnStatus: MaybeRefLike<string>
hasProvenance: MaybeRefLike<boolean>
}) {
return computed<PackageScore>(() =>
computeScore({
pkg: input.pkg.value,
resolvedVersion: input.resolvedVersion.value,
readmeHtml: input.readmeHtml.value,
analysis: input.analysis.value,
vulnCounts: input.vulnCounts.value,
vulnStatus: input.vulnStatus.value,
hasProvenance: input.hasProvenance.value,
}),
)
}
2 changes: 1 addition & 1 deletion app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const DEFAULT_SETTINGS: AppSettings = {
autoOpenURL: false,
},
sidebar: {
collapsed: [],
collapsed: ['quality-score'],
},
chartFilter: {
averageWindow: 0,
Expand Down
15 changes: 15 additions & 0 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,16 @@ if (import.meta.client) {
)
}

const packageScore = usePackageScore({
pkg: computed(() => pkg.value),
resolvedVersion: computed(() => resolvedVersion.value),
readmeHtml: computed(() => readmeData.value?.html ?? ''),
analysis: computed(() => packageAnalysis.value),
vulnCounts: computed(() => vulnTree.value?.totalCounts ?? null),
vulnStatus: computed(() => vulnTreeStatus.value),
hasProvenance: computed(() => !!displayVersion.value && hasProvenance(displayVersion.value)),
})

const isMounted = useMounted()

// Keep latestVersion for comparison (to show "(latest)" badge)
Expand Down Expand Up @@ -918,6 +928,11 @@ const showSkeleton = shallowRef(false)
</template>
</ClientOnly>

<!-- Quality Score -->
<ClientOnly>
<PackageQualityScore :score="packageScore" />
</ClientOnly>

<!-- Download stats -->
<PackageWeeklyDownloadStats
:packageName
Expand Down
23 changes: 23 additions & 0 deletions i18n/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,29 @@
"provenance_link_text": "Herkunft",
"trusted_publishing_link_text": "vertrauenswürdiges Publishing"
},
"score": {
"title": "Paketqualität",
"subtitle": "{earned} von {total} Punkten",
"categories": {
"documentation": "Dokumentation",
"maintenance": "Pflege",
"types": "Typisierung",
"bestPractices": "Best Practices",
"security": "Sicherheit"
},
"checks": {
"has-readme": "Aussagekräftige README",
"has-description": "Beschreibung vorhanden",
"update-frequency": "Kürzlich aktualisiert",
"has-types": "TypeScript-Typen",
"has-license": "Lizenz angegeben",
"has-repository": "Repository verlinkt",
"has-provenance": "Herkunft verifizierbar",
"has-esm": "ES-Module unterstützt",
"no-vulnerabilities": "Keine bekannten Schwachstellen",
"not-deprecated": "Nicht veraltet"
}
},
"keywords_title": "Schlüsselwörter",
"compatibility": "Kompatibilität",
"card": {
Expand Down
23 changes: 23 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,29 @@
"provenance_link_text": "provenance",
"trusted_publishing_link_text": "trusted publishing"
},
"score": {
"title": "Quality Score",
"subtitle": "{earned} of {total} points",
"categories": {
"documentation": "Documentation",
"maintenance": "Maintenance",
"types": "Type Coverage",
"bestPractices": "Best Practices",
"security": "Security"
},
"checks": {
"has-readme": "Has a substantial README",
"has-description": "Has a description",
"update-frequency": "Recently updated",
"has-types": "TypeScript types",
"has-license": "Has a license",
"has-repository": "Has a repository link",
"has-provenance": "Has publish provenance",
"has-esm": "Supports ES modules",
"no-vulnerabilities": "No known vulnerabilities",
"not-deprecated": "Not deprecated"
}
},
"keywords_title": "Keywords",
"compatibility": "Compatibility",
"card": {
Expand Down
Loading
Loading