@4da/mcp-server 6.0.1 → 6.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +316 -0
  2. package/README.md +19 -13
  3. package/dist/db.d.ts +88 -7
  4. package/dist/db.js +248 -63
  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 +54 -7
  10. package/dist/live/cache.d.ts +31 -2
  11. package/dist/live/cache.js +113 -1
  12. package/dist/live/changelog-classify.js +4 -1
  13. package/dist/live/changelog.d.ts +15 -0
  14. package/dist/live/changelog.js +67 -11
  15. package/dist/live/dev-scope.d.ts +2 -2
  16. package/dist/live/fix-paths.d.ts +118 -0
  17. package/dist/live/fix-paths.js +498 -0
  18. package/dist/live/go-registry.d.ts +5 -0
  19. package/dist/live/go-registry.js +1 -1
  20. package/dist/live/index.d.ts +30 -3
  21. package/dist/live/index.js +59 -6
  22. package/dist/live/lockfile-parents.d.ts +61 -0
  23. package/dist/live/lockfile-parents.js +360 -0
  24. package/dist/live/lockfile-parsers-pygo.d.ts +31 -0
  25. package/dist/live/lockfile-parsers-pygo.js +178 -28
  26. package/dist/live/lockfile-parsers.d.ts +4 -1
  27. package/dist/live/lockfile-parsers.js +57 -24
  28. package/dist/live/lockfile-types.d.ts +12 -3
  29. package/dist/live/lockfile-types.js +2 -1
  30. package/dist/live/osv-ranges.d.ts +79 -0
  31. package/dist/live/osv-ranges.js +133 -0
  32. package/dist/live/osv-scanner.d.ts +1 -0
  33. package/dist/live/osv-scanner.js +85 -78
  34. package/dist/live/range-satisfies.d.ts +21 -0
  35. package/dist/live/range-satisfies.js +190 -0
  36. package/dist/live/rate-limiter.d.ts +6 -0
  37. package/dist/live/rate-limiter.js +5 -0
  38. package/dist/live/registry-health.js +2 -0
  39. package/dist/live/resolution.d.ts +11 -1
  40. package/dist/live/resolution.js +16 -6
  41. package/dist/live/semver-utils.d.ts +8 -0
  42. package/dist/live/semver-utils.js +10 -2
  43. package/dist/live/types.d.ts +17 -1
  44. package/dist/live/upgrade-sources.d.ts +2 -0
  45. package/dist/live/upgrade-sources.js +7 -2
  46. package/dist/memory-store.d.ts +19 -0
  47. package/dist/memory-store.js +8 -0
  48. package/dist/native-bindings.d.ts +7 -2
  49. package/dist/native-bindings.js +4 -0
  50. package/dist/project-tree.d.ts +50 -8
  51. package/dist/project-tree.js +121 -29
  52. package/dist/schema-registry.js +2 -2
  53. package/dist/setup.d.ts +76 -3
  54. package/dist/setup.js +313 -130
  55. package/dist/sqlite-driver.d.ts +134 -0
  56. package/dist/sqlite-driver.js +359 -0
  57. package/dist/tool-dispatch.d.ts +10 -1
  58. package/dist/tool-dispatch.js +37 -2
  59. package/dist/tools/agent-memory.js +10 -2
  60. package/dist/tools/app-plan.d.ts +11 -1
  61. package/dist/tools/app-plan.js +32 -6
  62. package/dist/tools/briefing-task-scope.d.ts +21 -2
  63. package/dist/tools/briefing-task-scope.js +97 -4
  64. package/dist/tools/decision-memory.js +9 -3
  65. package/dist/tools/dependency-check-signals.d.ts +7 -0
  66. package/dist/tools/dependency-check-signals.js +17 -2
  67. package/dist/tools/dependency-check.d.ts +1 -1
  68. package/dist/tools/dependency-check.js +1 -1
  69. package/dist/tools/dependency-health.d.ts +1 -1
  70. package/dist/tools/dependency-health.js +5 -0
  71. package/dist/tools/knowledge-gap-ranges.d.ts +1 -1
  72. package/dist/tools/knowledge-gap-ranges.js +1 -1
  73. package/dist/tools/knowledge-gap-scope.d.ts +2 -2
  74. package/dist/tools/project-scope.d.ts +45 -0
  75. package/dist/tools/project-scope.js +119 -0
  76. package/dist/tools/record-feedback.js +1 -1
  77. package/dist/tools/upgrade-impact-literals.js +1 -1
  78. package/dist/tools/upgrade-impact-report.d.ts +10 -0
  79. package/dist/tools/upgrade-impact-report.js +32 -2
  80. package/dist/tools/upgrade-impact.js +1 -1
  81. package/dist/tools/upgrade-planner-fixpaths.d.ts +40 -0
  82. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  83. package/dist/tools/upgrade-planner.d.ts +31 -3
  84. package/dist/tools/upgrade-planner.js +74 -19
  85. package/dist/tools/vulnerability-scan-concise.d.ts +13 -1
  86. package/dist/tools/vulnerability-scan-concise.js +15 -3
  87. package/dist/tools/vulnerability-scan-format.d.ts +58 -1
  88. package/dist/tools/vulnerability-scan-format.js +107 -11
  89. package/dist/tools/vulnerability-scan.d.ts +104 -13
  90. package/dist/tools/vulnerability-scan.js +27 -3
  91. package/dist/tools/what-should-i-know.d.ts +22 -2
  92. package/dist/tools/what-should-i-know.js +121 -11
  93. package/package.json +10 -31
@@ -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
- }
@@ -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
  }
@@ -33,8 +33,18 @@ export interface GroupResolution {
33
33
  /** The full lockfile set plus drift entries (vulnerability scanning). */
34
34
  audit: ResolvedDependency[];
35
35
  drift: InstallDriftRecord[];
36
- /** The file the versions were read from, or null when the group resolved from nothing. */
36
+ /** The (first) file the versions were read from, or null when the group resolved from nothing. */
37
37
  source: ResolutionSourceRecord | null;
38
+ /** Every file read, `source` first (Python merges its lockfiles and requirement files). */
39
+ sources: ResolutionSourceRecord[];
40
+ /**
41
+ * Set when no lockfile pins this group's versions: the manifest and how many
42
+ * of its dependencies are declared ranges, which are not sent to OSV.
43
+ */
44
+ unresolved: {
45
+ manifest: string;
46
+ ranges: number;
47
+ } | null;
38
48
  signatures: Map<string, FileSignature>;
39
49
  resolvedAt: string;
40
50
  }
@@ -30,11 +30,20 @@ export function resolveGroup(group) {
30
30
  ...(manifest ? [manifest] : []),
31
31
  ]);
32
32
  const read = resolveVersionSource(group.dir, ecosystem);
33
+ const readFiles = read.source ? [read.source, ...(read.extraSources ?? [])] : [];
33
34
  // The manifest only matters when the resolver fell back to it; editing a
34
35
  // script in package.json must not throw away a scan of an unchanged lockfile.
35
- if (manifest && read.kind === "lockfile")
36
+ if (manifest && !readFiles.includes(manifest))
36
37
  signatures.delete(manifest);
37
38
  let resolved = resolveVersionsFrom(group.dir, group.deps, group.devDeps, ecosystem, read.versions, group.targets);
39
+ // No lockfile: a ranged dependency's version is the range's floor, a label.
40
+ const ranges = read.kind === "declared_ranges" ? read.ranges ?? new Map() : null;
41
+ if (ranges && ranges.size > 0) {
42
+ resolved = resolved.map((dep) => {
43
+ const declaredRange = ranges.get(dep.name);
44
+ return declaredRange ? { ...dep, declaredRange } : dep;
45
+ });
46
+ }
38
47
  let drift = [];
39
48
  let driftAudit = [];
40
49
  // Only a lockfile pins exact versions; comparing node_modules against a
@@ -50,11 +59,12 @@ export function resolveGroup(group) {
50
59
  const audit = [
51
60
  ...resolveAuditVersionsFrom(group.dir, group.deps, group.devDeps, ecosystem, read.versions, group.targets, resolved, read.instances),
52
61
  ...driftAudit,
53
- ];
54
- const source = read.source && read.kind
55
- ? { path: read.source, kind: read.kind, mtimeMs: signatureMtime(signatures.get(read.source)) }
56
- : null;
57
- return { group, resolved, audit, drift, source, signatures, resolvedAt };
62
+ ].filter((dep) => !dep.declaredRange);
63
+ const sources = read.kind
64
+ ? readFiles.map((file) => ({ path: file, kind: read.kind, mtimeMs: signatureMtime(signatures.get(file)) }))
65
+ : [];
66
+ const unresolved = ranges && ranges.size > 0 && read.source ? { manifest: read.source, ranges: ranges.size } : null;
67
+ return { group, resolved, audit, drift, source: sources[0] ?? null, sources, unresolved, signatures, resolvedAt };
58
68
  }
59
69
  /** True when any file the resolution depended on changed, appeared, or vanished. */
60
70
  export function groupIsStale(result) {
@@ -1,5 +1,13 @@
1
1
  import type { SemverDistance } from "./types.js";
2
2
  export declare function parseSemver(version: string): [number, number, number] | null;
3
+ /**
4
+ * How far `current` is behind `latest`. `label` is the kind of bump, by the
5
+ * caret rules npm and Cargo apply: below 1.0 a minor bump is breaking (^0.8.6
6
+ * does not admit 0.9.0) and below 0.1 a patch bump is too (^0.0.3 is =0.0.3),
7
+ * so both are labelled "major", the same as upgrade_impact and dependency_check.
8
+ * Until 6.0.2 sqlx 0.8 -> 0.9 and reqwest 0.12 -> 0.13 were labelled "minor".
9
+ * The numeric fields stay literal (0.8.6 -> 0.9.0 is major 0, minor 1).
10
+ */
3
11
  export declare function computeSemverDistance(current: string, latest: string): SemverDistance | null;
4
12
  export declare function isPreRelease(version: string): boolean;
5
13
  /**
@@ -5,6 +5,14 @@ export function parseSemver(version) {
5
5
  return null;
6
6
  return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
7
7
  }
8
+ /**
9
+ * How far `current` is behind `latest`. `label` is the kind of bump, by the
10
+ * caret rules npm and Cargo apply: below 1.0 a minor bump is breaking (^0.8.6
11
+ * does not admit 0.9.0) and below 0.1 a patch bump is too (^0.0.3 is =0.0.3),
12
+ * so both are labelled "major", the same as upgrade_impact and dependency_check.
13
+ * Until 6.0.2 sqlx 0.8 -> 0.9 and reqwest 0.12 -> 0.13 were labelled "minor".
14
+ * The numeric fields stay literal (0.8.6 -> 0.9.0 is major 0, minor 1).
15
+ */
8
16
  export function computeSemverDistance(current, latest) {
9
17
  const c = parseSemver(current);
10
18
  const l = parseSemver(latest);
@@ -17,9 +25,9 @@ export function computeSemverDistance(current, latest) {
17
25
  if (major > 0)
18
26
  label = "major";
19
27
  else if (minor > 0)
20
- label = "minor";
28
+ label = c[0] === 0 ? "major" : "minor";
21
29
  else if (patch > 0)
22
- label = "patch";
30
+ label = c[0] === 0 && c[1] === 0 ? "major" : "patch";
23
31
  return { major: Math.max(0, major), minor: Math.max(0, minor), patch: Math.max(0, patch), label };
24
32
  }
25
33
  export function isPreRelease(version) {
@@ -37,6 +37,12 @@ export interface ResolvedDependency {
37
37
  installDriftOf?: string;
38
38
  /** On drift entries: the command that reinstalls from the lockfile. */
39
39
  installFix?: InstallFixCommand;
40
+ /**
41
+ * Set when no lockfile pins this dependency: the requirement the manifest
42
+ * declares ("^1.2.0"). `version` is then that range's floor, NOT an
43
+ * installed version, and the dependency is never sent to OSV as if it were.
44
+ */
45
+ declaredRange?: string;
40
46
  }
41
47
  /**
42
48
  * One npm direct dependency whose installed copy differs from its lockfile.
@@ -54,7 +60,8 @@ export interface InstallDriftRecord {
54
60
  export interface ResolutionSourceRecord {
55
61
  /** Absolute path. */
56
62
  path: string;
57
- kind: "lockfile" | "manifest";
63
+ /** lockfile = exact versions; manifest = exact requirement pins; declared_ranges = no lockfile, range floors only. */
64
+ kind: "lockfile" | "manifest" | "declared_ranges";
58
65
  /** Modification time when it was read, or null if it vanished since. */
59
66
  mtimeMs: number | null;
60
67
  }
@@ -88,8 +95,12 @@ export interface OsvVulnerability {
88
95
  events: Array<{
89
96
  introduced?: string;
90
97
  fixed?: string;
98
+ last_affected?: string;
99
+ limit?: string;
91
100
  }>;
92
101
  }>;
102
+ /** Versions listed explicitly, in addition to (or instead of) the ranges. */
103
+ versions?: string[];
93
104
  }>;
94
105
  references?: Array<{
95
106
  type: string;
@@ -191,6 +202,11 @@ export interface RegistryPackageInfo {
191
202
  weeklyDownloads: number | null;
192
203
  isDev: boolean;
193
204
  fetchError: string | null;
205
+ /**
206
+ * No lockfile: the declared requirement ("^1.2.0"); `currentVersion` is then
207
+ * its floor, not an installed version.
208
+ */
209
+ declaredRange?: string;
194
210
  /**
195
211
  * The version node_modules actually holds, present only when it differs
196
212
  * from `currentVersion` (the lockfile's). Several differing installs across
@@ -59,6 +59,8 @@ export declare function isValidPackageName(name: string, ecosystem: UpgradeEcosy
59
59
  export declare function getRegistryIndex(net: UpgradeNet, ecosystem: UpgradeEcosystem, name: string): Promise<RegistryIndex | null>;
60
60
  /** The registry archive URL for one version, or null when it is not on the package's own registry. */
61
61
  export declare function archiveUrl(index: RegistryIndex, version: PublishedVersion): string | null;
62
+ /** Cache key of one version's parsed changelog; it carries CHANGELOG_PARSER_VERSION. */
63
+ export declare function changelogCacheKey(ecosystem: string, name: string, version: string): string;
62
64
  /** Parsed changelog shipped inside one version's archive (cached 7 days per version). */
63
65
  export declare function getChangelog(net: UpgradeNet, index: RegistryIndex, version: PublishedVersion): Promise<ChangelogResult>;
64
66
  /** OSV advisory ids affecting one version, or null when OSV could not be reached. */