From 335c0df48bd1e2395f1edfd1c5d6ae00e509ef7e Mon Sep 17 00:00:00 2001 From: Benny Huang Date: Mon, 10 Nov 2025 14:34:19 -0500 Subject: [PATCH 1/3] added author track record signal --- src/lib/config.ts | 6 ++ src/lib/data/collect.ts | 5 + src/lib/data/npm.ts | 22 ++++ src/lib/types.ts | 1 + test/lib/data/collect.test.ts | 4 + test/lib/data/npm.test.ts | 188 ++++++++++++++++++++++++++++++++++ 6 files changed, 226 insertions(+) diff --git a/src/lib/config.ts b/src/lib/config.ts index 2c26e92..8b8d57a 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -66,6 +66,12 @@ export const CONFIG: Config = { snapshotTests: { present: testsData.hasSnapshotTests, value: 2 }, }), }, + { + name: 'authorTrackRecord', + weight: 3, + description: 'Track record of strong authors', + benchmarks: (packageCount: number) => categorizeHigherIsBetter([20, 11, 5, 2], packageCount), + }, { name: 'stableVersioning', weight: 2, diff --git a/src/lib/data/collect.ts b/src/lib/data/collect.ts index 9646f80..0f31d2f 100644 --- a/src/lib/data/collect.ts +++ b/src/lib/data/collect.ts @@ -11,6 +11,7 @@ import { calculateReleaseFrequency } from '../utils/releases'; interface RawPackageData { readonly npm: NpmPackageData; readonly downloads: NpmDownloadData; + readonly authorPackageCount?: number; readonly github?: GitHubRepository; } @@ -23,6 +24,7 @@ async function fetchAllData(packageName: string): Promise { await npmCollector.fetchPackage(packageName); const npmData = npmCollector.getPackageData(); const downloadData = await npmCollector.fetchDownloadData(); + const authorPackageCount = await npmCollector.fetchAuthorPackageCount(); const repoInfo = extractRepoInfo(npmData.repository.url); const githubRepo = new GitHubRepo(repoInfo.owner, repoInfo.repo); @@ -43,6 +45,7 @@ async function fetchAllData(packageName: string): Promise { return { npm: npmData, downloads: downloadData, + ...(authorPackageCount !== undefined && { authorPackageCount }), ...(githubData && { github: githubData }), }; } @@ -57,6 +60,7 @@ function processPackageData(rawData: RawPackageData): PackageData { return { version: rawData.npm.version, weeklyDownloads: rawData.downloads.downloads, + ...(rawData.authorPackageCount !== undefined && { authorTrackRecord: rawData.authorPackageCount }), stableVersioning: { isStableMajorVersion: parseInt(majorVersion, 10) >= 1, hasMinorReleases: parseInt(minorVersion, 10) >= 1, @@ -73,6 +77,7 @@ function processPackageData(rawData: RawPackageData): PackageData { 'numberOfContributors(Maintenance)': processContributorsData(repository.commits), 'documentationCompleteness': analyzeDocumentationCompleteness(repository), 'testsChecklist': analyzeTestsPresence(repository), + ...(rawData.authorPackageCount !== undefined && { authorTrackRecord: rawData.authorPackageCount }), 'weeklyDownloads': rawData.downloads.downloads, 'githubStars': repository.stargazerCount ?? 0, 'stableVersioning': { diff --git a/src/lib/data/npm.ts b/src/lib/data/npm.ts index 0fe177c..2becbb1 100644 --- a/src/lib/data/npm.ts +++ b/src/lib/data/npm.ts @@ -8,6 +8,7 @@ export interface NpmPackageData { }; readonly isDeprecated: boolean; readonly hasProvenance?: boolean; + readonly maintainers?: Array<{ name: string }>; } export interface NpmDownloadData { @@ -37,6 +38,7 @@ export class NpmCollector { repository: response.repository, isDeprecated: Boolean(versionData?.deprecated), hasProvenance, + maintainers: response.maintainers, }; } @@ -59,4 +61,24 @@ export class NpmCollector { return await response.json() as NpmDownloadData; } + + async fetchAuthorPackageCount(): Promise { + // Use the first maintainer to search for their packages (libraries can have multiple maintainers) + const maintainer = this.packageData?.maintainers?.[0]; + if (!maintainer?.name) { + return undefined; + } + + try { + const response = await fetch(`https://registry.npmjs.org/-/v1/search?text=maintainer:${encodeURIComponent(maintainer.name)}&size=1`); + if (!response.ok) { + return undefined; + } + + const data = await response.json() as any; + return data.total ?? 0; + } catch { + return undefined; + } + } } \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts index 1d73655..e080b0c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -47,6 +47,7 @@ export type PackageData = { readonly 'numberOfContributors(Maintenance)'?: number; readonly 'documentationCompleteness'?: DocumentationCompleteness; readonly 'testsChecklist'?: TestsData; + readonly 'authorTrackRecord'?: number; readonly 'weeklyDownloads'?: number; readonly 'githubStars'?: number; readonly 'numberOfContributors(Popularity)'?: number; diff --git a/test/lib/data/collect.test.ts b/test/lib/data/collect.test.ts index 647a2b8..a343ed3 100644 --- a/test/lib/data/collect.test.ts +++ b/test/lib/data/collect.test.ts @@ -42,6 +42,7 @@ describe('collectPackageData', () => { fetchPackage: jest.fn().mockResolvedValue(undefined), getPackageData: jest.fn().mockReturnValue(mockNpmData), fetchDownloadData: jest.fn().mockResolvedValue(mockDownloadData), + fetchAuthorPackageCount: jest.fn().mockResolvedValue(42), }; const mockGitHubData = { @@ -99,6 +100,7 @@ describe('collectPackageData', () => { hasUnitTests: false, hasSnapshotTests: false, }, + 'authorTrackRecord': 42, 'weeklyDownloads': 10000, 'githubStars': 500, 'numberOfContributors(Popularity)': 2, @@ -118,6 +120,7 @@ describe('collectPackageData', () => { fetchPackage: jest.fn().mockResolvedValue(undefined), getPackageData: jest.fn().mockReturnValue(mockNpmData), fetchDownloadData: jest.fn().mockResolvedValue(mockDownloadData), + fetchAuthorPackageCount: jest.fn().mockResolvedValue(42), }; const mockGitHubInstance = { @@ -133,6 +136,7 @@ describe('collectPackageData', () => { expect(result).toEqual({ version: '1.0.0', + authorTrackRecord: 42, weeklyDownloads: 10000, stableVersioning: { isStableMajorVersion: true, diff --git a/test/lib/data/npm.test.ts b/test/lib/data/npm.test.ts index 9024c7e..134622e 100644 --- a/test/lib/data/npm.test.ts +++ b/test/lib/data/npm.test.ts @@ -285,4 +285,192 @@ describe('NpmCollector', () => { ); }); }); + + describe('fetchAuthorPackageCount', () => { + test('should fetch author package count successfully', async () => { + const mockPackageResponse = { + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0' }, + 'repository': { + type: 'git', + url: 'https://github.com/test/repo', + }, + 'maintainers': [ + { name: 'johndoe' }, + ], + 'versions': { + '1.0.0': { + name: 'test-package', + version: '1.0.0', + }, + }, + }; + + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockPackageResponse, + } as Response); + + await collector.fetchPackage('test-package'); + + const mockSearchResponse = { + objects: [ + { package: { name: 'package1' } }, + { package: { name: 'package2' } }, + ], + total: 42, + }; + + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockSearchResponse, + } as Response); + + const result = await collector.fetchAuthorPackageCount(); + + expect(mockedFetch).toHaveBeenCalledWith( + 'https://registry.npmjs.org/-/v1/search?text=maintainer:johndoe&size=1', + ); + expect(result).toBe(42); + }); + + test('should return undefined when no maintainers', async () => { + const mockPackageResponse = { + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0' }, + 'repository': { + type: 'git', + url: 'https://github.com/test/repo', + }, + 'versions': { + '1.0.0': { + name: 'test-package', + version: '1.0.0', + }, + }, + }; + + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockPackageResponse, + } as Response); + + await collector.fetchPackage('test-package'); + + const result = await collector.fetchAuthorPackageCount(); + + expect(result).toBeUndefined(); + }); + + test('should return undefined when search API fails', async () => { + const mockPackageResponse = { + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0' }, + 'repository': { + type: 'git', + url: 'https://github.com/test/repo', + }, + 'maintainers': [ + { name: 'johndoe' }, + ], + 'versions': { + '1.0.0': { + name: 'test-package', + version: '1.0.0', + }, + }, + }; + + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockPackageResponse, + } as Response); + + await collector.fetchPackage('test-package'); + + mockedFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + } as Response); + + const result = await collector.fetchAuthorPackageCount(); + + expect(result).toBeUndefined(); + }); + + test('should return undefined when search API throws error', async () => { + const mockPackageResponse = { + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0' }, + 'repository': { + type: 'git', + url: 'https://github.com/test/repo', + }, + 'maintainers': [ + { name: 'johndoe' }, + ], + 'versions': { + '1.0.0': { + name: 'test-package', + version: '1.0.0', + }, + }, + }; + + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockPackageResponse, + } as Response); + + await collector.fetchPackage('test-package'); + + mockedFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await collector.fetchAuthorPackageCount(); + + expect(result).toBeUndefined(); + }); + + test('should return 0 when total is 0', async () => { + const mockPackageResponse = { + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0' }, + 'repository': { + type: 'git', + url: 'https://github.com/test/repo', + }, + 'maintainers': [ + { name: 'newauthor', email: 'new@example.com' }, + ], + 'versions': { + '1.0.0': { + name: 'test-package', + version: '1.0.0', + }, + }, + }; + + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockPackageResponse, + } as Response); + + await collector.fetchPackage('test-package'); + + const mockSearchResponse = { + objects: [], + total: 0, + }; + + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockSearchResponse, + } as Response); + + const result = await collector.fetchAuthorPackageCount(); + + expect(result).toBe(0); + }); + }); }); \ No newline at end of file From f13ef2968f7d7cc98d39e60af0d8a86ca3162861 Mon Sep 17 00:00:00 2001 From: Benny Huang Date: Mon, 10 Nov 2025 15:03:04 -0500 Subject: [PATCH 2/3] updated read me --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 4140658..000cf0a 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ SUBSCORES === Quality === SCORE WEIGHT — Documentation Completeness .................... ★★★★★ 3 +— Tests checklist (unit/snapshot) ............... ★★★☆☆ 3 +— Author Track Record ........................... ★★★★★ 3 +— Stable versioning ............................. ★★★★★ 2 === Popularity === SCORE WEIGHT — Weekly Downloads .............................. ★★★★★ 3 @@ -146,6 +149,9 @@ Helps determine if the project is active and healthy, or abandoned. Signals incl Signals that are visible in the repo/package that showcases quality: * Documentation Completeness: High quality documentation makes the project easier to adopt and use (README, API References, Usage Examples). +* Tests checklist (unit/snapshot): Tests ensure correctness and prevent regressions. +* Author Track Record: Measures how many packages the author has published, more published packages often indicate greater experience. +* Stable versioning (>=1.x.x, not deprecated): Indicates API maturity and stability. ##### Popularity From 2493dc10d8df17390e46146d663238a50af3a3c1 Mon Sep 17 00:00:00 2001 From: Benny Huang Date: Tue, 11 Nov 2025 15:11:51 -0500 Subject: [PATCH 3/3] changed config name to be author package count and also uses the highest count of all the authors --- src/lib/config.ts | 4 ++-- src/lib/data/collect.ts | 8 +++---- src/lib/data/npm.ts | 32 +++++++++++++++++++-------- test/lib/data/collect.test.ts | 4 ++-- test/lib/data/npm.test.ts | 41 ----------------------------------- 5 files changed, 31 insertions(+), 58 deletions(-) diff --git a/src/lib/config.ts b/src/lib/config.ts index aea9119..37d92e1 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -67,9 +67,9 @@ export const CONFIG: Config = { }), }, { - name: 'authorTrackRecord', + name: 'authorPackageCount', weight: 3, - description: 'Track record of strong authors', + description: 'Highest package count among authors', benchmarks: (packageCount: number) => categorizeHigherIsBetter([20, 11, 5, 2], packageCount), }, { diff --git a/src/lib/data/collect.ts b/src/lib/data/collect.ts index a1f6b2b..9715476 100644 --- a/src/lib/data/collect.ts +++ b/src/lib/data/collect.ts @@ -24,7 +24,7 @@ async function fetchAllData(packageName: string): Promise { await npmCollector.fetchPackage(packageName); const npmData = npmCollector.getPackageData(); const downloadData = await npmCollector.fetchDownloadData(); - const authorPackageCount = await npmCollector.fetchAuthorPackageCount(); + const authorPackageCountData = await npmCollector.fetchAuthorPackageCount(); const repoInfo = extractRepoInfo(npmData.repository.url); const githubRepo = new GitHubRepo(repoInfo.owner, repoInfo.repo); @@ -45,7 +45,7 @@ async function fetchAllData(packageName: string): Promise { return { npm: npmData, downloads: downloadData, - ...(authorPackageCount !== undefined && { authorPackageCount }), + authorPackageCount: authorPackageCountData, ...(githubData && { github: githubData }), }; } @@ -60,7 +60,7 @@ function processPackageData(rawData: RawPackageData): PackageData { return { version: rawData.npm.version, weeklyDownloads: rawData.downloads.downloads, - ...(rawData.authorPackageCount !== undefined && { authorTrackRecord: rawData.authorPackageCount }), + authorPackageCount: rawData.authorPackageCount, stableVersioning: { isStableMajorVersion: parseInt(majorVersion, 10) >= 1, hasMinorReleases: parseInt(minorVersion, 10) >= 1, @@ -77,7 +77,7 @@ function processPackageData(rawData: RawPackageData): PackageData { numberOfContributors_Maintenance: processContributorsData(repository.commits), documentationCompleteness: analyzeDocumentationCompleteness(repository), testsChecklist: analyzeTestsPresence(repository), - authorTrackRecord: rawData.authorPackageCount, + authorPackageCount: rawData.authorPackageCount, weeklyDownloads: rawData.downloads.downloads, githubStars: repository.stargazerCount ?? 0, stableVersioning: { diff --git a/src/lib/data/npm.ts b/src/lib/data/npm.ts index 2becbb1..bce9f5b 100644 --- a/src/lib/data/npm.ts +++ b/src/lib/data/npm.ts @@ -63,20 +63,34 @@ export class NpmCollector { } async fetchAuthorPackageCount(): Promise { - // Use the first maintainer to search for their packages (libraries can have multiple maintainers) - const maintainer = this.packageData?.maintainers?.[0]; - if (!maintainer?.name) { + // Get the best (highest package count) maintainer + const maintainers = this.packageData?.maintainers; + if (!maintainers || maintainers.length === 0) { return undefined; } try { - const response = await fetch(`https://registry.npmjs.org/-/v1/search?text=maintainer:${encodeURIComponent(maintainer.name)}&size=1`); - if (!response.ok) { - return undefined; - } + const counts = await Promise.all( + maintainers.map(async (maintainer) => { + if (!maintainer?.name) { + return 0; + } + try { + const response = await fetch(`https://registry.npmjs.org/-/v1/search?text=maintainer:${encodeURIComponent(maintainer.name)}&size=1`); + if (!response.ok) { + return undefined; + } + const data = await response.json() as any; - const data = await response.json() as any; - return data.total ?? 0; + return data.total ?? 0; + } catch { + return 0; + } + }), + ); + const maxCount = Math.max(...counts); + // Return undefined if all requests failed + return maxCount > 0 ? maxCount : undefined; } catch { return undefined; } diff --git a/test/lib/data/collect.test.ts b/test/lib/data/collect.test.ts index 8944a0f..1f0dd72 100644 --- a/test/lib/data/collect.test.ts +++ b/test/lib/data/collect.test.ts @@ -100,7 +100,7 @@ describe('collectPackageData', () => { hasUnitTests: false, hasSnapshotTests: false, }, - authorTrackRecord: 42, + authorPackageCount: 42, weeklyDownloads: 10000, githubStars: 500, numberOfContributors_Popularity: 2, @@ -136,7 +136,7 @@ describe('collectPackageData', () => { expect(result).toEqual({ version: '1.0.0', - authorTrackRecord: 42, + authorPackageCount: 42, weeklyDownloads: 10000, stableVersioning: { isStableMajorVersion: true, diff --git a/test/lib/data/npm.test.ts b/test/lib/data/npm.test.ts index 134622e..b0369a1 100644 --- a/test/lib/data/npm.test.ts +++ b/test/lib/data/npm.test.ts @@ -431,46 +431,5 @@ describe('NpmCollector', () => { expect(result).toBeUndefined(); }); - - test('should return 0 when total is 0', async () => { - const mockPackageResponse = { - 'name': 'test-package', - 'dist-tags': { latest: '1.0.0' }, - 'repository': { - type: 'git', - url: 'https://github.com/test/repo', - }, - 'maintainers': [ - { name: 'newauthor', email: 'new@example.com' }, - ], - 'versions': { - '1.0.0': { - name: 'test-package', - version: '1.0.0', - }, - }, - }; - - mockedFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockPackageResponse, - } as Response); - - await collector.fetchPackage('test-package'); - - const mockSearchResponse = { - objects: [], - total: 0, - }; - - mockedFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockSearchResponse, - } as Response); - - const result = await collector.fetchAuthorPackageCount(); - - expect(result).toBe(0); - }); }); }); \ No newline at end of file