dep-oracle 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +33 -8
  2. package/dist/action/index.js +398 -16
  3. package/dist/badge-5Z3WAD2B.js +89 -0
  4. package/dist/badge-5Z3WAD2B.js.map +1 -0
  5. package/dist/chunk-32B3QIPY.js +1505 -0
  6. package/dist/chunk-32B3QIPY.js.map +1 -0
  7. package/dist/chunk-7DST6SNA.js +258 -0
  8. package/dist/chunk-7DST6SNA.js.map +1 -0
  9. package/dist/{chunk-TXSNFX3N.js → chunk-DLWG22RC.js} +403 -17
  10. package/dist/chunk-DLWG22RC.js.map +1 -0
  11. package/dist/chunk-HX6MGNBD.js +271 -0
  12. package/dist/chunk-HX6MGNBD.js.map +1 -0
  13. package/dist/chunk-IVXGOPRU.js +145 -0
  14. package/dist/chunk-IVXGOPRU.js.map +1 -0
  15. package/dist/chunk-SP3VYPXX.js +218 -0
  16. package/dist/chunk-SP3VYPXX.js.map +1 -0
  17. package/dist/chunk-T5EVLWZM.js +4234 -0
  18. package/dist/chunk-T5EVLWZM.js.map +1 -0
  19. package/dist/chunk-UMB5MJHL.js +239 -0
  20. package/dist/chunk-UMB5MJHL.js.map +1 -0
  21. package/dist/cli/index.js +163 -6499
  22. package/dist/cli/index.js.map +1 -1
  23. package/dist/index.js +9 -84
  24. package/dist/index.js.map +1 -1
  25. package/dist/mcp/server.js +33 -12
  26. package/dist/mcp/server.js.map +1 -1
  27. package/dist/npm-UB54H37N.js +9 -0
  28. package/dist/npm-UB54H37N.js.map +1 -0
  29. package/dist/orchestrator-VOOYKDPT.js +8 -0
  30. package/dist/orchestrator-VOOYKDPT.js.map +1 -0
  31. package/dist/python-U4G2GK4J.js +9 -0
  32. package/dist/python-U4G2GK4J.js.map +1 -0
  33. package/dist/server-WONIBSG4.js +640 -0
  34. package/dist/server-WONIBSG4.js.map +1 -0
  35. package/dist/store-Z5UANEBB.js +8 -0
  36. package/dist/store-Z5UANEBB.js.map +1 -0
  37. package/dist/trust-score-YXYDFVPZ.js +8 -0
  38. package/dist/trust-score-YXYDFVPZ.js.map +1 -0
  39. package/package.json +1 -1
  40. package/server.json +2 -2
  41. package/dist/chunk-TXSNFX3N.js.map +0 -1
@@ -0,0 +1,1505 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/collectors/orchestrator.ts
4
+ import pLimit from "p-limit";
5
+
6
+ // src/utils/logger.ts
7
+ import chalk from "chalk";
8
+ var _verbose = false;
9
+ function setVerbose(enabled) {
10
+ _verbose = enabled;
11
+ }
12
+ function isDebug() {
13
+ const val = process.env.DEP_ORACLE_DEBUG;
14
+ if (!val) return false;
15
+ return ["1", "true", "yes"].includes(val.toLowerCase());
16
+ }
17
+ function timestamp() {
18
+ return (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
19
+ }
20
+ function formatMessage(level, colorFn, msg) {
21
+ return `${chalk.dim(timestamp())} ${colorFn(level.padEnd(5))} ${msg}`;
22
+ }
23
+ var logger = {
24
+ debug(msg) {
25
+ if (!isDebug()) return;
26
+ process.stderr.write(formatMessage("DEBUG", chalk.gray, chalk.gray(msg)) + "\n");
27
+ },
28
+ info(msg) {
29
+ if (!_verbose && !isDebug()) return;
30
+ process.stderr.write(formatMessage("INFO", chalk.blue, msg) + "\n");
31
+ },
32
+ warn(msg) {
33
+ process.stderr.write(formatMessage("WARN", chalk.yellow, msg) + "\n");
34
+ },
35
+ error(msg) {
36
+ process.stderr.write(formatMessage("ERROR", chalk.red, msg) + "\n");
37
+ }
38
+ };
39
+ function createLogger(label) {
40
+ const prefix = chalk.dim(`[${label}]`);
41
+ return {
42
+ debug(msg) {
43
+ logger.debug(`${prefix} ${msg}`);
44
+ },
45
+ info(msg) {
46
+ logger.info(`${prefix} ${msg}`);
47
+ },
48
+ warn(msg) {
49
+ logger.warn(`${prefix} ${msg}`);
50
+ },
51
+ error(msg) {
52
+ logger.error(`${prefix} ${msg}`);
53
+ }
54
+ };
55
+ }
56
+
57
+ // src/utils/rate-limiter.ts
58
+ var RateLimiter = class {
59
+ tokens;
60
+ maxTokens;
61
+ refillIntervalMs;
62
+ lastRefill;
63
+ waitQueue = [];
64
+ /**
65
+ * @param maxRequests Maximum number of requests allowed in the window
66
+ * @param windowMs Window duration in milliseconds
67
+ */
68
+ constructor(maxRequests, windowMs) {
69
+ this.maxTokens = maxRequests;
70
+ this.tokens = maxRequests;
71
+ this.refillIntervalMs = windowMs;
72
+ this.lastRefill = Date.now();
73
+ }
74
+ /**
75
+ * Acquire a token. Resolves immediately when tokens are available,
76
+ * otherwise waits until the bucket is refilled.
77
+ */
78
+ async acquire() {
79
+ this.refill();
80
+ if (this.tokens > 0) {
81
+ this.tokens--;
82
+ return;
83
+ }
84
+ return new Promise((resolve) => {
85
+ this.waitQueue.push(resolve);
86
+ this.scheduleRefill();
87
+ });
88
+ }
89
+ /**
90
+ * Return the number of tokens currently available (without waiting).
91
+ */
92
+ get remaining() {
93
+ this.refill();
94
+ return this.tokens;
95
+ }
96
+ /**
97
+ * Return the number of milliseconds until the next refill.
98
+ */
99
+ get msUntilRefill() {
100
+ const elapsed = Date.now() - this.lastRefill;
101
+ return Math.max(0, this.refillIntervalMs - elapsed);
102
+ }
103
+ // -----------------------------------------------------------------------
104
+ // Internal
105
+ // -----------------------------------------------------------------------
106
+ refill() {
107
+ const now = Date.now();
108
+ const elapsed = now - this.lastRefill;
109
+ if (elapsed >= this.refillIntervalMs) {
110
+ const periods = Math.floor(elapsed / this.refillIntervalMs);
111
+ this.tokens = Math.min(this.maxTokens, this.tokens + periods * this.maxTokens);
112
+ this.lastRefill = now - elapsed % this.refillIntervalMs;
113
+ this.drainWaitQueue();
114
+ }
115
+ }
116
+ scheduleRefill() {
117
+ const delay = this.msUntilRefill;
118
+ if (delay <= 0) {
119
+ this.refill();
120
+ return;
121
+ }
122
+ setTimeout(() => {
123
+ this.refill();
124
+ }, delay);
125
+ }
126
+ drainWaitQueue() {
127
+ while (this.waitQueue.length > 0 && this.tokens > 0) {
128
+ this.tokens--;
129
+ const resolve = this.waitQueue.shift();
130
+ resolve?.();
131
+ }
132
+ }
133
+ };
134
+ var githubRateLimiter = new RateLimiter(5e3, 36e5);
135
+ var npmRateLimiter = new RateLimiter(300, 6e4);
136
+ var pypiRateLimiter = new RateLimiter(100, 6e4);
137
+
138
+ // src/collectors/base.ts
139
+ var BaseCollector = class {
140
+ cache;
141
+ /** Default cache TTL in seconds (24 hours). */
142
+ defaultTTL = 86400;
143
+ constructor(cache) {
144
+ this.cache = cache;
145
+ }
146
+ // ---------------------------------------------------------------------------
147
+ // Cache helpers
148
+ // ---------------------------------------------------------------------------
149
+ /** Build a deterministic cache key. */
150
+ cacheKey(pkg, version) {
151
+ return `${this.name}:${pkg}@${version}`;
152
+ }
153
+ /**
154
+ * Return a cached CollectorResult if one exists, otherwise `null`.
155
+ *
156
+ * When a cache hit is found the result is returned with `status: 'cached'`
157
+ * so callers can differentiate between fresh and cached data.
158
+ */
159
+ async getCached(pkg, version) {
160
+ const key = this.cacheKey(pkg, version);
161
+ try {
162
+ const cached = await this.cache.get(key);
163
+ if (cached !== null && cached !== void 0) {
164
+ logger.debug(`Cache hit for ${key}`);
165
+ return {
166
+ status: "cached",
167
+ data: cached,
168
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
169
+ };
170
+ }
171
+ } catch (err) {
172
+ logger.warn(
173
+ `Cache read failed for ${key}: ${err instanceof Error ? err.message : String(err)}`
174
+ );
175
+ }
176
+ return null;
177
+ }
178
+ /**
179
+ * Persist collector data in the cache.
180
+ *
181
+ * Failures are logged but never thrown -- caching is best-effort.
182
+ */
183
+ async setCache(pkg, version, data, ttl = this.defaultTTL) {
184
+ const key = this.cacheKey(pkg, version);
185
+ try {
186
+ await this.cache.set(key, data, ttl);
187
+ logger.debug(`Cache set for ${key} (ttl=${ttl}s)`);
188
+ } catch (err) {
189
+ logger.warn(
190
+ `Cache write failed for ${key}: ${err instanceof Error ? err.message : String(err)}`
191
+ );
192
+ }
193
+ }
194
+ };
195
+
196
+ // src/collectors/registry.ts
197
+ var RegistryCollector = class extends BaseCollector {
198
+ name = "registry";
199
+ constructor(cache) {
200
+ super(cache);
201
+ }
202
+ async collect(packageName, version) {
203
+ const cached = await this.getCached(packageName, version);
204
+ if (cached) return cached;
205
+ try {
206
+ const [packument, downloads] = await Promise.all([
207
+ this.fetchPackument(packageName),
208
+ this.fetchWeeklyDownloads(packageName)
209
+ ]);
210
+ const versionCount = packument.versions ? Object.keys(packument.versions).length : 0;
211
+ const timeEntries = packument.time ?? {};
212
+ const publishDates = Object.entries(timeEntries).filter(([key]) => key !== "created" && key !== "modified").map(([, value]) => new Date(value).getTime()).sort((a, b) => b - a);
213
+ const lastPublishDate = publishDates.length > 0 ? new Date(publishDates[0]).toISOString() : null;
214
+ const versionInfo = packument.versions?.[version];
215
+ const deprecated = versionInfo?.deprecated ? String(versionInfo.deprecated) : null;
216
+ const data = {
217
+ packageName,
218
+ version,
219
+ description: packument.description ?? null,
220
+ lastPublishDate,
221
+ versionCount,
222
+ deprecated,
223
+ weeklyDownloads: downloads,
224
+ license: packument.license ?? null,
225
+ repositoryUrl: this.extractRepoUrl(packument)
226
+ };
227
+ await this.setCache(packageName, version, data);
228
+ return {
229
+ status: "success",
230
+ data,
231
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
232
+ };
233
+ } catch (err) {
234
+ const message = err instanceof Error ? err.message : String(err);
235
+ logger.error(`RegistryCollector failed for ${packageName}@${version}: ${message}`);
236
+ return {
237
+ status: "error",
238
+ data: null,
239
+ error: message,
240
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
241
+ };
242
+ }
243
+ }
244
+ // ---------------------------------------------------------------------------
245
+ // Private helpers
246
+ // ---------------------------------------------------------------------------
247
+ async fetchPackument(packageName) {
248
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`;
249
+ logger.debug(`Fetching packument: ${url}`);
250
+ await npmRateLimiter.acquire();
251
+ const res = await fetch(url, {
252
+ headers: { Accept: "application/json" }
253
+ });
254
+ if (!res.ok) {
255
+ throw new Error(`npm registry returned ${res.status} for ${packageName}`);
256
+ }
257
+ return await res.json();
258
+ }
259
+ async fetchWeeklyDownloads(packageName) {
260
+ const url = `https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(packageName)}`;
261
+ logger.debug(`Fetching weekly downloads: ${url}`);
262
+ try {
263
+ await npmRateLimiter.acquire();
264
+ const res = await fetch(url, {
265
+ headers: { Accept: "application/json" }
266
+ });
267
+ if (!res.ok) {
268
+ logger.warn(`Downloads API returned ${res.status} for ${packageName}`);
269
+ return 0;
270
+ }
271
+ const body = await res.json();
272
+ return body.downloads ?? 0;
273
+ } catch {
274
+ logger.warn(`Could not fetch download stats for ${packageName}`);
275
+ return 0;
276
+ }
277
+ }
278
+ /**
279
+ * Extract a normalised GitHub/repo URL from the packument.
280
+ * npm stores repo URLs in several formats; we normalise to https.
281
+ */
282
+ extractRepoUrl(packument) {
283
+ const repo = packument.repository;
284
+ if (!repo) return null;
285
+ const raw = typeof repo === "string" ? repo : repo.url;
286
+ if (!raw) return null;
287
+ return raw.replace(/^git\+/, "").replace(/^git:\/\//, "https://").replace(/^ssh:\/\/git@github\.com/, "https://github.com").replace(/^git@github\.com:/, "https://github.com/").replace(/\.git$/, "");
288
+ }
289
+ };
290
+
291
+ // src/collectors/pypi-registry.ts
292
+ var PyPIRegistryCollector = class extends BaseCollector {
293
+ name = "pypi-registry";
294
+ constructor(cache) {
295
+ super(cache);
296
+ }
297
+ async collect(packageName, version) {
298
+ const cached = await this.getCached(packageName, version);
299
+ if (cached) return cached;
300
+ try {
301
+ const [metadataResult, downloadsResult] = await Promise.allSettled([
302
+ this.fetchMetadata(packageName),
303
+ this.fetchWeeklyDownloads(packageName)
304
+ ]);
305
+ const metadata = metadataResult.status === "fulfilled" ? metadataResult.value : null;
306
+ const downloads = downloadsResult.status === "fulfilled" ? downloadsResult.value : 0;
307
+ if (!metadata) {
308
+ throw new Error(`PyPI registry returned no data for ${packageName}`);
309
+ }
310
+ const versionCount = metadata.releases ? Object.keys(metadata.releases).length : 0;
311
+ const lastPublishDate = this.findLastPublishDate(metadata.releases);
312
+ const deprecated = this.checkYanked(metadata.releases, version);
313
+ const data = {
314
+ packageName,
315
+ version: version === "latest" ? metadata.info.version : version,
316
+ description: metadata.info.summary ?? null,
317
+ lastPublishDate,
318
+ versionCount,
319
+ deprecated,
320
+ weeklyDownloads: downloads,
321
+ license: metadata.info.license ?? null,
322
+ repositoryUrl: this.extractRepoUrl(metadata.info)
323
+ };
324
+ await this.setCache(packageName, version, data);
325
+ return {
326
+ status: "success",
327
+ data,
328
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
329
+ };
330
+ } catch (err) {
331
+ const message = err instanceof Error ? err.message : String(err);
332
+ logger.error(
333
+ `PyPIRegistryCollector failed for ${packageName}@${version}: ${message}`
334
+ );
335
+ return {
336
+ status: "error",
337
+ data: null,
338
+ error: message,
339
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
340
+ };
341
+ }
342
+ }
343
+ // ---------------------------------------------------------------------------
344
+ // Private helpers
345
+ // ---------------------------------------------------------------------------
346
+ async fetchMetadata(packageName) {
347
+ await pypiRateLimiter.acquire();
348
+ const url = `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`;
349
+ logger.debug(`Fetching PyPI metadata: ${url}`);
350
+ const res = await fetch(url, {
351
+ headers: { Accept: "application/json" }
352
+ });
353
+ if (!res.ok) {
354
+ if (res.status === 404) return null;
355
+ throw new Error(`PyPI registry returned ${res.status} for ${packageName}`);
356
+ }
357
+ return await res.json();
358
+ }
359
+ async fetchWeeklyDownloads(packageName) {
360
+ await pypiRateLimiter.acquire();
361
+ const url = `https://pypistats.org/api/packages/${encodeURIComponent(packageName)}/recent`;
362
+ logger.debug(`Fetching PyPI weekly downloads: ${url}`);
363
+ try {
364
+ const res = await fetch(url, {
365
+ headers: { Accept: "application/json" }
366
+ });
367
+ if (!res.ok) {
368
+ logger.warn(
369
+ `PyPI stats API returned ${res.status} for ${packageName}`
370
+ );
371
+ return 0;
372
+ }
373
+ const body = await res.json();
374
+ return body.data?.last_week ?? 0;
375
+ } catch {
376
+ logger.warn(`Could not fetch PyPI download stats for ${packageName}`);
377
+ return 0;
378
+ }
379
+ }
380
+ /**
381
+ * Find the most recent upload date across all releases.
382
+ */
383
+ findLastPublishDate(releases) {
384
+ if (!releases) return null;
385
+ const dates = [];
386
+ for (const files of Object.values(releases)) {
387
+ for (const file of files) {
388
+ const dateStr = file.upload_time_iso_8601 ?? file.upload_time;
389
+ if (dateStr) {
390
+ const ts = new Date(dateStr).getTime();
391
+ if (!isNaN(ts)) dates.push(ts);
392
+ }
393
+ }
394
+ }
395
+ if (dates.length === 0) return null;
396
+ dates.sort((a, b) => b - a);
397
+ return new Date(dates[0]).toISOString();
398
+ }
399
+ /**
400
+ * Check if the requested version is yanked (PyPI's deprecation mechanism).
401
+ * Returns the yank reason string, or null if not yanked.
402
+ */
403
+ checkYanked(releases, version) {
404
+ if (!releases || version === "latest") return null;
405
+ const files = releases[version];
406
+ if (!files || files.length === 0) return null;
407
+ const yankedFile = files.find((f) => f.yanked);
408
+ if (yankedFile) {
409
+ return yankedFile.yanked_reason || "This version has been yanked";
410
+ }
411
+ return null;
412
+ }
413
+ /**
414
+ * Extract a normalised repository URL from PyPI project_urls or home_page.
415
+ */
416
+ extractRepoUrl(info) {
417
+ const projectUrls = info.project_urls ?? {};
418
+ const repoKeys = [
419
+ "Source",
420
+ "Source Code",
421
+ "Repository",
422
+ "GitHub",
423
+ "Code",
424
+ "Homepage",
425
+ "source",
426
+ "source_code",
427
+ "repository",
428
+ "github",
429
+ "code",
430
+ "homepage"
431
+ ];
432
+ for (const key of repoKeys) {
433
+ const url = projectUrls[key];
434
+ if (url && (url.includes("github.com") || url.includes("gitlab.com") || url.includes("bitbucket.org"))) {
435
+ return url.replace(/\.git$/, "");
436
+ }
437
+ }
438
+ for (const url of Object.values(projectUrls)) {
439
+ if (url && (url.includes("github.com") || url.includes("gitlab.com") || url.includes("bitbucket.org"))) {
440
+ return url.replace(/\.git$/, "");
441
+ }
442
+ }
443
+ const homePage = info.home_page;
444
+ if (homePage && (homePage.includes("github.com") || homePage.includes("gitlab.com"))) {
445
+ return homePage.replace(/\.git$/, "");
446
+ }
447
+ return null;
448
+ }
449
+ };
450
+
451
+ // src/collectors/github.ts
452
+ var GitHubCollector = class extends BaseCollector {
453
+ name = "github";
454
+ token;
455
+ constructor(cache, githubToken) {
456
+ super(cache);
457
+ this.token = githubToken ?? process.env.GITHUB_TOKEN;
458
+ }
459
+ async collect(packageName, version) {
460
+ const cached = await this.getCached(packageName, version);
461
+ if (cached) return cached;
462
+ try {
463
+ const repoSlug = await this.resolveRepoSlug(packageName);
464
+ if (!repoSlug) {
465
+ return {
466
+ status: "error",
467
+ data: null,
468
+ error: "No GitHub repository found",
469
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
470
+ };
471
+ }
472
+ const { owner, repo } = repoSlug;
473
+ const [repoInfo, contributorCount, recentCommitCount, latestCommit, hasFunding] = await Promise.all([
474
+ this.fetchRepoInfo(owner, repo),
475
+ this.fetchContributorCount(owner, repo),
476
+ this.fetchRecentCommitCount(owner, repo),
477
+ this.fetchLatestCommit(owner, repo),
478
+ this.checkFundingYml(owner, repo)
479
+ ]);
480
+ const data = {
481
+ owner,
482
+ repo,
483
+ stars: repoInfo.stargazers_count,
484
+ forks: repoInfo.forks_count,
485
+ openIssues: repoInfo.open_issues_count,
486
+ updatedAt: repoInfo.updated_at,
487
+ archived: repoInfo.archived,
488
+ defaultBranch: repoInfo.default_branch,
489
+ contributorCount,
490
+ recentCommitCount,
491
+ lastCommitDate: latestCommit ? latestCommit.commit.committer?.date ?? null : null,
492
+ lastCommitSha: latestCommit?.sha ?? null,
493
+ hasFundingYml: hasFunding
494
+ };
495
+ await this.setCache(packageName, version, data);
496
+ return {
497
+ status: "success",
498
+ data,
499
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
500
+ };
501
+ } catch (err) {
502
+ const message = err instanceof Error ? err.message : String(err);
503
+ logger.error(`GitHubCollector failed for ${packageName}@${version}: ${message}`);
504
+ return {
505
+ status: "error",
506
+ data: null,
507
+ error: message,
508
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
509
+ };
510
+ }
511
+ }
512
+ // ---------------------------------------------------------------------------
513
+ // Repo slug resolution
514
+ // ---------------------------------------------------------------------------
515
+ /**
516
+ * Determine the GitHub owner/repo from the npm registry metadata.
517
+ * Falls back to a well-known heuristic for scoped packages.
518
+ */
519
+ async resolveRepoSlug(packageName) {
520
+ try {
521
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`;
522
+ await npmRateLimiter.acquire();
523
+ const res = await fetch(url, {
524
+ headers: { Accept: "application/json" }
525
+ });
526
+ if (!res.ok) return null;
527
+ const body = await res.json();
528
+ const repoField = body.repository;
529
+ const raw = typeof repoField === "string" ? repoField : repoField?.url;
530
+ if (!raw) return null;
531
+ return this.parseGitHubUrl(raw);
532
+ } catch {
533
+ return null;
534
+ }
535
+ }
536
+ /** Extract owner/repo from a variety of GitHub URL formats. */
537
+ parseGitHubUrl(raw) {
538
+ const normalised = raw.replace(/^git\+/, "").replace(/^git:\/\//, "https://").replace(/^ssh:\/\/git@github\.com/, "https://github.com").replace(/^git@github\.com:/, "https://github.com/").replace(/\.git$/, "");
539
+ const match = normalised.match(
540
+ /github\.com\/([^/]+)\/([^/]+)/
541
+ );
542
+ if (!match) return null;
543
+ const owner = match[1];
544
+ const repo = match[2];
545
+ const GITHUB_NAME = /^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/;
546
+ if (!GITHUB_NAME.test(owner) || !GITHUB_NAME.test(repo)) return null;
547
+ return { owner, repo };
548
+ }
549
+ // ---------------------------------------------------------------------------
550
+ // GitHub API calls
551
+ // ---------------------------------------------------------------------------
552
+ headers() {
553
+ const h = {
554
+ Accept: "application/vnd.github+json",
555
+ "X-GitHub-Api-Version": "2022-11-28"
556
+ };
557
+ if (this.token) {
558
+ h.Authorization = `Bearer ${this.token}`;
559
+ }
560
+ return h;
561
+ }
562
+ async fetchRepoInfo(owner, repo) {
563
+ const url = `https://api.github.com/repos/${owner}/${repo}`;
564
+ logger.debug(`GitHub: fetching repo info ${url}`);
565
+ await githubRateLimiter.acquire();
566
+ const res = await fetch(url, { headers: this.headers() });
567
+ if (!res.ok) {
568
+ throw new Error(`GitHub API ${res.status} for ${url}`);
569
+ }
570
+ return await res.json();
571
+ }
572
+ /**
573
+ * Get total contributor count using the Link header pagination trick.
574
+ * We request per_page=1&anon=true and read the `last` page number.
575
+ */
576
+ async fetchContributorCount(owner, repo) {
577
+ const url = `https://api.github.com/repos/${owner}/${repo}/contributors?per_page=1&anon=true`;
578
+ logger.debug(`GitHub: fetching contributor count ${url}`);
579
+ try {
580
+ await githubRateLimiter.acquire();
581
+ const res = await fetch(url, { headers: this.headers() });
582
+ if (!res.ok) return 0;
583
+ const count = this.extractLastPage(res.headers.get("link"));
584
+ if (count !== null) return count;
585
+ const body = await res.json();
586
+ return body.length;
587
+ } catch {
588
+ return 0;
589
+ }
590
+ }
591
+ /**
592
+ * Count commits in the last 30 days via the same Link-header trick.
593
+ */
594
+ async fetchRecentCommitCount(owner, repo) {
595
+ const since = new Date(
596
+ Date.now() - 30 * 24 * 60 * 60 * 1e3
597
+ ).toISOString();
598
+ const url = `https://api.github.com/repos/${owner}/${repo}/commits?since=${since}&per_page=1`;
599
+ logger.debug(`GitHub: fetching recent commit count ${url}`);
600
+ try {
601
+ await githubRateLimiter.acquire();
602
+ const res = await fetch(url, { headers: this.headers() });
603
+ if (!res.ok) return 0;
604
+ const count = this.extractLastPage(res.headers.get("link"));
605
+ if (count !== null) return count;
606
+ const body = await res.json();
607
+ return body.length;
608
+ } catch {
609
+ return 0;
610
+ }
611
+ }
612
+ /** Fetch the single most-recent commit. */
613
+ async fetchLatestCommit(owner, repo) {
614
+ const url = `https://api.github.com/repos/${owner}/${repo}/commits?per_page=1`;
615
+ logger.debug(`GitHub: fetching latest commit ${url}`);
616
+ try {
617
+ await githubRateLimiter.acquire();
618
+ const res = await fetch(url, { headers: this.headers() });
619
+ if (!res.ok) return null;
620
+ const body = await res.json();
621
+ return body[0] ?? null;
622
+ } catch {
623
+ return null;
624
+ }
625
+ }
626
+ /**
627
+ * Check whether the repository contains a .github/FUNDING.yml file.
628
+ * A 200 response means the file exists.
629
+ */
630
+ async checkFundingYml(owner, repo) {
631
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/.github/FUNDING.yml`;
632
+ logger.debug(`GitHub: checking FUNDING.yml ${url}`);
633
+ try {
634
+ await githubRateLimiter.acquire();
635
+ const res = await fetch(url, { headers: this.headers() });
636
+ return res.ok;
637
+ } catch {
638
+ return false;
639
+ }
640
+ }
641
+ // ---------------------------------------------------------------------------
642
+ // Utilities
643
+ // ---------------------------------------------------------------------------
644
+ /**
645
+ * Parse the GitHub `Link` header and return the last page number.
646
+ *
647
+ * Link: <...?page=42>; rel="last", <...?page=2>; rel="next"
648
+ *
649
+ * Returns `null` when there is no last page (single page of results).
650
+ */
651
+ extractLastPage(linkHeader) {
652
+ if (!linkHeader) return null;
653
+ const match = linkHeader.match(
654
+ /[?&]page=(\d+)[^>]*>;\s*rel="last"/
655
+ );
656
+ return match ? parseInt(match[1], 10) : null;
657
+ }
658
+ };
659
+
660
+ // src/collectors/security.ts
661
+ var SecurityCollector = class extends BaseCollector {
662
+ name = "security";
663
+ constructor(cache) {
664
+ super(cache);
665
+ }
666
+ async collect(packageName, version, ecosystem) {
667
+ const cached = await this.getCached(packageName, version);
668
+ if (cached) return cached;
669
+ try {
670
+ const vulns = await this.queryOsv(packageName, ecosystem);
671
+ const totalVulnerabilities = vulns.length;
672
+ const severityCounts = {
673
+ critical: 0,
674
+ high: 0,
675
+ medium: 0,
676
+ low: 0,
677
+ unknown: 0
678
+ };
679
+ for (const vuln of vulns) {
680
+ const severity = this.extractSeverity(vuln);
681
+ severityCounts[severity]++;
682
+ }
683
+ const vulnDates = vulns.map((v) => new Date(v.published ?? v.modified).getTime()).filter((t) => !isNaN(t)).sort((a, b) => b - a);
684
+ const latestVulnDate = vulnDates.length > 0 ? new Date(vulnDates[0]).toISOString() : null;
685
+ const patchDays = this.estimateAveragePatchDays(vulns);
686
+ const data = {
687
+ packageName,
688
+ version,
689
+ totalVulnerabilities,
690
+ severityCounts,
691
+ latestVulnDate,
692
+ averagePatchDays: patchDays,
693
+ vulnerabilities: vulns.map((v) => ({
694
+ id: v.id,
695
+ summary: v.summary ?? null,
696
+ severity: this.extractSeverity(v),
697
+ published: v.published ?? v.modified
698
+ }))
699
+ };
700
+ await this.setCache(packageName, version, data);
701
+ return {
702
+ status: "success",
703
+ data,
704
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
705
+ };
706
+ } catch (err) {
707
+ const message = err instanceof Error ? err.message : String(err);
708
+ logger.error(`SecurityCollector failed for ${packageName}@${version}: ${message}`);
709
+ return {
710
+ status: "error",
711
+ data: null,
712
+ error: message,
713
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
714
+ };
715
+ }
716
+ }
717
+ // ---------------------------------------------------------------------------
718
+ // OSV API
719
+ // ---------------------------------------------------------------------------
720
+ async queryOsv(packageName, ecosystem = "npm") {
721
+ const url = "https://api.osv.dev/v1/query";
722
+ logger.debug(`OSV: querying vulnerabilities for ${packageName} (ecosystem=${ecosystem})`);
723
+ const res = await fetch(url, {
724
+ method: "POST",
725
+ headers: { "Content-Type": "application/json" },
726
+ body: JSON.stringify({
727
+ package: {
728
+ name: packageName,
729
+ ecosystem
730
+ }
731
+ })
732
+ });
733
+ if (!res.ok) {
734
+ throw new Error(`OSV API returned ${res.status}`);
735
+ }
736
+ const body = await res.json();
737
+ return body.vulns ?? [];
738
+ }
739
+ // ---------------------------------------------------------------------------
740
+ // Severity extraction
741
+ // ---------------------------------------------------------------------------
742
+ /**
743
+ * Determine the highest severity label for a vulnerability.
744
+ *
745
+ * OSV may provide CVSS vectors, database-specific severity strings, or
746
+ * nothing at all. We try multiple sources in order of preference.
747
+ */
748
+ extractSeverity(vuln) {
749
+ if (vuln.severity && vuln.severity.length > 0) {
750
+ for (const s of vuln.severity) {
751
+ const level = this.cvssToLevel(s.score);
752
+ if (level !== "unknown") return level;
753
+ }
754
+ }
755
+ const dbSeverity = vuln.database_specific?.severity;
756
+ if (typeof dbSeverity === "string") {
757
+ const normalised = dbSeverity.toLowerCase().trim();
758
+ if (normalised === "critical") return "critical";
759
+ if (normalised === "high") return "high";
760
+ if (normalised === "moderate" || normalised === "medium") return "medium";
761
+ if (normalised === "low") return "low";
762
+ }
763
+ return "unknown";
764
+ }
765
+ /**
766
+ * Map a CVSS 3.x vector string to a severity level by extracting the
767
+ * base score. If the string looks like a plain number, use it directly.
768
+ */
769
+ cvssToLevel(scoreOrVector) {
770
+ let numeric;
771
+ if (scoreOrVector.startsWith("CVSS:")) {
772
+ return "unknown";
773
+ }
774
+ numeric = parseFloat(scoreOrVector);
775
+ if (isNaN(numeric)) return "unknown";
776
+ if (numeric >= 9) return "critical";
777
+ if (numeric >= 7) return "high";
778
+ if (numeric >= 4) return "medium";
779
+ if (numeric > 0) return "low";
780
+ return "unknown";
781
+ }
782
+ // ---------------------------------------------------------------------------
783
+ // Patch-time estimation
784
+ // ---------------------------------------------------------------------------
785
+ /**
786
+ * Estimate average number of days between a vulnerability being introduced
787
+ * and being fixed. Uses the range events in the OSV affected data.
788
+ *
789
+ * When no usable data is available, returns `null`.
790
+ */
791
+ estimateAveragePatchDays(vulns) {
792
+ const daysPerVuln = [];
793
+ for (const vuln of vulns) {
794
+ if (!vuln.affected) continue;
795
+ for (const affected of vuln.affected) {
796
+ if (!affected.ranges) continue;
797
+ for (const range of affected.ranges) {
798
+ let introduced;
799
+ let fixed;
800
+ for (const event of range.events) {
801
+ if (event.introduced) introduced = event.introduced;
802
+ if (event.fixed) fixed = event.fixed;
803
+ }
804
+ if (introduced && fixed) {
805
+ continue;
806
+ }
807
+ }
808
+ }
809
+ const published = vuln.published ? new Date(vuln.published).getTime() : NaN;
810
+ const modified = new Date(vuln.modified).getTime();
811
+ if (!isNaN(published) && !isNaN(modified) && modified > published) {
812
+ const days = (modified - published) / (1e3 * 60 * 60 * 24);
813
+ if (days > 0 && days < 3650) {
814
+ daysPerVuln.push(days);
815
+ }
816
+ }
817
+ }
818
+ if (daysPerVuln.length === 0) return null;
819
+ const total = daysPerVuln.reduce((sum, d) => sum + d, 0);
820
+ return Math.round(total / daysPerVuln.length);
821
+ }
822
+ };
823
+
824
+ // src/collectors/funding.ts
825
+ var FundingCollector = class extends BaseCollector {
826
+ name = "funding";
827
+ githubToken;
828
+ constructor(cache, githubToken) {
829
+ super(cache);
830
+ this.githubToken = githubToken ?? process.env.GITHUB_TOKEN;
831
+ }
832
+ async collect(packageName, version) {
833
+ const cached = await this.getCached(packageName, version);
834
+ if (cached) return cached;
835
+ try {
836
+ const [repoSlug, npmFunding] = await Promise.all([
837
+ this.resolveRepoSlug(packageName),
838
+ this.fetchNpmFunding(packageName)
839
+ ]);
840
+ const [fundingYml, openCollective] = await Promise.all([
841
+ repoSlug ? this.checkFundingYml(repoSlug.owner, repoSlug.repo) : Promise.resolve(null),
842
+ this.fetchOpenCollective(packageName)
843
+ ]);
844
+ const hasSponsors = fundingYml !== null && fundingYml.length > 0;
845
+ const hasOpenCollective = openCollective !== null && (openCollective.isActive ?? false);
846
+ const hasNpmFunding = npmFunding !== null;
847
+ let estimatedAnnualFunding = 0;
848
+ if (openCollective?.yearlyBudget) {
849
+ estimatedAnnualFunding = openCollective.yearlyBudget > 1e6 ? Math.round(openCollective.yearlyBudget / 100) : openCollective.yearlyBudget;
850
+ }
851
+ const data = {
852
+ packageName,
853
+ hasSponsors,
854
+ hasOpenCollective,
855
+ hasNpmFunding,
856
+ openCollectiveSlug: openCollective?.slug ?? null,
857
+ openCollectiveBackers: openCollective?.backersCount ?? 0,
858
+ estimatedAnnualFunding,
859
+ fundingUrls: this.buildFundingUrls(npmFunding, fundingYml, openCollective)
860
+ };
861
+ await this.setCache(packageName, version, data);
862
+ return {
863
+ status: "success",
864
+ data,
865
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
866
+ };
867
+ } catch (err) {
868
+ const message = err instanceof Error ? err.message : String(err);
869
+ logger.warn(`FundingCollector failed for ${packageName}@${version}: ${message}`);
870
+ const data = {
871
+ packageName,
872
+ hasSponsors: false,
873
+ hasOpenCollective: false,
874
+ hasNpmFunding: false,
875
+ openCollectiveSlug: null,
876
+ openCollectiveBackers: 0,
877
+ estimatedAnnualFunding: 0,
878
+ fundingUrls: []
879
+ };
880
+ return {
881
+ status: "success",
882
+ data,
883
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
884
+ };
885
+ }
886
+ }
887
+ // ---------------------------------------------------------------------------
888
+ // npm registry funding field
889
+ // ---------------------------------------------------------------------------
890
+ async fetchNpmFunding(packageName) {
891
+ try {
892
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`;
893
+ await npmRateLimiter.acquire();
894
+ const res = await fetch(url, {
895
+ headers: { Accept: "application/json" }
896
+ });
897
+ if (!res.ok) return null;
898
+ const body = await res.json();
899
+ if (!body.funding) return null;
900
+ if (typeof body.funding === "string") return body.funding;
901
+ if (Array.isArray(body.funding)) {
902
+ return body.funding.map(
903
+ (f) => typeof f === "string" ? f : f.url ?? ""
904
+ ).filter(Boolean);
905
+ }
906
+ if (typeof body.funding === "object" && body.funding.url) {
907
+ return body.funding.url;
908
+ }
909
+ return null;
910
+ } catch {
911
+ return null;
912
+ }
913
+ }
914
+ // ---------------------------------------------------------------------------
915
+ // GitHub FUNDING.yml
916
+ // ---------------------------------------------------------------------------
917
+ async checkFundingYml(owner, repo) {
918
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/.github/FUNDING.yml`;
919
+ logger.debug(`Funding: checking FUNDING.yml ${url}`);
920
+ try {
921
+ const headers = {
922
+ Accept: "application/vnd.github.raw+json",
923
+ "X-GitHub-Api-Version": "2022-11-28"
924
+ };
925
+ if (this.githubToken) {
926
+ headers.Authorization = `Bearer ${this.githubToken}`;
927
+ }
928
+ await githubRateLimiter.acquire();
929
+ const res = await fetch(url, { headers });
930
+ if (!res.ok) return null;
931
+ return await res.text();
932
+ } catch {
933
+ return null;
934
+ }
935
+ }
936
+ // ---------------------------------------------------------------------------
937
+ // OpenCollective
938
+ // ---------------------------------------------------------------------------
939
+ async fetchOpenCollective(packageName) {
940
+ const slug = packageName.replace(/^@[^/]+\//, "");
941
+ const url = `https://opencollective.com/${encodeURIComponent(slug)}.json`;
942
+ logger.debug(`Funding: checking OpenCollective ${url}`);
943
+ try {
944
+ const res = await fetch(url, {
945
+ headers: { Accept: "application/json" }
946
+ });
947
+ if (!res.ok) return null;
948
+ return await res.json();
949
+ } catch {
950
+ return null;
951
+ }
952
+ }
953
+ // ---------------------------------------------------------------------------
954
+ // Repo slug resolution (shared helper)
955
+ // ---------------------------------------------------------------------------
956
+ async resolveRepoSlug(packageName) {
957
+ try {
958
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`;
959
+ await npmRateLimiter.acquire();
960
+ const res = await fetch(url, {
961
+ headers: { Accept: "application/json" }
962
+ });
963
+ if (!res.ok) return null;
964
+ const body = await res.json();
965
+ const repoField = body.repository;
966
+ const raw = typeof repoField === "string" ? repoField : repoField?.url;
967
+ if (!raw) return null;
968
+ const normalised = raw.replace(/^git\+/, "").replace(/^git:\/\//, "https://").replace(/^ssh:\/\/git@github\.com/, "https://github.com").replace(/^git@github\.com:/, "https://github.com/").replace(/\.git$/, "");
969
+ const match = normalised.match(/github\.com\/([^/]+)\/([^/]+)/);
970
+ if (!match) return null;
971
+ return { owner: match[1], repo: match[2] };
972
+ } catch {
973
+ return null;
974
+ }
975
+ }
976
+ // ---------------------------------------------------------------------------
977
+ // Utilities
978
+ // ---------------------------------------------------------------------------
979
+ buildFundingUrls(npmFunding, fundingYml, oc) {
980
+ const urls = [];
981
+ if (npmFunding) {
982
+ if (typeof npmFunding === "string") {
983
+ urls.push(npmFunding);
984
+ } else {
985
+ urls.push(...npmFunding);
986
+ }
987
+ }
988
+ if (fundingYml) {
989
+ const ghMatch = fundingYml.match(/github:\s*(.+)/i);
990
+ if (ghMatch) {
991
+ const sponsors = ghMatch[1].trim().replace(/^\[/, "").replace(/]$/, "").split(",").map((s) => s.trim().replace(/['"]/g, "")).filter(Boolean);
992
+ const GITHUB_USERNAME = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/;
993
+ for (const sponsor of sponsors) {
994
+ if (GITHUB_USERNAME.test(sponsor)) {
995
+ urls.push(`https://github.com/sponsors/${sponsor}`);
996
+ }
997
+ }
998
+ }
999
+ const ocMatch = fundingYml.match(/open_collective:\s*(\S+)/i);
1000
+ if (ocMatch) {
1001
+ urls.push(`https://opencollective.com/${ocMatch[1].trim()}`);
1002
+ }
1003
+ const kofiMatch = fundingYml.match(/ko_fi:\s*(\S+)/i);
1004
+ if (kofiMatch) {
1005
+ urls.push(`https://ko-fi.com/${kofiMatch[1].trim()}`);
1006
+ }
1007
+ const patreonMatch = fundingYml.match(/patreon:\s*(\S+)/i);
1008
+ if (patreonMatch) {
1009
+ urls.push(`https://patreon.com/${patreonMatch[1].trim()}`);
1010
+ }
1011
+ }
1012
+ if (oc?.slug) {
1013
+ const ocUrl = `https://opencollective.com/${oc.slug}`;
1014
+ if (!urls.includes(ocUrl)) {
1015
+ urls.push(ocUrl);
1016
+ }
1017
+ }
1018
+ return [...new Set(urls)];
1019
+ }
1020
+ };
1021
+
1022
+ // src/collectors/popularity.ts
1023
+ var PopularityCollector = class extends BaseCollector {
1024
+ name = "popularity";
1025
+ constructor(cache) {
1026
+ super(cache);
1027
+ }
1028
+ async collect(packageName, version) {
1029
+ const cached = await this.getCached(packageName, version);
1030
+ if (cached) return cached;
1031
+ try {
1032
+ const [weeklyDownloads, monthlyDownloads, dependentCount] = await Promise.all([
1033
+ this.fetchDownloads(packageName, "last-week"),
1034
+ this.fetchDownloads(packageName, "last-month"),
1035
+ this.fetchDependentCount(packageName)
1036
+ ]);
1037
+ const monthlyWeeklyAvg = monthlyDownloads / 4;
1038
+ let trend = "stable";
1039
+ if (monthlyWeeklyAvg > 0) {
1040
+ const ratio = weeklyDownloads / monthlyWeeklyAvg;
1041
+ if (ratio > 1.1) {
1042
+ trend = "rising";
1043
+ } else if (ratio < 0.9) {
1044
+ trend = "declining";
1045
+ }
1046
+ }
1047
+ const data = {
1048
+ packageName,
1049
+ weeklyDownloads,
1050
+ monthlyDownloads,
1051
+ trend,
1052
+ dependentCount
1053
+ };
1054
+ await this.setCache(packageName, version, data);
1055
+ return {
1056
+ status: "success",
1057
+ data,
1058
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
1059
+ };
1060
+ } catch (err) {
1061
+ const message = err instanceof Error ? err.message : String(err);
1062
+ logger.error(`PopularityCollector failed for ${packageName}@${version}: ${message}`);
1063
+ return {
1064
+ status: "error",
1065
+ data: null,
1066
+ error: message,
1067
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
1068
+ };
1069
+ }
1070
+ }
1071
+ // ---------------------------------------------------------------------------
1072
+ // npm downloads API
1073
+ // ---------------------------------------------------------------------------
1074
+ async fetchDownloads(packageName, period) {
1075
+ const url = `https://api.npmjs.org/downloads/point/${period}/${encodeURIComponent(packageName)}`;
1076
+ logger.debug(`Popularity: fetching ${period} downloads: ${url}`);
1077
+ try {
1078
+ await npmRateLimiter.acquire();
1079
+ const res = await fetch(url, {
1080
+ headers: { Accept: "application/json" }
1081
+ });
1082
+ if (!res.ok) {
1083
+ logger.warn(`Downloads API returned ${res.status} for ${packageName} (${period})`);
1084
+ return 0;
1085
+ }
1086
+ const body = await res.json();
1087
+ return body.downloads ?? 0;
1088
+ } catch {
1089
+ logger.warn(`Could not fetch ${period} downloads for ${packageName}`);
1090
+ return 0;
1091
+ }
1092
+ }
1093
+ // ---------------------------------------------------------------------------
1094
+ // Dependent count
1095
+ // ---------------------------------------------------------------------------
1096
+ /**
1097
+ * Attempt to get the number of packages that depend on this one.
1098
+ *
1099
+ * The npm registry search API exposes a "dependents" count via the
1100
+ * /-/v1/search endpoint. This is an approximation.
1101
+ */
1102
+ async fetchDependentCount(packageName) {
1103
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(packageName)}&size=1`;
1104
+ logger.debug(`Popularity: fetching dependent count: ${url}`);
1105
+ try {
1106
+ await npmRateLimiter.acquire();
1107
+ const res = await fetch(url, {
1108
+ headers: { Accept: "application/json" }
1109
+ });
1110
+ if (!res.ok) return 0;
1111
+ const body = await res.json();
1112
+ if (body.objects && body.objects.length > 0) {
1113
+ const exactMatch = body.objects.find(
1114
+ (o) => o.package.name === packageName
1115
+ );
1116
+ if (exactMatch) {
1117
+ return await this.fetchDependentCountFromRegistry(packageName);
1118
+ }
1119
+ }
1120
+ return 0;
1121
+ } catch {
1122
+ return 0;
1123
+ }
1124
+ }
1125
+ /**
1126
+ * Secondary lookup: the npm registry exposes dependent info via the
1127
+ * abbreviated packument endpoint.
1128
+ */
1129
+ async fetchDependentCountFromRegistry(packageName) {
1130
+ try {
1131
+ const url = `https://www.npmjs.com/package/${encodeURIComponent(packageName)}`;
1132
+ await npmRateLimiter.acquire();
1133
+ const res = await fetch(url, {
1134
+ headers: {
1135
+ Accept: "text/html",
1136
+ "X-Spiferack": "1"
1137
+ // npm returns JSON when this header is set
1138
+ }
1139
+ });
1140
+ if (!res.ok) return 0;
1141
+ const body = await res.json();
1142
+ return body.dependents?.dependentsCount ?? 0;
1143
+ } catch {
1144
+ return 0;
1145
+ }
1146
+ }
1147
+ };
1148
+
1149
+ // src/collectors/license.ts
1150
+ var RISK_MAP = {
1151
+ // Safe -- permissive
1152
+ "MIT": "safe",
1153
+ "ISC": "safe",
1154
+ "BSD-2-Clause": "safe",
1155
+ "BSD-3-Clause": "safe",
1156
+ "Apache-2.0": "safe",
1157
+ "Unlicense": "safe",
1158
+ "0BSD": "safe",
1159
+ "CC0-1.0": "safe",
1160
+ "CC-BY-4.0": "safe",
1161
+ "Zlib": "safe",
1162
+ "BlueOak-1.0.0": "safe",
1163
+ "MIT-0": "safe",
1164
+ // Cautious -- weak copyleft
1165
+ "LGPL-2.1": "cautious",
1166
+ "LGPL-2.1-only": "cautious",
1167
+ "LGPL-2.1-or-later": "cautious",
1168
+ "LGPL-3.0": "cautious",
1169
+ "LGPL-3.0-only": "cautious",
1170
+ "LGPL-3.0-or-later": "cautious",
1171
+ "MPL-2.0": "cautious",
1172
+ "EPL-2.0": "cautious",
1173
+ "EPL-1.0": "cautious",
1174
+ "CDDL-1.0": "cautious",
1175
+ "CDDL-1.1": "cautious",
1176
+ // Risky -- strong copyleft
1177
+ "GPL-2.0": "risky",
1178
+ "GPL-2.0-only": "risky",
1179
+ "GPL-2.0-or-later": "risky",
1180
+ "GPL-3.0": "risky",
1181
+ "GPL-3.0-only": "risky",
1182
+ "GPL-3.0-or-later": "risky",
1183
+ "AGPL-3.0": "risky",
1184
+ "AGPL-3.0-only": "risky",
1185
+ "AGPL-3.0-or-later": "risky",
1186
+ "SSPL-1.0": "risky",
1187
+ "EUPL-1.2": "risky"
1188
+ };
1189
+ var OSI_APPROVED = /* @__PURE__ */ new Set([
1190
+ "MIT",
1191
+ "ISC",
1192
+ "BSD-2-Clause",
1193
+ "BSD-3-Clause",
1194
+ "Apache-2.0",
1195
+ "0BSD",
1196
+ "Unlicense",
1197
+ "LGPL-2.1",
1198
+ "LGPL-2.1-only",
1199
+ "LGPL-2.1-or-later",
1200
+ "LGPL-3.0",
1201
+ "LGPL-3.0-only",
1202
+ "LGPL-3.0-or-later",
1203
+ "MPL-2.0",
1204
+ "EPL-2.0",
1205
+ "EPL-1.0",
1206
+ "GPL-2.0",
1207
+ "GPL-2.0-only",
1208
+ "GPL-2.0-or-later",
1209
+ "GPL-3.0",
1210
+ "GPL-3.0-only",
1211
+ "GPL-3.0-or-later",
1212
+ "AGPL-3.0",
1213
+ "AGPL-3.0-only",
1214
+ "AGPL-3.0-or-later",
1215
+ "CDDL-1.0",
1216
+ "Artistic-2.0",
1217
+ "Zlib",
1218
+ "PostgreSQL",
1219
+ "EUPL-1.2",
1220
+ "ECL-2.0"
1221
+ ]);
1222
+ var LICENSE_ALIASES = {
1223
+ "apache 2.0": "Apache-2.0",
1224
+ "apache2": "Apache-2.0",
1225
+ "apache-2": "Apache-2.0",
1226
+ "apache license 2.0": "Apache-2.0",
1227
+ "bsd": "BSD-2-Clause",
1228
+ "bsd-2": "BSD-2-Clause",
1229
+ "bsd-3": "BSD-3-Clause",
1230
+ "bsd license": "BSD-2-Clause",
1231
+ "gpl": "GPL-3.0",
1232
+ "gpl-2": "GPL-2.0",
1233
+ "gpl-3": "GPL-3.0",
1234
+ "gplv2": "GPL-2.0",
1235
+ "gplv3": "GPL-3.0",
1236
+ "lgpl": "LGPL-3.0",
1237
+ "lgpl-2": "LGPL-2.1",
1238
+ "lgpl-3": "LGPL-3.0",
1239
+ "agpl": "AGPL-3.0",
1240
+ "agpl-3": "AGPL-3.0",
1241
+ "mpl": "MPL-2.0",
1242
+ "mpl-2": "MPL-2.0",
1243
+ "unlicensed": "Unlicense",
1244
+ "public domain": "Unlicense",
1245
+ "wtfpl": "WTFPL",
1246
+ "cc0": "CC0-1.0",
1247
+ "cc0-1.0": "CC0-1.0",
1248
+ "artistic-2.0": "Artistic-2.0"
1249
+ };
1250
+ var LicenseCollector = class extends BaseCollector {
1251
+ name = "license";
1252
+ constructor(cache) {
1253
+ super(cache);
1254
+ }
1255
+ async collect(packageName, version) {
1256
+ const cached = await this.getCached(packageName, version);
1257
+ if (cached) return cached;
1258
+ try {
1259
+ const rawLicense = await this.fetchLicense(packageName, version);
1260
+ const spdx = this.toSpdx(rawLicense);
1261
+ const risk = this.classifyRisk(spdx);
1262
+ const osiApproved = spdx !== null && OSI_APPROVED.has(spdx);
1263
+ const data = {
1264
+ packageName,
1265
+ version,
1266
+ raw: rawLicense,
1267
+ spdx,
1268
+ risk,
1269
+ osiApproved
1270
+ };
1271
+ await this.setCache(packageName, version, data);
1272
+ return {
1273
+ status: "success",
1274
+ data,
1275
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
1276
+ };
1277
+ } catch (err) {
1278
+ const message = err instanceof Error ? err.message : String(err);
1279
+ logger.error(`LicenseCollector failed for ${packageName}@${version}: ${message}`);
1280
+ return {
1281
+ status: "error",
1282
+ data: null,
1283
+ error: message,
1284
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
1285
+ };
1286
+ }
1287
+ }
1288
+ // ---------------------------------------------------------------------------
1289
+ // npm registry
1290
+ // ---------------------------------------------------------------------------
1291
+ /**
1292
+ * Fetch the license string from the npm registry.
1293
+ *
1294
+ * Tries the specific version first, then falls back to the top-level
1295
+ * packument "license" field.
1296
+ */
1297
+ async fetchLicense(packageName, version) {
1298
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`;
1299
+ logger.debug(`License: fetching packument ${url}`);
1300
+ await npmRateLimiter.acquire();
1301
+ const res = await fetch(url, {
1302
+ headers: { Accept: "application/json" }
1303
+ });
1304
+ if (!res.ok) {
1305
+ throw new Error(`npm registry returned ${res.status} for ${packageName}`);
1306
+ }
1307
+ const body = await res.json();
1308
+ const versionInfo = body.versions?.[version];
1309
+ if (versionInfo?.license) {
1310
+ return typeof versionInfo.license === "string" ? versionInfo.license : versionInfo.license.type ?? null;
1311
+ }
1312
+ if (body.license) {
1313
+ return typeof body.license === "string" ? body.license : body.license.type ?? null;
1314
+ }
1315
+ return null;
1316
+ }
1317
+ // ---------------------------------------------------------------------------
1318
+ // SPDX mapping
1319
+ // ---------------------------------------------------------------------------
1320
+ /**
1321
+ * Normalise a raw license string to an SPDX identifier.
1322
+ *
1323
+ * Handles common aliases, SPDX expression syntax (e.g. "(MIT OR Apache-2.0)"),
1324
+ * and case-insensitive matching.
1325
+ */
1326
+ toSpdx(raw) {
1327
+ if (!raw) return null;
1328
+ const trimmed = raw.trim();
1329
+ if (!trimmed) return null;
1330
+ if (RISK_MAP[trimmed] !== void 0 || OSI_APPROVED.has(trimmed)) {
1331
+ return trimmed;
1332
+ }
1333
+ const lower = trimmed.toLowerCase();
1334
+ const alias = LICENSE_ALIASES[lower];
1335
+ if (alias) return alias;
1336
+ const stripped = trimmed.replace(/[()]/g, "");
1337
+ const parts = stripped.split(/\s+(?:OR|AND)\s+/i);
1338
+ for (const part of parts) {
1339
+ const p = part.trim();
1340
+ if (RISK_MAP[p] !== void 0) return p;
1341
+ const pAlias = LICENSE_ALIASES[p.toLowerCase()];
1342
+ if (pAlias) return pAlias;
1343
+ }
1344
+ return trimmed;
1345
+ }
1346
+ // ---------------------------------------------------------------------------
1347
+ // Risk classification
1348
+ // ---------------------------------------------------------------------------
1349
+ classifyRisk(spdx) {
1350
+ if (!spdx) return "unknown";
1351
+ return RISK_MAP[spdx] ?? "unknown";
1352
+ }
1353
+ };
1354
+
1355
+ // src/collectors/orchestrator.ts
1356
+ var CollectorOrchestrator = class {
1357
+ cache;
1358
+ options;
1359
+ registryCollector;
1360
+ pypiRegistryCollector;
1361
+ githubCollector;
1362
+ securityCollector;
1363
+ fundingCollector;
1364
+ popularityCollector;
1365
+ licenseCollector;
1366
+ constructor(cache, options = {}) {
1367
+ this.cache = cache;
1368
+ this.options = {
1369
+ offline: options.offline ?? false,
1370
+ githubToken: options.githubToken ?? process.env.GITHUB_TOKEN ?? "",
1371
+ concurrency: options.concurrency ?? 10
1372
+ };
1373
+ this.registryCollector = new RegistryCollector(this.cache);
1374
+ this.pypiRegistryCollector = new PyPIRegistryCollector(this.cache);
1375
+ this.githubCollector = new GitHubCollector(this.cache, this.options.githubToken || void 0);
1376
+ this.securityCollector = new SecurityCollector(this.cache);
1377
+ this.fundingCollector = new FundingCollector(this.cache, this.options.githubToken || void 0);
1378
+ this.popularityCollector = new PopularityCollector(this.cache);
1379
+ this.licenseCollector = new LicenseCollector(this.cache);
1380
+ }
1381
+ /**
1382
+ * Run all collectors for the given package and version.
1383
+ *
1384
+ * In online mode every collector is invoked (cache-first). In offline mode
1385
+ * only the cache is consulted; if there is no cached entry the result gets
1386
+ * `status: 'offline'` with `data: null`.
1387
+ */
1388
+ async collectAll(packageName, version, ecosystem = "npm") {
1389
+ logger.info(
1390
+ `Collecting data for ${packageName}@${version} (ecosystem=${ecosystem}, offline=${String(this.options.offline)})`
1391
+ );
1392
+ const limit = pLimit(this.options.concurrency);
1393
+ const activeRegistryCollector = ecosystem === "pypi" ? this.pypiRegistryCollector : this.registryCollector;
1394
+ const osvEcosystem = ecosystem === "pypi" ? "PyPI" : "npm";
1395
+ const entries = [
1396
+ { key: "registry", collector: activeRegistryCollector },
1397
+ { key: "github", collector: this.githubCollector },
1398
+ { key: "security", collector: this.securityCollector },
1399
+ { key: "funding", collector: this.fundingCollector },
1400
+ { key: "popularity", collector: this.popularityCollector },
1401
+ { key: "license", collector: this.licenseCollector }
1402
+ ];
1403
+ const results = {};
1404
+ const COLLECTOR_TIMEOUT = 3e4;
1405
+ const tasks = entries.map(
1406
+ ({ key, collector }) => limit(async () => {
1407
+ let result;
1408
+ if (this.options.offline) {
1409
+ result = await this.offlineCollect(collector, packageName, version);
1410
+ } else {
1411
+ try {
1412
+ if (key === "security") {
1413
+ result = await Promise.race([
1414
+ this.securityCollector.collect(packageName, version, osvEcosystem),
1415
+ new Promise(
1416
+ (_, reject) => setTimeout(() => reject(new Error("Collector timeout")), COLLECTOR_TIMEOUT)
1417
+ )
1418
+ ]);
1419
+ } else {
1420
+ result = await Promise.race([
1421
+ this.onlineCollect(collector, packageName, version),
1422
+ new Promise(
1423
+ (_, reject) => setTimeout(() => reject(new Error("Collector timeout")), COLLECTOR_TIMEOUT)
1424
+ )
1425
+ ]);
1426
+ }
1427
+ } catch {
1428
+ logger.warn(`[${collector.name}] ${packageName}@${version} => timeout (${COLLECTOR_TIMEOUT}ms)`);
1429
+ result = {
1430
+ status: "error",
1431
+ data: null,
1432
+ error: `Timeout after ${COLLECTOR_TIMEOUT / 1e3}s`,
1433
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
1434
+ };
1435
+ }
1436
+ }
1437
+ logger.info(
1438
+ `[${collector.name}] ${packageName}@${version} => ${result.status}`
1439
+ );
1440
+ results[key] = result;
1441
+ })
1442
+ );
1443
+ await Promise.all(tasks);
1444
+ return results;
1445
+ }
1446
+ // ---------------------------------------------------------------------------
1447
+ // Online / Offline strategies
1448
+ // ---------------------------------------------------------------------------
1449
+ /**
1450
+ * Normal collection: delegate to the collector which checks cache internally.
1451
+ */
1452
+ async onlineCollect(collector, packageName, version) {
1453
+ try {
1454
+ return await collector.collect(packageName, version);
1455
+ } catch (err) {
1456
+ const message = err instanceof Error ? err.message : String(err);
1457
+ logger.error(`Unhandled error in ${collector.name}: ${message}`);
1458
+ return {
1459
+ status: "error",
1460
+ data: null,
1461
+ error: message,
1462
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
1463
+ };
1464
+ }
1465
+ }
1466
+ /**
1467
+ * Offline collection: only look in the cache. If nothing is cached return
1468
+ * a result with `status: 'offline'`.
1469
+ */
1470
+ async offlineCollect(collector, packageName, version) {
1471
+ try {
1472
+ const key = `${collector.name}:${packageName}@${version}`;
1473
+ const cached = await this.cache.get(key);
1474
+ if (cached !== null && cached !== void 0) {
1475
+ logger.debug(`Offline cache hit: ${key}`);
1476
+ return {
1477
+ status: "cached",
1478
+ data: cached,
1479
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
1480
+ };
1481
+ }
1482
+ return {
1483
+ status: "offline",
1484
+ data: null,
1485
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
1486
+ };
1487
+ } catch (err) {
1488
+ const message = err instanceof Error ? err.message : String(err);
1489
+ logger.warn(`Offline cache read failed for ${collector.name}: ${message}`);
1490
+ return {
1491
+ status: "offline",
1492
+ data: null,
1493
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString()
1494
+ };
1495
+ }
1496
+ }
1497
+ };
1498
+
1499
+ export {
1500
+ setVerbose,
1501
+ logger,
1502
+ createLogger,
1503
+ CollectorOrchestrator
1504
+ };
1505
+ //# sourceMappingURL=chunk-32B3QIPY.js.map