@bedolla/enriweb 0.1.6 → 0.1.8

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 (56) hide show
  1. package/README.md +48 -20
  2. package/dist/client/EnriProxyClient.d.ts +143 -6
  3. package/dist/client/EnriProxyClient.d.ts.map +1 -1
  4. package/dist/client/EnriProxyClient.js +208 -33
  5. package/dist/client/EnriProxyClient.js.map +1 -1
  6. package/dist/index.js +58 -18
  7. package/dist/index.js.map +1 -1
  8. package/dist/package-info.d.ts +26 -0
  9. package/dist/package-info.d.ts.map +1 -1
  10. package/dist/package-info.js +29 -2
  11. package/dist/package-info.js.map +1 -1
  12. package/dist/server/EnriWebServer.d.ts +33 -0
  13. package/dist/server/EnriWebServer.d.ts.map +1 -1
  14. package/dist/server/EnriWebServer.js +322 -23
  15. package/dist/server/EnriWebServer.js.map +1 -1
  16. package/dist/shared/Utf8SafeTextSlicer.d.ts +47 -0
  17. package/dist/shared/Utf8SafeTextSlicer.d.ts.map +1 -0
  18. package/dist/shared/Utf8SafeTextSlicer.js +97 -0
  19. package/dist/shared/Utf8SafeTextSlicer.js.map +1 -0
  20. package/dist/shared/validation.d.ts +13 -1
  21. package/dist/shared/validation.d.ts.map +1 -1
  22. package/dist/shared/validation.js +27 -14
  23. package/dist/shared/validation.js.map +1 -1
  24. package/dist/tools/WebFetchNpmProjection.d.ts +143 -0
  25. package/dist/tools/WebFetchNpmProjection.d.ts.map +1 -0
  26. package/dist/tools/WebFetchNpmProjection.js +480 -0
  27. package/dist/tools/WebFetchNpmProjection.js.map +1 -0
  28. package/dist/tools/WebFetchParamsParser.d.ts +26 -0
  29. package/dist/tools/WebFetchParamsParser.d.ts.map +1 -0
  30. package/dist/tools/WebFetchParamsParser.js +157 -0
  31. package/dist/tools/WebFetchParamsParser.js.map +1 -0
  32. package/dist/tools/WebFetchRangesExecutor.d.ts +86 -0
  33. package/dist/tools/WebFetchRangesExecutor.d.ts.map +1 -0
  34. package/dist/tools/WebFetchRangesExecutor.js +277 -0
  35. package/dist/tools/WebFetchRangesExecutor.js.map +1 -0
  36. package/dist/tools/WebFetchTool.d.ts +186 -81
  37. package/dist/tools/WebFetchTool.d.ts.map +1 -1
  38. package/dist/tools/WebFetchTool.js +142 -425
  39. package/dist/tools/WebFetchTool.js.map +1 -1
  40. package/dist/tools/WebFetchToolTextFormatter.d.ts +57 -0
  41. package/dist/tools/WebFetchToolTextFormatter.d.ts.map +1 -0
  42. package/dist/tools/WebFetchToolTextFormatter.js +135 -0
  43. package/dist/tools/WebFetchToolTextFormatter.js.map +1 -0
  44. package/dist/tools/WebSearchRegistryHttpReader.d.ts +116 -0
  45. package/dist/tools/WebSearchRegistryHttpReader.d.ts.map +1 -0
  46. package/dist/tools/WebSearchRegistryHttpReader.js +157 -0
  47. package/dist/tools/WebSearchRegistryHttpReader.js.map +1 -0
  48. package/dist/tools/WebSearchRegistryVerifier.d.ts +103 -7
  49. package/dist/tools/WebSearchRegistryVerifier.d.ts.map +1 -1
  50. package/dist/tools/WebSearchRegistryVerifier.js +375 -135
  51. package/dist/tools/WebSearchRegistryVerifier.js.map +1 -1
  52. package/dist/tools/WebSearchTool.d.ts +78 -0
  53. package/dist/tools/WebSearchTool.d.ts.map +1 -1
  54. package/dist/tools/WebSearchTool.js +191 -21
  55. package/dist/tools/WebSearchTool.js.map +1 -1
  56. package/package.json +3 -2
@@ -1,19 +1,34 @@
1
+ import { WebSearchRegistryHttpReader } from "./WebSearchRegistryHttpReader.js";
2
+ import { sliceUtf8Safe } from "../shared/Utf8SafeTextSlicer.js";
1
3
  /**
2
4
  * Enriches web search results with registry version verification.
3
5
  */
4
6
  export class WebSearchRegistryVerifier {
7
+ /**
8
+ * Capped JSON HTTP reader owning the registry transport concerns.
9
+ */
10
+ httpReader;
5
11
  /**
6
12
  * NuGet V3 service index endpoint.
7
13
  */
8
14
  static NUGET_SERVICE_INDEX_URL = "https://api.nuget.org/v3/index.json";
15
+ /**
16
+ * Maximum bytes read from one npm packument (full-version manifest).
17
+ *
18
+ * @remarks
19
+ * Popular packages legitimately exceed the shared 5 MB registry cap
20
+ * (thousands of versions with full metadata); parity with the EnriCode
21
+ * client plane's 16 MiB download budget for the same endpoint.
22
+ */
23
+ static NPM_PACKUMENT_MAX_BYTES = 16_777_216;
9
24
  /**
10
25
  * Default concurrency for registry verification.
11
26
  */
12
27
  static DEFAULT_CONCURRENCY = 3;
13
28
  /**
14
- * Maximum JSON payload accepted from a registry endpoint.
29
+ * Time one cached error entity stays fresh before a retry.
15
30
  */
16
- static MAX_JSON_BYTES = 5_000_000;
31
+ static ERROR_CACHE_TTL_MS = 60 * 1000;
17
32
  /**
18
33
  * Maximum cached verification entries (LRU eviction beyond this count).
19
34
  */
@@ -26,6 +41,16 @@ export class WebSearchRegistryVerifier {
26
41
  * In-memory TTL cache.
27
42
  */
28
43
  cache;
44
+ /**
45
+ * In-flight verification promises keyed by cache key.
46
+ *
47
+ * @remarks
48
+ * Single-flight coalescing: concurrent MCP requests verifying the same
49
+ * entity join one registry fetch instead of racing duplicates. Entries
50
+ * are removed when the verification settles (success, error or caller
51
+ * abort), so the map never outlives its requests.
52
+ */
53
+ inFlight = new Map();
29
54
  /**
30
55
  * Cached NuGet service index resolution.
31
56
  */
@@ -36,6 +61,10 @@ export class WebSearchRegistryVerifier {
36
61
  * @param deps - Dependencies
37
62
  */
38
63
  constructor(deps) {
64
+ this.httpReader = new WebSearchRegistryHttpReader({
65
+ fetchImpl: deps.fetchImpl,
66
+ timeoutMs: deps.timeoutMs
67
+ });
39
68
  this.deps = deps;
40
69
  this.cache = new Map();
41
70
  this.nugetServiceIndexCache = null;
@@ -51,7 +80,11 @@ export class WebSearchRegistryVerifier {
51
80
  if (signal?.aborted) {
52
81
  return [];
53
82
  }
54
- const candidates = this.collectCandidates(results);
83
+ // A non-array `results` payload (proxy shape drift) carries no
84
+ // candidates; it must degrade to unverified instead of throwing
85
+ // "results is not iterable" into the tool result.
86
+ const entries = Array.isArray(results) ? results : [];
87
+ const candidates = this.collectCandidates(entries);
55
88
  if (candidates.length === 0) {
56
89
  return [];
57
90
  }
@@ -59,9 +92,19 @@ export class WebSearchRegistryVerifier {
59
92
  if (signal?.aborted) {
60
93
  throw new Error("Verificación cancelada por el cliente.");
61
94
  }
62
- return await this.verifyCandidate(candidate.kind, candidate.name);
95
+ return await this.verifyCandidate(candidate.kind, candidate.name, signal);
63
96
  });
64
- return await this.runWithConcurrencyLimit(tasks, WebSearchRegistryVerifier.DEFAULT_CONCURRENCY);
97
+ try {
98
+ return await this.runWithConcurrencyLimit(tasks, WebSearchRegistryVerifier.DEFAULT_CONCURRENCY);
99
+ }
100
+ catch (error) {
101
+ // Cancellation must degrade to unverified results, never fail the search
102
+ // whose results already succeeded.
103
+ if (signal?.aborted) {
104
+ return [];
105
+ }
106
+ throw error;
107
+ }
65
108
  }
66
109
  /**
67
110
  * Collects registry candidates from search result URLs.
@@ -134,51 +177,146 @@ export class WebSearchRegistryVerifier {
134
177
  }
135
178
  return candidates;
136
179
  }
180
+ /**
181
+ * Maximum `releases` pages followed per GitHub verification.
182
+ */
183
+ static GITHUB_RELEASES_MAX_PAGES = 3;
137
184
  /**
138
185
  * Verifies a single candidate, using cache when available.
139
186
  *
140
187
  * @param kind - Registry kind
141
188
  * @param name - Candidate name
189
+ * @param signal - Optional caller abort signal forwarded to registry fetches
142
190
  * @returns Verification result
143
191
  */
144
- async verifyCandidate(kind, name) {
192
+ async verifyCandidate(kind, name, signal) {
145
193
  const cacheKey = `${kind}:${name.toLowerCase()}`;
146
194
  const nowMs = Date.now();
147
195
  const cached = this.cache.get(cacheKey);
148
196
  if (cached && cached.expiresAtMs > nowMs) {
197
+ // LRU semantics: `Map.set` on an existing key updates the value
198
+ // WITHOUT moving the insertion-order record (ECMAScript spec), so a
199
+ // true recency refresh requires delete-then-set; otherwise eviction
200
+ // below stays FIFO and evicts hot entries inserted early.
201
+ this.cache.delete(cacheKey);
202
+ this.cache.set(cacheKey, cached);
149
203
  return cached.value;
150
204
  }
151
- let value;
152
- try {
153
- if (kind === "npm") {
154
- value = await this.verifyNpm(name);
205
+ // Single-flight: concurrent MCP requests verifying the same entity
206
+ // join the in-flight promise instead of racing duplicate registry
207
+ // fetches (GitHub unauthenticated quota is 60 req/h per IP).
208
+ const inFlight = this.inFlight.get(cacheKey);
209
+ if (inFlight !== undefined) {
210
+ return await inFlight;
211
+ }
212
+ const verification = (async () => {
213
+ let value;
214
+ try {
215
+ if (kind === "npm") {
216
+ value = await this.verifyNpm(name, signal);
217
+ }
218
+ else if (kind === "pypi") {
219
+ value = await this.verifyPyPi(name, signal);
220
+ }
221
+ else if (kind === "crates") {
222
+ value = await this.verifyCrates(name, signal);
223
+ }
224
+ else if (kind === "nuget") {
225
+ value = await this.verifyNuGet(name, signal);
226
+ }
227
+ else {
228
+ value = await this.verifyGitHub(name, signal);
229
+ }
155
230
  }
156
- else if (kind === "pypi") {
157
- value = await this.verifyPyPi(name);
231
+ catch (error) {
232
+ // Map transport noise to stable Spanish before caching so error
233
+ // entities never carry raw undici English strings downstream.
234
+ value = {
235
+ kind,
236
+ name,
237
+ status: "error",
238
+ error: WebSearchRegistryVerifier.describeFetchError(error)
239
+ };
240
+ // Caller cancellations are not transient registry failures: caching
241
+ // them would poison the next search with "Operación cancelada…" rows,
242
+ // so the entity is returned uncached and re-verified on the next call.
243
+ if (WebSearchRegistryVerifier.isCallerCancellation(error, signal)) {
244
+ return value;
245
+ }
158
246
  }
159
- else if (kind === "crates") {
160
- value = await this.verifyCrates(name);
247
+ const ttlMs = value.status === "error" ? WebSearchRegistryVerifier.ERROR_CACHE_TTL_MS : this.deps.cacheTtlMs;
248
+ this.cache.delete(cacheKey);
249
+ this.cache.set(cacheKey, { value, expiresAtMs: nowMs + ttlMs });
250
+ while (this.cache.size > WebSearchRegistryVerifier.MAX_CACHE_ENTRIES) {
251
+ const oldestKey = this.cache.keys().next().value;
252
+ if (oldestKey === undefined) {
253
+ break;
254
+ }
255
+ this.cache.delete(oldestKey);
161
256
  }
162
- else if (kind === "nuget") {
163
- value = await this.verifyNuGet(name);
257
+ return value;
258
+ })();
259
+ this.inFlight.set(cacheKey, verification);
260
+ try {
261
+ return await verification;
262
+ }
263
+ finally {
264
+ this.inFlight.delete(cacheKey);
265
+ }
266
+ }
267
+ /**
268
+ * Reports whether one failure is a caller cancellation.
269
+ *
270
+ * @remarks
271
+ * Internal subfetch timeouts are reclassified as `TimeoutError` upstream
272
+ * and stay cacheable like any transient error; only genuine caller aborts
273
+ * (or errors shaped like one, e.g. a Spanish "cancelada" message) skip
274
+ * the error cache.
275
+ *
276
+ * @param error - Failure from a registry fetch.
277
+ * @param signal - Caller abort signal threaded into the verification.
278
+ * @returns True for caller cancellations.
279
+ */
280
+ static isCallerCancellation(error, signal) {
281
+ if (signal?.aborted) {
282
+ return true;
283
+ }
284
+ if (error instanceof Error) {
285
+ if (error.name === "TimeoutError" || /timeout|timed out|ETIMEDOUT/iu.test(error.message)) {
286
+ return false;
164
287
  }
165
- else {
166
- value = await this.verifyGitHub(name);
288
+ if (error.name === "AbortError" || /abort|cancelad/iu.test(error.message)) {
289
+ return true;
167
290
  }
168
291
  }
169
- catch (error) {
170
- const message = error instanceof Error ? error.message : String(error);
171
- value = { kind, name, status: "error", error: message };
172
- }
173
- this.cache.set(cacheKey, { value, expiresAtMs: nowMs + this.deps.cacheTtlMs });
174
- while (this.cache.size > WebSearchRegistryVerifier.MAX_CACHE_ENTRIES) {
175
- const oldestKey = this.cache.keys().next().value;
176
- if (oldestKey === undefined) {
177
- break;
292
+ return false;
293
+ }
294
+ /**
295
+ * Maps transport failures to stable Spanish text.
296
+ *
297
+ * @param error - Failure from a registry fetch.
298
+ * @returns Spanish description for the cached error entity.
299
+ */
300
+ static describeFetchError(error) {
301
+ if (error instanceof Error) {
302
+ const name = error.name;
303
+ const message = error.message;
304
+ if (name === "TimeoutError" || /timeout|timed out|ETIMEDOUT/iu.test(message)) {
305
+ return "Tiempo de espera agotado al consultar el registro; se reintentará en la próxima búsqueda.";
306
+ }
307
+ if (name === "AbortError" || /abort|cancelad/iu.test(message)) {
308
+ return "Operación cancelada antes de completar la verificación del registro.";
178
309
  }
179
- this.cache.delete(oldestKey);
310
+ // Unknown failures (undici transport errors are English by nature,
311
+ // e.g. "fetch failed", "ENOTFOUND", "ECONNRESET") never reach the
312
+ // model raw: they are wrapped in a bounded Spanish note so
313
+ // `verified[].error` stays model-facing Spanish with a short
314
+ // technical tail.
315
+ const bounded = sliceUtf8Safe(message.replace(/\s+/gu, " ").trim(), 0, 120);
316
+ return `Fallo de red al consultar el registro (${bounded.length > 0 ? bounded : "sin detalle"}).`;
180
317
  }
181
- return value;
318
+ const boundedRaw = sliceUtf8Safe(String(error).replace(/\s+/gu, " ").trim(), 0, 120);
319
+ return `Fallo de red al consultar el registro (${boundedRaw.length > 0 ? boundedRaw : "sin detalle"}).`;
182
320
  }
183
321
  /**
184
322
  * Runs tasks with a fixed concurrency limit.
@@ -213,13 +351,21 @@ export class WebSearchRegistryVerifier {
213
351
  * Verifies npm package versions via the npm registry.
214
352
  *
215
353
  * @param packageName - npm package name (may be scoped)
354
+ * @param signal - Optional caller abort signal
216
355
  * @returns Verified registry entity
217
356
  */
218
- async verifyNpm(packageName) {
357
+ async verifyNpm(packageName, signal) {
219
358
  const encoded = encodeURIComponent(packageName);
220
359
  const sourceUrl = `https://registry.npmjs.org/${encoded}`;
221
- const bodyRaw = await this.fetchJson(sourceUrl, { accept: "application/json" });
222
- const body = this.tryGetRecord(bodyRaw);
360
+ // Popular packuments (full metadata for thousands of versions) exceed the
361
+ // default 5 MB cap; parity with the EnriCode client plane, which reads
362
+ // the same packument endpoint with a 16 MiB budget.
363
+ const bodyRaw = await this.httpReader.fetchJson(sourceUrl, {
364
+ accept: "application/json",
365
+ signal,
366
+ maxBytes: WebSearchRegistryVerifier.NPM_PACKUMENT_MAX_BYTES
367
+ });
368
+ const body = this.tryGetRecord(bodyRaw.value);
223
369
  if (!body) {
224
370
  throw new Error(`Respuesta npm inesperada para ${sourceUrl}`);
225
371
  }
@@ -258,13 +404,14 @@ export class WebSearchRegistryVerifier {
258
404
  * Verifies PyPI project versions via the PyPI JSON API.
259
405
  *
260
406
  * @param projectName - PyPI project name
407
+ * @param signal - Optional caller abort signal
261
408
  * @returns Verified registry entity
262
409
  */
263
- async verifyPyPi(projectName) {
410
+ async verifyPyPi(projectName, signal) {
264
411
  const encoded = encodeURIComponent(projectName);
265
412
  const sourceUrl = `https://pypi.org/pypi/${encoded}/json`;
266
- const bodyRaw = await this.fetchJson(sourceUrl, { accept: "application/json" });
267
- const body = this.tryGetRecord(bodyRaw);
413
+ const bodyRaw = await this.httpReader.fetchJson(sourceUrl, { accept: "application/json", signal });
414
+ const body = this.tryGetRecord(bodyRaw.value);
268
415
  if (!body) {
269
416
  throw new Error(`Respuesta PyPI inesperada para ${sourceUrl}`);
270
417
  }
@@ -297,13 +444,14 @@ export class WebSearchRegistryVerifier {
297
444
  * Verifies crates.io package versions via the crates.io API.
298
445
  *
299
446
  * @param crateName - Crate name
447
+ * @param signal - Optional caller abort signal
300
448
  * @returns Verified registry entity
301
449
  */
302
- async verifyCrates(crateName) {
450
+ async verifyCrates(crateName, signal) {
303
451
  const encoded = encodeURIComponent(crateName);
304
452
  const sourceUrl = `https://crates.io/api/v1/crates/${encoded}`;
305
- const bodyRaw = await this.fetchJson(sourceUrl, { accept: "application/json" });
306
- const body = this.tryGetRecord(bodyRaw);
453
+ const bodyRaw = await this.httpReader.fetchJson(sourceUrl, { accept: "application/json", signal });
454
+ const body = this.tryGetRecord(bodyRaw.value);
307
455
  if (!body) {
308
456
  throw new Error(`Respuesta crates.io inesperada para ${sourceUrl}`);
309
457
  }
@@ -360,11 +508,12 @@ export class WebSearchRegistryVerifier {
360
508
  * Verifies NuGet package versions via NuGet V3 endpoints.
361
509
  *
362
510
  * @param packageId - NuGet package ID
511
+ * @param signal - Optional caller abort signal
363
512
  * @returns Verified registry entity
364
513
  */
365
- async verifyNuGet(packageId) {
514
+ async verifyNuGet(packageId, signal) {
366
515
  const lowerId = packageId.toLowerCase();
367
- const serviceIndex = await this.getNuGetServiceIndex();
516
+ const serviceIndex = await this.getNuGetServiceIndex(signal);
368
517
  if (!serviceIndex.packageBaseAddressUrl) {
369
518
  return {
370
519
  kind: "nuget",
@@ -374,8 +523,8 @@ export class WebSearchRegistryVerifier {
374
523
  };
375
524
  }
376
525
  const versionsUrl = `${serviceIndex.packageBaseAddressUrl}${lowerId}/index.json`;
377
- const versionsBodyRaw = await this.fetchJson(versionsUrl, { accept: "application/json" });
378
- const versionsBody = this.tryGetRecord(versionsBodyRaw);
526
+ const versionsBodyRaw = await this.httpReader.fetchJson(versionsUrl, { accept: "application/json", signal });
527
+ const versionsBody = this.tryGetRecord(versionsBodyRaw.value);
379
528
  if (!versionsBody) {
380
529
  throw new Error(`Respuesta NuGet inesperada para ${versionsUrl}`);
381
530
  }
@@ -386,10 +535,10 @@ export class WebSearchRegistryVerifier {
386
535
  const bestStable = this.pickBestSemVer(versionStrings, { prerelease: false });
387
536
  const bestPre = this.pickBestSemVer(versionStrings, { prerelease: true });
388
537
  const stablePublishedAt = bestStable && serviceIndex.registrationsBaseUrl
389
- ? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestStable)
538
+ ? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestStable, signal)
390
539
  : null;
391
540
  const prePublishedAt = bestPre && serviceIndex.registrationsBaseUrl
392
- ? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestPre)
541
+ ? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestPre, signal)
393
542
  : null;
394
543
  return {
395
544
  kind: "nuget",
@@ -406,10 +555,18 @@ export class WebSearchRegistryVerifier {
406
555
  /**
407
556
  * Verifies GitHub repository releases via GitHub REST API.
408
557
  *
558
+ * @remarks
559
+ * `latest_stable` comes from `/releases/latest` (GitHub's own newest
560
+ * non-prerelease, non-draft pick, immune to first-page truncation);
561
+ * prerelease candidates page through `/releases?per_page=100` following
562
+ * the `Link` header (up to {@link GITHUB_RELEASES_MAX_PAGES} pages, i.e.
563
+ * 300 newest releases — beyond that the first pages are what we keep).
564
+ *
409
565
  * @param repoSlug - Repository slug in the form "owner/repo"
566
+ * @param signal - Optional caller abort signal
410
567
  * @returns Verified registry entity
411
568
  */
412
- async verifyGitHub(repoSlug) {
569
+ async verifyGitHub(repoSlug, signal) {
413
570
  const cleanSlug = repoSlug.trim().replace(/\.git$/iu, "");
414
571
  const parts = cleanSlug.split("/");
415
572
  const owner = parts[0];
@@ -417,96 +574,98 @@ export class WebSearchRegistryVerifier {
417
574
  if (!owner || !repo) {
418
575
  return { kind: "github", name: repoSlug, status: "error", error: "Repositorio inválido." };
419
576
  }
420
- const sourceUrl = `https://api.github.com/repos/${owner}/${repo}/releases?per_page=100`;
421
- const releasesRaw = await this.fetchJson(sourceUrl, {
422
- accept: "application/vnd.github+json",
423
- githubToken: this.deps.githubToken
424
- });
425
- const releases = Array.isArray(releasesRaw) ? releasesRaw : [];
426
- const stableCandidates = [];
427
- const preCandidates = [];
428
- for (const item of releases) {
429
- const record = this.tryGetRecord(item);
430
- if (!record) {
431
- continue;
432
- }
433
- if (record["draft"] === true) {
434
- continue;
435
- }
436
- const tagNameRaw = this.tryGetNonEmptyString(record["tag_name"]);
437
- if (!tagNameRaw) {
438
- continue;
577
+ const latestUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
578
+ const listUrl = `https://api.github.com/repos/${owner}/${repo}/releases?per_page=100`;
579
+ let latestStable = null;
580
+ try {
581
+ const latestPage = await this.httpReader.fetchJson(latestUrl, {
582
+ accept: "application/vnd.github+json",
583
+ githubToken: this.deps.githubToken,
584
+ signal
585
+ });
586
+ const latest = this.tryParseGitHubReleaseCandidate(latestPage.value);
587
+ if (latest !== null && !latest.prerelease) {
588
+ latestStable = { version: latest.version, publishedAt: latest.publishedAt };
439
589
  }
440
- const tagName = tagNameRaw.startsWith("v") ? tagNameRaw.slice(1) : tagNameRaw;
441
- const publishedAt = this.tryGetNonEmptyString(record["published_at"]);
442
- const isPrerelease = record["prerelease"] === true;
443
- const candidate = { version: tagName, publishedAt: publishedAt ?? null, prerelease: isPrerelease };
444
- if (isPrerelease) {
445
- preCandidates.push(candidate);
590
+ }
591
+ catch (error) {
592
+ // /releases/latest 404s when the repo has no published stable release
593
+ // at all; any other failure fails the entity honestly.
594
+ const message = error instanceof Error ? error.message : String(error);
595
+ if (!/HTTP 404/u.test(message)) {
596
+ throw error;
446
597
  }
447
- else {
448
- stableCandidates.push(candidate);
598
+ }
599
+ const stableListFallbacks = [];
600
+ const preCandidates = [];
601
+ let nextUrl = listUrl;
602
+ let fetchedPages = 0;
603
+ while (nextUrl !== null && fetchedPages < WebSearchRegistryVerifier.GITHUB_RELEASES_MAX_PAGES) {
604
+ const page = await this.httpReader.fetchJson(nextUrl, {
605
+ accept: "application/vnd.github+json",
606
+ githubToken: this.deps.githubToken,
607
+ signal
608
+ });
609
+ fetchedPages += 1;
610
+ const releases = Array.isArray(page.value) ? page.value : [];
611
+ for (const item of releases) {
612
+ const candidate = this.tryParseGitHubReleaseCandidate(item);
613
+ if (candidate === null) {
614
+ continue;
615
+ }
616
+ if (candidate.prerelease) {
617
+ preCandidates.push(candidate);
618
+ }
619
+ else {
620
+ stableListFallbacks.push(candidate);
621
+ }
449
622
  }
623
+ nextUrl = page.nextUrl;
450
624
  }
451
- const bestStable = this.pickBestVersionCandidate(stableCandidates, { prerelease: false });
625
+ const bestStable = latestStable ?? this.pickBestVersionCandidate(stableListFallbacks, { prerelease: false });
452
626
  const bestPre = this.pickBestVersionCandidate(preCandidates, { prerelease: true });
453
627
  return {
454
628
  kind: "github",
455
629
  name: repoSlug,
456
630
  latest_stable: bestStable
457
- ? { version: bestStable.version, published_at: bestStable.publishedAt ?? undefined, source_url: sourceUrl }
631
+ ? {
632
+ version: bestStable.version,
633
+ published_at: bestStable.publishedAt ?? undefined,
634
+ // Provenance honesty: attribute the fallback pick to the list
635
+ // endpoint it actually came from, not to /releases/latest.
636
+ source_url: latestStable !== null ? latestUrl : listUrl
637
+ }
458
638
  : undefined,
459
639
  latest_prerelease: bestPre
460
- ? { version: bestPre.version, published_at: bestPre.publishedAt ?? undefined, source_url: sourceUrl }
640
+ ? { version: bestPre.version, published_at: bestPre.publishedAt ?? undefined, source_url: listUrl }
461
641
  : undefined,
462
642
  status: "ok"
463
643
  };
464
644
  }
465
645
  /**
466
- * Performs an HTTP GET expecting a JSON response.
646
+ * Parses one GitHub release entry into a version candidate.
467
647
  *
468
- * @param url - Target URL
469
- * @param options - Request options
470
- * @returns Parsed JSON object/array
648
+ * @param raw - Release entry from the GitHub API
649
+ * @returns Candidate, or null for drafts/entries without a tag
471
650
  */
472
- async fetchJson(url, options) {
473
- const controller = new AbortController();
474
- const timeout = setTimeout(() => controller.abort(), this.deps.timeoutMs);
475
- const headers = {
476
- Accept: options.accept,
477
- "User-Agent": "enriweb"
478
- };
479
- if (options.githubToken && options.githubToken.trim()) {
480
- headers["Authorization"] = `Bearer ${options.githubToken.trim()}`;
651
+ tryParseGitHubReleaseCandidate(raw) {
652
+ const record = this.tryGetRecord(raw);
653
+ if (!record) {
654
+ return null;
481
655
  }
482
- try {
483
- const response = await this.deps.fetchImpl(url, {
484
- method: "GET",
485
- headers,
486
- signal: controller.signal
487
- });
488
- if (!response.ok) {
489
- throw new Error(`HTTP ${String(response.status)} para ${url}`);
490
- }
491
- const declaredLengthRaw = response.headers.get("content-length");
492
- const declaredLength = declaredLengthRaw !== null ? Number.parseInt(declaredLengthRaw, 10) : NaN;
493
- if (Number.isFinite(declaredLength) && declaredLength > WebSearchRegistryVerifier.MAX_JSON_BYTES) {
494
- throw new Error(`La respuesta JSON de ${url} excede el máximo de ${String(WebSearchRegistryVerifier.MAX_JSON_BYTES)} bytes.`);
495
- }
496
- const text = await response.text();
497
- if (Buffer.byteLength(text, "utf8") > WebSearchRegistryVerifier.MAX_JSON_BYTES) {
498
- throw new Error(`La respuesta JSON de ${url} excede el máximo de ${String(WebSearchRegistryVerifier.MAX_JSON_BYTES)} bytes.`);
499
- }
500
- try {
501
- return JSON.parse(text);
502
- }
503
- catch {
504
- throw new Error(`Respuesta no JSON del registro en ${url}.`);
505
- }
656
+ if (record["draft"] === true) {
657
+ return null;
506
658
  }
507
- finally {
508
- clearTimeout(timeout);
659
+ const tagNameRaw = this.tryGetNonEmptyString(record["tag_name"]);
660
+ if (!tagNameRaw) {
661
+ return null;
509
662
  }
663
+ const tagName = tagNameRaw.startsWith("v") ? tagNameRaw.slice(1) : tagNameRaw;
664
+ return {
665
+ version: tagName,
666
+ publishedAt: this.tryGetNonEmptyString(record["published_at"]),
667
+ prerelease: record["prerelease"] === true
668
+ };
510
669
  }
511
670
  /**
512
671
  * Attempts to parse the npm package name from an npmjs.com URL.
@@ -519,7 +678,17 @@ export class WebSearchRegistryVerifier {
519
678
  if (hostname !== "www.npmjs.com" && hostname !== "npmjs.com") {
520
679
  return null;
521
680
  }
522
- const segments = url.pathname.split("/").filter(Boolean);
681
+ const segments = url.pathname
682
+ .split("/")
683
+ .filter(Boolean)
684
+ .map((segment) => {
685
+ try {
686
+ return decodeURIComponent(segment);
687
+ }
688
+ catch {
689
+ return segment;
690
+ }
691
+ });
523
692
  if (segments.length < 2 || segments[0] !== "package") {
524
693
  return null;
525
694
  }
@@ -527,6 +696,13 @@ export class WebSearchRegistryVerifier {
527
696
  if (!first) {
528
697
  return null;
529
698
  }
699
+ if (first.startsWith("@") && first.includes("/")) {
700
+ const [scope, name] = first.split("/");
701
+ if (!scope || !name) {
702
+ return null;
703
+ }
704
+ return `${scope}/${name}`;
705
+ }
530
706
  if (first.startsWith("@")) {
531
707
  const second = segments[2];
532
708
  if (!second) {
@@ -650,6 +826,10 @@ export class WebSearchRegistryVerifier {
650
826
  /**
651
827
  * Best-effort prerelease detection for PyPI version strings.
652
828
  *
829
+ * @remarks
830
+ * Covers PEP 440 separators and attached suffixes (`1.0b1`, `2.0beta1`)
831
+ * where the marker follows digits without a delimiter.
832
+ *
653
833
  * @param version - Version string
654
834
  * @returns True when prerelease-like
655
835
  */
@@ -661,6 +841,9 @@ export class WebSearchRegistryVerifier {
661
841
  if (/(?:^|[._-])(?:a|b|rc|dev|alpha|beta|pre|preview)\d*/.test(lower)) {
662
842
  return true;
663
843
  }
844
+ if (/\d(?:a|b|rc|dev|alpha|beta|pre|preview)\d*/.test(lower)) {
845
+ return true;
846
+ }
664
847
  return false;
665
848
  }
666
849
  /**
@@ -713,41 +896,86 @@ export class WebSearchRegistryVerifier {
713
896
  }
714
897
  let bestByTime = filtered[0];
715
898
  for (const item of filtered.slice(1)) {
716
- if (item.publishedAt && bestByTime.publishedAt) {
717
- if (item.publishedAt > bestByTime.publishedAt) {
718
- bestByTime = item;
719
- }
720
- }
721
- else if (item.publishedAt && !bestByTime.publishedAt) {
899
+ const itemTime = this.tryParseTimeMs(item.publishedAt);
900
+ const bestTime = bestByTime !== undefined ? this.tryParseTimeMs(bestByTime.publishedAt) : null;
901
+ if (itemTime !== null && (bestTime === null || itemTime > bestTime)) {
722
902
  bestByTime = item;
723
903
  }
724
904
  }
725
905
  return bestByTime;
726
906
  }
907
+ /**
908
+ * Parses one ISO timestamp to epoch milliseconds.
909
+ *
910
+ * @param value - Timestamp string, if present.
911
+ * @returns Epoch milliseconds, or null when missing or unparsable.
912
+ */
913
+ tryParseTimeMs(value) {
914
+ if (value === null || !value.trim()) {
915
+ return null;
916
+ }
917
+ const parsed = Date.parse(value);
918
+ return Number.isFinite(parsed) ? parsed : null;
919
+ }
727
920
  /**
728
921
  * Parses SemVer strings (best-effort).
729
922
  *
923
+ * @remarks
924
+ * Accepts two to four numeric components (`1.2` → `1.2.0`, `1.2.3.4`
925
+ * keeps the fourth as a build tiebreak) so partial registry versions
926
+ * still rank instead of dropping out.
927
+ *
730
928
  * @param raw - Version string
731
929
  * @returns Parsed semver or null
732
930
  */
733
931
  tryParseSemVer(raw) {
734
932
  const trimmed = raw.trim();
735
933
  const normalized = trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
736
- const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(normalized);
934
+ const match = /^(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(normalized);
737
935
  if (!match) {
738
- return null;
936
+ return this.tryParseAttachedPrerelease(normalized);
739
937
  }
740
938
  const major = Number.parseInt(match[1] ?? "", 10);
741
939
  const minor = Number.parseInt(match[2] ?? "", 10);
742
- const patch = Number.parseInt(match[3] ?? "", 10);
743
- if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) {
940
+ const patch = match[3] === undefined ? 0 : Number.parseInt(match[3], 10);
941
+ const build = match[4] === undefined ? 0 : Number.parseInt(match[4], 10);
942
+ if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch) || !Number.isFinite(build)) {
744
943
  return null;
745
944
  }
746
- const prereleaseRaw = match[4];
945
+ const prereleaseRaw = match[5];
747
946
  const prerelease = prereleaseRaw
748
947
  ? prereleaseRaw.split(".").filter((p) => p.length > 0)
749
948
  : [];
750
- return { raw: normalized, major, minor, patch, prerelease };
949
+ return { raw: normalized, major, minor, patch, build, prerelease };
950
+ }
951
+ /**
952
+ * Parses versions with attached prerelease suffixes (`2024.1b1`, `2.0rc2`).
953
+ *
954
+ * @param normalized - Version string without a leading "v".
955
+ * @returns Parsed semver or null.
956
+ */
957
+ tryParseAttachedPrerelease(normalized) {
958
+ const match = /^(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?(a|b|rc|alpha|beta|pre|preview|dev)(\d*)$/i.exec(normalized);
959
+ if (!match) {
960
+ return null;
961
+ }
962
+ const major = Number.parseInt(match[1] ?? "", 10);
963
+ const minor = Number.parseInt(match[2] ?? "", 10);
964
+ const patch = match[3] === undefined ? 0 : Number.parseInt(match[3], 10);
965
+ const build = match[4] === undefined ? 0 : Number.parseInt(match[4], 10);
966
+ if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch) || !Number.isFinite(build)) {
967
+ return null;
968
+ }
969
+ const marker = (match[5] ?? "").toLowerCase();
970
+ const number = match[6] ?? "";
971
+ return {
972
+ raw: normalized,
973
+ major,
974
+ minor,
975
+ patch,
976
+ build,
977
+ prerelease: [number ? `${marker}.${number}` : marker]
978
+ };
751
979
  }
752
980
  /**
753
981
  * Compares two SemVer values.
@@ -766,6 +994,9 @@ export class WebSearchRegistryVerifier {
766
994
  if (a.patch !== b.patch) {
767
995
  return a.patch - b.patch;
768
996
  }
997
+ if (a.build !== b.build) {
998
+ return a.build - b.build;
999
+ }
769
1000
  const aPre = a.prerelease;
770
1001
  const bPre = b.prerelease;
771
1002
  if (aPre.length === 0 && bPre.length === 0) {
@@ -831,6 +1062,7 @@ export class WebSearchRegistryVerifier {
831
1062
  return null;
832
1063
  }
833
1064
  let best = null;
1065
+ let bestMs = -1;
834
1066
  for (const item of releaseFiles) {
835
1067
  const record = this.tryGetRecord(item);
836
1068
  if (!record) {
@@ -840,8 +1072,13 @@ export class WebSearchRegistryVerifier {
840
1072
  if (!uploadTime) {
841
1073
  continue;
842
1074
  }
843
- if (!best || uploadTime > best) {
1075
+ const uploadMs = this.tryParseTimeMs(uploadTime);
1076
+ if (uploadMs === null) {
1077
+ continue;
1078
+ }
1079
+ if (best === null || uploadMs > bestMs) {
844
1080
  best = uploadTime;
1081
+ bestMs = uploadMs;
845
1082
  }
846
1083
  }
847
1084
  return best;
@@ -849,17 +1086,19 @@ export class WebSearchRegistryVerifier {
849
1086
  /**
850
1087
  * Resolves NuGet V3 endpoints from the service index, with caching.
851
1088
  *
1089
+ * @param signal - Optional caller abort signal
852
1090
  * @returns Service index cache
853
1091
  */
854
- async getNuGetServiceIndex() {
1092
+ async getNuGetServiceIndex(signal) {
855
1093
  const nowMs = Date.now();
856
1094
  if (this.nugetServiceIndexCache && this.nugetServiceIndexCache.expiresAtMs > nowMs) {
857
1095
  return this.nugetServiceIndexCache;
858
1096
  }
859
- const bodyRaw = await this.fetchJson(WebSearchRegistryVerifier.NUGET_SERVICE_INDEX_URL, {
860
- accept: "application/json"
1097
+ const bodyRaw = await this.httpReader.fetchJson(WebSearchRegistryVerifier.NUGET_SERVICE_INDEX_URL, {
1098
+ accept: "application/json",
1099
+ signal
861
1100
  });
862
- const body = this.tryGetRecord(bodyRaw);
1101
+ const body = this.tryGetRecord(bodyRaw.value);
863
1102
  if (!body) {
864
1103
  throw new Error("Respuesta inesperada del índice de NuGet.");
865
1104
  }
@@ -907,13 +1146,14 @@ export class WebSearchRegistryVerifier {
907
1146
  * @param registrationsBaseUrl - Registrations base URL
908
1147
  * @param lowerId - Lowercase package ID
909
1148
  * @param version - Version string
1149
+ * @param signal - Optional caller abort signal
910
1150
  * @returns Published ISO 8601 timestamp or null
911
1151
  */
912
- async tryFetchNuGetLeafPublishedAt(registrationsBaseUrl, lowerId, version) {
1152
+ async tryFetchNuGetLeafPublishedAt(registrationsBaseUrl, lowerId, version, signal) {
913
1153
  const leafUrl = `${registrationsBaseUrl}${lowerId}/${encodeURIComponent(version)}.json`;
914
1154
  try {
915
- const bodyRaw = await this.fetchJson(leafUrl, { accept: "application/json" });
916
- const body = this.tryGetRecord(bodyRaw);
1155
+ const bodyRaw = await this.httpReader.fetchJson(leafUrl, { accept: "application/json", signal });
1156
+ const body = this.tryGetRecord(bodyRaw.value);
917
1157
  if (!body) {
918
1158
  return null;
919
1159
  }