From 34c5eb259cecc5662677f164965eafacd8f64b2e Mon Sep 17 00:00:00 2001 From: Kim T Date: Mon, 20 Jul 2026 19:53:56 -0700 Subject: [PATCH 1/2] feat: add attested and downloads fields to File/Package types Supports two new registry signals discussed alongside the recent security fixes: - `attested?: boolean` on FileInterface - whether a file has a GitHub Artifact Attestation linking it back to the CI run/commit that built it. Computed once at import time (registry/fetch.ts, in a follow-up PR) since a published file's hash/url/release never change afterwards - unlike `downloads`, this isn't recomputed on every build. - `downloads?: number` at three levels - per-file (FileInterface), per-version rollup (PackageBase, summed via the new packageDownloadsTotal() helper), and per-package rollup across all versions (PackageInterface, via the new Package.getTotalDownloads(), wired into toJSON()). Recomputed at registry build time from the GitHub Releases API (registry/downloads.ts, in a follow-up PR) since download counts change continuously. Both fields are omitted rather than written as `false`/`0` - keeps generated JSON smaller, and neither "not fetched" nor "genuinely zero" is worth distinguishing for either signal. PackageFileValidator extended to type-check them when present. Verified packageDownloadsTotal/getTotalDownloads rollup math directly with dedicated tests (not just zero-value fixtures), and separately end-to-end against live GitHub API data for a real registry package (aaronaanderson/Terrain) during development - see the registry-side follow-up PR for that verification. No behavior change for existing consumers: both fields are optional and additive to the existing schema. Co-Authored-By: Claude Opus 4.8 --- src/classes/Package.ts | 15 +++++++++++++++ src/helpers/package.ts | 9 +++++++++ src/types/File.ts | 9 +++++++++ src/types/Package.ts | 7 +++++++ tests/classes/Package.test.ts | 8 ++++++++ tests/helpers/package.test.ts | 14 ++++++++++++++ 6 files changed, 62 insertions(+) diff --git a/src/classes/Package.ts b/src/classes/Package.ts index 0dbaf9f..02c83bb 100644 --- a/src/classes/Package.ts +++ b/src/classes/Package.ts @@ -73,11 +73,26 @@ export class Package extends Base { return Array.from(this.versions.keys()).sort(semver.rcompare)[0] || '0.0.0'; } + // Rollup across every published version's own downloads rollup (itself a sum of that + // version's files) - i.e. total downloads for this package, all-time. Pure aggregation over + // whatever `downloads` values are already present; does not fetch anything itself. + getTotalDownloads(): number { + let total = 0; + for (const [, pkgVersion] of this.versions) { + total += pkgVersion.downloads || 0; + } + return total; + } + toJSON() { + const totalDownloads = this.getTotalDownloads(); return { slug: this.slug, version: this.version, versions: Object.fromEntries(this.versions), + // Omit rather than write `downloads: 0` - keeps generated JSON smaller, and "0" wouldn't + // distinguish "genuinely zero downloads" from "not fetched yet" anyway. + ...(totalDownloads > 0 && { downloads: totalDownloads }), }; } } diff --git a/src/helpers/package.ts b/src/helpers/package.ts index 93b541d..ac2bd11 100644 --- a/src/helpers/package.ts +++ b/src/helpers/package.ts @@ -55,6 +55,13 @@ export function packageVersionLatest(pkg: PackageInterface) { return Array.from(Object.keys(pkg.versions)).sort(semver.rcompare)[0] || '0.0.0'; } +// Sums files[].downloads for a single version - pure aggregation, no network access. Callers +// that have populated each file's `downloads` from an external source (e.g. the registry's +// build-time GitHub fetch) use this to set the version-level rollup before export. +export function packageDownloadsTotal(pkgVersion: PackageVersion): number { + return pkgVersion.files.reduce((total, file) => total + (file.downloads || 0), 0); +} + // This is a first version using zod library for validation. // If it works well, consider updating all types to infer from Zod objects. // This will remove duplication of code between types and validators. @@ -67,6 +74,8 @@ export const PackageSystemValidator = z.object({ export const PackageFileValidator = z.object({ architectures: z.nativeEnum(Architecture).array().min(1).max(Object.keys(Architecture).length), + attested: z.boolean().optional(), + downloads: z.number().min(0).optional(), sha256: z.string().length(64), size: z.number().min(8).max(9999999999), systems: PackageSystemValidator.array().min(1).max(Object.keys(SystemType).length), diff --git a/src/types/File.ts b/src/types/File.ts index b3c4f91..e751a8b 100644 --- a/src/types/File.ts +++ b/src/types/File.ts @@ -4,6 +4,15 @@ import { System } from './System.js'; export interface FileInterface { architectures: Architecture[]; + // Whether this exact file (identified by its sha256) has a GitHub Artifact Attestation + // linking it back to the CI run/commit that built it. Computed once at import time (the + // hash/url/release this is tied to never changes for a published version), not recomputed + // on every build - see registry/src/fetch.ts. + attested?: boolean; + // Cumulative download count for this exact file, sourced from the GitHub Releases API. + // Recomputed at registry build time (unlike attested, this changes continuously) - see + // registry/src/downloads.ts. Not authored/committed metadata. + downloads?: number; open?: string; sha256: string; size: number; diff --git a/src/types/Package.ts b/src/types/Package.ts index c049c6d..13ed8c8 100644 --- a/src/types/Package.ts +++ b/src/types/Package.ts @@ -8,6 +8,10 @@ export interface PackageInterface { slug: string; version: string; versions: PackageVersions; + // Rollup of every version's `downloads` (itself a rollup of that version's files) - i.e. + // total downloads across all published versions of this package, all-time. Computed by + // Package.getTotalDownloads(), not authored/committed data. + downloads?: number; } export interface PackageVersions { @@ -21,6 +25,9 @@ export interface PackageBase { date: string; description: string; donate?: string; + // Rollup of this version's files[].downloads (sum across all files for this release). + // Computed at registry build time - see packageDownloadsTotal() and downloads.ts. + downloads?: number; image: string; installed?: boolean; license: License; diff --git a/tests/classes/Package.test.ts b/tests/classes/Package.test.ts index d32e167..3050abe 100644 --- a/tests/classes/Package.test.ts +++ b/tests/classes/Package.test.ts @@ -59,6 +59,14 @@ test('Package latest version', () => { expect(pkg.latestVersion()).toEqual('3.2.1'); }); +test('Package total downloads rolls up across versions', () => { + const pkg = new Package(PLUGIN_PACKAGE.slug); + pkg.addVersion('1.3.0', { ...PLUGIN, downloads: 100 }); + pkg.addVersion(PLUGIN_PACKAGE.version, { ...PLUGIN, downloads: 250 }); + expect(pkg.getTotalDownloads()).toEqual(350); + expect((pkg.toJSON() as PackageInterface).downloads).toEqual(350); +}); + test('Package get version or latest', () => { const PLUGIN_V2: PackageVersion = structuredClone(PLUGIN); PLUGIN_V2.name = 'Plugin V2'; diff --git a/tests/helpers/package.test.ts b/tests/helpers/package.test.ts index 33f1cab..58168b9 100644 --- a/tests/helpers/package.test.ts +++ b/tests/helpers/package.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'vitest'; import { packageCompatibleFiles, + packageDownloadsTotal, packageRecommendations, packageVersionLatest, PackageVersionValidator, @@ -15,6 +16,19 @@ test('Package version latest', () => { expect(packageVersionLatest(PLUGIN_PACKAGE_MULTIPLE)).toEqual('1.3.2'); }); +test('Package downloads total sums across files', () => { + const pluginWithDownloads: PackageVersion = { + ...PLUGIN, + files: PLUGIN.files.map((file, index) => ({ ...file, downloads: index === 0 ? undefined : (index + 1) * 10 })), + }; + // files[0].downloads left undefined - missing/not-yet-fetched data must count as 0, not NaN. + expect(packageDownloadsTotal(pluginWithDownloads)).toEqual(20 + 30 + 40 + 50); +}); + +test('Package downloads total is zero when no files have downloads', () => { + expect(packageDownloadsTotal(PLUGIN)).toEqual(0); +}); + test('Package recommendations pass', () => { expect(packageRecommendations(PLUGIN)).toEqual([ { From 405ba09af40ccae3e121b5a49b30e0530a2f669d Mon Sep 17 00:00:00 2001 From: Kim T Date: Mon, 20 Jul 2026 20:01:06 -0700 Subject: [PATCH 2/2] 0.1.55 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8ab4c52..339f100 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@open-audio-stack/core", - "version": "0.1.54", + "version": "0.1.55", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@open-audio-stack/core", - "version": "0.1.54", + "version": "0.1.55", "license": "cc0-1.0", "dependencies": { "@vscode/sudo-prompt": "^9.3.1", diff --git a/package.json b/package.json index ab2b846..af59c27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@open-audio-stack/core", - "version": "0.1.54", + "version": "0.1.55", "description": "Open-source audio plugin management software", "type": "module", "main": "./build/index.js",