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