@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.
- package/README.md +48 -20
- package/dist/client/EnriProxyClient.d.ts +143 -6
- package/dist/client/EnriProxyClient.d.ts.map +1 -1
- package/dist/client/EnriProxyClient.js +208 -33
- package/dist/client/EnriProxyClient.js.map +1 -1
- package/dist/index.js +58 -18
- package/dist/index.js.map +1 -1
- package/dist/package-info.d.ts +26 -0
- package/dist/package-info.d.ts.map +1 -1
- package/dist/package-info.js +29 -2
- package/dist/package-info.js.map +1 -1
- package/dist/server/EnriWebServer.d.ts +33 -0
- package/dist/server/EnriWebServer.d.ts.map +1 -1
- package/dist/server/EnriWebServer.js +322 -23
- package/dist/server/EnriWebServer.js.map +1 -1
- package/dist/shared/Utf8SafeTextSlicer.d.ts +47 -0
- package/dist/shared/Utf8SafeTextSlicer.d.ts.map +1 -0
- package/dist/shared/Utf8SafeTextSlicer.js +97 -0
- package/dist/shared/Utf8SafeTextSlicer.js.map +1 -0
- package/dist/shared/validation.d.ts +13 -1
- package/dist/shared/validation.d.ts.map +1 -1
- package/dist/shared/validation.js +27 -14
- package/dist/shared/validation.js.map +1 -1
- package/dist/tools/WebFetchNpmProjection.d.ts +143 -0
- package/dist/tools/WebFetchNpmProjection.d.ts.map +1 -0
- package/dist/tools/WebFetchNpmProjection.js +480 -0
- package/dist/tools/WebFetchNpmProjection.js.map +1 -0
- package/dist/tools/WebFetchParamsParser.d.ts +26 -0
- package/dist/tools/WebFetchParamsParser.d.ts.map +1 -0
- package/dist/tools/WebFetchParamsParser.js +157 -0
- package/dist/tools/WebFetchParamsParser.js.map +1 -0
- package/dist/tools/WebFetchRangesExecutor.d.ts +86 -0
- package/dist/tools/WebFetchRangesExecutor.d.ts.map +1 -0
- package/dist/tools/WebFetchRangesExecutor.js +277 -0
- package/dist/tools/WebFetchRangesExecutor.js.map +1 -0
- package/dist/tools/WebFetchTool.d.ts +186 -81
- package/dist/tools/WebFetchTool.d.ts.map +1 -1
- package/dist/tools/WebFetchTool.js +142 -425
- package/dist/tools/WebFetchTool.js.map +1 -1
- package/dist/tools/WebFetchToolTextFormatter.d.ts +57 -0
- package/dist/tools/WebFetchToolTextFormatter.d.ts.map +1 -0
- package/dist/tools/WebFetchToolTextFormatter.js +135 -0
- package/dist/tools/WebFetchToolTextFormatter.js.map +1 -0
- package/dist/tools/WebSearchRegistryHttpReader.d.ts +116 -0
- package/dist/tools/WebSearchRegistryHttpReader.d.ts.map +1 -0
- package/dist/tools/WebSearchRegistryHttpReader.js +157 -0
- package/dist/tools/WebSearchRegistryHttpReader.js.map +1 -0
- package/dist/tools/WebSearchRegistryVerifier.d.ts +103 -7
- package/dist/tools/WebSearchRegistryVerifier.d.ts.map +1 -1
- package/dist/tools/WebSearchRegistryVerifier.js +375 -135
- package/dist/tools/WebSearchRegistryVerifier.js.map +1 -1
- package/dist/tools/WebSearchTool.d.ts +78 -0
- package/dist/tools/WebSearchTool.d.ts.map +1 -1
- package/dist/tools/WebSearchTool.js +191 -21
- package/dist/tools/WebSearchTool.js.map +1 -1
- 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
|
-
*
|
|
29
|
+
* Time one cached error entity stays fresh before a retry.
|
|
15
30
|
*/
|
|
16
|
-
static
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
288
|
+
if (error.name === "AbortError" || /abort|cancelad/iu.test(error.message)) {
|
|
289
|
+
return true;
|
|
167
290
|
}
|
|
168
291
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
if (!
|
|
431
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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(
|
|
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
|
-
? {
|
|
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:
|
|
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
|
-
*
|
|
646
|
+
* Parses one GitHub release entry into a version candidate.
|
|
467
647
|
*
|
|
468
|
-
* @param
|
|
469
|
-
* @
|
|
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
|
-
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
508
|
-
|
|
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
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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+)
|
|
934
|
+
const match = /^(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(normalized);
|
|
737
935
|
if (!match) {
|
|
738
|
-
return
|
|
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]
|
|
743
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
}
|