From 63b35ebdbe6d62a7d21b12b4a5db895eaa4e3267 Mon Sep 17 00:00:00 2001 From: anilb Date: Thu, 1 Oct 2026 15:07:46 +0200 Subject: [PATCH 1/5] feat: oss index categories Signed-off-by: anilb --- api/src/lib/oss-index.ts | 63 ++++++- .../versions/v1-alpha/oss-index/categories.ts | 145 ++++++++++++++++ api/tests/oss-index-categories.test.ts | 158 ++++++++++++++++++ api/tests/oss-index.test.ts | 34 ++++ 4 files changed, 392 insertions(+), 8 deletions(-) create mode 100644 api/src/versions/v1-alpha/oss-index/categories.ts create mode 100644 api/tests/oss-index-categories.test.ts diff --git a/api/src/lib/oss-index.ts b/api/src/lib/oss-index.ts index 42ff316a2..47327624a 100644 --- a/api/src/lib/oss-index.ts +++ b/api/src/lib/oss-index.ts @@ -16,7 +16,7 @@ export type TopCollectionTuple = [ avgScore: number, ]; -export type TopProjectTuple = [ +type ProjectHead = [ id: string, count: number, name: string, @@ -25,13 +25,20 @@ export type TopProjectTuple = [ avgScore: number, v1HealthScore: number, description: string, - status: string, +]; + +type HealthTail = [ healthScore: number | null, healthLabel: string | null, scaledHealthScore: number | null, healthMaxScore: number | null, ]; +export type TopProjectTuple = [...head: ProjectHead, status: string, ...health: HealthTail]; + +// The categories pipe returns the project tuple without its status slot. +export type CategoryTopProjectTuple = [...head: ProjectHead, ...health: HealthTail]; + // Counts and the UInt64 software value must be non-negative safe integers. The 0-1 averages use // inRange; the dropped v1 score only needs a finite number. const isNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value); @@ -46,9 +53,7 @@ export const isTopCollectionTuple = (value: unknown): value is TopCollectionTupl isCount(value[3]) && inRange(value[4], 1); -export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => - Array.isArray(value) && - value.length >= 13 && +const isProjectHead = (value: unknown[]) => isString(value[0]) && isCount(value[1]) && isString(value[2]) && @@ -56,10 +61,21 @@ export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => isCount(value[4]) && inRange(value[5], 1) && isNumber(value[6]) && - isString(value[7]) && + isString(value[7]); + +const isHealthTail = (value: unknown[], start: number) => + isHealthScore(value[start], value[start + 1], value[start + 3]) && + isNullableNumber(value[start + 2]); + +export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => + Array.isArray(value) && + value.length >= 13 && + isProjectHead(value) && isString(value[8]) && - isHealthScore(value[9], value[10], value[12]) && - isNullableNumber(value[11]); + isHealthTail(value, 9); + +export const isCategoryTopProjectTuple = (value: unknown): value is CategoryTopProjectTuple => + Array.isArray(value) && value.length >= 12 && isProjectHead(value) && isHealthTail(value, 8); export const TopCollection = Type.Object( { @@ -103,6 +119,11 @@ export const TopProject = Type.Object( ); export type TopProject = Static; +export const CategoryTopProject = Type.Omit(TopProject, ['status'], { + title: 'CategoryTopProject', +}); +export type CategoryTopProject = Static; + export const toTopCollection = ([ id, count, @@ -139,6 +160,32 @@ export const toTopProject = ([ status, }); +export const toCategoryTopProject = ([ + id, + count, + name, + logo, + softwareValue, + avgScore, + , + description, + healthScore, + healthLabel, + , + healthMaxScore, +]: CategoryTopProjectTuple): CategoryTopProject => ({ + id, + count, + name, + logoUrl: logo, + description, + softwareValue, + avgScore, + healthScore, + healthLabel: healthLabel as CategoryTopProject['healthLabel'], + healthMaxScore, +}); + export const sortValues = ['totalContributors', 'softwareValue'] as const; export const sortQuery = Type.Object({ diff --git a/api/src/versions/v1-alpha/oss-index/categories.ts b/api/src/versions/v1-alpha/oss-index/categories.ts new file mode 100644 index 000000000..fdd15dd68 --- /dev/null +++ b/api/src/versions/v1-alpha/oss-index/categories.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; +import { Type } from '@sinclair/typebox'; + +import { fetchPipe } from '../../../clients/tinybird.js'; +import { NotFoundError } from '../../../lib/errors.js'; +import { + CategoryTopProject, + isCategoryTopProjectTuple, + isTopCollectionTuple, + sortQuery, + TopCollection, + toCategoryTopProject, + toTopCollection, + type CategoryTopProjectTuple, + type TopCollectionTuple, +} from '../../../lib/oss-index.js'; +import { isString } from '../../../lib/security.js'; + +const groupPipePath = '/v0/pipes/category_groups_list.json'; +const categoriesPipePath = '/v0/pipes/categories_oss_index.json'; + +interface GroupRow { + name: string; + slug: string; + type: string; +} + +interface Row { + id: string; + name: string; + slug: string; + totalContributors: number; + softwareValue: number; + avgScore: number; + topCollections: TopCollectionTuple[]; + topProjects: CategoryTopProjectTuple[]; +} + +const isNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value); + +const isGroupRow = (row: GroupRow) => + isString(row.name) && isString(row.slug) && isString(row.type); + +const isRow = (row: Row) => + isString(row.id) && + isString(row.name) && + isString(row.slug) && + isNumber(row.totalContributors) && + isNumber(row.softwareValue) && + isNumber(row.avgScore) && + Array.isArray(row.topCollections) && + row.topCollections.every(isTopCollectionTuple) && + Array.isArray(row.topProjects) && + row.topProjects.every(isCategoryTopProjectTuple); + +const Query = Type.Object({ + categoryGroupSlug: Type.String({ + minLength: 1, + description: 'Slug of the category group, for example `runtime-horizontal`.', + }), + ...sortQuery.properties, +}); + +const OssIndexCategory = Type.Object( + { + id: Type.String({ description: 'Identifier of the category.' }), + name: Type.String({ description: 'Display name of the category.' }), + slug: Type.String({ description: 'URL slug of the category.' }), + totalContributors: Type.Number({ description: 'Contributors across the category.' }), + softwareValue: Type.Number({ description: 'Software value of the category.' }), + avgScore: Type.Number({ description: 'Average health score of the category, from 0 to 1.' }), + topCollections: Type.Array(TopCollection, { + description: 'Leading collections of the category, ordered by contributors.', + }), + topProjects: Type.Array(CategoryTopProject, { + description: 'Leading projects of the category, ordered by contributors.', + }), + }, + { title: 'OssIndexCategory' }, +); + +const OssIndexCategories = Type.Object( + { + name: Type.String({ description: 'Display name of the category group.' }), + slug: Type.String({ description: 'URL slug of the category group.' }), + type: Type.String({ + description: 'Type of the group, for example `vertical` or `horizontal`.', + }), + categories: Type.Array(OssIndexCategory, { + description: 'Categories of the group in the requested order. Empty when it has none.', + }), + }, + { title: 'OssIndexCategories' }, +); + +const categoryRoutes: FastifyPluginAsyncTypebox = async (scope) => { + scope.get( + '/oss-index/categories', + { + schema: { + tags: ['OSS Index'], + summary: 'List the categories of an OSS Index category group', + description: + 'Returns a category group with each of its categories, their totals and their leading collections and projects. ' + + '`sort` orders the categories by contributors or by software value. ' + + 'Answers 404 when no group has the given slug.', + querystring: Query, + response: { 200: OssIndexCategories }, + }, + }, + async (request) => { + const { categoryGroupSlug, sort } = request.query; + const [group] = await fetchPipe( + request, + groupPipePath, + { slug: categoryGroupSlug }, + isGroupRow, + ); + if (!group) { + throw new NotFoundError('Category group not found'); + } + + const rows = await fetchPipe( + request, + categoriesPipePath, + { categoryGroupSlug, orderBy: sort ?? 'totalContributors' }, + isRow, + ); + return { + name: group.name, + slug: group.slug, + type: group.type, + categories: rows.map((row) => ({ + ...row, + topCollections: row.topCollections.map(toTopCollection), + topProjects: row.topProjects.map(toCategoryTopProject), + })), + }; + }, + ); +}; + +export default categoryRoutes; diff --git a/api/tests/oss-index-categories.test.ts b/api/tests/oss-index-categories.test.ts new file mode 100644 index 000000000..c3e4da709 --- /dev/null +++ b/api/tests/oss-index-categories.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + callsTo, + mockFetch, + pipeCalls, + tinybirdStub, + useApp, + type OpenApiDoc, +} from './helpers/tinybird.js'; + +const route = '/v1-alpha/oss-index/categories'; +const slug = 'runtime-horizontal'; +const groupsPath = '/v0/pipes/category_groups_list.json'; +const categoriesPath = '/v0/pipes/categories_oss_index.json'; + +const group = (overrides: Record = {}) => ({ + name: 'Runtime', + slug, + type: 'horizontal', + ...overrides, +}); + +const row = (overrides: Record = {}) => ({ + id: 'k1', + name: 'Container Runtime', + slug: 'container-runtime', + totalContributors: 325024, + softwareValue: 22334455569, + avgScore: 0.255, + topCollections: [['c1', 292599, 'Container Engines', 14653059859, 0.29]], + topProjects: [['p1', 119387, 'Kubernetes', 'https://logo.test/k.png', 6002018897, 0, 86, 'K8s']], + ...overrides, +}); + +let groups: unknown[] = []; +let rows: unknown[] = []; +const { get } = useApp(); + +beforeEach(() => { + groups = [group()]; + rows = [row()]; + mockFetch.mockImplementation( + tinybirdStub((url) => (url.pathname === groupsPath ? groups : rows)), + ); +}); + +describe('GET /v1-alpha/oss-index/categories', () => { + it('answers the group details with its mapped categories', async () => { + const res = await get(`${route}?categoryGroupSlug=${slug}`); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + name: 'Runtime', + slug, + type: 'horizontal', + categories: [ + { + id: 'k1', + name: 'Container Runtime', + slug: 'container-runtime', + totalContributors: 325024, + softwareValue: 22334455569, + avgScore: 0.255, + topCollections: [ + { + id: 'c1', + count: 292599, + name: 'Container Engines', + softwareValue: 14653059859, + avgScore: 0.29, + }, + ], + topProjects: [ + { + id: 'p1', + count: 119387, + name: 'Kubernetes', + logoUrl: 'https://logo.test/k.png', + description: 'K8s', + softwareValue: 6002018897, + avgScore: 0, + healthScore: 86, + }, + ], + }, + ], + }); + }); + + it('looks the group up by slug, then lists its categories', async () => { + await get(`${route}?categoryGroupSlug=${slug}`); + expect(pipeCalls().map((url) => url.pathname)).toEqual([groupsPath, categoriesPath]); + expect(callsTo(groupsPath)[0]?.searchParams.get('slug')).toBe(slug); + const [call] = callsTo(categoriesPath); + expect(call?.searchParams.get('categoryGroupSlug')).toBe(slug); + expect(call?.searchParams.get('orderBy')).toBe('totalContributors'); + }); + + it('forwards an explicit sort as orderBy', async () => { + await get(`${route}?categoryGroupSlug=${slug}&sort=softwareValue`); + expect(callsTo(categoriesPath)[0]?.searchParams.get('orderBy')).toBe('softwareValue'); + }); + + it('answers an empty category list', async () => { + rows = []; + const res = await get(`${route}?categoryGroupSlug=${slug}`); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ...group(), categories: [] }); + }); + + it('answers 404 for an unknown group without listing categories', async () => { + groups = []; + const res = await get(`${route}?categoryGroupSlug=nope`); + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ code: 'not_found' }); + expect(callsTo(categoriesPath)).toHaveLength(0); + }); + + it.each([ + ['a missing slug', ''], + ['an empty slug', '?categoryGroupSlug='], + ['an unknown sort', `?categoryGroupSlug=${slug}&sort=bogus`], + ])('rejects %s with 400 before calling a pipe', async (_label, qs) => { + const res = await get(`${route}${qs}`); + expect(res.statusCode).toBe(400); + expect(pipeCalls()).toHaveLength(0); + }); + + it.each([ + ['a missing category id', row({ id: undefined })], + ['a non-array topProjects', row({ topProjects: 'x' })], + ['a malformed collection tuple', row({ topCollections: [['c1', 1, null, 1, 1]] })], + ['a short project tuple', row({ topProjects: [['p1', 1, 'n']] })], + ['a nine slot project tuple', row({ topProjects: [['p1', 1, 'n', 'l', 1, 1, 1, 'd', 's']] })], + ['a null project logo', row({ topProjects: [['p1', 1, 'n', null, 1, 1, 1, 'd']] })], + ])('answers 503 upstream_unavailable for %s', async (_label, bad) => { + rows = [bad]; + const res = await get(`${route}?categoryGroupSlug=${slug}`); + expect(res.statusCode).toBe(503); + expect(res.body).toContain('upstream_unavailable'); + }); + + it('answers 503 upstream_unavailable for a malformed group row', async () => { + groups = [group({ name: null })]; + const res = await get(`${route}?categoryGroupSlug=${slug}`); + expect(res.statusCode).toBe(503); + expect(res.body).toContain('upstream_unavailable'); + expect(callsTo(categoriesPath)).toHaveLength(0); + }); + + it('is listed in the OpenAPI spec under the OSS Index tag', async () => { + const spec = (await get('/v1-alpha/openapi.json')).json(); + const op = spec.paths[route]?.get as { tags?: string[] } | undefined; + expect(op?.tags).toEqual(['OSS Index']); + }); +}); diff --git a/api/tests/oss-index.test.ts b/api/tests/oss-index.test.ts index 9e0b3f8b0..bc3a3fe4e 100644 --- a/api/tests/oss-index.test.ts +++ b/api/tests/oss-index.test.ts @@ -4,7 +4,9 @@ import { describe, expect, it } from 'vitest'; import { isTopCollectionTuple, + isCategoryTopProjectTuple, isTopProjectTuple, + toCategoryTopProject, toTopCollection, toTopProject, } from '../src/lib/oss-index.js'; @@ -67,6 +69,38 @@ describe('toTopProject', () => { }); }); +const categoryProjectTuple = projectTuple.slice(0, 8); + +describe('toCategoryTopProject', () => { + it('names each tuple slot and renames logo to logoUrl', () => { + expect(toCategoryTopProject(categoryProjectTuple as never)).toEqual({ + id: 'p1', + count: 30, + name: 'Node.js', + logoUrl: 'https://logo.test/n.png', + description: 'Runtime', + softwareValue: 2400, + avgScore: 0.8, + healthScore: 86, + }); + }); +}); + +describe('isCategoryTopProjectTuple', () => { + it('accepts an eight slot tuple', () => { + expect(isCategoryTopProjectTuple(categoryProjectTuple)).toBe(true); + }); + + it.each([ + ['too short', projectTuple.slice(0, 7)], + ['nine slots', projectTuple], + ['a null logo', ['p1', 30, 'n', null, 2400, 0.8, 86, 'd']], + ['a string health score', ['p1', 30, 'n', 'l', 2400, 0.8, '86', 'd']], + ])('rejects a tuple that is %s', (_label, value) => { + expect(isCategoryTopProjectTuple(value)).toBe(false); + }); +}); + describe('tuple guards', () => { it('accept well-formed tuples', () => { expect(isTopCollectionTuple(collectionTuple)).toBe(true); From 7eec2cff69b6f1f1cdfe9fe4125cc1d7b09a0151 Mon Sep 17 00:00:00 2001 From: anilb Date: Thu, 1 Oct 2026 15:13:30 +0200 Subject: [PATCH 2/5] fix: category count guards Signed-off-by: anilb --- api/src/versions/v1-alpha/oss-index/categories.ts | 13 ++++++++----- api/tests/oss-index-categories.test.ts | 3 +++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/api/src/versions/v1-alpha/oss-index/categories.ts b/api/src/versions/v1-alpha/oss-index/categories.ts index fdd15dd68..68d7f221c 100644 --- a/api/src/versions/v1-alpha/oss-index/categories.ts +++ b/api/src/versions/v1-alpha/oss-index/categories.ts @@ -16,7 +16,7 @@ import { type CategoryTopProjectTuple, type TopCollectionTuple, } from '../../../lib/oss-index.js'; -import { isString } from '../../../lib/security.js'; +import { isCount, isString } from '../../../lib/security.js'; const groupPipePath = '/v0/pipes/category_groups_list.json'; const categoriesPipePath = '/v0/pipes/categories_oss_index.json'; @@ -47,8 +47,8 @@ const isRow = (row: Row) => isString(row.id) && isString(row.name) && isString(row.slug) && - isNumber(row.totalContributors) && - isNumber(row.softwareValue) && + isCount(row.totalContributors) && + isCount(row.softwareValue) && isNumber(row.avgScore) && Array.isArray(row.topCollections) && row.topCollections.every(isTopCollectionTuple) && @@ -68,8 +68,11 @@ const OssIndexCategory = Type.Object( id: Type.String({ description: 'Identifier of the category.' }), name: Type.String({ description: 'Display name of the category.' }), slug: Type.String({ description: 'URL slug of the category.' }), - totalContributors: Type.Number({ description: 'Contributors across the category.' }), - softwareValue: Type.Number({ description: 'Software value of the category.' }), + totalContributors: Type.Integer({ + minimum: 0, + description: 'Contributors across the category.', + }), + softwareValue: Type.Integer({ minimum: 0, description: 'Software value of the category.' }), avgScore: Type.Number({ description: 'Average health score of the category, from 0 to 1.' }), topCollections: Type.Array(TopCollection, { description: 'Leading collections of the category, ordered by contributors.', diff --git a/api/tests/oss-index-categories.test.ts b/api/tests/oss-index-categories.test.ts index c3e4da709..c2fab9708 100644 --- a/api/tests/oss-index-categories.test.ts +++ b/api/tests/oss-index-categories.test.ts @@ -135,6 +135,9 @@ describe('GET /v1-alpha/oss-index/categories', () => { ['a short project tuple', row({ topProjects: [['p1', 1, 'n']] })], ['a nine slot project tuple', row({ topProjects: [['p1', 1, 'n', 'l', 1, 1, 1, 'd', 's']] })], ['a null project logo', row({ topProjects: [['p1', 1, 'n', null, 1, 1, 1, 'd']] })], + ['a negative totalContributors', row({ totalContributors: -1 })], + ['a fractional softwareValue', row({ softwareValue: 1.5 })], + ['a fractional project count', row({ topProjects: [['p1', 1.5, 'n', 'l', 1, 1, 1, 'd']] })], ])('answers 503 upstream_unavailable for %s', async (_label, bad) => { rows = [bad]; const res = await get(`${route}?categoryGroupSlug=${slug}`); From 8adf0917bd3f34c77ee0eb2535b0dcdad6469f90 Mon Sep 17 00:00:00 2001 From: anilb Date: Wed, 14 Oct 2026 14:28:18 +0200 Subject: [PATCH 3/5] fix: category tuple shape Signed-off-by: anilb --- api/tests/oss-index-categories.test.ts | 18 ++++++++++++++++-- api/tests/oss-index.test.ts | 8 +++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/api/tests/oss-index-categories.test.ts b/api/tests/oss-index-categories.test.ts index c2fab9708..13e9ce999 100644 --- a/api/tests/oss-index-categories.test.ts +++ b/api/tests/oss-index-categories.test.ts @@ -31,7 +31,22 @@ const row = (overrides: Record = {}) => ({ softwareValue: 22334455569, avgScore: 0.255, topCollections: [['c1', 292599, 'Container Engines', 14653059859, 0.29]], - topProjects: [['p1', 119387, 'Kubernetes', 'https://logo.test/k.png', 6002018897, 0, 86, 'K8s']], + topProjects: [ + [ + 'p1', + 119387, + 'Kubernetes', + 'https://logo.test/k.png', + 6002018897, + 0, + 86, + 'K8s', + 74, + 'healthy', + 74, + 100, + ], + ], ...overrides, }); @@ -133,7 +148,6 @@ describe('GET /v1-alpha/oss-index/categories', () => { ['a non-array topProjects', row({ topProjects: 'x' })], ['a malformed collection tuple', row({ topCollections: [['c1', 1, null, 1, 1]] })], ['a short project tuple', row({ topProjects: [['p1', 1, 'n']] })], - ['a nine slot project tuple', row({ topProjects: [['p1', 1, 'n', 'l', 1, 1, 1, 'd', 's']] })], ['a null project logo', row({ topProjects: [['p1', 1, 'n', null, 1, 1, 1, 'd']] })], ['a negative totalContributors', row({ totalContributors: -1 })], ['a fractional softwareValue', row({ softwareValue: 1.5 })], diff --git a/api/tests/oss-index.test.ts b/api/tests/oss-index.test.ts index bc3a3fe4e..d4af87a0e 100644 --- a/api/tests/oss-index.test.ts +++ b/api/tests/oss-index.test.ts @@ -91,9 +91,15 @@ describe('isCategoryTopProjectTuple', () => { expect(isCategoryTopProjectTuple(categoryProjectTuple)).toBe(true); }); + it.each([ + ['a scored', [...categoryProjectTuple, 74, 'healthy', 74, 100]], + ['an unscored', [...categoryProjectTuple, null, null, null, null]], + ])('accepts a twelve slot tuple with %s health tail', (_label, value) => { + expect(isCategoryTopProjectTuple(value)).toBe(true); + }); + it.each([ ['too short', projectTuple.slice(0, 7)], - ['nine slots', projectTuple], ['a null logo', ['p1', 30, 'n', null, 2400, 0.8, 86, 'd']], ['a string health score', ['p1', 30, 'n', 'l', 2400, 0.8, '86', 'd']], ])('rejects a tuple that is %s', (_label, value) => { From 53d8d91156759ae5eb5754cc7d1213d4a974c06d Mon Sep 17 00:00:00 2001 From: anilb Date: Wed, 14 Oct 2026 14:46:46 +0200 Subject: [PATCH 4/5] test: v2 category health Signed-off-by: anilb --- api/tests/oss-index-categories.test.ts | 18 ++++++++++++--- api/tests/oss-index.test.ts | 31 ++++++++++++++++---------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/api/tests/oss-index-categories.test.ts b/api/tests/oss-index-categories.test.ts index 13e9ce999..42c9bb8b3 100644 --- a/api/tests/oss-index-categories.test.ts +++ b/api/tests/oss-index-categories.test.ts @@ -96,7 +96,9 @@ describe('GET /v1-alpha/oss-index/categories', () => { description: 'K8s', softwareValue: 6002018897, avgScore: 0, - healthScore: 86, + healthScore: 74, + healthLabel: 'healthy', + healthMaxScore: 100, }, ], }, @@ -148,10 +150,20 @@ describe('GET /v1-alpha/oss-index/categories', () => { ['a non-array topProjects', row({ topProjects: 'x' })], ['a malformed collection tuple', row({ topCollections: [['c1', 1, null, 1, 1]] })], ['a short project tuple', row({ topProjects: [['p1', 1, 'n']] })], - ['a null project logo', row({ topProjects: [['p1', 1, 'n', null, 1, 1, 1, 'd']] })], + [ + 'a project tuple without health fields', + row({ topProjects: [['p1', 1, 'n', 'l', 1, 1, 1, 'd']] }), + ], + [ + 'a null project logo', + row({ topProjects: [['p1', 1, 'n', null, 1, 1, 1, 'd', null, null, null, null]] }), + ], ['a negative totalContributors', row({ totalContributors: -1 })], ['a fractional softwareValue', row({ softwareValue: 1.5 })], - ['a fractional project count', row({ topProjects: [['p1', 1.5, 'n', 'l', 1, 1, 1, 'd']] })], + [ + 'a fractional project count', + row({ topProjects: [['p1', 1.5, 'n', 'l', 1, 1, 1, 'd', null, null, null, null]] }), + ], ])('answers 503 upstream_unavailable for %s', async (_label, bad) => { rows = [bad]; const res = await get(`${route}?categoryGroupSlug=${slug}`); diff --git a/api/tests/oss-index.test.ts b/api/tests/oss-index.test.ts index d4af87a0e..90e5557f3 100644 --- a/api/tests/oss-index.test.ts +++ b/api/tests/oss-index.test.ts @@ -69,10 +69,13 @@ describe('toTopProject', () => { }); }); -const categoryProjectTuple = projectTuple.slice(0, 8); +// The categories pipe sends the project tuple without its status slot. +const categoryProjectTuple = [...projectTuple.slice(0, 8), ...projectTuple.slice(9)]; +const withCategorySlot = (index: number, value: unknown) => + categoryProjectTuple.map((slot, i) => (i === index ? value : slot)); describe('toCategoryTopProject', () => { - it('names each tuple slot and renames logo to logoUrl', () => { + it('names each tuple slot, renames logo to logoUrl and takes the v2 health fields', () => { expect(toCategoryTopProject(categoryProjectTuple as never)).toEqual({ id: 'p1', count: 30, @@ -81,27 +84,31 @@ describe('toCategoryTopProject', () => { description: 'Runtime', softwareValue: 2400, avgScore: 0.8, - healthScore: 86, + healthScore: 74, + healthLabel: 'healthy', + healthMaxScore: 100, }); }); }); describe('isCategoryTopProjectTuple', () => { - it('accepts an eight slot tuple', () => { - expect(isCategoryTopProjectTuple(categoryProjectTuple)).toBe(true); - }); - it.each([ - ['a scored', [...categoryProjectTuple, 74, 'healthy', 74, 100]], - ['an unscored', [...categoryProjectTuple, null, null, null, null]], + ['a scored', categoryProjectTuple], + ['an unscored', [...categoryProjectTuple.slice(0, 8), null, null, null, null]], ])('accepts a twelve slot tuple with %s health tail', (_label, value) => { expect(isCategoryTopProjectTuple(value)).toBe(true); }); it.each([ - ['too short', projectTuple.slice(0, 7)], - ['a null logo', ['p1', 30, 'n', null, 2400, 0.8, 86, 'd']], - ['a string health score', ['p1', 30, 'n', 'l', 2400, 0.8, '86', 'd']], + ['too short', categoryProjectTuple.slice(0, 11)], + ['without the health fields', categoryProjectTuple.slice(0, 8)], + ['a null logo', withCategorySlot(3, null)], + ['a string v1 health score', withCategorySlot(6, '86')], + ['an unknown health label', withCategorySlot(9, 'great')], + [ + 'a health score above its maximum', + [...categoryProjectTuple.slice(0, 8), 80, 'healthy', 100, 75], + ], ])('rejects a tuple that is %s', (_label, value) => { expect(isCategoryTopProjectTuple(value)).toBe(false); }); From 3b61d7cacb7ea72a7ba81d5c2596b60eec59a732 Mon Sep 17 00:00:00 2001 From: anilb Date: Wed, 14 Oct 2026 15:11:36 +0200 Subject: [PATCH 5/5] fix: category avg bounds Signed-off-by: anilb --- api/src/lib/oss-index.ts | 2 +- api/src/versions/v1-alpha/oss-index/categories.ts | 12 +++++++----- api/tests/oss-index-categories.test.ts | 10 ++++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/api/src/lib/oss-index.ts b/api/src/lib/oss-index.ts index 47327624a..52c269ee9 100644 --- a/api/src/lib/oss-index.ts +++ b/api/src/lib/oss-index.ts @@ -59,7 +59,7 @@ const isProjectHead = (value: unknown[]) => isString(value[2]) && isString(value[3]) && isCount(value[4]) && - inRange(value[5], 1) && + inRange(value[5] as number, 1) && isNumber(value[6]) && isString(value[7]); diff --git a/api/src/versions/v1-alpha/oss-index/categories.ts b/api/src/versions/v1-alpha/oss-index/categories.ts index 68d7f221c..4f190270e 100644 --- a/api/src/versions/v1-alpha/oss-index/categories.ts +++ b/api/src/versions/v1-alpha/oss-index/categories.ts @@ -16,7 +16,7 @@ import { type CategoryTopProjectTuple, type TopCollectionTuple, } from '../../../lib/oss-index.js'; -import { isCount, isString } from '../../../lib/security.js'; +import { inRange, isCount, isString } from '../../../lib/security.js'; const groupPipePath = '/v0/pipes/category_groups_list.json'; const categoriesPipePath = '/v0/pipes/categories_oss_index.json'; @@ -38,8 +38,6 @@ interface Row { topProjects: CategoryTopProjectTuple[]; } -const isNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value); - const isGroupRow = (row: GroupRow) => isString(row.name) && isString(row.slug) && isString(row.type); @@ -49,7 +47,7 @@ const isRow = (row: Row) => isString(row.slug) && isCount(row.totalContributors) && isCount(row.softwareValue) && - isNumber(row.avgScore) && + inRange(row.avgScore, 1) && Array.isArray(row.topCollections) && row.topCollections.every(isTopCollectionTuple) && Array.isArray(row.topProjects) && @@ -73,7 +71,11 @@ const OssIndexCategory = Type.Object( description: 'Contributors across the category.', }), softwareValue: Type.Integer({ minimum: 0, description: 'Software value of the category.' }), - avgScore: Type.Number({ description: 'Average health score of the category, from 0 to 1.' }), + avgScore: Type.Number({ + minimum: 0, + maximum: 1, + description: 'Average health score of the category, from 0 to 1.', + }), topCollections: Type.Array(TopCollection, { description: 'Leading collections of the category, ordered by contributors.', }), diff --git a/api/tests/oss-index-categories.test.ts b/api/tests/oss-index-categories.test.ts index 42c9bb8b3..199ae2382 100644 --- a/api/tests/oss-index-categories.test.ts +++ b/api/tests/oss-index-categories.test.ts @@ -6,6 +6,7 @@ import { callsTo, mockFetch, pipeCalls, + resolveSchema, tinybirdStub, useApp, type OpenApiDoc, @@ -159,6 +160,8 @@ describe('GET /v1-alpha/oss-index/categories', () => { row({ topProjects: [['p1', 1, 'n', null, 1, 1, 1, 'd', null, null, null, null]] }), ], ['a negative totalContributors', row({ totalContributors: -1 })], + ['a negative avgScore', row({ avgScore: -0.1 })], + ['an avgScore above 1', row({ avgScore: 1.1 })], ['a fractional softwareValue', row({ softwareValue: 1.5 })], [ 'a fractional project count', @@ -184,4 +187,11 @@ describe('GET /v1-alpha/oss-index/categories', () => { const op = spec.paths[route]?.get as { tags?: string[] } | undefined; expect(op?.tags).toEqual(['OSS Index']); }); + + it('publishes the 0 to 1 bounds the row guard enforces on avgScore', async () => { + const spec = (await get('/v1-alpha/openapi.json')).json(); + const ok = spec.paths[route]?.get?.responses['200']?.content['application/json']?.schema; + const category = resolveSchema(spec, resolveSchema(spec, ok)?.properties?.categories?.items); + expect(category?.properties?.avgScore).toMatchObject({ minimum: 0, maximum: 1 }); + }); });