Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,13 @@ SUBSCORES
=== Quality === SCORE WEIGHT
— Documentation Completeness .................... ★★★★★ 5
— Tests checklist (unit/snapshot) ............... ★★★☆☆ 5
— Author Track Record ........................... ★★★★★ 5
— Stable versioning ............................. ★★★★★ 5
— Changelog includes feats/fixes ................ ★★★★★ 5

=== Popularity === SCORE WEIGHT
— Weekly Downloads .............................. ★★★★★ 15
— Repo stars .................................... ★★★★☆ 15
— Repo stars .................................... ★★★★☆ 10
— Contributors .................................. ★★★★☆ 5
```

Expand Down Expand Up @@ -152,6 +153,7 @@ Signals that are visible in the repo/package that showcases quality:

* Documentation Completeness: High quality document
* 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.
* Changelog includes feats/fixes: Checks if there are feats/fixes published in the release notes.
* Stable versioning (>=1.x.x, not deprecated): Indicates API maturity and stability.ation makes the project easier to adopt and use (README, API References, Usage Examples).

Expand Down
8 changes: 7 additions & 1 deletion src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const CONFIG: Config = {
signals: [
{
name: 'documentationCompleteness',
defaultWeight: 10,
defaultWeight: 5,
description: 'Presence of README, API reference, and usage examples',
benchmarks: (docData: DocumentationCompleteness) => categorizeByChecklist(
{
Expand All @@ -64,6 +64,12 @@ export const CONFIG: Config = {
snapshotTests: { present: testsData.hasSnapshotTests, value: 2 },
}),
},
{
name: 'authorPackageCount',
defaultWeight: 5,
description: 'Highest package count among authors',
benchmarks: (packageCount: number) => categorizeHigherIsBetter([20, 11, 5, 2], packageCount),
},
{
name: 'releaseNotesIncludeFeatsAndFixes',
defaultWeight: 5,
Expand Down
5 changes: 5 additions & 0 deletions src/lib/data/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { calculateReleaseFrequency } from '../utils/releases';
interface RawPackageData {
readonly npm: NpmPackageData;
readonly downloads: NpmDownloadData;
readonly authorPackageCount?: number;
readonly github?: GitHubRepository;
}

Expand All @@ -23,6 +24,7 @@ async function fetchAllData(packageName: string): Promise<RawPackageData> {
await npmCollector.fetchPackage(packageName);
const npmData = npmCollector.getPackageData();
const downloadData = await npmCollector.fetchDownloadData();
const authorPackageCountData = await npmCollector.fetchAuthorPackageCount();

const repoInfo = extractRepoInfo(npmData.repository.url);
const githubRepo = new GitHubRepo(repoInfo.owner, repoInfo.repo);
Expand All @@ -43,6 +45,7 @@ async function fetchAllData(packageName: string): Promise<RawPackageData> {
return {
npm: npmData,
downloads: downloadData,
authorPackageCount: authorPackageCountData,
...(githubData && { github: githubData }),
};
}
Expand All @@ -57,6 +60,7 @@ function processPackageData(rawData: RawPackageData): PackageData {
return {
version: rawData.npm.version,
weeklyDownloads: rawData.downloads.downloads,
authorPackageCount: rawData.authorPackageCount,
stableVersioning: {
isStableMajorVersion: parseInt(majorVersion, 10) >= 1,
hasMinorReleases: parseInt(minorVersion, 10) >= 1,
Expand All @@ -73,6 +77,7 @@ function processPackageData(rawData: RawPackageData): PackageData {
numberOfContributors_Maintenance: processContributorsData(repository.commits),
documentationCompleteness: analyzeDocumentationCompleteness(repository),
testsChecklist: analyzeTestsPresence(repository),
authorPackageCount: rawData.authorPackageCount,
releaseNotesIncludeFeatsAndFixes: analyzeReleaseNotesContent(repository),
weeklyDownloads: rawData.downloads.downloads,
githubStars: repository.stargazerCount ?? 0,
Expand Down
36 changes: 36 additions & 0 deletions src/lib/data/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface NpmPackageData {
};
readonly isDeprecated: boolean;
readonly hasProvenance?: boolean;
readonly maintainers?: Array<{ name: string }>;
}

export interface NpmDownloadData {
Expand Down Expand Up @@ -37,6 +38,7 @@ export class NpmCollector {
repository: response.repository,
isDeprecated: Boolean(versionData?.deprecated),
hasProvenance,
maintainers: response.maintainers,
};
}

Expand All @@ -59,4 +61,38 @@ export class NpmCollector {

return await response.json() as NpmDownloadData;
}

async fetchAuthorPackageCount(): Promise<number | undefined> {
// Get the best (highest package count) maintainer
const maintainers = this.packageData?.maintainers;
if (!maintainers || maintainers.length === 0) {
return undefined;
}

try {
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;

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;
}
}
}
1 change: 1 addition & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type PackageData = {
readonly numberOfContributors_Maintenance?: number;
readonly documentationCompleteness?: DocumentationCompleteness;
readonly testsChecklist?: TestsData;
readonly authorTrackRecord?: number;
readonly releaseNotesIncludeFeatsAndFixes?: ReleaseNotesData;
readonly weeklyDownloads?: number;
readonly githubStars?: number;
Expand Down
4 changes: 4 additions & 0 deletions test/lib/data/collect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -99,6 +100,7 @@ describe('collectPackageData', () => {
hasUnitTests: false,
hasSnapshotTests: false,
},
authorPackageCount: 42,
releaseNotesIncludeFeatsAndFixes: {
hasFeats: true,
hasFixes: true,
Expand All @@ -122,6 +124,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 = {
Expand All @@ -137,6 +140,7 @@ describe('collectPackageData', () => {

expect(result).toEqual({
version: '1.0.0',
authorPackageCount: 42,
weeklyDownloads: 10000,
stableVersioning: {
isStableMajorVersion: true,
Expand Down
147 changes: 147 additions & 0 deletions test/lib/data/npm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,4 +285,151 @@ 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();
});
});
});