@4da/mcp-server 6.0.2 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +433 -0
  2. package/README.md +26 -18
  3. package/dist/db.d.ts +73 -8
  4. package/dist/db.js +180 -43
  5. package/dist/doctor.d.ts +1 -1
  6. package/dist/doctor.js +15 -9
  7. package/dist/embeddings.js +19 -7
  8. package/dist/gitignore.js +9 -3
  9. package/dist/index.js +43 -4
  10. package/dist/live/cache.d.ts +31 -2
  11. package/dist/live/cache.js +113 -1
  12. package/dist/live/changelog-classify.d.ts +9 -3
  13. package/dist/live/changelog-classify.js +125 -12
  14. package/dist/live/changelog.d.ts +22 -3
  15. package/dist/live/changelog.js +132 -38
  16. package/dist/live/dev-scope.d.ts +2 -2
  17. package/dist/live/fix-paths.d.ts +118 -0
  18. package/dist/live/fix-paths.js +498 -0
  19. package/dist/live/github-releases.d.ts +69 -0
  20. package/dist/live/github-releases.js +252 -0
  21. package/dist/live/go-registry.d.ts +5 -0
  22. package/dist/live/go-registry.js +1 -1
  23. package/dist/live/go-releases.d.ts +48 -0
  24. package/dist/live/go-releases.js +196 -0
  25. package/dist/live/index.d.ts +34 -8
  26. package/dist/live/index.js +66 -7
  27. package/dist/live/lockfile-parents.d.ts +61 -0
  28. package/dist/live/lockfile-parents.js +360 -0
  29. package/dist/live/lockfile-parsers-pygo.d.ts +31 -0
  30. package/dist/live/lockfile-parsers-pygo.js +178 -28
  31. package/dist/live/lockfile-parsers.d.ts +4 -1
  32. package/dist/live/lockfile-parsers.js +57 -24
  33. package/dist/live/lockfile-types.d.ts +12 -3
  34. package/dist/live/lockfile-types.js +2 -1
  35. package/dist/live/osv-ranges.d.ts +85 -0
  36. package/dist/live/osv-ranges.js +136 -0
  37. package/dist/live/osv-scanner.d.ts +1 -0
  38. package/dist/live/osv-scanner.js +85 -78
  39. package/dist/live/package-archive.d.ts +2 -0
  40. package/dist/live/package-archive.js +7 -4
  41. package/dist/live/pypi-releases.d.ts +44 -0
  42. package/dist/live/pypi-releases.js +103 -0
  43. package/dist/live/range-satisfies.d.ts +21 -0
  44. package/dist/live/range-satisfies.js +190 -0
  45. package/dist/live/rate-limiter.d.ts +6 -0
  46. package/dist/live/rate-limiter.js +5 -0
  47. package/dist/live/registry-health.js +2 -0
  48. package/dist/live/release-metadata.d.ts +17 -8
  49. package/dist/live/release-metadata.js +6 -3
  50. package/dist/live/resolution.d.ts +11 -1
  51. package/dist/live/resolution.js +16 -6
  52. package/dist/live/semver-utils.d.ts +8 -0
  53. package/dist/live/semver-utils.js +10 -2
  54. package/dist/live/types.d.ts +17 -1
  55. package/dist/live/upgrade-go.d.ts +62 -0
  56. package/dist/live/upgrade-go.js +130 -0
  57. package/dist/live/upgrade-pypi.d.ts +42 -0
  58. package/dist/live/upgrade-pypi.js +85 -0
  59. package/dist/live/upgrade-sources.d.ts +30 -11
  60. package/dist/live/upgrade-sources.js +93 -21
  61. package/dist/live/zip-archive.d.ts +22 -0
  62. package/dist/live/zip-archive.js +98 -0
  63. package/dist/memory-store.d.ts +19 -0
  64. package/dist/memory-store.js +8 -0
  65. package/dist/native-bindings.d.ts +7 -2
  66. package/dist/native-bindings.js +4 -0
  67. package/dist/project-tree.d.ts +50 -8
  68. package/dist/project-tree.js +121 -29
  69. package/dist/schema-registry.js +5 -5
  70. package/dist/server-instructions.d.ts +6 -0
  71. package/dist/server-instructions.js +20 -15
  72. package/dist/sqlite-driver.d.ts +134 -0
  73. package/dist/sqlite-driver.js +359 -0
  74. package/dist/tool-dispatch.d.ts +8 -1
  75. package/dist/tool-dispatch.js +17 -2
  76. package/dist/tools/agent-memory.js +10 -2
  77. package/dist/tools/app-plan.d.ts +11 -1
  78. package/dist/tools/app-plan.js +32 -6
  79. package/dist/tools/briefing-task-scope.d.ts +21 -2
  80. package/dist/tools/briefing-task-scope.js +97 -4
  81. package/dist/tools/decision-memory.js +9 -3
  82. package/dist/tools/dependency-check-signals.d.ts +23 -5
  83. package/dist/tools/dependency-check-signals.js +62 -19
  84. package/dist/tools/dependency-check-versions.d.ts +94 -0
  85. package/dist/tools/dependency-check-versions.js +213 -0
  86. package/dist/tools/dependency-check.d.ts +27 -11
  87. package/dist/tools/dependency-check.js +321 -74
  88. package/dist/tools/dependency-health.d.ts +1 -1
  89. package/dist/tools/dependency-health.js +5 -0
  90. package/dist/tools/knowledge-gap-ranges.d.ts +1 -1
  91. package/dist/tools/knowledge-gap-ranges.js +1 -1
  92. package/dist/tools/knowledge-gap-scope.d.ts +2 -2
  93. package/dist/tools/project-scope.d.ts +45 -0
  94. package/dist/tools/project-scope.js +119 -0
  95. package/dist/tools/record-feedback.js +1 -1
  96. package/dist/tools/upgrade-impact-callsites.d.ts +1 -0
  97. package/dist/tools/upgrade-impact-callsites.js +23 -5
  98. package/dist/tools/upgrade-impact-changelog.d.ts +41 -0
  99. package/dist/tools/upgrade-impact-changelog.js +79 -0
  100. package/dist/tools/upgrade-impact-imports.d.ts +42 -0
  101. package/dist/tools/upgrade-impact-imports.js +260 -0
  102. package/dist/tools/upgrade-impact-literals.js +1 -1
  103. package/dist/tools/upgrade-impact-report.d.ts +23 -0
  104. package/dist/tools/upgrade-impact-report.js +45 -6
  105. package/dist/tools/upgrade-impact.d.ts +13 -4
  106. package/dist/tools/upgrade-impact.js +236 -85
  107. package/dist/tools/upgrade-planner-fixpaths.d.ts +40 -0
  108. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  109. package/dist/tools/upgrade-planner.d.ts +31 -3
  110. package/dist/tools/upgrade-planner.js +74 -19
  111. package/dist/tools/vulnerability-scan-concise.d.ts +13 -1
  112. package/dist/tools/vulnerability-scan-concise.js +15 -3
  113. package/dist/tools/vulnerability-scan-format.d.ts +58 -1
  114. package/dist/tools/vulnerability-scan-format.js +107 -11
  115. package/dist/tools/vulnerability-scan.d.ts +104 -13
  116. package/dist/tools/vulnerability-scan.js +27 -3
  117. package/dist/tools/what-should-i-know.d.ts +22 -2
  118. package/dist/tools/what-should-i-know.js +122 -12
  119. package/package.json +10 -32
@@ -7,18 +7,48 @@
7
7
  *
8
8
  * Privacy: sends only package names + versions (public manifest data).
9
9
  */
10
- import { fetchWithTimeout, fetchJson } from "./http-utils.js";
10
+ import { fetchWithTimeout } from "./http-utils.js";
11
11
  import { cvssBaseScore } from "./cvss.js";
12
- import { compareVersions, isComparable, maxVersion, samePackageName } from "./version-compare.js";
12
+ import { compareVersions, isComparable, maxVersion } from "./version-compare.js";
13
+ import { collectAffectedSpans } from "./osv-ranges.js";
13
14
  const OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
14
15
  const OSV_VULN_URL = "https://api.osv.dev/v1/vulns/";
15
16
  const OSV_TIMEOUT_MS = 15_000;
16
17
  const OSV_CACHE_TTL = 3600; // 1 hour
17
18
  const OSV_DETAIL_TTL = 86_400; // 24 hours — advisory details change rarely
18
19
  const MAX_BATCH_SIZE = 1000;
19
- // Bounded concurrency for advisory-detail hydration. Only the *vulnerable*
20
- // subset is hydrated (not every scanned dep), so this stays small in practice.
21
- const HYDRATE_CONCURRENCY = 8;
20
+ /**
21
+ * Advisory details have no batch endpoint: one GET /v1/vulns/{id} each. They
22
+ * were fetched in rounds of 8, each round waiting for its slowest request, so
23
+ * a project with 900 distinct advisories (superset) made ~113 sequential
24
+ * round trips. Now a pool of HYDRATE_CONCURRENCY workers keeps that many
25
+ * requests in flight (osv-scanner itself hydrates in parallel), a failed or
26
+ * throttled request is retried once, and every result is cached for 24 h.
27
+ */
28
+ const HYDRATE_CONCURRENCY = 24;
29
+ /** querybatch chunks (1,000 dependencies each) sent at once. */
30
+ const BATCH_CONCURRENCY = 4;
31
+ const DETAIL_RETRY_DELAY_MS = 750;
32
+ /**
33
+ * Detail fetches in flight, shared by every scanner in the process. The
34
+ * startup warm-up scan and the first tool call's scan used to run side by
35
+ * side and fetch the same advisories twice, both before either could cache
36
+ * them; a concurrent scan now waits on the request already made.
37
+ */
38
+ const detailsInFlight = new Map();
39
+ /** Run `work` over `items` with at most `limit` in flight; results in input order. */
40
+ async function pool(items, limit, work) {
41
+ const out = new Array(items.length);
42
+ let next = 0;
43
+ const worker = async () => {
44
+ while (next < items.length) {
45
+ const i = next++;
46
+ out[i] = await work(items[i]);
47
+ }
48
+ };
49
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
50
+ return out;
51
+ }
22
52
  export class OsvScanner {
23
53
  cache;
24
54
  rateLimiter;
@@ -92,12 +122,18 @@ export class OsvScanner {
92
122
  // Phase 1 — enumerate advisory IDs per dep via the lightweight batch index.
93
123
  // querybatch returns only `{ id, modified }`; the rich fields (severity,
94
124
  // fixed version, summary, references) come from per-advisory hydration below.
95
- const matches = [];
96
- for (let offset = 0; offset < deps.length; offset += MAX_BATCH_SIZE) {
97
- if (!this.rateLimiter.canProceed("osv")) {
125
+ const chunks = [];
126
+ for (let offset = 0; offset < deps.length; offset += MAX_BATCH_SIZE)
127
+ chunks.push(deps.slice(offset, offset + MAX_BATCH_SIZE));
128
+ // Every chunk is answered or the scan fails: a partial answer would call
129
+ // the unanswered dependencies clean. The rate limit is checked for all of
130
+ // them before any is sent.
131
+ for (let i = 0; i < chunks.length; i++) {
132
+ if (!this.rateLimiter.consume("osv")) {
98
133
  throw new Error("OSV rate limit reached before all dependency batches completed");
99
134
  }
100
- const chunk = deps.slice(offset, offset + MAX_BATCH_SIZE);
135
+ }
136
+ const answered = await pool(chunks, BATCH_CONCURRENCY, async (chunk) => {
101
137
  const queries = chunk.map((d) => ({
102
138
  package: { name: d.name, ecosystem: d.ecosystem },
103
139
  ...(d.version ? { version: d.version } : {}),
@@ -110,29 +146,27 @@ export class OsvScanner {
110
146
  if (!response.ok) {
111
147
  throw new Error(`OSV API error: ${response.status}`);
112
148
  }
113
- this.rateLimiter.consume("osv");
114
149
  const data = (await response.json());
115
- for (let i = 0; i < chunk.length; i++) {
116
- const ids = (data.results[i]?.vulns ?? []).map((v) => v.id);
117
- matches.push({ dep: chunk[i], ids });
118
- }
119
- }
150
+ return chunk.map((dep, i) => ({ dep, ids: (data.results[i]?.vulns ?? []).map((v) => v.id) }));
151
+ });
152
+ const matches = answered.flat();
120
153
  // Phase 2 — hydrate each unique advisory once (cached, bounded concurrency).
121
154
  const uniqueIds = [...new Set(matches.flatMap((m) => m.ids))];
122
155
  const detailById = await this.hydrateDetails(uniqueIds);
123
156
  // Phase 3 — map to entries and cache per-dep (empty results cached too, so
124
157
  // clean deps are not re-queried).
125
158
  const results = [];
159
+ const rows = [];
126
160
  for (const { dep, ids } of matches) {
127
161
  // Drop advisories OSV has withdrawn/retracted — surfacing them is a false
128
162
  // positive. Un-hydrated ids (detail fetch failed) are kept: we can't know,
129
163
  // and dropping a real advisory is worse than keeping an un-enriched one.
130
164
  const liveIds = ids.filter((id) => !detailById.get(id)?.withdrawn);
131
165
  const depVulns = liveIds.map((id) => mapVulnerability(detailById.get(id) ?? { id }, dep));
132
- const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
133
- this.cache.set(cacheKey, depVulns, "osv", OSV_CACHE_TTL);
166
+ rows.push([`osv:${dep.ecosystem}:${dep.name}:${dep.version}`, depVulns]);
134
167
  results.push(...depVulns);
135
168
  }
169
+ this.cache.setMany(rows, "osv", OSV_CACHE_TTL);
136
170
  return results;
137
171
  }
138
172
  /**
@@ -151,26 +185,40 @@ export class OsvScanner {
151
185
  else
152
186
  toFetch.push(id);
153
187
  }
154
- for (let i = 0; i < toFetch.length; i += HYDRATE_CONCURRENCY) {
155
- const slice = toFetch.slice(i, i + HYDRATE_CONCURRENCY);
156
- const settled = await Promise.allSettled(slice.map((id) => this.fetchDetail(id)));
157
- settled.forEach((res, j) => {
158
- if (res.status === "fulfilled" && res.value) {
159
- const id = slice[j];
160
- out.set(id, res.value);
161
- this.cache.set(`osv:detail:${id}`, res.value, "osv", OSV_DETAIL_TTL);
162
- }
163
- });
164
- }
188
+ const fetched = [];
189
+ await pool(toFetch, HYDRATE_CONCURRENCY, async (id) => {
190
+ let pending = detailsInFlight.get(id);
191
+ if (!pending) {
192
+ pending = this.fetchDetail(id).finally(() => detailsInFlight.delete(id));
193
+ detailsInFlight.set(id, pending);
194
+ }
195
+ const detail = await pending;
196
+ if (detail) {
197
+ out.set(id, detail);
198
+ fetched.push([`osv:detail:${id}`, detail]);
199
+ }
200
+ });
201
+ this.cache.setMany(fetched, "osv", OSV_DETAIL_TTL);
165
202
  return out;
166
203
  }
204
+ /** One advisory's record; retried once after a throttle, a server error or a network failure. Null when it cannot be had. */
167
205
  async fetchDetail(id) {
168
- try {
169
- return await fetchJson(`${OSV_VULN_URL}${encodeURIComponent(id)}`, {}, OSV_TIMEOUT_MS);
170
- }
171
- catch {
172
- return null;
206
+ for (let attempt = 0; attempt < 2; attempt++) {
207
+ try {
208
+ const response = await fetchWithTimeout(`${OSV_VULN_URL}${encodeURIComponent(id)}`, {}, OSV_TIMEOUT_MS);
209
+ if (response.ok)
210
+ return (await response.json());
211
+ // 404 and other client errors are answers, not failures to retry.
212
+ if (response.status !== 429 && response.status < 500)
213
+ return null;
214
+ }
215
+ catch {
216
+ // network failure or timeout: retry once
217
+ }
218
+ if (attempt === 0)
219
+ await new Promise((resolve) => setTimeout(resolve, DETAIL_RETRY_DELAY_MS));
173
220
  }
221
+ return null;
174
222
  }
175
223
  }
176
224
  /**
@@ -313,7 +361,7 @@ function cvssToSeverity(score) {
313
361
  export function extractFixedVersion(affected, packageName, ecosystem, installedVersion) {
314
362
  if (!affected)
315
363
  return null;
316
- const intervals = collectVulnerableIntervals(affected, packageName, ecosystem);
364
+ const intervals = collectAffectedSpans(affected, packageName, ecosystem);
317
365
  const fixes = intervals
318
366
  .map((i) => i.fixed)
319
367
  .filter((f) => f !== null);
@@ -330,7 +378,9 @@ export function extractFixedVersion(affected, packageName, ecosystem, installedV
330
378
  // unreadable bound compares as 0, which keeps those intervals eligible on
331
379
  // the introduced side only; "0" (since inception) orders below everything.
332
380
  const containing = intervals.find((i) => cmp(installedVersion, i.introduced) >= 0 &&
333
- (i.fixed === null || cmp(installedVersion, i.fixed) < 0));
381
+ (i.fixed !== null
382
+ ? cmp(installedVersion, i.fixed) < 0
383
+ : i.lastAffected === null || cmp(installedVersion, i.lastAffected) <= 0));
334
384
  if (containing?.fixed && cmp(containing.fixed, installedVersion) > 0) {
335
385
  return containing.fixed;
336
386
  }
@@ -341,46 +391,3 @@ export function extractFixedVersion(affected, packageName, ecosystem, installedV
341
391
  return null;
342
392
  return above.reduce((min, v) => (cmp(v, min) < 0 ? v : min), above[0]);
343
393
  }
344
- /**
345
- * Flatten the matching package's version ranges into [introduced, fixed)
346
- * intervals. OSV sorts events within a range; each `introduced` opens an
347
- * interval and the next `fixed` closes it. GIT ranges hold commit hashes,
348
- * not versions, and are skipped.
349
- */
350
- function collectVulnerableIntervals(affected, packageName, ecosystem) {
351
- const intervals = [];
352
- for (const a of affected) {
353
- // Names compare the registry's way: PEP 503 for PyPI (`Jinja2` is
354
- // `jinja2`), `-`/`_` folding for crates. An exact compare found no range
355
- // for Jinja2 or PyYAML and reported "no fix version published" for both.
356
- if (a.package.ecosystem !== ecosystem || !samePackageName(a.package.name, packageName, ecosystem))
357
- continue;
358
- for (const range of a.ranges || []) {
359
- if (range.type === "GIT")
360
- continue;
361
- let open = null;
362
- for (const event of range.events || []) {
363
- if (event.introduced !== undefined) {
364
- if (open)
365
- intervals.push(open);
366
- open = { introduced: event.introduced, fixed: null };
367
- }
368
- else if (event.fixed !== undefined) {
369
- if (open) {
370
- open.fixed = event.fixed;
371
- intervals.push(open);
372
- open = null;
373
- }
374
- else {
375
- // `fixed` with no preceding `introduced` — treat as vulnerable
376
- // since inception ("0" = OSV's since-the-beginning sentinel).
377
- intervals.push({ introduced: "0", fixed: event.fixed });
378
- }
379
- }
380
- }
381
- if (open)
382
- intervals.push(open);
383
- }
384
- }
385
- return intervals;
386
- }
@@ -44,6 +44,8 @@ export declare function iterateTar(tar: Buffer): Generator<TarEntry>;
44
44
  * files are skipped.
45
45
  */
46
46
  export declare function readRootTextFiles(gz: Buffer, accept: (basename: string) => boolean): Map<string, string>;
47
+ /** Download a registry archive, refusing at MAX_COMPRESSED_BYTES. */
48
+ export declare function fetchArchiveBytes(url: string, fetchFn?: FetchFn, headers?: Record<string, string>): Promise<Buffer>;
47
49
  /** Download a registry archive (size-capped) and return the accepted root-level text files. */
48
50
  export declare function fetchArchiveRootFiles(url: string, accept: (basename: string) => boolean, fetchFn?: FetchFn, headers?: Record<string, string>): Promise<Map<string, string>>;
49
51
  export {};
@@ -176,11 +176,14 @@ async function readCapped(response, cap) {
176
176
  }
177
177
  return Buffer.concat(chunks);
178
178
  }
179
- /** Download a registry archive (size-capped) and return the accepted root-level text files. */
180
- export async function fetchArchiveRootFiles(url, accept, fetchFn = defaultFetch, headers = {}) {
179
+ /** Download a registry archive, refusing at MAX_COMPRESSED_BYTES. */
180
+ export async function fetchArchiveBytes(url, fetchFn = defaultFetch, headers = {}) {
181
181
  const response = await fetchFn(url, { headers }, ARCHIVE_TIMEOUT_MS);
182
182
  if (!response.ok)
183
183
  throw new ArchiveError(`archive download failed: HTTP ${response.status}`);
184
- const gz = await readCapped(response, MAX_COMPRESSED_BYTES);
185
- return readRootTextFiles(gz, accept);
184
+ return readCapped(response, MAX_COMPRESSED_BYTES);
185
+ }
186
+ /** Download a registry archive (size-capped) and return the accepted root-level text files. */
187
+ export async function fetchArchiveRootFiles(url, accept, fetchFn = defaultFetch, headers = {}) {
188
+ return readRootTextFiles(await fetchArchiveBytes(url, fetchFn, headers), accept);
186
189
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * PyPI release index for dependency_check, from the JSON API
3
+ * (`GET pypi.org/pypi/<name>/json`): every release with its upload time and
4
+ * whether it is yanked.
5
+ *
6
+ * - A release's publish time is its EARLIEST file upload (later wheels for
7
+ * new platforms do not make it newer).
8
+ * - A release is yanked when every one of its files is (PEP 592); pip then
9
+ * skips it unless pinned with `==`.
10
+ * - A release with no files cannot be installed and is left out.
11
+ * - Versions are PEP 440 and are kept as the registry spells them.
12
+ *
13
+ * Not read, and reported as not applicable by the signals: publisher identity
14
+ * (the JSON API carries none), install-time hooks (PyPI exposes none in its
15
+ * index; an sdist-only release runs its build backend at install), and
16
+ * per-release dependency lists (one request per release).
17
+ *
18
+ * Privacy: the request names the package and nothing else.
19
+ */
20
+ import type { LiveCache } from "./cache.js";
21
+ import type { RateLimiter } from "./rate-limiter.js";
22
+ import type { PackageReleaseIndex, RegistryLookup, ReleaseMetadataSource } from "./release-metadata.js";
23
+ interface PyPIFile {
24
+ upload_time_iso_8601?: string;
25
+ yanked?: boolean;
26
+ yanked_reason?: string | null;
27
+ }
28
+ interface PyPIProject {
29
+ info?: {
30
+ name?: string;
31
+ };
32
+ releases?: Record<string, PyPIFile[] | null>;
33
+ }
34
+ export declare const PYPI_NOT_APPLICABLE = "not applicable on PyPI: its JSON API carries no publisher identity or install-hook metadata";
35
+ /** The index the signals read, from one JSON API document. */
36
+ export declare function projectPypiProject(name: string, doc: PyPIProject): PackageReleaseIndex;
37
+ export declare class PypiReleasesReader implements ReleaseMetadataSource {
38
+ private cache;
39
+ private rateLimiter;
40
+ constructor(cache: LiveCache, rateLimiter: RateLimiter);
41
+ getReleases(name: string): Promise<RegistryLookup<PackageReleaseIndex>>;
42
+ getCreatedAt(name: string): Promise<RegistryLookup<string | null>>;
43
+ }
44
+ export {};
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * PyPI release index for dependency_check, from the JSON API
4
+ * (`GET pypi.org/pypi/<name>/json`): every release with its upload time and
5
+ * whether it is yanked.
6
+ *
7
+ * - A release's publish time is its EARLIEST file upload (later wheels for
8
+ * new platforms do not make it newer).
9
+ * - A release is yanked when every one of its files is (PEP 592); pip then
10
+ * skips it unless pinned with `==`.
11
+ * - A release with no files cannot be installed and is left out.
12
+ * - Versions are PEP 440 and are kept as the registry spells them.
13
+ *
14
+ * Not read, and reported as not applicable by the signals: publisher identity
15
+ * (the JSON API carries none), install-time hooks (PyPI exposes none in its
16
+ * index; an sdist-only release runs its build backend at install), and
17
+ * per-release dependency lists (one request per release).
18
+ *
19
+ * Privacy: the request names the package and nothing else.
20
+ */
21
+ import { fetchWithTimeout } from "./http-utils.js";
22
+ import { parsePep440 } from "./version-compare.js";
23
+ import { normalizePypiName } from "./upgrade-pypi.js";
24
+ const PYPI_URL = "https://pypi.org/pypi";
25
+ const TIMEOUT_MS = 30_000;
26
+ const TTL_SECONDS = 3600;
27
+ export const PYPI_NOT_APPLICABLE = "not applicable on PyPI: its JSON API carries no publisher identity or install-hook metadata";
28
+ /** The index the signals read, from one JSON API document. */
29
+ export function projectPypiProject(name, doc) {
30
+ const releases = {};
31
+ let createdAt = null;
32
+ for (const [version, rawFiles] of Object.entries(doc.releases ?? {})) {
33
+ const files = Array.isArray(rawFiles) ? rawFiles : [];
34
+ if (files.length === 0 || !parsePep440(version))
35
+ continue;
36
+ const times = files.map((f) => f.upload_time_iso_8601).filter((t) => typeof t === "string").sort();
37
+ const publishedAt = times[0] ?? null;
38
+ if (publishedAt && (!createdAt || publishedAt < createdAt))
39
+ createdAt = publishedAt;
40
+ const yanked = files.every((f) => f.yanked === true);
41
+ const reason = yanked ? files.find((f) => f.yanked_reason)?.yanked_reason ?? null : null;
42
+ releases[version] = {
43
+ version,
44
+ publishedAt,
45
+ trust: 0,
46
+ trustEvidence: PYPI_NOT_APPLICABLE,
47
+ installScripts: null,
48
+ deprecated: reason ? `yanked: ${reason}` : null,
49
+ withdrawn: yanked ? "yanked" : null,
50
+ dependencies: null,
51
+ };
52
+ }
53
+ return { ecosystem: "PyPI", name: doc.info?.name ?? name, createdAt, releases };
54
+ }
55
+ export class PypiReleasesReader {
56
+ cache;
57
+ rateLimiter;
58
+ constructor(cache, rateLimiter) {
59
+ this.cache = cache;
60
+ this.rateLimiter = rateLimiter;
61
+ }
62
+ async getReleases(name) {
63
+ const normalized = normalizePypiName(name);
64
+ const key = `pypi-releases:${normalized}`;
65
+ const hit = this.cache.get(key);
66
+ if (hit)
67
+ return { status: "ok", data: hit };
68
+ if (!this.rateLimiter.consume("pypi")) {
69
+ return { status: "unreachable", detail: "PyPI rate limit reached for this minute" };
70
+ }
71
+ let response;
72
+ try {
73
+ response = await fetchWithTimeout(`${PYPI_URL}/${encodeURIComponent(normalized)}/json`, { headers: { Accept: "application/json" } }, TIMEOUT_MS);
74
+ }
75
+ catch (err) {
76
+ return { status: "unreachable", detail: `PyPI: ${errorText(err)}` };
77
+ }
78
+ if (response.status === 404)
79
+ return { status: "not_found", detail: `"${name}" is not on PyPI` };
80
+ if (!response.ok)
81
+ return { status: "unreachable", detail: `PyPI answered HTTP ${response.status}` };
82
+ let doc;
83
+ try {
84
+ doc = (await response.json());
85
+ }
86
+ catch (err) {
87
+ return { status: "unreachable", detail: `PyPI: unreadable project document (${errorText(err)})` };
88
+ }
89
+ if (!doc.releases || typeof doc.releases !== "object") {
90
+ return { status: "unreachable", detail: `PyPI returned no release list for ${name}` };
91
+ }
92
+ const index = projectPypiProject(name, doc);
93
+ this.cache.set(key, index, "pypi", TTL_SECONDS);
94
+ return { status: "ok", data: index };
95
+ }
96
+ async getCreatedAt(name) {
97
+ const lookup = await this.getReleases(name);
98
+ return lookup.status === "ok" ? { status: "ok", data: lookup.data.createdAt } : lookup;
99
+ }
100
+ }
101
+ function errorText(err) {
102
+ return err instanceof Error ? err.message : String(err);
103
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Does a declared requirement admit a version? npm ranges (package.json,
3
+ * package-lock `dependencies`, yarn) and Cargo version requirements.
4
+ *
5
+ * Used to tell "the parent already allows the fixed version, so a lockfile
6
+ * refresh is enough" from "waiting on upstream". The planner labelled
7
+ * minimist 1.2.5, braces 3.0.2 and mio 0.8.0 "waiting on upstream" although
8
+ * every parent's range admitted the fix, and a refresh fixed each one
9
+ * (fix-path oracle, 2026-10-10).
10
+ *
11
+ * Unknown syntax (git and file specs, `npm:` aliases, dist-tags) yields null,
12
+ * never a guess.
13
+ */
14
+ /**
15
+ * Whether an npm range admits `version`. Null when the range is not a
16
+ * registry semver range (git, file, link, workspace, alias, tag) or the
17
+ * version is unreadable.
18
+ */
19
+ export declare function npmRangeAdmits(range: string, version: string): boolean | null;
20
+ /** Whether a Cargo version requirement ("0.8", "^1.2", ">=0.10, <0.11", "=1.0.5") admits `version`. Null when unreadable. */
21
+ export declare function cargoReqAdmits(req: string, version: string): boolean | null;
@@ -0,0 +1,190 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Does a declared requirement admit a version? npm ranges (package.json,
4
+ * package-lock `dependencies`, yarn) and Cargo version requirements.
5
+ *
6
+ * Used to tell "the parent already allows the fixed version, so a lockfile
7
+ * refresh is enough" from "waiting on upstream". The planner labelled
8
+ * minimist 1.2.5, braces 3.0.2 and mio 0.8.0 "waiting on upstream" although
9
+ * every parent's range admitted the fix, and a refresh fixed each one
10
+ * (fix-path oracle, 2026-10-10).
11
+ *
12
+ * Unknown syntax (git and file specs, `npm:` aliases, dist-tags) yields null,
13
+ * never a guess.
14
+ */
15
+ import { comparePrecedence, parseSemverPrecedence } from "./semver-precedence.js";
16
+ const WILD = /^[xX*]$/;
17
+ function parsePartial(raw) {
18
+ const text = raw.trim().replace(/^v/, "").replace(/\+.*$/, "");
19
+ if (text === "" || WILD.test(text))
20
+ return { major: null, minor: null, patch: null, prerelease: [] };
21
+ const m = /^(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(text);
22
+ if (!m)
23
+ return null;
24
+ const num = (s) => (s === undefined || WILD.test(s) ? null : Number(s));
25
+ const major = num(m[1]);
26
+ const minor = major === null ? null : num(m[2]);
27
+ const patch = minor === null ? null : num(m[3]);
28
+ return { major, minor, patch, prerelease: m[4] && patch !== null ? m[4].split(".") : [] };
29
+ }
30
+ const sv = (major, minor, patch, prerelease = []) => ({ major, minor, patch, prerelease });
31
+ /** The lowest version of the next line: the `-0` prerelease, so 2.0.0-beta is excluded by "<2.0.0-0". */
32
+ const below = (major, minor, patch) => ({ op: "<", v: sv(major, minor, patch, ["0"]) });
33
+ function floorOf(p) {
34
+ return sv(p.major ?? 0, p.minor ?? 0, p.patch ?? 0, p.prerelease);
35
+ }
36
+ function caret(p) {
37
+ if (p.major === null)
38
+ return [];
39
+ const lo = { op: ">=", v: floorOf(p) };
40
+ if (p.major > 0)
41
+ return [lo, below(p.major + 1, 0, 0)];
42
+ if (p.minor === null)
43
+ return [lo, below(1, 0, 0)];
44
+ if (p.minor > 0)
45
+ return [lo, below(0, p.minor + 1, 0)];
46
+ if (p.patch === null)
47
+ return [lo, below(0, 1, 0)];
48
+ return [lo, below(0, 0, p.patch + 1)];
49
+ }
50
+ function tilde(p) {
51
+ if (p.major === null)
52
+ return [];
53
+ const lo = { op: ">=", v: floorOf(p) };
54
+ if (p.minor === null)
55
+ return [lo, below(p.major + 1, 0, 0)];
56
+ return [lo, below(p.major, p.minor + 1, 0)];
57
+ }
58
+ /** An x-range or partial version as a range: "1.2" = >=1.2.0 <1.3.0-0. */
59
+ function xrange(p) {
60
+ if (p.major === null)
61
+ return [];
62
+ if (p.minor === null)
63
+ return [{ op: ">=", v: sv(p.major, 0, 0) }, below(p.major + 1, 0, 0)];
64
+ if (p.patch === null)
65
+ return [{ op: ">=", v: sv(p.major, p.minor, 0) }, below(p.major, p.minor + 1, 0)];
66
+ return [{ op: "=", v: floorOf(p) }];
67
+ }
68
+ function primitive(op, p) {
69
+ if (op === "=")
70
+ return xrange(p);
71
+ if (p.major === null)
72
+ return op === "<" || op === ">" ? null : [];
73
+ const full = p.patch !== null;
74
+ if (full)
75
+ return [{ op, v: floorOf(p) }];
76
+ // Partial bounds widen or narrow to the whole line, as node-semver does.
77
+ const next = p.minor === null ? sv(p.major + 1, 0, 0) : sv(p.major, p.minor + 1, 0);
78
+ switch (op) {
79
+ case ">=":
80
+ return [{ op: ">=", v: floorOf(p) }];
81
+ case ">":
82
+ return [{ op: ">=", v: next }];
83
+ case "<":
84
+ return [{ op: "<", v: sv(p.major, p.minor ?? 0, 0, ["0"]) }];
85
+ case "<=":
86
+ return [{ op: "<", v: { ...next, prerelease: ["0"] } }];
87
+ }
88
+ }
89
+ /** One comparator token: "^1.2", ">=1.0.0", "~0.3", "1.x". `bareIsCaret` = Cargo's reading of "1.2.3". */
90
+ function comparatorSet(token, bareIsCaret) {
91
+ const m = /^(\^|~>?|>=|<=|>|<|=)?\s*(.*)$/.exec(token.trim());
92
+ if (!m)
93
+ return null;
94
+ const p = parsePartial(m[2]);
95
+ if (!p)
96
+ return null;
97
+ switch (m[1]) {
98
+ case "^":
99
+ return caret(p);
100
+ case "~":
101
+ case "~>":
102
+ return tilde(p);
103
+ case ">=":
104
+ case "<=":
105
+ case ">":
106
+ case "<":
107
+ return primitive(m[1], p);
108
+ case "=":
109
+ return xrange(p);
110
+ default:
111
+ return bareIsCaret && p.major !== null ? caret(p) : xrange(p);
112
+ }
113
+ }
114
+ function test(set, version) {
115
+ for (const c of set) {
116
+ const d = comparePrecedence(version, c.v);
117
+ const ok = c.op === ">=" ? d >= 0 : c.op === ">" ? d > 0 : c.op === "<" ? d < 0 : c.op === "<=" ? d <= 0 : d === 0;
118
+ if (!ok)
119
+ return false;
120
+ }
121
+ if (version.prerelease.length === 0)
122
+ return true;
123
+ // A prerelease satisfies only a set that names a prerelease of the same
124
+ // MAJOR.MINOR.PATCH (node-semver and Cargo agree on this).
125
+ return set.some((c) => c.v.prerelease.length > 0 &&
126
+ !(c.v.prerelease.length === 1 && c.v.prerelease[0] === "0" && c.op === "<") &&
127
+ c.v.major === version.major &&
128
+ c.v.minor === version.minor &&
129
+ c.v.patch === version.patch);
130
+ }
131
+ /**
132
+ * Whether an npm range admits `version`. Null when the range is not a
133
+ * registry semver range (git, file, link, workspace, alias, tag) or the
134
+ * version is unreadable.
135
+ */
136
+ export function npmRangeAdmits(range, version) {
137
+ const v = parseSemverPrecedence(version);
138
+ if (!v)
139
+ return null;
140
+ // yarn berry writes "npm:^1.2.0"; an alias ("npm:other@^1") names another package.
141
+ const text = range.trim().replace(/^npm:(?=[\^~<>=\dvxX*\s]|$)/, "");
142
+ // Protocols (git+https:, file:, link:, workspace:, npm:alias@), paths, GitHub shorthand.
143
+ if (/^[a-z][a-z+]*:/i.test(text) || text.includes("/"))
144
+ return null;
145
+ // A dist-tag ("latest", "next", "beta").
146
+ if (/^[a-z][\w.-]*$/i.test(text) && !WILD.test(text))
147
+ return null;
148
+ for (const alt of text.split("||")) {
149
+ const part = alt.trim();
150
+ let set = [];
151
+ const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(part);
152
+ if (hyphen) {
153
+ const lo = parsePartial(hyphen[1]);
154
+ const hi = parsePartial(hyphen[2]);
155
+ if (!lo || !hi)
156
+ return null;
157
+ set.push({ op: ">=", v: floorOf(lo) });
158
+ const upper = hi.patch !== null ? [{ op: "<=", v: floorOf(hi) }] : primitive("<=", hi);
159
+ if (!upper)
160
+ return null;
161
+ set.push(...upper);
162
+ }
163
+ else {
164
+ // ">= 1.2.3" with a space after the operator is one comparator.
165
+ for (const token of part.replace(/([<>=~^])\s+/g, "$1").split(/\s+/).filter(Boolean)) {
166
+ const c = comparatorSet(token, false);
167
+ if (c === null)
168
+ return null;
169
+ set = set.concat(c);
170
+ }
171
+ }
172
+ if (test(set, v))
173
+ return true;
174
+ }
175
+ return false;
176
+ }
177
+ /** Whether a Cargo version requirement ("0.8", "^1.2", ">=0.10, <0.11", "=1.0.5") admits `version`. Null when unreadable. */
178
+ export function cargoReqAdmits(req, version) {
179
+ const v = parseSemverPrecedence(version);
180
+ if (!v)
181
+ return null;
182
+ let set = [];
183
+ for (const token of req.split(",").map((t) => t.trim()).filter(Boolean)) {
184
+ const c = comparatorSet(token, true);
185
+ if (c === null)
186
+ return null;
187
+ set = set.concat(c);
188
+ }
189
+ return test(set, v);
190
+ }
@@ -12,6 +12,12 @@ export declare const DEFAULT_RATE_LIMITS: {
12
12
  osv: {
13
13
  maxPerMinute: number;
14
14
  };
15
+ "osv-package": {
16
+ maxPerMinute: number;
17
+ };
18
+ "fixpath-registry": {
19
+ maxPerMinute: number;
20
+ };
15
21
  hn: {
16
22
  maxPerMinute: number;
17
23
  };
@@ -47,6 +47,11 @@ export class RateLimiter {
47
47
  }
48
48
  export const DEFAULT_RATE_LIMITS = {
49
49
  osv: { maxPerMinute: 10 },
50
+ // Per-package advisory lists for fix-path checks (/v1/query), one request per vulnerable package.
51
+ "osv-package": { maxPerMinute: 300 },
52
+ // Fix-path lookups on registry CDNs (crates.io sparse index, abbreviated npm
53
+ // documents): separate from the API buckets so a plan never starves health checks.
54
+ "fixpath-registry": { maxPerMinute: 600 },
50
55
  hn: { maxPerMinute: 30 },
51
56
  npm: { maxPerMinute: 120 },
52
57
  crates: { maxPerMinute: 50 },
@@ -10,6 +10,7 @@ function unavailable(dep, fetchError) {
10
10
  latestVersion: null, latestStableVersion: null, versionsBehind: null,
11
11
  deprecated: false, deprecationMessage: null, lastPublished: null,
12
12
  license: null, weeklyDownloads: null, isDev: dep.isDev, fetchError,
13
+ ...(dep.declaredRange ? { declaredRange: dep.declaredRange } : {}),
13
14
  };
14
15
  }
15
16
  export async function fetchRegistryHealthFor(deps, registries, enabled) {
@@ -70,5 +71,6 @@ function restampRegistryContext(info, dep) {
70
71
  currentVersion: dep.version,
71
72
  isDev: dep.isDev,
72
73
  versionsBehind: dep.version && latest ? computeSemverDistance(dep.version, latest) : null,
74
+ ...(dep.declaredRange ? { declaredRange: dep.declaredRange } : {}),
73
75
  };
74
76
  }