diff --git a/app/components/Package/QualityScore.vue b/app/components/Package/QualityScore.vue new file mode 100644 index 0000000000..6c181b8b1f --- /dev/null +++ b/app/components/Package/QualityScore.vue @@ -0,0 +1,128 @@ + + + diff --git a/app/composables/npm/usePackageScore.ts b/app/composables/npm/usePackageScore.ts new file mode 100644 index 0000000000..5c63d0af3c --- /dev/null +++ b/app/composables/npm/usePackageScore.ts @@ -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 = Ref | ComputedRef + +export function usePackageScore(input: { + pkg: MaybeRefLike + resolvedVersion: MaybeRefLike + readmeHtml: MaybeRefLike + analysis: MaybeRefLike + vulnCounts: MaybeRefLike<{ total: number; critical: number; high: number } | null | undefined> + vulnStatus: MaybeRefLike + hasProvenance: MaybeRefLike +}) { + return computed(() => + 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, + }), + ) +} diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 112bbe4a31..806602473b 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -64,7 +64,7 @@ const DEFAULT_SETTINGS: AppSettings = { autoOpenURL: false, }, sidebar: { - collapsed: [], + collapsed: ['quality-score'], }, chartFilter: { averageWindow: 0, diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index a83a101243..daa3756be6 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -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) @@ -918,6 +928,11 @@ const showSkeleton = shallowRef(false) + + + + + { }) }) + describe('PackageQualityScore', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(PackageQualityScore, { + props: { + score: { + percentage: 85, + totalPoints: 12, + maxPoints: 14, + checks: [ + { id: 'has-readme', category: 'documentation', points: 2, maxPoints: 2 }, + { id: 'has-description', category: 'documentation', points: 1, maxPoints: 1 }, + { id: 'update-frequency', category: 'maintenance', points: 2, maxPoints: 2 }, + { id: 'has-types', category: 'types', points: 1, maxPoints: 2 }, + { id: 'has-license', category: 'bestPractices', points: 1, maxPoints: 1 }, + { id: 'has-repository', category: 'bestPractices', points: 1, maxPoints: 1 }, + { id: 'has-provenance', category: 'bestPractices', points: 0, maxPoints: 1 }, + { id: 'has-esm', category: 'bestPractices', points: 1, maxPoints: 1 }, + { id: 'no-vulnerabilities', category: 'security', points: 2, maxPoints: 2 }, + { id: 'not-deprecated', category: 'security', points: 1, maxPoints: 1 }, + ], + }, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('PackageKeywords', () => { it('should have no accessibility violations without keywords', async () => { const component = await mountSuspended(PackageKeywords, { diff --git a/test/nuxt/composables/use-package-score.spec.ts b/test/nuxt/composables/use-package-score.spec.ts new file mode 100644 index 0000000000..98aec964d0 --- /dev/null +++ b/test/nuxt/composables/use-package-score.spec.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest' + +const VERSION: SlimVersion = { + version: '1.0.0', + tags: [], +} + +function createPkg(overrides: Partial = {}): SlimPackument { + return { + '_id': 'test', + 'name': 'test', + 'description': 'A test package', + 'license': 'MIT', + 'repository': { type: 'git', url: 'https://github.com/test/test' }, + 'dist-tags': { latest: '1.0.0' }, + 'time': { '1.0.0': new Date().toISOString(), 'created': '2020-01-01' }, + 'versions': { '1.0.0': VERSION }, + 'requestedVersion': null, + ...overrides, + } as SlimPackument +} + +function createAnalysis(overrides: Partial = {}): PackageAnalysisResponse { + return { + package: 'test', + version: '1.0.0', + moduleFormat: 'esm', + types: { kind: 'included' }, + devDependencySuggestion: 'none', + ...overrides, + } as PackageAnalysisResponse +} + +function createBaseInput() { + return { + pkg: ref(createPkg()), + resolvedVersion: ref('1.0.0'), + readmeHtml: ref(`

Test Package

${'x'.repeat(600)}

`), + analysis: ref(createAnalysis()), + vulnCounts: ref<{ total: number; critical: number; high: number } | null>({ + total: 0, + critical: 0, + high: 0, + }), + vulnStatus: ref('success'), + hasProvenance: ref(true), + } +} + +describe('usePackageScore', () => { + it('returns 100% for a perfect package', () => { + const score = usePackageScore(createBaseInput()) + + expect(score.value.percentage).toBe(100) + expect(score.value.totalPoints).toBe(score.value.maxPoints) + }) + + describe('documentation', () => { + it('gives 0/2 for missing README', () => { + const input = createBaseInput() + input.readmeHtml.value = '' + + const check = usePackageScore(input).value.checks.find(c => c.id === 'has-readme')! + expect(check.points).toBe(0) + expect(check.maxPoints).toBe(2) + }) + + it('gives 1/2 for a short README', () => { + const input = createBaseInput() + input.readmeHtml.value = '

Short

' + + const check = usePackageScore(input).value.checks.find(c => c.id === 'has-readme')! + expect(check.points).toBe(1) + }) + + it('gives 2/2 for a substantial README', () => { + const check = usePackageScore(createBaseInput()).value.checks.find( + c => c.id === 'has-readme', + )! + expect(check.points).toBe(2) + }) + + it('gives 0/1 for missing description', () => { + const input = createBaseInput() + input.pkg.value = createPkg({ description: '' }) + + const check = usePackageScore(input).value.checks.find(c => c.id === 'has-description')! + expect(check.points).toBe(0) + }) + }) + + describe('maintenance', () => { + it('gives 2/2 for package updated within 1 year', () => { + const check = usePackageScore(createBaseInput()).value.checks.find( + c => c.id === 'update-frequency', + )! + expect(check.points).toBe(2) + }) + + it('gives 1/2 for package updated within 2 years', () => { + const input = createBaseInput() + const eighteenMonthsAgo = new Date(Date.now() - 18 * 30 * 24 * 60 * 60 * 1000).toISOString() + input.pkg.value = createPkg({ time: { '1.0.0': eighteenMonthsAgo, 'created': '2020-01-01' } }) + + const check = usePackageScore(input).value.checks.find(c => c.id === 'update-frequency')! + expect(check.points).toBe(1) + }) + + it('gives 0/2 for package older than 2 years', () => { + const input = createBaseInput() + const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000).toISOString() + input.pkg.value = createPkg({ time: { '1.0.0': threeYearsAgo, 'created': '2020-01-01' } }) + + const check = usePackageScore(input).value.checks.find(c => c.id === 'update-frequency')! + expect(check.points).toBe(0) + }) + }) + + describe('types', () => { + it('gives 2/2 for bundled types', () => { + const check = usePackageScore(createBaseInput()).value.checks.find(c => c.id === 'has-types')! + expect(check.points).toBe(2) + }) + + it('gives 1/2 for @types', () => { + const input = createBaseInput() + input.analysis.value = createAnalysis({ + types: { kind: '@types', packageName: '@types/test' }, + }) + + const check = usePackageScore(input).value.checks.find(c => c.id === 'has-types')! + expect(check.points).toBe(1) + }) + + it('gives 0/2 for no types', () => { + const input = createBaseInput() + input.analysis.value = createAnalysis({ types: { kind: 'none' } }) + + const check = usePackageScore(input).value.checks.find(c => c.id === 'has-types')! + expect(check.points).toBe(0) + }) + }) + + describe('best practices', () => { + it('gives 0 for missing license', () => { + const input = createBaseInput() + input.pkg.value = createPkg({ license: undefined }) + + const check = usePackageScore(input).value.checks.find(c => c.id === 'has-license')! + expect(check.points).toBe(0) + }) + + it('gives 0 for missing provenance', () => { + const input = createBaseInput() + input.hasProvenance.value = false + + const check = usePackageScore(input).value.checks.find(c => c.id === 'has-provenance')! + expect(check.points).toBe(0) + }) + + it('gives 0 for CJS-only', () => { + const input = createBaseInput() + input.analysis.value = createAnalysis({ moduleFormat: 'cjs' }) + + const check = usePackageScore(input).value.checks.find(c => c.id === 'has-esm')! + expect(check.points).toBe(0) + }) + + it('gives 1 for dual ESM/CJS', () => { + const input = createBaseInput() + input.analysis.value = createAnalysis({ moduleFormat: 'dual' }) + + const check = usePackageScore(input).value.checks.find(c => c.id === 'has-esm')! + expect(check.points).toBe(1) + }) + }) + + describe('security', () => { + it('gives 2/2 for no vulnerabilities', () => { + const check = usePackageScore(createBaseInput()).value.checks.find( + c => c.id === 'no-vulnerabilities', + )! + expect(check.points).toBe(2) + }) + + it('gives 1/2 for only moderate/low vulnerabilities', () => { + const input = createBaseInput() + input.vulnCounts.value = { total: 3, critical: 0, high: 0 } + + const check = usePackageScore(input).value.checks.find(c => c.id === 'no-vulnerabilities')! + expect(check.points).toBe(1) + }) + + it('gives 0/2 for critical/high vulnerabilities', () => { + const input = createBaseInput() + input.vulnCounts.value = { total: 2, critical: 1, high: 0 } + + const check = usePackageScore(input).value.checks.find(c => c.id === 'no-vulnerabilities')! + expect(check.points).toBe(0) + }) + + it('excludes vuln check while loading', () => { + const input = createBaseInput() + input.vulnStatus.value = 'pending' + + const score = usePackageScore(input).value + expect(score.checks.find(c => c.id === 'no-vulnerabilities')).toBeUndefined() + // maxPoints is reduced (no vuln check = 2 less) + expect(score.maxPoints).toBe(usePackageScore(createBaseInput()).value.maxPoints - 2) + }) + + it('gives 0 for deprecated packages', () => { + const input = createBaseInput() + const deprecated: SlimVersion = { ...VERSION, deprecated: 'Use something else' } + input.pkg.value = createPkg({ versions: { '1.0.0': deprecated } }) + + const check = usePackageScore(input).value.checks.find(c => c.id === 'not-deprecated')! + expect(check.points).toBe(0) + }) + }) + + describe('edge cases', () => { + it('handles null pkg gracefully', () => { + const input = createBaseInput() + input.pkg.value = null + + const score = usePackageScore(input).value + expect(score.percentage).toBeGreaterThanOrEqual(0) + expect(score.percentage).toBeLessThanOrEqual(100) + }) + + it('handles null analysis gracefully', () => { + const input = createBaseInput() + input.analysis.value = null + + const score = usePackageScore(input).value + const types = score.checks.find(c => c.id === 'has-types')! + const esm = score.checks.find(c => c.id === 'has-esm')! + expect(types.points).toBe(0) + expect(esm.points).toBe(0) + }) + }) + + it('is reactive to input changes', () => { + const input = createBaseInput() + const score = usePackageScore(input) + + expect(score.value.percentage).toBe(100) + + input.hasProvenance.value = false + expect(score.value.percentage).toBeLessThan(100) + }) +})