@bedolla/enriweb 0.1.5 → 0.1.7

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 +176 -5
  3. package/dist/client/EnriProxyClient.d.ts.map +1 -1
  4. package/dist/client/EnriProxyClient.js +255 -31
  5. package/dist/client/EnriProxyClient.js.map +1 -1
  6. package/dist/index.js +65 -25
  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 +338 -28
  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 +41 -17
  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 +187 -71
  37. package/dist/tools/WebFetchTool.d.ts.map +1 -1
  38. package/dist/tools/WebFetchTool.js +145 -367
  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 +111 -6
  49. package/dist/tools/WebSearchRegistryVerifier.d.ts.map +1 -1
  50. package/dist/tools/WebSearchRegistryVerifier.js +406 -125
  51. package/dist/tools/WebSearchRegistryVerifier.js.map +1 -1
  52. package/dist/tools/WebSearchTool.d.ts +85 -2
  53. package/dist/tools/WebSearchTool.d.ts.map +1 -1
  54. package/dist/tools/WebSearchTool.js +197 -19
  55. package/dist/tools/WebSearchTool.js.map +1 -1
  56. package/package.json +3 -2
@@ -1,15 +1,38 @@
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;
28
+ /**
29
+ * Time one cached error entity stays fresh before a retry.
30
+ */
31
+ static ERROR_CACHE_TTL_MS = 60 * 1000;
32
+ /**
33
+ * Maximum cached verification entries (LRU eviction beyond this count).
34
+ */
35
+ static MAX_CACHE_ENTRIES = 200;
13
36
  /**
14
37
  * Dependencies.
15
38
  */
@@ -18,6 +41,16 @@ export class WebSearchRegistryVerifier {
18
41
  * In-memory TTL cache.
19
42
  */
20
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();
21
54
  /**
22
55
  * Cached NuGet service index resolution.
23
56
  */
@@ -28,6 +61,10 @@ export class WebSearchRegistryVerifier {
28
61
  * @param deps - Dependencies
29
62
  */
30
63
  constructor(deps) {
64
+ this.httpReader = new WebSearchRegistryHttpReader({
65
+ fetchImpl: deps.fetchImpl,
66
+ timeoutMs: deps.timeoutMs
67
+ });
31
68
  this.deps = deps;
32
69
  this.cache = new Map();
33
70
  this.nugetServiceIndexCache = null;
@@ -36,17 +73,38 @@ export class WebSearchRegistryVerifier {
36
73
  * Attempts to verify canonical versions for registry entities found in search results.
37
74
  *
38
75
  * @param results - Search results
76
+ * @param signal - Optional caller abort signal (stops queueing new verifications)
39
77
  * @returns Verified entities (best-effort)
40
78
  */
41
- async verifyFromSearchResults(results) {
42
- const candidates = this.collectCandidates(results);
79
+ async verifyFromSearchResults(results, signal) {
80
+ if (signal?.aborted) {
81
+ return [];
82
+ }
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);
43
88
  if (candidates.length === 0) {
44
89
  return [];
45
90
  }
46
91
  const tasks = candidates.map((candidate) => async () => {
47
- return await this.verifyCandidate(candidate.kind, candidate.name);
92
+ if (signal?.aborted) {
93
+ throw new Error("Verificación cancelada por el cliente.");
94
+ }
95
+ return await this.verifyCandidate(candidate.kind, candidate.name, signal);
48
96
  });
49
- 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
+ }
50
108
  }
51
109
  /**
52
110
  * Collects registry candidates from search result URLs.
@@ -58,9 +116,12 @@ export class WebSearchRegistryVerifier {
58
116
  const unique = new Set();
59
117
  const candidates = [];
60
118
  for (const entry of results) {
61
- if (!entry.url || candidates.length >= this.deps.maxEntitiesPerCall) {
119
+ if (candidates.length >= this.deps.maxEntitiesPerCall) {
62
120
  break;
63
121
  }
122
+ if (!entry.url) {
123
+ continue;
124
+ }
64
125
  let parsedUrl;
65
126
  try {
66
127
  parsedUrl = new URL(entry.url);
@@ -116,44 +177,146 @@ export class WebSearchRegistryVerifier {
116
177
  }
117
178
  return candidates;
118
179
  }
180
+ /**
181
+ * Maximum `releases` pages followed per GitHub verification.
182
+ */
183
+ static GITHUB_RELEASES_MAX_PAGES = 3;
119
184
  /**
120
185
  * Verifies a single candidate, using cache when available.
121
186
  *
122
187
  * @param kind - Registry kind
123
188
  * @param name - Candidate name
189
+ * @param signal - Optional caller abort signal forwarded to registry fetches
124
190
  * @returns Verification result
125
191
  */
126
- async verifyCandidate(kind, name) {
192
+ async verifyCandidate(kind, name, signal) {
127
193
  const cacheKey = `${kind}:${name.toLowerCase()}`;
128
194
  const nowMs = Date.now();
129
195
  const cached = this.cache.get(cacheKey);
130
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);
131
203
  return cached.value;
132
204
  }
133
- let value;
134
- try {
135
- if (kind === "npm") {
136
- 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
+ }
137
230
  }
138
- else if (kind === "pypi") {
139
- 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
+ }
140
246
  }
141
- else if (kind === "crates") {
142
- 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);
143
256
  }
144
- else if (kind === "nuget") {
145
- 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;
146
287
  }
147
- else {
148
- value = await this.verifyGitHub(name);
288
+ if (error.name === "AbortError" || /abort|cancelad/iu.test(error.message)) {
289
+ return true;
149
290
  }
150
291
  }
151
- catch (error) {
152
- const message = error instanceof Error ? error.message : String(error);
153
- value = { kind, name, status: "error", error: message };
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.";
309
+ }
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"}).`;
154
317
  }
155
- this.cache.set(cacheKey, { value, expiresAtMs: nowMs + this.deps.cacheTtlMs });
156
- 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"}).`;
157
320
  }
158
321
  /**
159
322
  * Runs tasks with a fixed concurrency limit.
@@ -188,15 +351,23 @@ export class WebSearchRegistryVerifier {
188
351
  * Verifies npm package versions via the npm registry.
189
352
  *
190
353
  * @param packageName - npm package name (may be scoped)
354
+ * @param signal - Optional caller abort signal
191
355
  * @returns Verified registry entity
192
356
  */
193
- async verifyNpm(packageName) {
357
+ async verifyNpm(packageName, signal) {
194
358
  const encoded = encodeURIComponent(packageName);
195
359
  const sourceUrl = `https://registry.npmjs.org/${encoded}`;
196
- const bodyRaw = await this.fetchJson(sourceUrl, { accept: "application/json" });
197
- 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);
198
369
  if (!body) {
199
- throw new Error(`Unexpected npm registry response for ${sourceUrl}`);
370
+ throw new Error(`Respuesta npm inesperada para ${sourceUrl}`);
200
371
  }
201
372
  const distTags = this.tryGetRecord(body["dist-tags"]);
202
373
  const versionsRecord = this.tryGetRecord(body["versions"]);
@@ -233,15 +404,16 @@ export class WebSearchRegistryVerifier {
233
404
  * Verifies PyPI project versions via the PyPI JSON API.
234
405
  *
235
406
  * @param projectName - PyPI project name
407
+ * @param signal - Optional caller abort signal
236
408
  * @returns Verified registry entity
237
409
  */
238
- async verifyPyPi(projectName) {
410
+ async verifyPyPi(projectName, signal) {
239
411
  const encoded = encodeURIComponent(projectName);
240
412
  const sourceUrl = `https://pypi.org/pypi/${encoded}/json`;
241
- const bodyRaw = await this.fetchJson(sourceUrl, { accept: "application/json" });
242
- const body = this.tryGetRecord(bodyRaw);
413
+ const bodyRaw = await this.httpReader.fetchJson(sourceUrl, { accept: "application/json", signal });
414
+ const body = this.tryGetRecord(bodyRaw.value);
243
415
  if (!body) {
244
- throw new Error(`Unexpected PyPI response for ${sourceUrl}`);
416
+ throw new Error(`Respuesta PyPI inesperada para ${sourceUrl}`);
245
417
  }
246
418
  const releases = this.tryGetRecord(body["releases"]);
247
419
  const candidates = [];
@@ -272,15 +444,16 @@ export class WebSearchRegistryVerifier {
272
444
  * Verifies crates.io package versions via the crates.io API.
273
445
  *
274
446
  * @param crateName - Crate name
447
+ * @param signal - Optional caller abort signal
275
448
  * @returns Verified registry entity
276
449
  */
277
- async verifyCrates(crateName) {
450
+ async verifyCrates(crateName, signal) {
278
451
  const encoded = encodeURIComponent(crateName);
279
452
  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);
453
+ const bodyRaw = await this.httpReader.fetchJson(sourceUrl, { accept: "application/json", signal });
454
+ const body = this.tryGetRecord(bodyRaw.value);
282
455
  if (!body) {
283
- throw new Error(`Unexpected crates.io response for ${sourceUrl}`);
456
+ throw new Error(`Respuesta crates.io inesperada para ${sourceUrl}`);
284
457
  }
285
458
  const versions = Array.isArray(body["versions"]) ? body["versions"] : [];
286
459
  const stableCandidates = [];
@@ -335,24 +508,25 @@ export class WebSearchRegistryVerifier {
335
508
  * Verifies NuGet package versions via NuGet V3 endpoints.
336
509
  *
337
510
  * @param packageId - NuGet package ID
511
+ * @param signal - Optional caller abort signal
338
512
  * @returns Verified registry entity
339
513
  */
340
- async verifyNuGet(packageId) {
514
+ async verifyNuGet(packageId, signal) {
341
515
  const lowerId = packageId.toLowerCase();
342
- const serviceIndex = await this.getNuGetServiceIndex();
516
+ const serviceIndex = await this.getNuGetServiceIndex(signal);
343
517
  if (!serviceIndex.packageBaseAddressUrl) {
344
518
  return {
345
519
  kind: "nuget",
346
520
  name: packageId,
347
521
  status: "error",
348
- error: "NuGet service index did not provide PackageBaseAddress."
522
+ error: "El índice de NuGet no proporcionó PackageBaseAddress."
349
523
  };
350
524
  }
351
525
  const versionsUrl = `${serviceIndex.packageBaseAddressUrl}${lowerId}/index.json`;
352
- const versionsBodyRaw = await this.fetchJson(versionsUrl, { accept: "application/json" });
353
- const versionsBody = this.tryGetRecord(versionsBodyRaw);
526
+ const versionsBodyRaw = await this.httpReader.fetchJson(versionsUrl, { accept: "application/json", signal });
527
+ const versionsBody = this.tryGetRecord(versionsBodyRaw.value);
354
528
  if (!versionsBody) {
355
- throw new Error(`Unexpected NuGet response for ${versionsUrl}`);
529
+ throw new Error(`Respuesta NuGet inesperada para ${versionsUrl}`);
356
530
  }
357
531
  const versions = Array.isArray(versionsBody["versions"]) ? versionsBody["versions"] : [];
358
532
  const versionStrings = versions
@@ -361,10 +535,10 @@ export class WebSearchRegistryVerifier {
361
535
  const bestStable = this.pickBestSemVer(versionStrings, { prerelease: false });
362
536
  const bestPre = this.pickBestSemVer(versionStrings, { prerelease: true });
363
537
  const stablePublishedAt = bestStable && serviceIndex.registrationsBaseUrl
364
- ? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestStable)
538
+ ? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestStable, signal)
365
539
  : null;
366
540
  const prePublishedAt = bestPre && serviceIndex.registrationsBaseUrl
367
- ? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestPre)
541
+ ? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestPre, signal)
368
542
  : null;
369
543
  return {
370
544
  kind: "nuget",
@@ -381,92 +555,117 @@ export class WebSearchRegistryVerifier {
381
555
  /**
382
556
  * Verifies GitHub repository releases via GitHub REST API.
383
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
+ *
384
565
  * @param repoSlug - Repository slug in the form "owner/repo"
566
+ * @param signal - Optional caller abort signal
385
567
  * @returns Verified registry entity
386
568
  */
387
- async verifyGitHub(repoSlug) {
388
- const parts = repoSlug.split("/");
569
+ async verifyGitHub(repoSlug, signal) {
570
+ const cleanSlug = repoSlug.trim().replace(/\.git$/iu, "");
571
+ const parts = cleanSlug.split("/");
389
572
  const owner = parts[0];
390
573
  const repo = parts[1];
391
574
  if (!owner || !repo) {
392
- return { kind: "github", name: repoSlug, status: "error", error: "Invalid repo slug." };
575
+ return { kind: "github", name: repoSlug, status: "error", error: "Repositorio inválido." };
393
576
  }
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;
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 };
413
589
  }
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);
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;
420
597
  }
421
- else {
422
- 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
+ }
423
622
  }
623
+ nextUrl = page.nextUrl;
424
624
  }
425
- const bestStable = this.pickBestVersionCandidate(stableCandidates, { prerelease: false });
625
+ const bestStable = latestStable ?? this.pickBestVersionCandidate(stableListFallbacks, { prerelease: false });
426
626
  const bestPre = this.pickBestVersionCandidate(preCandidates, { prerelease: true });
427
627
  return {
428
628
  kind: "github",
429
629
  name: repoSlug,
430
630
  latest_stable: bestStable
431
- ? { 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
+ }
432
638
  : undefined,
433
639
  latest_prerelease: bestPre
434
- ? { version: bestPre.version, published_at: bestPre.publishedAt ?? undefined, source_url: sourceUrl }
640
+ ? { version: bestPre.version, published_at: bestPre.publishedAt ?? undefined, source_url: listUrl }
435
641
  : undefined,
436
642
  status: "ok"
437
643
  };
438
644
  }
439
645
  /**
440
- * Performs an HTTP GET expecting a JSON response.
646
+ * Parses one GitHub release entry into a version candidate.
441
647
  *
442
- * @param url - Target URL
443
- * @param options - Request options
444
- * @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
445
650
  */
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()}`;
651
+ tryParseGitHubReleaseCandidate(raw) {
652
+ const record = this.tryGetRecord(raw);
653
+ if (!record) {
654
+ return null;
455
655
  }
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();
656
+ if (record["draft"] === true) {
657
+ return null;
466
658
  }
467
- finally {
468
- clearTimeout(timeout);
659
+ const tagNameRaw = this.tryGetNonEmptyString(record["tag_name"]);
660
+ if (!tagNameRaw) {
661
+ return null;
469
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
+ };
470
669
  }
471
670
  /**
472
671
  * Attempts to parse the npm package name from an npmjs.com URL.
@@ -479,7 +678,17 @@ export class WebSearchRegistryVerifier {
479
678
  if (hostname !== "www.npmjs.com" && hostname !== "npmjs.com") {
480
679
  return null;
481
680
  }
482
- 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
+ });
483
692
  if (segments.length < 2 || segments[0] !== "package") {
484
693
  return null;
485
694
  }
@@ -487,6 +696,13 @@ export class WebSearchRegistryVerifier {
487
696
  if (!first) {
488
697
  return null;
489
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
+ }
490
706
  if (first.startsWith("@")) {
491
707
  const second = segments[2];
492
708
  if (!second) {
@@ -566,10 +782,11 @@ export class WebSearchRegistryVerifier {
566
782
  return null;
567
783
  }
568
784
  const owner = segments[0];
569
- const repo = segments[1];
570
- if (!owner || !repo) {
785
+ const repoRaw = segments[1];
786
+ if (!owner || !repoRaw) {
571
787
  return null;
572
788
  }
789
+ const repo = repoRaw.replace(/\.git$/iu, "");
573
790
  return `${owner}/${repo}`;
574
791
  }
575
792
  /**
@@ -609,6 +826,10 @@ export class WebSearchRegistryVerifier {
609
826
  /**
610
827
  * Best-effort prerelease detection for PyPI version strings.
611
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
+ *
612
833
  * @param version - Version string
613
834
  * @returns True when prerelease-like
614
835
  */
@@ -620,6 +841,9 @@ export class WebSearchRegistryVerifier {
620
841
  if (/(?:^|[._-])(?:a|b|rc|dev|alpha|beta|pre|preview)\d*/.test(lower)) {
621
842
  return true;
622
843
  }
844
+ if (/\d(?:a|b|rc|dev|alpha|beta|pre|preview)\d*/.test(lower)) {
845
+ return true;
846
+ }
623
847
  return false;
624
848
  }
625
849
  /**
@@ -672,41 +896,86 @@ export class WebSearchRegistryVerifier {
672
896
  }
673
897
  let bestByTime = filtered[0];
674
898
  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) {
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)) {
681
902
  bestByTime = item;
682
903
  }
683
904
  }
684
905
  return bestByTime;
685
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
+ }
686
920
  /**
687
921
  * Parses SemVer strings (best-effort).
688
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
+ *
689
928
  * @param raw - Version string
690
929
  * @returns Parsed semver or null
691
930
  */
692
931
  tryParseSemVer(raw) {
693
932
  const trimmed = raw.trim();
694
933
  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);
934
+ const match = /^(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(normalized);
696
935
  if (!match) {
697
- return null;
936
+ return this.tryParseAttachedPrerelease(normalized);
698
937
  }
699
938
  const major = Number.parseInt(match[1] ?? "", 10);
700
939
  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)) {
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)) {
703
943
  return null;
704
944
  }
705
- const prereleaseRaw = match[4];
945
+ const prereleaseRaw = match[5];
706
946
  const prerelease = prereleaseRaw
707
947
  ? prereleaseRaw.split(".").filter((p) => p.length > 0)
708
948
  : [];
709
- 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
+ };
710
979
  }
711
980
  /**
712
981
  * Compares two SemVer values.
@@ -725,6 +994,9 @@ export class WebSearchRegistryVerifier {
725
994
  if (a.patch !== b.patch) {
726
995
  return a.patch - b.patch;
727
996
  }
997
+ if (a.build !== b.build) {
998
+ return a.build - b.build;
999
+ }
728
1000
  const aPre = a.prerelease;
729
1001
  const bPre = b.prerelease;
730
1002
  if (aPre.length === 0 && bPre.length === 0) {
@@ -790,6 +1062,7 @@ export class WebSearchRegistryVerifier {
790
1062
  return null;
791
1063
  }
792
1064
  let best = null;
1065
+ let bestMs = -1;
793
1066
  for (const item of releaseFiles) {
794
1067
  const record = this.tryGetRecord(item);
795
1068
  if (!record) {
@@ -799,8 +1072,13 @@ export class WebSearchRegistryVerifier {
799
1072
  if (!uploadTime) {
800
1073
  continue;
801
1074
  }
802
- if (!best || uploadTime > best) {
1075
+ const uploadMs = this.tryParseTimeMs(uploadTime);
1076
+ if (uploadMs === null) {
1077
+ continue;
1078
+ }
1079
+ if (best === null || uploadMs > bestMs) {
803
1080
  best = uploadTime;
1081
+ bestMs = uploadMs;
804
1082
  }
805
1083
  }
806
1084
  return best;
@@ -808,19 +1086,21 @@ export class WebSearchRegistryVerifier {
808
1086
  /**
809
1087
  * Resolves NuGet V3 endpoints from the service index, with caching.
810
1088
  *
1089
+ * @param signal - Optional caller abort signal
811
1090
  * @returns Service index cache
812
1091
  */
813
- async getNuGetServiceIndex() {
1092
+ async getNuGetServiceIndex(signal) {
814
1093
  const nowMs = Date.now();
815
1094
  if (this.nugetServiceIndexCache && this.nugetServiceIndexCache.expiresAtMs > nowMs) {
816
1095
  return this.nugetServiceIndexCache;
817
1096
  }
818
- const bodyRaw = await this.fetchJson(WebSearchRegistryVerifier.NUGET_SERVICE_INDEX_URL, {
819
- accept: "application/json"
1097
+ const bodyRaw = await this.httpReader.fetchJson(WebSearchRegistryVerifier.NUGET_SERVICE_INDEX_URL, {
1098
+ accept: "application/json",
1099
+ signal
820
1100
  });
821
- const body = this.tryGetRecord(bodyRaw);
1101
+ const body = this.tryGetRecord(bodyRaw.value);
822
1102
  if (!body) {
823
- throw new Error("Unexpected NuGet service index response.");
1103
+ throw new Error("Respuesta inesperada del índice de NuGet.");
824
1104
  }
825
1105
  const resources = Array.isArray(body["resources"]) ? body["resources"] : [];
826
1106
  let packageBase = null;
@@ -866,13 +1146,14 @@ export class WebSearchRegistryVerifier {
866
1146
  * @param registrationsBaseUrl - Registrations base URL
867
1147
  * @param lowerId - Lowercase package ID
868
1148
  * @param version - Version string
1149
+ * @param signal - Optional caller abort signal
869
1150
  * @returns Published ISO 8601 timestamp or null
870
1151
  */
871
- async tryFetchNuGetLeafPublishedAt(registrationsBaseUrl, lowerId, version) {
1152
+ async tryFetchNuGetLeafPublishedAt(registrationsBaseUrl, lowerId, version, signal) {
872
1153
  const leafUrl = `${registrationsBaseUrl}${lowerId}/${encodeURIComponent(version)}.json`;
873
1154
  try {
874
- const bodyRaw = await this.fetchJson(leafUrl, { accept: "application/json" });
875
- const body = this.tryGetRecord(bodyRaw);
1155
+ const bodyRaw = await this.httpReader.fetchJson(leafUrl, { accept: "application/json", signal });
1156
+ const body = this.tryGetRecord(bodyRaw.value);
876
1157
  if (!body) {
877
1158
  return null;
878
1159
  }