Skip to content

Commit 3a34ed5

Browse files
committed
feat: package quality score
1 parent b43cc0a commit 3a34ed5

File tree

6 files changed

+315
-1
lines changed

6 files changed

+315
-1
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
score: PackageScore
4+
}>()
5+
6+
const categories = computed(() => {
7+
const order = ['documentation', 'maintenance', 'types', 'bestPractices', 'security'] as const
8+
return order.map(cat => ({
9+
key: cat,
10+
checks: props.score.checks.filter(c => c.category === cat),
11+
}))
12+
})
13+
14+
function scoreColor(pct: number): string {
15+
if (pct >= 80) return 'text-green-600 dark:text-green-400'
16+
if (pct >= 50) return 'text-amber-600 dark:text-amber-400'
17+
return 'text-red-600 dark:text-red-400'
18+
}
19+
20+
function ringColor(pct: number): string {
21+
if (pct >= 80) return 'stroke-green-600 dark:stroke-green-400'
22+
if (pct >= 50) return 'stroke-amber-600 dark:stroke-amber-400'
23+
return 'stroke-red-600 dark:stroke-red-400'
24+
}
25+
26+
const circumference = 2 * Math.PI * 16
27+
const dashOffset = computed(() => circumference - (props.score.percentage / 100) * circumference)
28+
29+
const scoreLabel = computed(() =>
30+
$t('package.score.subtitle', { earned: props.score.totalPoints, total: props.score.maxPoints }),
31+
)
32+
</script>
33+
34+
<template>
35+
<CollapsibleSection :title="$t('package.score.title')" id="quality-score">
36+
<template #actions>
37+
<div
38+
class="relative w-7 h-7 shrink-0"
39+
role="img"
40+
:aria-label="`${score.percentage}% — ${scoreLabel}`"
41+
>
42+
<svg viewBox="0 0 36 36" class="w-full h-full -rotate-90" aria-hidden="true">
43+
<circle cx="18" cy="18" r="16" fill="none" class="stroke-border" stroke-width="3" />
44+
<circle
45+
cx="18"
46+
cy="18"
47+
r="16"
48+
fill="none"
49+
:class="[ringColor(score.percentage), 'transition-[stroke-dashoffset] duration-500']"
50+
stroke-width="3"
51+
stroke-linecap="round"
52+
:stroke-dasharray="circumference"
53+
:stroke-dashoffset="dashOffset"
54+
/>
55+
</svg>
56+
<span
57+
class="absolute inset-0 flex items-center justify-center text-3xs font-mono font-medium"
58+
:class="scoreColor(score.percentage)"
59+
aria-hidden="true"
60+
>
61+
{{ score.percentage }}
62+
</span>
63+
</div>
64+
</template>
65+
66+
<!-- Checks by category -->
67+
<div class="space-y-3">
68+
<div v-for="cat in categories" :key="cat.key">
69+
<h3 class="text-2xs text-fg-subtle uppercase tracking-wider mb-1.5">
70+
{{ $t(`package.score.categories.${cat.key}`) }}
71+
</h3>
72+
<ul class="space-y-1 list-none m-0 p-0">
73+
<li
74+
v-for="check in cat.checks"
75+
:key="check.id"
76+
class="flex items-start gap-2 text-sm py-0.5"
77+
>
78+
<span
79+
class="w-4 h-4 shrink-0 mt-0.5"
80+
:class="
81+
check.points === check.maxPoints
82+
? 'i-lucide:check text-green-600 dark:text-green-400'
83+
: check.points > 0
84+
? 'i-lucide:minus text-amber-600 dark:text-amber-400'
85+
: 'i-lucide:x text-fg-subtle'
86+
"
87+
aria-hidden="true"
88+
/>
89+
<span class="flex-1 text-fg-muted" :class="{ 'text-fg-subtle': check.points === 0 }">
90+
{{ $t(`package.score.checks.${check.id}`) }}
91+
</span>
92+
<span class="font-mono text-2xs text-fg-subtle shrink-0">
93+
{{ check.points }}/{{ check.maxPoints }}
94+
</span>
95+
</li>
96+
</ul>
97+
</div>
98+
</div>
99+
</CollapsibleSection>
100+
</template>
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
export type ScoreCategory = 'documentation' | 'maintenance' | 'types' | 'bestPractices' | 'security'
2+
3+
export interface ScoreCheck {
4+
id: string
5+
category: ScoreCategory
6+
points: number
7+
maxPoints: number
8+
}
9+
10+
export interface PackageScore {
11+
percentage: number
12+
totalPoints: number
13+
maxPoints: number
14+
checks: ScoreCheck[]
15+
}
16+
17+
interface ScoreInput {
18+
pkg: SlimPackument | null | undefined
19+
resolvedVersion: string | null | undefined
20+
readmeHtml: string
21+
analysis: PackageAnalysisResponse | null | undefined
22+
vulnCounts: { total: number; critical: number; high: number } | null | undefined
23+
vulnStatus: string
24+
hasProvenance: boolean
25+
}
26+
27+
function computeScore(input: ScoreInput): PackageScore {
28+
const { pkg, resolvedVersion, readmeHtml, analysis, vulnCounts, vulnStatus, hasProvenance } =
29+
input
30+
const checks: ScoreCheck[] = []
31+
32+
// --- Documentation (3 pts) ---
33+
// README: 0 = none, 1 = exists but short, 2 = substantial (> 500 chars of content)
34+
const hasReadme = !!readmeHtml
35+
const readmePoints = !hasReadme ? 0 : readmeHtml.length > 500 ? 2 : 1
36+
checks.push({ id: 'has-readme', category: 'documentation', points: readmePoints, maxPoints: 2 })
37+
38+
const hasDescription = !!pkg?.description?.trim()
39+
checks.push({
40+
id: 'has-description',
41+
category: 'documentation',
42+
points: hasDescription ? 1 : 0,
43+
maxPoints: 1,
44+
})
45+
46+
// --- Maintenance (2 pts) ---
47+
// 0 = older than 2 years, 1 = within 2 years, 2 = within 1 year
48+
const publishTime = resolvedVersion && pkg?.time?.[resolvedVersion]
49+
const msSincePublish = publishTime ? Date.now() - new Date(publishTime).getTime() : null
50+
const oneYear = 365 * 24 * 60 * 60 * 1000
51+
const twoYears = 2 * oneYear
52+
53+
const maintenancePoints =
54+
msSincePublish !== null && msSincePublish < oneYear
55+
? 2
56+
: msSincePublish !== null && msSincePublish < twoYears
57+
? 1
58+
: 0
59+
checks.push({
60+
id: 'update-frequency',
61+
category: 'maintenance',
62+
points: maintenancePoints,
63+
maxPoints: 2,
64+
})
65+
66+
// --- Types (2 pts) ---
67+
// 0 = none, 1 = @types available, 2 = bundled in package
68+
const typesKind = analysis?.types?.kind
69+
const typesPoints = typesKind === 'included' ? 2 : typesKind === '@types' ? 1 : 0
70+
checks.push({ id: 'has-types', category: 'types', points: typesPoints, maxPoints: 2 })
71+
72+
// --- Best Practices (4 pts, each 1) ---
73+
checks.push({
74+
id: 'has-license',
75+
category: 'bestPractices',
76+
points: pkg?.license ? 1 : 0,
77+
maxPoints: 1,
78+
})
79+
checks.push({
80+
id: 'has-repository',
81+
category: 'bestPractices',
82+
points: pkg?.repository ? 1 : 0,
83+
maxPoints: 1,
84+
})
85+
checks.push({
86+
id: 'has-provenance',
87+
category: 'bestPractices',
88+
points: hasProvenance ? 1 : 0,
89+
maxPoints: 1,
90+
})
91+
92+
const hasEsm = analysis?.moduleFormat === 'esm' || analysis?.moduleFormat === 'dual'
93+
checks.push({ id: 'has-esm', category: 'bestPractices', points: hasEsm ? 1 : 0, maxPoints: 1 })
94+
95+
// --- Security (3 pts) ---
96+
// Vulnerabilities are excluded from the score until loaded to avoid score jumps.
97+
// 0 = has critical/high, 1 = only moderate/low, 2 = none
98+
const vulnsLoaded = vulnStatus === 'success'
99+
if (vulnsLoaded) {
100+
const vulnPoints =
101+
!vulnCounts || vulnCounts.total === 0
102+
? 2
103+
: vulnCounts.critical === 0 && vulnCounts.high === 0
104+
? 1
105+
: 0
106+
checks.push({
107+
id: 'no-vulnerabilities',
108+
category: 'security',
109+
points: vulnPoints,
110+
maxPoints: 2,
111+
})
112+
}
113+
114+
const latestTag = pkg?.['dist-tags']?.latest
115+
const latestVersion = latestTag ? pkg?.versions[latestTag] : null
116+
const isNotDeprecated = !latestVersion?.deprecated
117+
checks.push({
118+
id: 'not-deprecated',
119+
category: 'security',
120+
points: isNotDeprecated ? 1 : 0,
121+
maxPoints: 1,
122+
})
123+
124+
const totalPoints = checks.reduce((sum, c) => sum + c.points, 0)
125+
const maxPoints = checks.reduce((sum, c) => sum + c.maxPoints, 0)
126+
const percentage = maxPoints > 0 ? Math.round((totalPoints / maxPoints) * 100) : 0
127+
128+
return { percentage, totalPoints, maxPoints, checks }
129+
}
130+
131+
type MaybeRefLike<T> = Ref<T> | ComputedRef<T>
132+
133+
export function usePackageScore(input: {
134+
pkg: MaybeRefLike<SlimPackument | null | undefined>
135+
resolvedVersion: MaybeRefLike<string | null | undefined>
136+
readmeHtml: MaybeRefLike<string>
137+
analysis: MaybeRefLike<PackageAnalysisResponse | null | undefined>
138+
vulnCounts: MaybeRefLike<{ total: number; critical: number; high: number } | null | undefined>
139+
vulnStatus: MaybeRefLike<string>
140+
hasProvenance: MaybeRefLike<boolean>
141+
}) {
142+
return computed<PackageScore>(() =>
143+
computeScore({
144+
pkg: input.pkg.value,
145+
resolvedVersion: input.resolvedVersion.value,
146+
readmeHtml: input.readmeHtml.value,
147+
analysis: input.analysis.value,
148+
vulnCounts: input.vulnCounts.value,
149+
vulnStatus: input.vulnStatus.value,
150+
hasProvenance: input.hasProvenance.value,
151+
}),
152+
)
153+
}

app/composables/useSettings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ const DEFAULT_SETTINGS: AppSettings = {
6464
autoOpenURL: false,
6565
},
6666
sidebar: {
67-
collapsed: [],
67+
collapsed: ['quality-score'],
6868
},
6969
chartFilter: {
7070
averageWindow: 0,

app/pages/package/[[org]]/[name].vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,16 @@ if (import.meta.client) {
295295
)
296296
}
297297
298+
const packageScore = usePackageScore({
299+
pkg: computed(() => pkg.value),
300+
resolvedVersion: computed(() => resolvedVersion.value),
301+
readmeHtml: computed(() => readmeData.value?.html ?? ''),
302+
analysis: computed(() => packageAnalysis.value),
303+
vulnCounts: computed(() => vulnTree.value?.totalCounts ?? null),
304+
vulnStatus: computed(() => vulnTreeStatus.value),
305+
hasProvenance: computed(() => !!displayVersion.value && hasProvenance(displayVersion.value)),
306+
})
307+
298308
const isMounted = useMounted()
299309
300310
// Keep latestVersion for comparison (to show "(latest)" badge)
@@ -918,6 +928,11 @@ const showSkeleton = shallowRef(false)
918928
</template>
919929
</ClientOnly>
920930

931+
<!-- Quality Score -->
932+
<ClientOnly>
933+
<PackageQualityScore :score="packageScore" />
934+
</ClientOnly>
935+
921936
<!-- Download stats -->
922937
<PackageWeeklyDownloadStats
923938
:packageName

i18n/locales/de-DE.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,29 @@
343343
"provenance_link_text": "Herkunft",
344344
"trusted_publishing_link_text": "vertrauenswürdiges Publishing"
345345
},
346+
"score": {
347+
"title": "Paketqualität",
348+
"subtitle": "{earned} von {total} Punkten",
349+
"categories": {
350+
"documentation": "Dokumentation",
351+
"maintenance": "Pflege",
352+
"types": "Typisierung",
353+
"bestPractices": "Best Practices",
354+
"security": "Sicherheit"
355+
},
356+
"checks": {
357+
"has-readme": "Aussagekräftige README",
358+
"has-description": "Beschreibung vorhanden",
359+
"update-frequency": "Kürzlich aktualisiert",
360+
"has-types": "TypeScript-Typen",
361+
"has-license": "Lizenz angegeben",
362+
"has-repository": "Repository verlinkt",
363+
"has-provenance": "Herkunft verifizierbar",
364+
"has-esm": "ES-Module unterstützt",
365+
"no-vulnerabilities": "Keine bekannten Schwachstellen",
366+
"not-deprecated": "Nicht veraltet"
367+
}
368+
},
346369
"keywords_title": "Schlüsselwörter",
347370
"compatibility": "Kompatibilität",
348371
"card": {

i18n/locales/en.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,29 @@
363363
"provenance_link_text": "provenance",
364364
"trusted_publishing_link_text": "trusted publishing"
365365
},
366+
"score": {
367+
"title": "Quality Score",
368+
"subtitle": "{earned} of {total} points",
369+
"categories": {
370+
"documentation": "Documentation",
371+
"maintenance": "Maintenance",
372+
"types": "Type Coverage",
373+
"bestPractices": "Best Practices",
374+
"security": "Security"
375+
},
376+
"checks": {
377+
"has-readme": "Has a substantial README",
378+
"has-description": "Has a description",
379+
"update-frequency": "Recently updated",
380+
"has-types": "TypeScript types",
381+
"has-license": "Has a license",
382+
"has-repository": "Has a repository link",
383+
"has-provenance": "Has publish provenance",
384+
"has-esm": "Supports ES modules",
385+
"no-vulnerabilities": "No known vulnerabilities",
386+
"not-deprecated": "Not deprecated"
387+
}
388+
},
366389
"keywords_title": "Keywords",
367390
"compatibility": "Compatibility",
368391
"card": {

0 commit comments

Comments
 (0)