@bedolla/enriweb 0.1.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 (35) hide show
  1. package/LICENSE +619 -0
  2. package/README.md +182 -0
  3. package/dist/client/EnriProxyClient.d.ts +271 -0
  4. package/dist/client/EnriProxyClient.d.ts.map +1 -0
  5. package/dist/client/EnriProxyClient.js +221 -0
  6. package/dist/client/EnriProxyClient.js.map +1 -0
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +148 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/package-info.d.ts +8 -0
  12. package/dist/package-info.d.ts.map +1 -0
  13. package/dist/package-info.js +28 -0
  14. package/dist/package-info.js.map +1 -0
  15. package/dist/server/EnriWebServer.d.ts +70 -0
  16. package/dist/server/EnriWebServer.d.ts.map +1 -0
  17. package/dist/server/EnriWebServer.js +218 -0
  18. package/dist/server/EnriWebServer.js.map +1 -0
  19. package/dist/shared/validation.d.ts +54 -0
  20. package/dist/shared/validation.d.ts.map +1 -0
  21. package/dist/shared/validation.js +112 -0
  22. package/dist/shared/validation.js.map +1 -0
  23. package/dist/tools/WebFetchTool.d.ts +232 -0
  24. package/dist/tools/WebFetchTool.d.ts.map +1 -0
  25. package/dist/tools/WebFetchTool.js +429 -0
  26. package/dist/tools/WebFetchTool.js.map +1 -0
  27. package/dist/tools/WebSearchRegistryVerifier.d.ts +323 -0
  28. package/dist/tools/WebSearchRegistryVerifier.d.ts.map +1 -0
  29. package/dist/tools/WebSearchRegistryVerifier.js +890 -0
  30. package/dist/tools/WebSearchRegistryVerifier.js.map +1 -0
  31. package/dist/tools/WebSearchTool.d.ts +132 -0
  32. package/dist/tools/WebSearchTool.d.ts.map +1 -0
  33. package/dist/tools/WebSearchTool.js +101 -0
  34. package/dist/tools/WebSearchTool.js.map +1 -0
  35. package/package.json +54 -0
@@ -0,0 +1,890 @@
1
+ /**
2
+ * Enriches web search results with registry version verification.
3
+ */
4
+ export class WebSearchRegistryVerifier {
5
+ /**
6
+ * NuGet V3 service index endpoint.
7
+ */
8
+ static NUGET_SERVICE_INDEX_URL = "https://api.nuget.org/v3/index.json";
9
+ /**
10
+ * Default concurrency for registry verification.
11
+ */
12
+ static DEFAULT_CONCURRENCY = 3;
13
+ /**
14
+ * Dependencies.
15
+ */
16
+ deps;
17
+ /**
18
+ * In-memory TTL cache.
19
+ */
20
+ cache;
21
+ /**
22
+ * Cached NuGet service index resolution.
23
+ */
24
+ nugetServiceIndexCache;
25
+ /**
26
+ * Creates a new {@link WebSearchRegistryVerifier}.
27
+ *
28
+ * @param deps - Dependencies
29
+ */
30
+ constructor(deps) {
31
+ this.deps = deps;
32
+ this.cache = new Map();
33
+ this.nugetServiceIndexCache = null;
34
+ }
35
+ /**
36
+ * Attempts to verify canonical versions for registry entities found in search results.
37
+ *
38
+ * @param results - Search results
39
+ * @returns Verified entities (best-effort)
40
+ */
41
+ async verifyFromSearchResults(results) {
42
+ const candidates = this.collectCandidates(results);
43
+ if (candidates.length === 0) {
44
+ return [];
45
+ }
46
+ const tasks = candidates.map((candidate) => async () => {
47
+ return await this.verifyCandidate(candidate.kind, candidate.name);
48
+ });
49
+ return await this.runWithConcurrencyLimit(tasks, WebSearchRegistryVerifier.DEFAULT_CONCURRENCY);
50
+ }
51
+ /**
52
+ * Collects registry candidates from search result URLs.
53
+ *
54
+ * @param results - Search results
55
+ * @returns Candidate list
56
+ */
57
+ collectCandidates(results) {
58
+ const unique = new Set();
59
+ const candidates = [];
60
+ for (const entry of results) {
61
+ if (!entry.url || candidates.length >= this.deps.maxEntitiesPerCall) {
62
+ break;
63
+ }
64
+ let parsedUrl;
65
+ try {
66
+ parsedUrl = new URL(entry.url);
67
+ }
68
+ catch {
69
+ continue;
70
+ }
71
+ const npmName = this.tryParseNpmPackageName(parsedUrl);
72
+ if (npmName) {
73
+ const key = `npm:${npmName}`;
74
+ if (!unique.has(key)) {
75
+ unique.add(key);
76
+ candidates.push({ kind: "npm", name: npmName });
77
+ }
78
+ continue;
79
+ }
80
+ const pypiName = this.tryParsePyPiProjectName(parsedUrl);
81
+ if (pypiName) {
82
+ const key = `pypi:${pypiName}`;
83
+ if (!unique.has(key)) {
84
+ unique.add(key);
85
+ candidates.push({ kind: "pypi", name: pypiName });
86
+ }
87
+ continue;
88
+ }
89
+ const cratesName = this.tryParseCratesName(parsedUrl);
90
+ if (cratesName) {
91
+ const key = `crates:${cratesName}`;
92
+ if (!unique.has(key)) {
93
+ unique.add(key);
94
+ candidates.push({ kind: "crates", name: cratesName });
95
+ }
96
+ continue;
97
+ }
98
+ const nugetId = this.tryParseNuGetPackageId(parsedUrl);
99
+ if (nugetId) {
100
+ const key = `nuget:${nugetId.toLowerCase()}`;
101
+ if (!unique.has(key)) {
102
+ unique.add(key);
103
+ candidates.push({ kind: "nuget", name: nugetId });
104
+ }
105
+ continue;
106
+ }
107
+ const repo = this.tryParseGitHubRepoSlug(parsedUrl);
108
+ if (repo) {
109
+ const key = `github:${repo.toLowerCase()}`;
110
+ if (!unique.has(key)) {
111
+ unique.add(key);
112
+ candidates.push({ kind: "github", name: repo });
113
+ }
114
+ continue;
115
+ }
116
+ }
117
+ return candidates;
118
+ }
119
+ /**
120
+ * Verifies a single candidate, using cache when available.
121
+ *
122
+ * @param kind - Registry kind
123
+ * @param name - Candidate name
124
+ * @returns Verification result
125
+ */
126
+ async verifyCandidate(kind, name) {
127
+ const cacheKey = `${kind}:${name.toLowerCase()}`;
128
+ const nowMs = Date.now();
129
+ const cached = this.cache.get(cacheKey);
130
+ if (cached && cached.expiresAtMs > nowMs) {
131
+ return cached.value;
132
+ }
133
+ let value;
134
+ try {
135
+ if (kind === "npm") {
136
+ value = await this.verifyNpm(name);
137
+ }
138
+ else if (kind === "pypi") {
139
+ value = await this.verifyPyPi(name);
140
+ }
141
+ else if (kind === "crates") {
142
+ value = await this.verifyCrates(name);
143
+ }
144
+ else if (kind === "nuget") {
145
+ value = await this.verifyNuGet(name);
146
+ }
147
+ else {
148
+ value = await this.verifyGitHub(name);
149
+ }
150
+ }
151
+ catch (error) {
152
+ const message = error instanceof Error ? error.message : String(error);
153
+ value = { kind, name, status: "error", error: message };
154
+ }
155
+ this.cache.set(cacheKey, { value, expiresAtMs: nowMs + this.deps.cacheTtlMs });
156
+ return value;
157
+ }
158
+ /**
159
+ * Runs tasks with a fixed concurrency limit.
160
+ *
161
+ * @param tasks - Async tasks
162
+ * @param concurrency - Concurrency limit
163
+ * @returns Results in original order
164
+ */
165
+ async runWithConcurrencyLimit(tasks, concurrency) {
166
+ const results = new Array(tasks.length);
167
+ let nextIndex = 0;
168
+ const worker = async () => {
169
+ // eslint-disable-next-line no-constant-condition
170
+ while (true) {
171
+ const currentIndex = nextIndex;
172
+ nextIndex += 1;
173
+ if (currentIndex >= tasks.length) {
174
+ return;
175
+ }
176
+ results[currentIndex] = await tasks[currentIndex]();
177
+ }
178
+ };
179
+ const poolSize = Math.max(1, Math.min(concurrency, tasks.length));
180
+ const workers = new Array(poolSize);
181
+ for (let i = 0; i < poolSize; i += 1) {
182
+ workers[i] = worker();
183
+ }
184
+ await Promise.all(workers);
185
+ return results;
186
+ }
187
+ /**
188
+ * Verifies npm package versions via the npm registry.
189
+ *
190
+ * @param packageName - npm package name (may be scoped)
191
+ * @returns Verified registry entity
192
+ */
193
+ async verifyNpm(packageName) {
194
+ const encoded = encodeURIComponent(packageName);
195
+ const sourceUrl = `https://registry.npmjs.org/${encoded}`;
196
+ const bodyRaw = await this.fetchJson(sourceUrl, { accept: "application/json" });
197
+ const body = this.tryGetRecord(bodyRaw);
198
+ if (!body) {
199
+ throw new Error(`Unexpected npm registry response for ${sourceUrl}`);
200
+ }
201
+ const distTags = this.tryGetRecord(body["dist-tags"]);
202
+ const versionsRecord = this.tryGetRecord(body["versions"]);
203
+ const timeRecord = this.tryGetRecord(body["time"]);
204
+ const versionKeys = versionsRecord ? Object.keys(versionsRecord) : [];
205
+ const bestStable = this.pickBestSemVer(versionKeys, { prerelease: false });
206
+ const bestPrerelease = this.pickBestSemVer(versionKeys, { prerelease: true });
207
+ const distLatest = distTags ? this.tryGetNonEmptyString(distTags["latest"]) : null;
208
+ const stableVersion = bestStable ??
209
+ (distLatest && !this.isPrereleaseVersion(distLatest) ? distLatest : null);
210
+ const stablePublishedAt = stableVersion && timeRecord
211
+ ? this.tryGetNonEmptyString(timeRecord[stableVersion])
212
+ : null;
213
+ const prereleasePublishedAt = bestPrerelease && timeRecord
214
+ ? this.tryGetNonEmptyString(timeRecord[bestPrerelease])
215
+ : null;
216
+ return {
217
+ kind: "npm",
218
+ name: packageName,
219
+ latest_stable: stableVersion
220
+ ? { version: stableVersion, published_at: stablePublishedAt ?? undefined, source_url: sourceUrl }
221
+ : undefined,
222
+ latest_prerelease: bestPrerelease
223
+ ? {
224
+ version: bestPrerelease,
225
+ published_at: prereleasePublishedAt ?? undefined,
226
+ source_url: sourceUrl
227
+ }
228
+ : undefined,
229
+ status: "ok"
230
+ };
231
+ }
232
+ /**
233
+ * Verifies PyPI project versions via the PyPI JSON API.
234
+ *
235
+ * @param projectName - PyPI project name
236
+ * @returns Verified registry entity
237
+ */
238
+ async verifyPyPi(projectName) {
239
+ const encoded = encodeURIComponent(projectName);
240
+ const sourceUrl = `https://pypi.org/pypi/${encoded}/json`;
241
+ const bodyRaw = await this.fetchJson(sourceUrl, { accept: "application/json" });
242
+ const body = this.tryGetRecord(bodyRaw);
243
+ if (!body) {
244
+ throw new Error(`Unexpected PyPI response for ${sourceUrl}`);
245
+ }
246
+ const releases = this.tryGetRecord(body["releases"]);
247
+ const candidates = [];
248
+ if (releases) {
249
+ for (const version of Object.keys(releases)) {
250
+ candidates.push({
251
+ version,
252
+ publishedAt: this.tryGetLatestPyPiUploadIso(releases[version]),
253
+ prerelease: this.isLikelyPyPiPrerelease(version)
254
+ });
255
+ }
256
+ }
257
+ const stable = this.pickBestVersionCandidate(candidates, { prerelease: false });
258
+ const pre = this.pickBestVersionCandidate(candidates, { prerelease: true });
259
+ return {
260
+ kind: "pypi",
261
+ name: projectName,
262
+ latest_stable: stable
263
+ ? { version: stable.version, published_at: stable.publishedAt ?? undefined, source_url: sourceUrl }
264
+ : undefined,
265
+ latest_prerelease: pre
266
+ ? { version: pre.version, published_at: pre.publishedAt ?? undefined, source_url: sourceUrl }
267
+ : undefined,
268
+ status: "ok"
269
+ };
270
+ }
271
+ /**
272
+ * Verifies crates.io package versions via the crates.io API.
273
+ *
274
+ * @param crateName - Crate name
275
+ * @returns Verified registry entity
276
+ */
277
+ async verifyCrates(crateName) {
278
+ const encoded = encodeURIComponent(crateName);
279
+ const sourceUrl = `https://crates.io/api/v1/crates/${encoded}`;
280
+ const bodyRaw = await this.fetchJson(sourceUrl, { accept: "application/json" });
281
+ const body = this.tryGetRecord(bodyRaw);
282
+ if (!body) {
283
+ throw new Error(`Unexpected crates.io response for ${sourceUrl}`);
284
+ }
285
+ const versions = Array.isArray(body["versions"]) ? body["versions"] : [];
286
+ const stableCandidates = [];
287
+ const prereleaseCandidates = [];
288
+ const publishedByVersion = new Map();
289
+ for (const item of versions) {
290
+ const record = this.tryGetRecord(item);
291
+ if (!record) {
292
+ continue;
293
+ }
294
+ const num = this.tryGetNonEmptyString(record["num"]);
295
+ if (!num) {
296
+ continue;
297
+ }
298
+ if (record["yanked"] === true) {
299
+ continue;
300
+ }
301
+ const createdAt = this.tryGetNonEmptyString(record["created_at"]);
302
+ if (createdAt) {
303
+ publishedByVersion.set(num, createdAt);
304
+ }
305
+ if (this.isPrereleaseVersion(num)) {
306
+ prereleaseCandidates.push(num);
307
+ }
308
+ else {
309
+ stableCandidates.push(num);
310
+ }
311
+ }
312
+ const bestStable = this.pickBestSemVer(stableCandidates, { prerelease: false });
313
+ const bestPre = this.pickBestSemVer(prereleaseCandidates, { prerelease: true });
314
+ return {
315
+ kind: "crates",
316
+ name: crateName,
317
+ latest_stable: bestStable
318
+ ? {
319
+ version: bestStable,
320
+ published_at: publishedByVersion.get(bestStable) ?? undefined,
321
+ source_url: sourceUrl
322
+ }
323
+ : undefined,
324
+ latest_prerelease: bestPre
325
+ ? {
326
+ version: bestPre,
327
+ published_at: publishedByVersion.get(bestPre) ?? undefined,
328
+ source_url: sourceUrl
329
+ }
330
+ : undefined,
331
+ status: "ok"
332
+ };
333
+ }
334
+ /**
335
+ * Verifies NuGet package versions via NuGet V3 endpoints.
336
+ *
337
+ * @param packageId - NuGet package ID
338
+ * @returns Verified registry entity
339
+ */
340
+ async verifyNuGet(packageId) {
341
+ const lowerId = packageId.toLowerCase();
342
+ const serviceIndex = await this.getNuGetServiceIndex();
343
+ if (!serviceIndex.packageBaseAddressUrl) {
344
+ return {
345
+ kind: "nuget",
346
+ name: packageId,
347
+ status: "error",
348
+ error: "NuGet service index did not provide PackageBaseAddress."
349
+ };
350
+ }
351
+ const versionsUrl = `${serviceIndex.packageBaseAddressUrl}${lowerId}/index.json`;
352
+ const versionsBodyRaw = await this.fetchJson(versionsUrl, { accept: "application/json" });
353
+ const versionsBody = this.tryGetRecord(versionsBodyRaw);
354
+ if (!versionsBody) {
355
+ throw new Error(`Unexpected NuGet response for ${versionsUrl}`);
356
+ }
357
+ const versions = Array.isArray(versionsBody["versions"]) ? versionsBody["versions"] : [];
358
+ const versionStrings = versions
359
+ .map((v) => (typeof v === "string" ? v : null))
360
+ .filter((v) => typeof v === "string" && v.trim().length > 0);
361
+ const bestStable = this.pickBestSemVer(versionStrings, { prerelease: false });
362
+ const bestPre = this.pickBestSemVer(versionStrings, { prerelease: true });
363
+ const stablePublishedAt = bestStable && serviceIndex.registrationsBaseUrl
364
+ ? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestStable)
365
+ : null;
366
+ const prePublishedAt = bestPre && serviceIndex.registrationsBaseUrl
367
+ ? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestPre)
368
+ : null;
369
+ return {
370
+ kind: "nuget",
371
+ name: packageId,
372
+ latest_stable: bestStable
373
+ ? { version: bestStable, published_at: stablePublishedAt ?? undefined, source_url: versionsUrl }
374
+ : undefined,
375
+ latest_prerelease: bestPre
376
+ ? { version: bestPre, published_at: prePublishedAt ?? undefined, source_url: versionsUrl }
377
+ : undefined,
378
+ status: "ok"
379
+ };
380
+ }
381
+ /**
382
+ * Verifies GitHub repository releases via GitHub REST API.
383
+ *
384
+ * @param repoSlug - Repository slug in the form "owner/repo"
385
+ * @returns Verified registry entity
386
+ */
387
+ async verifyGitHub(repoSlug) {
388
+ const parts = repoSlug.split("/");
389
+ const owner = parts[0];
390
+ const repo = parts[1];
391
+ if (!owner || !repo) {
392
+ return { kind: "github", name: repoSlug, status: "error", error: "Invalid repo slug." };
393
+ }
394
+ const sourceUrl = `https://api.github.com/repos/${owner}/${repo}/releases?per_page=100`;
395
+ const releasesRaw = await this.fetchJson(sourceUrl, {
396
+ accept: "application/vnd.github+json",
397
+ githubToken: this.deps.githubToken
398
+ });
399
+ const releases = Array.isArray(releasesRaw) ? releasesRaw : [];
400
+ const stableCandidates = [];
401
+ const preCandidates = [];
402
+ for (const item of releases) {
403
+ const record = this.tryGetRecord(item);
404
+ if (!record) {
405
+ continue;
406
+ }
407
+ if (record["draft"] === true) {
408
+ continue;
409
+ }
410
+ const tagNameRaw = this.tryGetNonEmptyString(record["tag_name"]);
411
+ if (!tagNameRaw) {
412
+ continue;
413
+ }
414
+ const tagName = tagNameRaw.startsWith("v") ? tagNameRaw.slice(1) : tagNameRaw;
415
+ const publishedAt = this.tryGetNonEmptyString(record["published_at"]);
416
+ const isPrerelease = record["prerelease"] === true;
417
+ const candidate = { version: tagName, publishedAt: publishedAt ?? null, prerelease: isPrerelease };
418
+ if (isPrerelease) {
419
+ preCandidates.push(candidate);
420
+ }
421
+ else {
422
+ stableCandidates.push(candidate);
423
+ }
424
+ }
425
+ const bestStable = this.pickBestVersionCandidate(stableCandidates, { prerelease: false });
426
+ const bestPre = this.pickBestVersionCandidate(preCandidates, { prerelease: true });
427
+ return {
428
+ kind: "github",
429
+ name: repoSlug,
430
+ latest_stable: bestStable
431
+ ? { version: bestStable.version, published_at: bestStable.publishedAt ?? undefined, source_url: sourceUrl }
432
+ : undefined,
433
+ latest_prerelease: bestPre
434
+ ? { version: bestPre.version, published_at: bestPre.publishedAt ?? undefined, source_url: sourceUrl }
435
+ : undefined,
436
+ status: "ok"
437
+ };
438
+ }
439
+ /**
440
+ * Performs an HTTP GET expecting a JSON response.
441
+ *
442
+ * @param url - Target URL
443
+ * @param options - Request options
444
+ * @returns Parsed JSON object/array
445
+ */
446
+ async fetchJson(url, options) {
447
+ const controller = new AbortController();
448
+ const timeout = setTimeout(() => controller.abort(), this.deps.timeoutMs);
449
+ const headers = {
450
+ Accept: options.accept,
451
+ "User-Agent": "enriweb"
452
+ };
453
+ if (options.githubToken && options.githubToken.trim()) {
454
+ headers["Authorization"] = `Bearer ${options.githubToken.trim()}`;
455
+ }
456
+ try {
457
+ const response = await this.deps.fetchImpl(url, {
458
+ method: "GET",
459
+ headers,
460
+ signal: controller.signal
461
+ });
462
+ if (!response.ok) {
463
+ throw new Error(`HTTP ${response.status} for ${url}`);
464
+ }
465
+ return await response.json();
466
+ }
467
+ finally {
468
+ clearTimeout(timeout);
469
+ }
470
+ }
471
+ /**
472
+ * Attempts to parse the npm package name from an npmjs.com URL.
473
+ *
474
+ * @param url - Parsed URL
475
+ * @returns Package name or null
476
+ */
477
+ tryParseNpmPackageName(url) {
478
+ const hostname = url.hostname.toLowerCase();
479
+ if (hostname !== "www.npmjs.com" && hostname !== "npmjs.com") {
480
+ return null;
481
+ }
482
+ const segments = url.pathname.split("/").filter(Boolean);
483
+ if (segments.length < 2 || segments[0] !== "package") {
484
+ return null;
485
+ }
486
+ const first = segments[1];
487
+ if (!first) {
488
+ return null;
489
+ }
490
+ if (first.startsWith("@")) {
491
+ const second = segments[2];
492
+ if (!second) {
493
+ return null;
494
+ }
495
+ return `${first}/${second}`;
496
+ }
497
+ return first;
498
+ }
499
+ /**
500
+ * Attempts to parse the PyPI project name from a pypi.org URL.
501
+ *
502
+ * @param url - Parsed URL
503
+ * @returns Project name or null
504
+ */
505
+ tryParsePyPiProjectName(url) {
506
+ const hostname = url.hostname.toLowerCase();
507
+ if (hostname !== "pypi.org") {
508
+ return null;
509
+ }
510
+ const segments = url.pathname.split("/").filter(Boolean);
511
+ if (segments.length < 2 || segments[0] !== "project") {
512
+ return null;
513
+ }
514
+ const name = segments[1];
515
+ return name ? name : null;
516
+ }
517
+ /**
518
+ * Attempts to parse the crate name from a crates.io URL.
519
+ *
520
+ * @param url - Parsed URL
521
+ * @returns Crate name or null
522
+ */
523
+ tryParseCratesName(url) {
524
+ const hostname = url.hostname.toLowerCase();
525
+ if (hostname !== "crates.io") {
526
+ return null;
527
+ }
528
+ const segments = url.pathname.split("/").filter(Boolean);
529
+ if (segments.length < 2 || segments[0] !== "crates") {
530
+ return null;
531
+ }
532
+ const name = segments[1];
533
+ return name ? name : null;
534
+ }
535
+ /**
536
+ * Attempts to parse a NuGet package ID from a nuget.org URL.
537
+ *
538
+ * @param url - Parsed URL
539
+ * @returns NuGet package ID or null
540
+ */
541
+ tryParseNuGetPackageId(url) {
542
+ const hostname = url.hostname.toLowerCase();
543
+ if (hostname !== "www.nuget.org" && hostname !== "nuget.org") {
544
+ return null;
545
+ }
546
+ const segments = url.pathname.split("/").filter(Boolean);
547
+ if (segments.length < 2 || segments[0] !== "packages") {
548
+ return null;
549
+ }
550
+ const id = segments[1];
551
+ return id ? id : null;
552
+ }
553
+ /**
554
+ * Attempts to parse a GitHub repository slug from a github.com URL.
555
+ *
556
+ * @param url - Parsed URL
557
+ * @returns Repo slug (owner/repo) or null
558
+ */
559
+ tryParseGitHubRepoSlug(url) {
560
+ const hostname = url.hostname.toLowerCase();
561
+ if (hostname !== "github.com") {
562
+ return null;
563
+ }
564
+ const segments = url.pathname.split("/").filter(Boolean);
565
+ if (segments.length < 2) {
566
+ return null;
567
+ }
568
+ const owner = segments[0];
569
+ const repo = segments[1];
570
+ if (!owner || !repo) {
571
+ return null;
572
+ }
573
+ return `${owner}/${repo}`;
574
+ }
575
+ /**
576
+ * Returns a record if the input is a plain object.
577
+ *
578
+ * @param value - Unknown value
579
+ * @returns Record or null
580
+ */
581
+ tryGetRecord(value) {
582
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
583
+ return null;
584
+ }
585
+ return value;
586
+ }
587
+ /**
588
+ * Returns a non-empty string if possible.
589
+ *
590
+ * @param value - Unknown value
591
+ * @returns Trimmed string or null
592
+ */
593
+ tryGetNonEmptyString(value) {
594
+ if (typeof value !== "string") {
595
+ return null;
596
+ }
597
+ const trimmed = value.trim();
598
+ return trimmed.length > 0 ? trimmed : null;
599
+ }
600
+ /**
601
+ * Determines whether a version string is a prerelease using SemVer rules.
602
+ *
603
+ * @param version - Version string
604
+ * @returns True when prerelease
605
+ */
606
+ isPrereleaseVersion(version) {
607
+ return version.includes("-");
608
+ }
609
+ /**
610
+ * Best-effort prerelease detection for PyPI version strings.
611
+ *
612
+ * @param version - Version string
613
+ * @returns True when prerelease-like
614
+ */
615
+ isLikelyPyPiPrerelease(version) {
616
+ const lower = version.toLowerCase();
617
+ if (lower.includes("-")) {
618
+ return true;
619
+ }
620
+ if (/(?:^|[._-])(?:a|b|rc|dev|alpha|beta|pre|preview)\d*/.test(lower)) {
621
+ return true;
622
+ }
623
+ return false;
624
+ }
625
+ /**
626
+ * Picks the best SemVer candidate from a list of versions.
627
+ *
628
+ * @param versions - Version list
629
+ * @param options - Selection options
630
+ * @returns Best version string or null
631
+ */
632
+ pickBestSemVer(versions, options) {
633
+ let best = null;
634
+ for (const raw of versions) {
635
+ const parsed = this.tryParseSemVer(raw);
636
+ if (!parsed) {
637
+ continue;
638
+ }
639
+ const isPre = parsed.prerelease.length > 0;
640
+ if (options.prerelease !== isPre) {
641
+ continue;
642
+ }
643
+ if (!best || this.compareSemVer(parsed, best) > 0) {
644
+ best = parsed;
645
+ }
646
+ }
647
+ return best ? best.raw : null;
648
+ }
649
+ /**
650
+ * Picks the best candidate from stable or prerelease sets.
651
+ *
652
+ * @param candidates - Candidate list
653
+ * @param options - Selection options
654
+ * @returns Best candidate or null
655
+ */
656
+ pickBestVersionCandidate(candidates, options) {
657
+ const filtered = candidates.filter((c) => c.prerelease === options.prerelease);
658
+ if (filtered.length === 0) {
659
+ return null;
660
+ }
661
+ const semverParsed = filtered
662
+ .map((c) => ({ candidate: c, parsed: this.tryParseSemVer(c.version) }))
663
+ .filter((x) => x.parsed !== null);
664
+ if (semverParsed.length > 0) {
665
+ let best = semverParsed[0];
666
+ for (const item of semverParsed.slice(1)) {
667
+ if (this.compareSemVer(item.parsed, best.parsed) > 0) {
668
+ best = item;
669
+ }
670
+ }
671
+ return best.candidate;
672
+ }
673
+ let bestByTime = filtered[0];
674
+ for (const item of filtered.slice(1)) {
675
+ if (item.publishedAt && bestByTime.publishedAt) {
676
+ if (item.publishedAt > bestByTime.publishedAt) {
677
+ bestByTime = item;
678
+ }
679
+ }
680
+ else if (item.publishedAt && !bestByTime.publishedAt) {
681
+ bestByTime = item;
682
+ }
683
+ }
684
+ return bestByTime;
685
+ }
686
+ /**
687
+ * Parses SemVer strings (best-effort).
688
+ *
689
+ * @param raw - Version string
690
+ * @returns Parsed semver or null
691
+ */
692
+ tryParseSemVer(raw) {
693
+ const trimmed = raw.trim();
694
+ const normalized = trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
695
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(normalized);
696
+ if (!match) {
697
+ return null;
698
+ }
699
+ const major = Number.parseInt(match[1] ?? "", 10);
700
+ const minor = Number.parseInt(match[2] ?? "", 10);
701
+ const patch = Number.parseInt(match[3] ?? "", 10);
702
+ if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) {
703
+ return null;
704
+ }
705
+ const prereleaseRaw = match[4];
706
+ const prerelease = prereleaseRaw
707
+ ? prereleaseRaw.split(".").filter((p) => p.length > 0)
708
+ : [];
709
+ return { raw: normalized, major, minor, patch, prerelease };
710
+ }
711
+ /**
712
+ * Compares two SemVer values.
713
+ *
714
+ * @param a - SemVer a
715
+ * @param b - SemVer b
716
+ * @returns Comparison result
717
+ */
718
+ compareSemVer(a, b) {
719
+ if (a.major !== b.major) {
720
+ return a.major - b.major;
721
+ }
722
+ if (a.minor !== b.minor) {
723
+ return a.minor - b.minor;
724
+ }
725
+ if (a.patch !== b.patch) {
726
+ return a.patch - b.patch;
727
+ }
728
+ const aPre = a.prerelease;
729
+ const bPre = b.prerelease;
730
+ if (aPre.length === 0 && bPre.length === 0) {
731
+ return 0;
732
+ }
733
+ if (aPre.length === 0) {
734
+ return 1;
735
+ }
736
+ if (bPre.length === 0) {
737
+ return -1;
738
+ }
739
+ const len = Math.max(aPre.length, bPre.length);
740
+ for (let i = 0; i < len; i += 1) {
741
+ const aId = aPre[i];
742
+ const bId = bPre[i];
743
+ if (aId === undefined) {
744
+ return -1;
745
+ }
746
+ if (bId === undefined) {
747
+ return 1;
748
+ }
749
+ const aNum = this.tryParseInt(aId);
750
+ const bNum = this.tryParseInt(bId);
751
+ if (aNum !== null && bNum !== null) {
752
+ if (aNum !== bNum) {
753
+ return aNum - bNum;
754
+ }
755
+ continue;
756
+ }
757
+ if (aNum !== null && bNum === null) {
758
+ return -1;
759
+ }
760
+ if (aNum === null && bNum !== null) {
761
+ return 1;
762
+ }
763
+ if (aId !== bId) {
764
+ return aId < bId ? -1 : 1;
765
+ }
766
+ }
767
+ return 0;
768
+ }
769
+ /**
770
+ * Attempts to parse an integer, returning null for non-numeric identifiers.
771
+ *
772
+ * @param value - String value
773
+ * @returns Parsed integer or null
774
+ */
775
+ tryParseInt(value) {
776
+ if (!/^\d+$/.test(value)) {
777
+ return null;
778
+ }
779
+ const parsed = Number.parseInt(value, 10);
780
+ return Number.isFinite(parsed) ? parsed : null;
781
+ }
782
+ /**
783
+ * Extracts the most recent PyPI upload timestamp for a release entry.
784
+ *
785
+ * @param releaseFiles - Releases[version] value
786
+ * @returns Latest upload time in ISO 8601 or null
787
+ */
788
+ tryGetLatestPyPiUploadIso(releaseFiles) {
789
+ if (!Array.isArray(releaseFiles)) {
790
+ return null;
791
+ }
792
+ let best = null;
793
+ for (const item of releaseFiles) {
794
+ const record = this.tryGetRecord(item);
795
+ if (!record) {
796
+ continue;
797
+ }
798
+ const uploadTime = this.tryGetNonEmptyString(record["upload_time_iso_8601"]);
799
+ if (!uploadTime) {
800
+ continue;
801
+ }
802
+ if (!best || uploadTime > best) {
803
+ best = uploadTime;
804
+ }
805
+ }
806
+ return best;
807
+ }
808
+ /**
809
+ * Resolves NuGet V3 endpoints from the service index, with caching.
810
+ *
811
+ * @returns Service index cache
812
+ */
813
+ async getNuGetServiceIndex() {
814
+ const nowMs = Date.now();
815
+ if (this.nugetServiceIndexCache && this.nugetServiceIndexCache.expiresAtMs > nowMs) {
816
+ return this.nugetServiceIndexCache;
817
+ }
818
+ const bodyRaw = await this.fetchJson(WebSearchRegistryVerifier.NUGET_SERVICE_INDEX_URL, {
819
+ accept: "application/json"
820
+ });
821
+ const body = this.tryGetRecord(bodyRaw);
822
+ if (!body) {
823
+ throw new Error("Unexpected NuGet service index response.");
824
+ }
825
+ const resources = Array.isArray(body["resources"]) ? body["resources"] : [];
826
+ let packageBase = null;
827
+ let registrations = null;
828
+ for (const item of resources) {
829
+ const record = this.tryGetRecord(item);
830
+ if (!record) {
831
+ continue;
832
+ }
833
+ const id = this.tryGetNonEmptyString(record["@id"]);
834
+ const typeValue = record["@type"];
835
+ const types = [];
836
+ if (typeof typeValue === "string") {
837
+ types.push(typeValue);
838
+ }
839
+ else if (Array.isArray(typeValue)) {
840
+ for (const t of typeValue) {
841
+ if (typeof t === "string") {
842
+ types.push(t);
843
+ }
844
+ }
845
+ }
846
+ if (!id || types.length === 0) {
847
+ continue;
848
+ }
849
+ if (!packageBase && types.some((t) => t.startsWith("PackageBaseAddress"))) {
850
+ packageBase = id.endsWith("/") ? id : `${id}/`;
851
+ }
852
+ if (!registrations && types.some((t) => t.startsWith("RegistrationsBaseUrl"))) {
853
+ registrations = id.endsWith("/") ? id : `${id}/`;
854
+ }
855
+ }
856
+ this.nugetServiceIndexCache = {
857
+ packageBaseAddressUrl: packageBase,
858
+ registrationsBaseUrl: registrations,
859
+ expiresAtMs: nowMs + 24 * 60 * 60 * 1000
860
+ };
861
+ return this.nugetServiceIndexCache;
862
+ }
863
+ /**
864
+ * Attempts to fetch NuGet registration leaf to extract published timestamp.
865
+ *
866
+ * @param registrationsBaseUrl - Registrations base URL
867
+ * @param lowerId - Lowercase package ID
868
+ * @param version - Version string
869
+ * @returns Published ISO 8601 timestamp or null
870
+ */
871
+ async tryFetchNuGetLeafPublishedAt(registrationsBaseUrl, lowerId, version) {
872
+ const leafUrl = `${registrationsBaseUrl}${lowerId}/${encodeURIComponent(version)}.json`;
873
+ try {
874
+ const bodyRaw = await this.fetchJson(leafUrl, { accept: "application/json" });
875
+ const body = this.tryGetRecord(bodyRaw);
876
+ if (!body) {
877
+ return null;
878
+ }
879
+ const catalog = this.tryGetRecord(body["catalogEntry"]);
880
+ if (!catalog) {
881
+ return null;
882
+ }
883
+ return this.tryGetNonEmptyString(catalog["published"]);
884
+ }
885
+ catch {
886
+ return null;
887
+ }
888
+ }
889
+ }
890
+ //# sourceMappingURL=WebSearchRegistryVerifier.js.map