From 6945ff56021d1e7457a9bad4a96b13bfd16ac0ff Mon Sep 17 00:00:00 2001 From: anilb Date: Thu, 1 Oct 2026 15:02:29 +0200 Subject: [PATCH 1/5] feat: oss index groups Signed-off-by: anilb --- api/src/lib/oss-index.ts | 120 +++++++++++++++ api/src/versions/v1-alpha/oss-index/groups.ts | 121 ++++++++++++++++ api/tests/oss-index-groups.test.ts | 137 ++++++++++++++++++ api/tests/oss-index.test.ts | 78 ++++++++++ 4 files changed, 456 insertions(+) create mode 100644 api/src/lib/oss-index.ts create mode 100644 api/src/versions/v1-alpha/oss-index/groups.ts create mode 100644 api/tests/oss-index-groups.test.ts create mode 100644 api/tests/oss-index.test.ts diff --git a/api/src/lib/oss-index.ts b/api/src/lib/oss-index.ts new file mode 100644 index 000000000..3fa8134a8 --- /dev/null +++ b/api/src/lib/oss-index.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import { Type, type Static } from '@sinclair/typebox'; + +import { dataEnum, isString } from './security.js'; + +// The pipes return these rows as positional tuples, so each slot is checked by type before the +// route names it. The columns are non-nullable in the pipes, so a null is a malformed row. +export type TopCollectionTuple = [ + id: string, + count: number, + name: string, + softwareValue: number, + avgScore: number, +]; + +export type TopProjectTuple = [ + id: string, + count: number, + name: string, + logo: string, + softwareValue: number, + avgScore: number, + healthScore: number, + description: string, + status: string, +]; + +const isNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value); + +export const isTopCollectionTuple = (value: unknown): value is TopCollectionTuple => + Array.isArray(value) && + value.length === 5 && + isString(value[0]) && + isNumber(value[1]) && + isString(value[2]) && + isNumber(value[3]) && + isNumber(value[4]); + +export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => + Array.isArray(value) && + value.length === 9 && + isString(value[0]) && + isNumber(value[1]) && + isString(value[2]) && + isString(value[3]) && + isNumber(value[4]) && + isNumber(value[5]) && + isNumber(value[6]) && + isString(value[7]) && + isString(value[8]); + +export const TopCollection = Type.Object( + { + id: Type.String({ description: 'Identifier of the collection.' }), + count: Type.Number({ description: 'Contributors in the collection.' }), + name: Type.String({ description: 'Display name of the collection.' }), + softwareValue: Type.Number({ description: 'Software value of the collection.' }), + avgScore: Type.Number({ description: 'Average health score of the collection, from 0 to 1.' }), + }, + { title: 'TopCollection' }, +); +export type TopCollection = Static; + +export const TopProject = Type.Object( + { + id: Type.String({ description: 'Identifier of the project.' }), + count: Type.Number({ description: 'Contributors to the project.' }), + name: Type.String({ description: 'Display name of the project.' }), + logoUrl: Type.String({ description: 'URL of the project logo.' }), + description: Type.String({ description: 'Short description of the project.' }), + softwareValue: Type.Number({ description: 'Software value of the project.' }), + avgScore: Type.Number({ description: 'Average score of the project, from 0 to 1.' }), + healthScore: Type.Number({ description: 'Health score of the project, from 0 to 100.' }), + status: Type.String({ description: 'Lifecycle status of the project, for example `active`.' }), + }, + { title: 'TopProject' }, +); +export type TopProject = Static; + +export const toTopCollection = ([ + id, + count, + name, + softwareValue, + avgScore, +]: TopCollectionTuple): TopCollection => ({ id, count, name, softwareValue, avgScore }); + +export const toTopProject = ([ + id, + count, + name, + logo, + softwareValue, + avgScore, + healthScore, + description, + status, +]: TopProjectTuple): TopProject => ({ + id, + count, + name, + logoUrl: logo, + description, + softwareValue, + avgScore, + healthScore, + status, +}); + +export const sortValues = ['totalContributors', 'softwareValue'] as const; + +export const sortQuery = Type.Object({ + sort: Type.Optional( + dataEnum( + sortValues, + 'Orders the results, highest first. Defaults to `totalContributors`; `softwareValue` orders by software value.', + ), + ), +}); diff --git a/api/src/versions/v1-alpha/oss-index/groups.ts b/api/src/versions/v1-alpha/oss-index/groups.ts new file mode 100644 index 000000000..d19921be3 --- /dev/null +++ b/api/src/versions/v1-alpha/oss-index/groups.ts @@ -0,0 +1,121 @@ +// 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 { + isTopCollectionTuple, + isTopProjectTuple, + sortQuery, + TopCollection, + toTopCollection, + TopProject, + toTopProject, + type TopCollectionTuple, + type TopProjectTuple, +} from '../../../lib/oss-index.js'; +import { isString } from '../../../lib/security.js'; + +const pipePath = '/v0/pipes/category_groups_oss_index.json'; + +interface Row { + id: string; + name: string; + type: string; + slug: string; + totalContributors: number; + softwareValue: number; + avgScore: number; + projectCount: number; + topCollections: TopCollectionTuple[]; + topProjects: TopProjectTuple[]; +} + +const isNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value); + +const isRow = (row: Row) => + isString(row.id) && + isString(row.name) && + isString(row.type) && + isString(row.slug) && + isNumber(row.totalContributors) && + isNumber(row.softwareValue) && + isNumber(row.avgScore) && + isNumber(row.projectCount) && + Array.isArray(row.topCollections) && + row.topCollections.every(isTopCollectionTuple) && + Array.isArray(row.topProjects) && + row.topProjects.every(isTopProjectTuple); + +const Query = Type.Object({ + type: Type.Optional( + Type.String({ + description: 'Keeps the groups of this type, for example `vertical` or `horizontal`.', + }), + ), + ...sortQuery.properties, +}); + +const OssIndexGroup = Type.Object( + { + id: Type.String({ description: 'Identifier of the category group.' }), + name: Type.String({ description: 'Display name of the category group.' }), + type: Type.String({ + description: 'Type of the group, for example `vertical` or `horizontal`.', + }), + slug: Type.String({ description: 'URL slug of the category group.' }), + totalContributors: Type.Number({ description: 'Contributors across the group.' }), + softwareValue: Type.Number({ description: 'Software value of the group.' }), + avgScore: Type.Number({ description: 'Average health score of the group, from 0 to 1.' }), + projectCount: Type.Number({ description: 'Projects in the group.' }), + topCollections: Type.Array(TopCollection, { + description: 'Leading collections of the group, ordered by contributors.', + }), + topProjects: Type.Array(TopProject, { + description: 'Leading projects of the group, ordered by contributors.', + }), + }, + { title: 'OssIndexGroup' }, +); + +const Groups = Type.Object({ + data: Type.Array(OssIndexGroup, { + description: 'Category groups in the requested order. Empty when none match `type`.', + }), +}); + +const groupRoutes: FastifyPluginAsyncTypebox = async (scope) => { + scope.get( + '/oss-index/groups', + { + schema: { + tags: ['OSS Index'], + summary: 'List the OSS Index category groups', + description: + 'Returns every category group with its totals and its leading collections and projects. ' + + '`type` keeps one group type, and `sort` orders the groups by contributors or by software value.', + querystring: Query, + response: { 200: Groups }, + }, + }, + async (request) => { + const { type, sort } = request.query; + const rows = await fetchPipe( + request, + pipePath, + { type: type || undefined, orderBy: sort ?? 'totalContributors' }, + isRow, + ); + return { + data: rows.map((row) => ({ + ...row, + topCollections: row.topCollections.map(toTopCollection), + topProjects: row.topProjects.map(toTopProject), + })), + }; + }, + ); +}; + +export default groupRoutes; diff --git a/api/tests/oss-index-groups.test.ts b/api/tests/oss-index-groups.test.ts new file mode 100644 index 000000000..938199941 --- /dev/null +++ b/api/tests/oss-index-groups.test.ts @@ -0,0 +1,137 @@ +// 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/groups'; +const pipePath = '/v0/pipes/category_groups_oss_index.json'; + +const row = (overrides: Record = {}) => ({ + id: 'g1', + name: 'Programming Languages', + type: 'horizontal', + slug: 'programming-languages-horizontal', + totalContributors: 9305223, + softwareValue: 135666024614, + avgScore: 0.41, + projectCount: 5279, + topCollections: [['c1', 726549, 'Frontend', 1917065599, 0.45]], + topProjects: [ + ['p1', 315948, 'Node.js', 'https://logo.test/n.png', 24048486264, 0, 86, 'Runtime', 'active'], + ], + ...overrides, +}); + +let rows: unknown[] = []; +const { get } = useApp(); + +beforeEach(() => { + rows = [row()]; + mockFetch.mockImplementation(tinybirdStub(() => rows)); +}); + +describe('GET /v1-alpha/oss-index/groups', () => { + it('maps group fields and converts both tuple lists', async () => { + const res = await get(route); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + data: [ + { + id: 'g1', + name: 'Programming Languages', + type: 'horizontal', + slug: 'programming-languages-horizontal', + totalContributors: 9305223, + softwareValue: 135666024614, + avgScore: 0.41, + projectCount: 5279, + topCollections: [ + { + id: 'c1', + count: 726549, + name: 'Frontend', + softwareValue: 1917065599, + avgScore: 0.45, + }, + ], + topProjects: [ + { + id: 'p1', + count: 315948, + name: 'Node.js', + logoUrl: 'https://logo.test/n.png', + description: 'Runtime', + softwareValue: 24048486264, + avgScore: 0, + healthScore: 86, + status: 'active', + }, + ], + }, + ], + }); + }); + + it('answers an empty list', async () => { + rows = []; + const res = await get(route); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ data: [] }); + }); + + it('forwards totalContributors as orderBy by default and omits type', async () => { + await get(route); + expect(pipeCalls()).toHaveLength(1); + const [call] = callsTo(pipePath); + expect(call?.searchParams.get('orderBy')).toBe('totalContributors'); + expect(call?.searchParams.has('type')).toBe(false); + }); + + it('forwards an explicit sort as orderBy', async () => { + await get(`${route}?sort=softwareValue`); + expect(callsTo(pipePath)[0]?.searchParams.get('orderBy')).toBe('softwareValue'); + }); + + it('forwards type when given', async () => { + await get(`${route}?type=vertical`); + expect(callsTo(pipePath)[0]?.searchParams.get('type')).toBe('vertical'); + }); + + it('omits an empty type', async () => { + await get(`${route}?type=`); + expect(callsTo(pipePath)[0]?.searchParams.has('type')).toBe(false); + }); + + it('rejects an unknown sort with 400 before calling the pipe', async () => { + const res = await get(`${route}?sort=bogus`); + expect(res.statusCode).toBe(400); + expect(pipeCalls()).toHaveLength(0); + }); + + it.each([ + ['a missing group 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 null project logo', row({ topProjects: [['p1', 1, 'n', null, 1, 1, 1, 'd', 's']] })], + ])('answers 503 upstream_unavailable for %s', async (_label, bad) => { + rows = [bad]; + const res = await get(route); + expect(res.statusCode).toBe(503); + expect(res.body).toContain('upstream_unavailable'); + }); + + 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 new file mode 100644 index 000000000..9474afbfd --- /dev/null +++ b/api/tests/oss-index.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; + +import { + isTopCollectionTuple, + isTopProjectTuple, + toTopCollection, + toTopProject, +} from '../src/lib/oss-index.js'; + +const collectionTuple = ['c1', 12, 'Web Frameworks', 1500, 0.45]; +const projectTuple = [ + 'p1', + 30, + 'Node.js', + 'https://logo.test/n.png', + 2400, + 0.8, + 86, + 'Runtime', + 'active', +]; + +describe('toTopCollection', () => { + it('names each tuple slot', () => { + expect(toTopCollection(collectionTuple as never)).toEqual({ + id: 'c1', + count: 12, + name: 'Web Frameworks', + softwareValue: 1500, + avgScore: 0.45, + }); + }); +}); + +describe('toTopProject', () => { + it('names each tuple slot and renames logo to logoUrl', () => { + expect(toTopProject(projectTuple 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, + status: 'active', + }); + }); +}); + +describe('tuple guards', () => { + it('accept well-formed tuples', () => { + expect(isTopCollectionTuple(collectionTuple)).toBe(true); + expect(isTopProjectTuple(projectTuple)).toBe(true); + }); + + it.each([ + ['not an array', 'x'], + ['too short', collectionTuple.slice(0, 4)], + ['too long', [...collectionTuple, 1]], + ['a null slot', ['c1', 12, null, 1500, 0.45]], + ['a string count', ['c1', '12', 'n', 1500, 0.45]], + ['a non-finite score', ['c1', 12, 'n', 1500, Number.NaN]], + ])('reject a collection tuple that is %s', (_label, value) => { + expect(isTopCollectionTuple(value)).toBe(false); + }); + + it.each([ + ['too short', projectTuple.slice(0, 8)], + ['a null logo', ['p1', 30, 'n', null, 2400, 0.8, 86, 'd', 'active']], + ['a numeric status', ['p1', 30, 'n', 'l', 2400, 0.8, 86, 'd', 1]], + ['a string health score', ['p1', 30, 'n', 'l', 2400, 0.8, '86', 'd', 'active']], + ])('reject a project tuple that is %s', (_label, value) => { + expect(isTopProjectTuple(value)).toBe(false); + }); +}); From 77742298b0c39c57e936fe49bc5821f432a96cce Mon Sep 17 00:00:00 2001 From: anilb Date: Thu, 1 Oct 2026 15:11:59 +0200 Subject: [PATCH 2/5] fix: integer count guards Signed-off-by: anilb --- api/src/lib/oss-index.ts | 20 ++++++++++--------- api/src/versions/v1-alpha/oss-index/groups.ts | 14 ++++++------- api/tests/oss-index-groups.test.ts | 8 ++++++++ api/tests/oss-index.test.ts | 6 ++++++ 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/api/src/lib/oss-index.ts b/api/src/lib/oss-index.ts index 3fa8134a8..55cd5567a 100644 --- a/api/src/lib/oss-index.ts +++ b/api/src/lib/oss-index.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { Type, type Static } from '@sinclair/typebox'; -import { dataEnum, isString } from './security.js'; +import { dataEnum, isCount, isString } from './security.js'; // The pipes return these rows as positional tuples, so each slot is checked by type before the // route names it. The columns are non-nullable in the pipes, so a null is a malformed row. @@ -26,25 +26,27 @@ export type TopProjectTuple = [ status: string, ]; +// Counts and the UInt64 software value must be non-negative safe integers; only the Float64 +// scores use the finite-number check. const isNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value); export const isTopCollectionTuple = (value: unknown): value is TopCollectionTuple => Array.isArray(value) && value.length === 5 && isString(value[0]) && - isNumber(value[1]) && + isCount(value[1]) && isString(value[2]) && - isNumber(value[3]) && + isCount(value[3]) && isNumber(value[4]); export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => Array.isArray(value) && value.length === 9 && isString(value[0]) && - isNumber(value[1]) && + isCount(value[1]) && isString(value[2]) && isString(value[3]) && - isNumber(value[4]) && + isCount(value[4]) && isNumber(value[5]) && isNumber(value[6]) && isString(value[7]) && @@ -53,9 +55,9 @@ export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => export const TopCollection = Type.Object( { id: Type.String({ description: 'Identifier of the collection.' }), - count: Type.Number({ description: 'Contributors in the collection.' }), + count: Type.Integer({ minimum: 0, description: 'Contributors in the collection.' }), name: Type.String({ description: 'Display name of the collection.' }), - softwareValue: Type.Number({ description: 'Software value of the collection.' }), + softwareValue: Type.Integer({ minimum: 0, description: 'Software value of the collection.' }), avgScore: Type.Number({ description: 'Average health score of the collection, from 0 to 1.' }), }, { title: 'TopCollection' }, @@ -65,11 +67,11 @@ export type TopCollection = Static; export const TopProject = Type.Object( { id: Type.String({ description: 'Identifier of the project.' }), - count: Type.Number({ description: 'Contributors to the project.' }), + count: Type.Integer({ minimum: 0, description: 'Contributors to the project.' }), name: Type.String({ description: 'Display name of the project.' }), logoUrl: Type.String({ description: 'URL of the project logo.' }), description: Type.String({ description: 'Short description of the project.' }), - softwareValue: Type.Number({ description: 'Software value of the project.' }), + softwareValue: Type.Integer({ minimum: 0, description: 'Software value of the project.' }), avgScore: Type.Number({ description: 'Average score of the project, from 0 to 1.' }), healthScore: Type.Number({ description: 'Health score of the project, from 0 to 100.' }), status: Type.String({ description: 'Lifecycle status of the project, for example `active`.' }), diff --git a/api/src/versions/v1-alpha/oss-index/groups.ts b/api/src/versions/v1-alpha/oss-index/groups.ts index d19921be3..25c719896 100644 --- a/api/src/versions/v1-alpha/oss-index/groups.ts +++ b/api/src/versions/v1-alpha/oss-index/groups.ts @@ -15,7 +15,7 @@ import { type TopCollectionTuple, type TopProjectTuple, } from '../../../lib/oss-index.js'; -import { isString } from '../../../lib/security.js'; +import { isCount, isString } from '../../../lib/security.js'; const pipePath = '/v0/pipes/category_groups_oss_index.json'; @@ -39,10 +39,10 @@ const isRow = (row: Row) => isString(row.name) && isString(row.type) && isString(row.slug) && - isNumber(row.totalContributors) && - isNumber(row.softwareValue) && + isCount(row.totalContributors) && + isCount(row.softwareValue) && isNumber(row.avgScore) && - isNumber(row.projectCount) && + isCount(row.projectCount) && Array.isArray(row.topCollections) && row.topCollections.every(isTopCollectionTuple) && Array.isArray(row.topProjects) && @@ -65,10 +65,10 @@ const OssIndexGroup = Type.Object( description: 'Type of the group, for example `vertical` or `horizontal`.', }), slug: Type.String({ description: 'URL slug of the category group.' }), - totalContributors: Type.Number({ description: 'Contributors across the group.' }), - softwareValue: Type.Number({ description: 'Software value of the group.' }), + totalContributors: Type.Integer({ minimum: 0, description: 'Contributors across the group.' }), + softwareValue: Type.Integer({ minimum: 0, description: 'Software value of the group.' }), avgScore: Type.Number({ description: 'Average health score of the group, from 0 to 1.' }), - projectCount: Type.Number({ description: 'Projects in the group.' }), + projectCount: Type.Integer({ minimum: 0, description: 'Projects in the group.' }), topCollections: Type.Array(TopCollection, { description: 'Leading collections of the group, ordered by contributors.', }), diff --git a/api/tests/oss-index-groups.test.ts b/api/tests/oss-index-groups.test.ts index 938199941..e0b448b4c 100644 --- a/api/tests/oss-index-groups.test.ts +++ b/api/tests/oss-index-groups.test.ts @@ -120,6 +120,14 @@ describe('GET /v1-alpha/oss-index/groups', () => { ['a missing group id', row({ id: undefined })], ['a non-array topProjects', row({ topProjects: 'x' })], ['a malformed collection tuple', row({ topCollections: [['c1', 1, null, 1, 1]] })], + ['a negative totalContributors', row({ totalContributors: -1 })], + ['a fractional projectCount', row({ projectCount: 1.5 })], + ['a fractional softwareValue', row({ softwareValue: 10.5 })], + ['a negative collection tuple count', row({ topCollections: [['c1', -1, 'n', 1, 1]] })], + [ + 'a fractional project tuple count', + row({ topProjects: [['p1', 1.5, 'n', 'l', 1, 1, 1, 'd', 's']] }), + ], ['a short project tuple', row({ topProjects: [['p1', 1, 'n']] })], ['a null project logo', row({ topProjects: [['p1', 1, 'n', null, 1, 1, 1, 'd', 's']] })], ])('answers 503 upstream_unavailable for %s', async (_label, bad) => { diff --git a/api/tests/oss-index.test.ts b/api/tests/oss-index.test.ts index 9474afbfd..b68079e9f 100644 --- a/api/tests/oss-index.test.ts +++ b/api/tests/oss-index.test.ts @@ -63,6 +63,9 @@ describe('tuple guards', () => { ['a null slot', ['c1', 12, null, 1500, 0.45]], ['a string count', ['c1', '12', 'n', 1500, 0.45]], ['a non-finite score', ['c1', 12, 'n', 1500, Number.NaN]], + ['a negative count', ['c1', -1, 'n', 1500, 0.45]], + ['a fractional count', ['c1', 1.5, 'n', 1500, 0.45]], + ['a fractional software value', ['c1', 12, 'n', 1500.5, 0.45]], ])('reject a collection tuple that is %s', (_label, value) => { expect(isTopCollectionTuple(value)).toBe(false); }); @@ -72,6 +75,9 @@ describe('tuple guards', () => { ['a null logo', ['p1', 30, 'n', null, 2400, 0.8, 86, 'd', 'active']], ['a numeric status', ['p1', 30, 'n', 'l', 2400, 0.8, 86, 'd', 1]], ['a string health score', ['p1', 30, 'n', 'l', 2400, 0.8, '86', 'd', 'active']], + ['a negative count', ['p1', -30, 'n', 'l', 2400, 0.8, 86, 'd', 'active']], + ['a fractional count', ['p1', 30.5, 'n', 'l', 2400, 0.8, 86, 'd', 'active']], + ['a negative software value', ['p1', 30, 'n', 'l', -2400, 0.8, 86, 'd', 'active']], ])('reject a project tuple that is %s', (_label, value) => { expect(isTopProjectTuple(value)).toBe(false); }); From e0d7df408099c3a54e91377fd0796b0985da8b10 Mon Sep 17 00:00:00 2001 From: anilb Date: Wed, 14 Oct 2026 14:26:14 +0200 Subject: [PATCH 3/5] fix: accept live tuple shape Signed-off-by: anilb --- api/src/lib/oss-index.ts | 6 +++--- api/tests/oss-index-groups.test.ts | 16 +++++++++++++++- api/tests/oss-index.test.ts | 2 ++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/api/src/lib/oss-index.ts b/api/src/lib/oss-index.ts index 55cd5567a..ede5fd85d 100644 --- a/api/src/lib/oss-index.ts +++ b/api/src/lib/oss-index.ts @@ -4,8 +4,8 @@ import { Type, type Static } from '@sinclair/typebox'; import { dataEnum, isCount, isString } from './security.js'; -// The pipes return these rows as positional tuples, so each slot is checked by type before the -// route names it. The columns are non-nullable in the pipes, so a null is a malformed row. +// The pipes return positional tuples, so each slot the route names is type-checked and a null there +// is malformed. Project tuples end in unread nullable health v2 slots, hence the minimum length. export type TopCollectionTuple = [ id: string, count: number, @@ -41,7 +41,7 @@ export const isTopCollectionTuple = (value: unknown): value is TopCollectionTupl export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => Array.isArray(value) && - value.length === 9 && + value.length >= 9 && isString(value[0]) && isCount(value[1]) && isString(value[2]) && diff --git a/api/tests/oss-index-groups.test.ts b/api/tests/oss-index-groups.test.ts index e0b448b4c..0f864c703 100644 --- a/api/tests/oss-index-groups.test.ts +++ b/api/tests/oss-index-groups.test.ts @@ -25,7 +25,21 @@ const row = (overrides: Record = {}) => ({ projectCount: 5279, topCollections: [['c1', 726549, 'Frontend', 1917065599, 0.45]], topProjects: [ - ['p1', 315948, 'Node.js', 'https://logo.test/n.png', 24048486264, 0, 86, 'Runtime', 'active'], + [ + 'p1', + 315948, + 'Node.js', + 'https://logo.test/n.png', + 24048486264, + 0, + 86, + 'Runtime', + 'active', + 74, + 'healthy', + 74, + 100, + ], ], ...overrides, }); diff --git a/api/tests/oss-index.test.ts b/api/tests/oss-index.test.ts index b68079e9f..086f4871c 100644 --- a/api/tests/oss-index.test.ts +++ b/api/tests/oss-index.test.ts @@ -54,6 +54,8 @@ describe('tuple guards', () => { it('accept well-formed tuples', () => { expect(isTopCollectionTuple(collectionTuple)).toBe(true); expect(isTopProjectTuple(projectTuple)).toBe(true); + expect(isTopProjectTuple([...projectTuple, 74, 'healthy', 74, 100])).toBe(true); + expect(isTopProjectTuple([...projectTuple, null, null, null, null])).toBe(true); }); it.each([ From d24e1da3ad698cbcccb7e84bee59847c26de222a Mon Sep 17 00:00:00 2001 From: anilb Date: Wed, 14 Oct 2026 14:44:18 +0200 Subject: [PATCH 4/5] feat: v2 health score Signed-off-by: anilb --- api/src/lib/collections.ts | 12 ++------ api/src/lib/oss-index.ts | 35 +++++++++++++++++++----- api/src/lib/overview.ts | 12 ++++++++ api/tests/oss-index-groups.test.ts | 15 ++++++++-- api/tests/oss-index.test.ts | 44 ++++++++++++++++++++++-------- 5 files changed, 87 insertions(+), 31 deletions(-) diff --git a/api/src/lib/collections.ts b/api/src/lib/collections.ts index 2266acde9..f260cb341 100644 --- a/api/src/lib/collections.ts +++ b/api/src/lib/collections.ts @@ -4,7 +4,7 @@ import { Type, type Static, type TSchema } from '@sinclair/typebox'; import { nullableEnum, nullableNumber, nullableString } from '../schemas/common.js'; import { LeaderboardType } from './leaderboards.js'; -import { HealthLabel, ImpactLabel, LifecycleLabel } from './overview.js'; +import { HealthLabel, ImpactLabel, isHealthScore, LifecycleLabel } from './overview.js'; import { dataEnum, enumGuard, isCount, isString } from './security.js'; // OpenAPI 3.0 has no type 'null', so a nullable object carries `nullable` like the scalars in schemas/common.ts. @@ -267,15 +267,12 @@ export interface CollectionProjectRow { const isProjectType = enumGuard(CollectionProjectType); const isStatus = enumGuard(ProjectStatus); -const isHealthLabel = enumGuard(HealthLabel); const isLifecycleLabel = enumGuard(LifecycleLabel); const isImpactLabel = enumGuard(ImpactLabel); const isLeaderboardType = enumGuard(LeaderboardType); const upTo = (value: unknown, max: number) => value === null || (isCount(value) && (value as number) <= max); -// The maxima the pipe derives from the covered categories. -const healthMaxScores = new Set([60, 65, 75, 100, null]); const isPercentage = (value: unknown) => Number.isFinite(value); const isAchievement = (value: unknown) => @@ -310,12 +307,7 @@ export const isCollectionProjectRow = (row: unknown): row is CollectionProjectRo isPercentage(candidate.organizationDependencyPercentage) && Array.isArray(candidate.achievements) && candidate.achievements.every(isAchievement) && - upTo(candidate.healthScoreV2, 100) && - isHealthLabel(candidate.healthLabel) && - healthMaxScores.has(candidate.healthMaxScore) && - (candidate.healthScoreV2 === null || - candidate.healthMaxScore === null || - (candidate.healthScoreV2 as number) <= (candidate.healthMaxScore as number)) && + isHealthScore(candidate.healthScoreV2, candidate.healthLabel, candidate.healthMaxScore) && upTo(candidate.maintainerHealthScoreV2, 40) && upTo(candidate.securitySupplyChainScoreV2, 35) && upTo(candidate.developmentActivityScoreV2, 25) && diff --git a/api/src/lib/oss-index.ts b/api/src/lib/oss-index.ts index ede5fd85d..b6299f1e7 100644 --- a/api/src/lib/oss-index.ts +++ b/api/src/lib/oss-index.ts @@ -2,10 +2,12 @@ // SPDX-License-Identifier: MIT import { Type, type Static } from '@sinclair/typebox'; +import { nullableNumber } from '../schemas/common.js'; +import { HealthLabel, isHealthScore } from './overview.js'; import { dataEnum, isCount, isString } from './security.js'; -// The pipes return positional tuples, so each slot the route names is type-checked and a null there -// is malformed. Project tuples end in unread nullable health v2 slots, hence the minimum length. +// The pipes return positional tuples, so each slot is type-checked before the route names it. Project +// tuples keep the v1 health score, which the API drops, and end in the nullable v2 health fields. export type TopCollectionTuple = [ id: string, count: number, @@ -21,14 +23,19 @@ export type TopProjectTuple = [ logo: string, softwareValue: number, avgScore: number, - healthScore: number, + v1HealthScore: number, description: string, status: string, + healthScore: number | null, + healthLabel: string | null, + scaledHealthScore: number | null, + healthMaxScore: number | null, ]; // Counts and the UInt64 software value must be non-negative safe integers; only the Float64 // scores use the finite-number check. const isNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value); +const isNullableNumber = (value: unknown) => value === null || isNumber(value); export const isTopCollectionTuple = (value: unknown): value is TopCollectionTuple => Array.isArray(value) && @@ -41,7 +48,7 @@ export const isTopCollectionTuple = (value: unknown): value is TopCollectionTupl export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => Array.isArray(value) && - value.length >= 9 && + value.length >= 13 && isString(value[0]) && isCount(value[1]) && isString(value[2]) && @@ -50,7 +57,9 @@ export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => isNumber(value[5]) && isNumber(value[6]) && isString(value[7]) && - isString(value[8]); + isString(value[8]) && + isHealthScore(value[9], value[10], value[12]) && + isNullableNumber(value[11]); export const TopCollection = Type.Object( { @@ -73,7 +82,13 @@ export const TopProject = Type.Object( description: Type.String({ description: 'Short description of the project.' }), softwareValue: Type.Integer({ minimum: 0, description: 'Software value of the project.' }), avgScore: Type.Number({ description: 'Average score of the project, from 0 to 1.' }), - healthScore: Type.Number({ description: 'Health score of the project, from 0 to 100.' }), + healthScore: nullableNumber( + 'Health score, the sum of the three category scores, from 0 to `healthMaxScore`. Null when fewer than two categories have data.', + ), + healthLabel: HealthLabel, + healthMaxScore: nullableNumber( + 'Highest score the covered categories allow: 60, 65, 75 or 100. Null when unknown.', + ), status: Type.String({ description: 'Lifecycle status of the project, for example `active`.' }), }, { title: 'TopProject' }, @@ -95,9 +110,13 @@ export const toTopProject = ([ logo, softwareValue, avgScore, - healthScore, + , description, status, + healthScore, + healthLabel, + , + healthMaxScore, ]: TopProjectTuple): TopProject => ({ id, count, @@ -107,6 +126,8 @@ export const toTopProject = ([ softwareValue, avgScore, healthScore, + healthLabel: healthLabel as TopProject['healthLabel'], + healthMaxScore, status, }); diff --git a/api/src/lib/overview.ts b/api/src/lib/overview.ts index 181f1784c..bd6f97e78 100644 --- a/api/src/lib/overview.ts +++ b/api/src/lib/overview.ts @@ -3,6 +3,7 @@ import { Type, type TObject } from '@sinclair/typebox'; import { nullableEnum } from '../schemas/common.js'; +import { enumGuard, isCount } from './security.js'; // Values are exposed as the data holds them, matching the Overview tab. export const HealthLabel = nullableEnum( @@ -10,6 +11,17 @@ export const HealthLabel = nullableEnum( 'Band of the health score. Null when the score is null.', ); +const isHealthLabel = enumGuard(HealthLabel); +// The maxima the pipes derive from the covered categories. +const healthMaxScores = new Set([60, 65, 75, 100, null]); + +// The v2 health score with its band and maximum, as the project pipes send them. +export const isHealthScore = (score: unknown, label: unknown, maxScore: unknown) => + (score === null || (isCount(score) && (score as number) <= 100)) && + isHealthLabel(label) && + healthMaxScores.has(maxScore) && + (score === null || maxScore === null || (score as number) <= (maxScore as number)); + export const LifecycleLabel = nullableEnum( ['active', 'stable', 'declining', 'inert', 'abandoned', 'archived'] as const, 'Activity state of the project or selected repositories. Null when unknown.', diff --git a/api/tests/oss-index-groups.test.ts b/api/tests/oss-index-groups.test.ts index 0f864c703..2e43762cb 100644 --- a/api/tests/oss-index-groups.test.ts +++ b/api/tests/oss-index-groups.test.ts @@ -85,7 +85,9 @@ describe('GET /v1-alpha/oss-index/groups', () => { description: 'Runtime', softwareValue: 24048486264, avgScore: 0, - healthScore: 86, + healthScore: 74, + healthLabel: 'healthy', + healthMaxScore: 100, status: 'active', }, ], @@ -140,10 +142,17 @@ describe('GET /v1-alpha/oss-index/groups', () => { ['a negative collection tuple count', row({ topCollections: [['c1', -1, 'n', 1, 1]] })], [ 'a fractional project tuple count', - row({ topProjects: [['p1', 1.5, 'n', 'l', 1, 1, 1, 'd', 's']] }), + row({ topProjects: [['p1', 1.5, 'n', 'l', 1, 1, 1, 'd', 's', null, null, null, null]] }), ], ['a short project tuple', row({ topProjects: [['p1', 1, 'n']] })], - ['a null project logo', row({ topProjects: [['p1', 1, 'n', null, 1, 1, 1, 'd', 's']] })], + [ + 'a project tuple without health fields', + 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', 's', null, null, null, null]] }), + ], ])('answers 503 upstream_unavailable for %s', async (_label, bad) => { rows = [bad]; const res = await get(route); diff --git a/api/tests/oss-index.test.ts b/api/tests/oss-index.test.ts index 086f4871c..70b0a3ba8 100644 --- a/api/tests/oss-index.test.ts +++ b/api/tests/oss-index.test.ts @@ -20,7 +20,13 @@ const projectTuple = [ 86, 'Runtime', 'active', + 74, + 'healthy', + 74, + 100, ]; +const withSlot = (index: number, value: unknown) => + projectTuple.map((slot, i) => (i === index ? value : slot)); describe('toTopCollection', () => { it('names each tuple slot', () => { @@ -35,7 +41,7 @@ describe('toTopCollection', () => { }); describe('toTopProject', () => { - 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(toTopProject(projectTuple as never)).toEqual({ id: 'p1', count: 30, @@ -44,18 +50,29 @@ describe('toTopProject', () => { description: 'Runtime', softwareValue: 2400, avgScore: 0.8, - healthScore: 86, + healthScore: 74, + healthLabel: 'healthy', + healthMaxScore: 100, status: 'active', }); }); + + it('keeps null v2 health fields for an unscored project', () => { + const unscored = [...projectTuple.slice(0, 9), null, null, null, null]; + expect(toTopProject(unscored as never)).toMatchObject({ + healthScore: null, + healthLabel: null, + healthMaxScore: null, + }); + }); }); describe('tuple guards', () => { it('accept well-formed tuples', () => { expect(isTopCollectionTuple(collectionTuple)).toBe(true); expect(isTopProjectTuple(projectTuple)).toBe(true); - expect(isTopProjectTuple([...projectTuple, 74, 'healthy', 74, 100])).toBe(true); - expect(isTopProjectTuple([...projectTuple, null, null, null, null])).toBe(true); + expect(isTopProjectTuple([...projectTuple.slice(0, 9), null, null, null, null])).toBe(true); + expect(isTopProjectTuple([...projectTuple.slice(0, 9), 64, 'excellent', 85, 75])).toBe(true); }); it.each([ @@ -73,13 +90,18 @@ describe('tuple guards', () => { }); it.each([ - ['too short', projectTuple.slice(0, 8)], - ['a null logo', ['p1', 30, 'n', null, 2400, 0.8, 86, 'd', 'active']], - ['a numeric status', ['p1', 30, 'n', 'l', 2400, 0.8, 86, 'd', 1]], - ['a string health score', ['p1', 30, 'n', 'l', 2400, 0.8, '86', 'd', 'active']], - ['a negative count', ['p1', -30, 'n', 'l', 2400, 0.8, 86, 'd', 'active']], - ['a fractional count', ['p1', 30.5, 'n', 'l', 2400, 0.8, 86, 'd', 'active']], - ['a negative software value', ['p1', 30, 'n', 'l', -2400, 0.8, 86, 'd', 'active']], + ['too short', projectTuple.slice(0, 12)], + ['without the health fields', projectTuple.slice(0, 9)], + ['a null logo', withSlot(3, null)], + ['a numeric status', withSlot(8, 1)], + ['a string v1 health score', withSlot(6, '86')], + ['a negative count', withSlot(1, -30)], + ['a fractional count', withSlot(1, 30.5)], + ['a negative software value', withSlot(4, -2400)], + ['an unknown health label', withSlot(10, 'great')], + ['a health score above its maximum', [...projectTuple.slice(0, 9), 80, 'healthy', 100, 75]], + ['a health maximum no category set gives', withSlot(12, 90)], + ['a string scaled health score', withSlot(11, '74')], ])('reject a project tuple that is %s', (_label, value) => { expect(isTopProjectTuple(value)).toBe(false); }); From 0f771526fabe9e97d728fde5aa475c119c2e5ee9 Mon Sep 17 00:00:00 2001 From: anilb Date: Wed, 14 Oct 2026 15:08:51 +0200 Subject: [PATCH 5/5] fix: health pairing and bounds Signed-off-by: anilb --- api/src/lib/oss-index.ts | 22 ++++++++++----- api/src/lib/overview.ts | 15 +++++++---- api/src/versions/v1-alpha/oss-index/groups.ts | 12 +++++---- api/tests/collections-projects.test.ts | 27 +++++++++++++++++++ api/tests/oss-index-groups.test.ts | 19 +++++++++++++ api/tests/oss-index.test.ts | 11 ++++++++ 6 files changed, 89 insertions(+), 17 deletions(-) diff --git a/api/src/lib/oss-index.ts b/api/src/lib/oss-index.ts index b6299f1e7..42ff316a2 100644 --- a/api/src/lib/oss-index.ts +++ b/api/src/lib/oss-index.ts @@ -4,7 +4,7 @@ import { Type, type Static } from '@sinclair/typebox'; import { nullableNumber } from '../schemas/common.js'; import { HealthLabel, isHealthScore } from './overview.js'; -import { dataEnum, isCount, isString } from './security.js'; +import { dataEnum, inRange, isCount, isString } from './security.js'; // The pipes return positional tuples, so each slot is type-checked before the route names it. Project // tuples keep the v1 health score, which the API drops, and end in the nullable v2 health fields. @@ -32,8 +32,8 @@ export type TopProjectTuple = [ healthMaxScore: number | null, ]; -// Counts and the UInt64 software value must be non-negative safe integers; only the Float64 -// scores use the finite-number check. +// 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); const isNullableNumber = (value: unknown) => value === null || isNumber(value); @@ -44,7 +44,7 @@ export const isTopCollectionTuple = (value: unknown): value is TopCollectionTupl isCount(value[1]) && isString(value[2]) && isCount(value[3]) && - isNumber(value[4]); + inRange(value[4], 1); export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => Array.isArray(value) && @@ -54,7 +54,7 @@ export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => isString(value[2]) && isString(value[3]) && isCount(value[4]) && - isNumber(value[5]) && + inRange(value[5], 1) && isNumber(value[6]) && isString(value[7]) && isString(value[8]) && @@ -67,7 +67,11 @@ export const TopCollection = Type.Object( count: Type.Integer({ minimum: 0, description: 'Contributors in the collection.' }), name: Type.String({ description: 'Display name of the collection.' }), softwareValue: Type.Integer({ minimum: 0, description: 'Software value of the collection.' }), - avgScore: Type.Number({ description: 'Average health score of the collection, from 0 to 1.' }), + avgScore: Type.Number({ + minimum: 0, + maximum: 1, + description: 'Average health score of the collection, from 0 to 1.', + }), }, { title: 'TopCollection' }, ); @@ -81,7 +85,11 @@ export const TopProject = Type.Object( logoUrl: Type.String({ description: 'URL of the project logo.' }), description: Type.String({ description: 'Short description of the project.' }), softwareValue: Type.Integer({ minimum: 0, description: 'Software value of the project.' }), - avgScore: Type.Number({ description: 'Average score of the project, from 0 to 1.' }), + avgScore: Type.Number({ + minimum: 0, + maximum: 1, + description: 'Average score of the project, from 0 to 1.', + }), healthScore: nullableNumber( 'Health score, the sum of the three category scores, from 0 to `healthMaxScore`. Null when fewer than two categories have data.', ), diff --git a/api/src/lib/overview.ts b/api/src/lib/overview.ts index bd6f97e78..e5d29f959 100644 --- a/api/src/lib/overview.ts +++ b/api/src/lib/overview.ts @@ -15,12 +15,17 @@ const isHealthLabel = enumGuard(HealthLabel); // The maxima the pipes derive from the covered categories. const healthMaxScores = new Set([60, 65, 75, 100, null]); -// The v2 health score with its band and maximum, as the project pipes send them. +// The v2 health score, band and maximum. The band is null exactly when the score is, and repo +// records carry a score without a maximum. export const isHealthScore = (score: unknown, label: unknown, maxScore: unknown) => - (score === null || (isCount(score) && (score as number) <= 100)) && - isHealthLabel(label) && - healthMaxScores.has(maxScore) && - (score === null || maxScore === null || (score as number) <= (maxScore as number)); + score === null + ? label === null && maxScore === null + : isCount(score) && + (score as number) <= 100 && + label !== null && + isHealthLabel(label) && + healthMaxScores.has(maxScore) && + (maxScore === null || (score as number) <= (maxScore as number)); export const LifecycleLabel = nullableEnum( ['active', 'stable', 'declining', 'inert', 'abandoned', 'archived'] as const, diff --git a/api/src/versions/v1-alpha/oss-index/groups.ts b/api/src/versions/v1-alpha/oss-index/groups.ts index 25c719896..eebd7b09e 100644 --- a/api/src/versions/v1-alpha/oss-index/groups.ts +++ b/api/src/versions/v1-alpha/oss-index/groups.ts @@ -15,7 +15,7 @@ import { type TopCollectionTuple, type TopProjectTuple, } from '../../../lib/oss-index.js'; -import { isCount, isString } from '../../../lib/security.js'; +import { inRange, isCount, isString } from '../../../lib/security.js'; const pipePath = '/v0/pipes/category_groups_oss_index.json'; @@ -32,8 +32,6 @@ interface Row { topProjects: TopProjectTuple[]; } -const isNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value); - const isRow = (row: Row) => isString(row.id) && isString(row.name) && @@ -41,7 +39,7 @@ const isRow = (row: Row) => isString(row.slug) && isCount(row.totalContributors) && isCount(row.softwareValue) && - isNumber(row.avgScore) && + inRange(row.avgScore, 1) && isCount(row.projectCount) && Array.isArray(row.topCollections) && row.topCollections.every(isTopCollectionTuple) && @@ -67,7 +65,11 @@ const OssIndexGroup = Type.Object( slug: Type.String({ description: 'URL slug of the category group.' }), totalContributors: Type.Integer({ minimum: 0, description: 'Contributors across the group.' }), softwareValue: Type.Integer({ minimum: 0, description: 'Software value of the group.' }), - avgScore: Type.Number({ description: 'Average health score of the group, from 0 to 1.' }), + avgScore: Type.Number({ + minimum: 0, + maximum: 1, + description: 'Average health score of the group, from 0 to 1.', + }), projectCount: Type.Integer({ minimum: 0, description: 'Projects in the group.' }), topCollections: Type.Array(TopCollection, { description: 'Leading collections of the group, ordered by contributors.', diff --git a/api/tests/collections-projects.test.ts b/api/tests/collections-projects.test.ts index d4e5c22d6..7392be5bc 100644 --- a/api/tests/collections-projects.test.ts +++ b/api/tests/collections-projects.test.ts @@ -229,6 +229,33 @@ describe('response', () => { expect(body.data[0]).not.toHaveProperty('healthScoreV2'); }); + it.each([ + ['a score with a null label', { healthScoreV2: 77, healthLabel: null }], + [ + 'a label with a null score', + { healthScoreV2: null, healthLabel: 'healthy', healthMaxScore: null }, + ], + [ + 'a maximum with a null score', + { healthScoreV2: null, healthLabel: null, healthMaxScore: 100 }, + ], + ])('answers 503 for %s', async (_label, patch) => { + all = [{ ...row(1), ...patch }]; + const res = await get(route); + expect(res.statusCode).toBe(503); + }); + + it('accepts a repo row with a score and no maximum', async () => { + all = [{ ...row(1, 'repo'), healthScoreV2: 61, healthLabel: 'fair', healthMaxScore: null }]; + const body = await bodyOf(); + expect(body.data[0]).toMatchObject({ + type: 'repo', + healthScore: 61, + healthLabel: 'fair', + healthMaxScore: null, + }); + }); + it('answers 503 for rows of an unexpected shape', async () => { mockFetch.mockImplementation(async () => tinybirdResponse([{ id: 'x' }])); const res = await get(route); diff --git a/api/tests/oss-index-groups.test.ts b/api/tests/oss-index-groups.test.ts index 2e43762cb..0b0133ff6 100644 --- a/api/tests/oss-index-groups.test.ts +++ b/api/tests/oss-index-groups.test.ts @@ -8,6 +8,7 @@ import { pipeCalls, tinybirdStub, useApp, + resolveSchema, type OpenApiDoc, } from './helpers/tinybird.js'; @@ -149,6 +150,12 @@ describe('GET /v1-alpha/oss-index/groups', () => { 'a project tuple without health fields', row({ topProjects: [['p1', 1, 'n', 'l', 1, 1, 1, 'd', 's']] }), ], + ['a negative avgScore', row({ avgScore: -0.1 })], + ['an avgScore above 1', row({ avgScore: 1.1 })], + [ + 'a project tuple with a score and no label', + row({ topProjects: [['p1', 1, 'n', 'l', 1, 1, 1, 'd', 's', 74, null, 74, 100]] }), + ], [ 'a null project logo', row({ topProjects: [['p1', 1, 'n', null, 1, 1, 1, 'd', 's', null, null, null, null]] }), @@ -165,4 +172,16 @@ describe('GET /v1-alpha/oss-index/groups', () => { 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 every avgScore', async () => { + const spec = (await get('/v1-alpha/openapi.json')).json(); + const ok = spec.paths[route]?.get?.responses['200']?.content['application/json']?.schema; + const group = resolveSchema(spec, resolveSchema(spec, ok)?.properties?.data?.items)?.properties; + const bounds = { minimum: 0, maximum: 1 }; + expect(group?.avgScore).toMatchObject(bounds); + const nested = (key: string) => + resolveSchema(spec, resolveSchema(spec, group?.[key]?.items)?.properties?.avgScore); + expect(nested('topCollections')).toMatchObject(bounds); + expect(nested('topProjects')).toMatchObject(bounds); + }); }); diff --git a/api/tests/oss-index.test.ts b/api/tests/oss-index.test.ts index 70b0a3ba8..9e0b3f8b0 100644 --- a/api/tests/oss-index.test.ts +++ b/api/tests/oss-index.test.ts @@ -73,6 +73,7 @@ describe('tuple guards', () => { expect(isTopProjectTuple(projectTuple)).toBe(true); expect(isTopProjectTuple([...projectTuple.slice(0, 9), null, null, null, null])).toBe(true); expect(isTopProjectTuple([...projectTuple.slice(0, 9), 64, 'excellent', 85, 75])).toBe(true); + expect(isTopProjectTuple([...projectTuple.slice(0, 9), 61, 'fair', 61, null])).toBe(true); }); it.each([ @@ -85,6 +86,8 @@ describe('tuple guards', () => { ['a negative count', ['c1', -1, 'n', 1500, 0.45]], ['a fractional count', ['c1', 1.5, 'n', 1500, 0.45]], ['a fractional software value', ['c1', 12, 'n', 1500.5, 0.45]], + ['a negative score', ['c1', 12, 'n', 1500, -0.1]], + ['a score above 1', ['c1', 12, 'n', 1500, 1.1]], ])('reject a collection tuple that is %s', (_label, value) => { expect(isTopCollectionTuple(value)).toBe(false); }); @@ -102,6 +105,14 @@ describe('tuple guards', () => { ['a health score above its maximum', [...projectTuple.slice(0, 9), 80, 'healthy', 100, 75]], ['a health maximum no category set gives', withSlot(12, 90)], ['a string scaled health score', withSlot(11, '74')], + ['a negative average score', withSlot(5, -0.1)], + ['an average score above 1', withSlot(5, 1.1)], + ['a health score with a null label', [...projectTuple.slice(0, 9), 74, null, 74, 100]], + [ + 'a null health score with a label', + [...projectTuple.slice(0, 9), null, 'healthy', null, null], + ], + ['a null health score with a maximum', [...projectTuple.slice(0, 9), null, null, null, 100]], ])('reject a project tuple that is %s', (_label, value) => { expect(isTopProjectTuple(value)).toBe(false); });