@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
@@ -19,62 +19,212 @@ import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import { emptySource, found, InstanceSet } from "./lockfile-types.js";
21
21
  // =============================================================================
22
- // Python: poetry.lock > uv.lock > Pipfile.lock > requirements.txt
22
+ // Python: every lockfile and requirements file in the directory, merged
23
23
  // =============================================================================
24
24
  /** PEP 503 normalised name: lowercase, runs of `-`, `_`, `.` become one `-`. */
25
25
  export function normalizePythonName(name) {
26
26
  return name.trim().toLowerCase().replace(/[-_.]+/g, "-");
27
27
  }
28
+ /** The TOML lockfiles: arrays of [[package]] tables. */
29
+ const PYTHON_TOML_LOCKS = ["poetry.lock", "uv.lock", "pdm.lock"];
30
+ /**
31
+ * Requirement files in a project directory: `*requirements*.txt` beside it
32
+ * (requirements-dev.txt, dev_requirements.txt, _test_minimum_requirements.txt)
33
+ * and every `.txt` in a `requirements/` folder (pip-compile-multi: superset
34
+ * keeps its 100 runtime pins in requirements/base.txt, which nothing read).
35
+ */
36
+ export function requirementFiles(cwd) {
37
+ const out = [];
38
+ try {
39
+ for (const name of fs.readdirSync(cwd).sort()) {
40
+ if (/requirements[\w.-]*\.txt$/i.test(name) || includesRequirements(path.join(cwd, name)))
41
+ out.push(path.join(cwd, name));
42
+ }
43
+ }
44
+ catch {
45
+ return out;
46
+ }
47
+ try {
48
+ for (const name of fs.readdirSync(path.join(cwd, "requirements")).sort()) {
49
+ if (/\.txt$/i.test(name))
50
+ out.push(path.join(cwd, "requirements", name));
51
+ }
52
+ }
53
+ catch {
54
+ // no requirements/ folder
55
+ }
56
+ return out;
57
+ }
58
+ /** Above this a .txt is not read to decide whether it is a requirements file. */
59
+ const MAX_SNIFF_BYTES = 256 * 1024;
60
+ /**
61
+ * A `.txt` whose name does not say "requirements" but that includes one with
62
+ * `-r` and pins packages itself: CTFd's development.txt (`-r requirements.txt`,
63
+ * then pytest==5.4.2 and 21 more pins, 6 advisories pip-audit reports).
64
+ */
65
+ function includesRequirements(file) {
66
+ if (!/\.txt$/i.test(file) || /requirements[\w.-]*\.txt$/i.test(file))
67
+ return false;
68
+ try {
69
+ const stat = fs.statSync(file);
70
+ if (!stat.isFile() || stat.size > MAX_SNIFF_BYTES)
71
+ return false;
72
+ const content = fs.readFileSync(file, "utf-8");
73
+ return /^\s*(?:-r|--requirement)[\s=]+\S+\.txt\s*$/m.test(content) && parseRequirements(content).length > 0;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /**
80
+ * Whether a requirements file is dev-only by name: requirements-dev.txt,
81
+ * dev-requirements.txt, requirements/testing.txt, _test_minimum_requirements.txt,
82
+ * docs-requirements.txt. The convention is near universal, but it is a name:
83
+ * a package any other file also lists keeps an unknown scope, never "dev".
84
+ */
85
+ export function devRequirementsFile(file) {
86
+ const name = path.basename(file).replace(/\.txt$/i, "");
87
+ return /(?:^|[-_.])(?:dev|devel|develop|development|test|tests|testing|lint|linting|docs?|ci|typing|mypy|bench|benchmarks?)(?:[-_.]|$)/i.test(name);
88
+ }
89
+ /**
90
+ * Every Python source in a directory, merged: poetry.lock, uv.lock, pdm.lock,
91
+ * Pipfile.lock AND the requirement files. One source per ecosystem dropped
92
+ * whatever sat beside the first: langchain's libs/community has a
93
+ * poetry.lock and _test_minimum_requirements.txt, and only the lock was read.
94
+ * The version a direct dependency resolves to comes from a lockfile first.
95
+ */
28
96
  export function resolvePython(cwd) {
29
- // poetry.lock and uv.lock are TOML arrays of [[package]] tables.
30
- for (const file of ["poetry.lock", "uv.lock"]) {
97
+ const versions = new Map();
98
+ const instances = new InstanceSet();
99
+ const sources = [];
100
+ let lockfile = false;
101
+ const take = (name, version, dev) => {
102
+ instances.add(name, version, dev);
103
+ if (!versions.has(name))
104
+ versions.set(name, version);
105
+ };
106
+ for (const file of PYTHON_TOML_LOCKS) {
31
107
  const lockPath = path.join(cwd, file);
32
108
  if (!fs.existsSync(lockPath))
33
109
  continue;
34
110
  try {
35
111
  const read = readTomlPackageLock(fs.readFileSync(lockPath, "utf-8"));
36
- if (read.instances.size > 0)
37
- return found(read.versions, read.instances, lockPath, "lockfile");
112
+ if (read.instances.size === 0)
113
+ continue;
114
+ for (const i of read.instances.toArray())
115
+ take(i.name, i.version, i.dev);
116
+ sources.push(lockPath);
117
+ lockfile = true;
38
118
  }
39
- catch { /* fall through */ }
119
+ catch { /* unreadable: the next source */ }
40
120
  }
41
121
  const pipfileLock = path.join(cwd, "Pipfile.lock");
42
122
  if (fs.existsSync(pipfileLock)) {
43
123
  try {
44
124
  const lock = JSON.parse(fs.readFileSync(pipfileLock, "utf-8"));
45
- const versions = new Map();
46
- const instances = new InstanceSet();
125
+ let any = false;
47
126
  for (const [section, dev] of [["default", false], ["develop", true]]) {
48
127
  for (const [name, info] of Object.entries(lock[section] ?? {})) {
49
128
  const version = info.version?.replace(/^===?/, "");
50
129
  if (!version)
51
130
  continue;
52
- const normalized = normalizePythonName(name);
53
- instances.add(normalized, version, dev);
54
- if (!versions.has(normalized))
55
- versions.set(normalized, version);
131
+ take(normalizePythonName(name), version, dev);
132
+ any = true;
56
133
  }
57
134
  }
58
- if (instances.size > 0)
59
- return found(versions, instances, pipfileLock, "lockfile");
135
+ if (any) {
136
+ sources.push(pipfileLock);
137
+ lockfile = true;
138
+ }
60
139
  }
61
- catch { /* fall through */ }
140
+ catch { /* unreadable */ }
62
141
  }
63
- const reqPath = path.join(cwd, "requirements.txt");
64
- if (fs.existsSync(reqPath)) {
65
- const versions = new Map();
66
- const instances = new InstanceSet();
67
- try {
68
- for (const { name, version } of parseRequirements(fs.readFileSync(reqPath, "utf-8"))) {
69
- instances.add(name, version);
70
- if (!versions.has(name))
71
- versions.set(name, version);
72
- }
142
+ for (const file of requirementFiles(cwd)) {
143
+ const dev = devRequirementsFile(file) ? true : undefined;
144
+ const pins = readRequirementsFile(file);
145
+ if (pins === null)
146
+ continue;
147
+ for (const { name, version } of pins)
148
+ take(name, version, dev);
149
+ sources.push(file);
150
+ }
151
+ if (sources.length === 0)
152
+ return emptySource();
153
+ return found(versions, instances, sources[0], lockfile ? "lockfile" : "manifest", { extraSources: sources.slice(1) });
154
+ }
155
+ /**
156
+ * Exact pins from one requirements file, following `-r`/`--requirement`
157
+ * includes (relative to the file) and applying `-c`/`--constraint` files:
158
+ * a constraints file installs nothing by itself, so its pins only give a
159
+ * version to a name the requirements list without one. Null when unreadable.
160
+ */
161
+ export function readRequirementsFile(file, visited = new Set()) {
162
+ const key = path.resolve(file).toLowerCase();
163
+ if (visited.has(key))
164
+ return [];
165
+ visited.add(key);
166
+ let content;
167
+ try {
168
+ content = fs.readFileSync(file, "utf-8");
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ const dir = path.dirname(file);
174
+ const out = parseRequirements(content);
175
+ const constraints = new Map();
176
+ for (const { option, target } of requirementOptions(content)) {
177
+ const included = path.resolve(dir, target);
178
+ if (option === "r")
179
+ out.push(...(readRequirementsFile(included, visited) ?? []));
180
+ else
181
+ for (const pin of parseRequirements(safeRead(included)))
182
+ constraints.set(pin.name, pin.version);
183
+ }
184
+ if (constraints.size > 0) {
185
+ const pinned = new Set(out.map((p) => p.name));
186
+ for (const name of unpinnedRequirementNames(content)) {
187
+ const version = constraints.get(name);
188
+ if (version && !pinned.has(name))
189
+ out.push({ name, version });
73
190
  }
74
- catch { /* skip */ }
75
- return found(versions, instances, reqPath, "manifest");
76
191
  }
77
- return emptySource();
192
+ return out;
193
+ }
194
+ function safeRead(file) {
195
+ try {
196
+ return fs.readFileSync(file, "utf-8");
197
+ }
198
+ catch {
199
+ return "";
200
+ }
201
+ }
202
+ /** `-r file` / `--requirement=file` / `-c file` / `--constraint file` lines (local paths only). */
203
+ function requirementOptions(content) {
204
+ const out = [];
205
+ for (const raw of content.replace(/\\\r?\n/g, " ").split(/\r?\n/)) {
206
+ const m = /^\s*(?:-(r|c)|--(requirement|constraint))(?:\s*=\s*|\s+)(\S+)/.exec(raw);
207
+ if (!m || /^[a-z][a-z+]*:\/\//i.test(m[3]))
208
+ continue;
209
+ out.push({ option: (m[1] ?? (m[2] === "requirement" ? "r" : "c")), target: m[3] });
210
+ }
211
+ return out;
212
+ }
213
+ /** Names a requirements file lists with no exact pin ("flask", "flask>=2"), PEP 503 normalised. */
214
+ function unpinnedRequirementNames(content) {
215
+ const out = [];
216
+ for (const raw of content.replace(/\\\r?\n/g, " ").split(/\r?\n/)) {
217
+ const line = raw.replace(/(^|\s)#.*$/, "").trim();
218
+ if (!line || line.startsWith("-"))
219
+ continue;
220
+ const spec = line.split(";")[0].trim();
221
+ if (/===?/.test(spec))
222
+ continue;
223
+ const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(spec);
224
+ if (m)
225
+ out.push(normalizePythonName(m[1]));
226
+ }
227
+ return out;
78
228
  }
79
229
  /**
80
230
  * [[package]] tables from poetry.lock (any version) or uv.lock. A pre-1.2
@@ -25,7 +25,10 @@
25
25
  import { InstanceSet, type VersionSource } from "./lockfile-types.js";
26
26
  import type { OsvEcosystem } from "./types.js";
27
27
  export type { PackageInstance, VersionSource } from "./lockfile-types.js";
28
- /** Every lock file the resolver may read for this ecosystem, present or not, in priority order. */
28
+ /**
29
+ * Every lock file the resolver may read for this ecosystem, present or not,
30
+ * in priority order; for Python also every requirements file present now.
31
+ */
29
32
  export declare function lockfileCandidates(cwd: string, ecosystem: OsvEcosystem): string[];
30
33
  /** The manifest the resolver falls back to for this ecosystem, or null when it has none. */
31
34
  export declare function manifestCandidate(cwd: string, ecosystem: OsvEcosystem): string | null;
@@ -27,22 +27,25 @@ import * as fs from "node:fs";
27
27
  import * as path from "node:path";
28
28
  import { readBunLock } from "./bun-lockfile.js";
29
29
  import { readPnpmLock, readYarnLock } from "./js-lockfile-readers.js";
30
- import { resolveGo, resolvePython } from "./lockfile-parsers-pygo.js";
30
+ import { requirementFiles, resolveGo, resolvePython } from "./lockfile-parsers-pygo.js";
31
31
  import { emptySource, found, InstanceSet } from "./lockfile-types.js";
32
32
  const LOCKFILES = {
33
33
  npm: ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock"],
34
34
  "crates.io": ["Cargo.lock"],
35
- PyPI: ["poetry.lock", "uv.lock", "Pipfile.lock"],
35
+ PyPI: ["poetry.lock", "uv.lock", "pdm.lock", "Pipfile.lock"],
36
36
  Go: ["go.mod", "go.sum"],
37
37
  };
38
38
  const MANIFESTS = {
39
39
  npm: "package.json",
40
40
  "crates.io": "Cargo.toml",
41
- PyPI: "requirements.txt",
42
41
  };
43
- /** Every lock file the resolver may read for this ecosystem, present or not, in priority order. */
42
+ /**
43
+ * Every lock file the resolver may read for this ecosystem, present or not,
44
+ * in priority order; for Python also every requirements file present now.
45
+ */
44
46
  export function lockfileCandidates(cwd, ecosystem) {
45
- return (LOCKFILES[ecosystem] ?? []).map((name) => path.join(cwd, name));
47
+ const fixed = (LOCKFILES[ecosystem] ?? []).map((name) => path.join(cwd, name));
48
+ return ecosystem === "PyPI" ? [...fixed, ...requirementFiles(cwd)] : fixed;
46
49
  }
47
50
  /** The manifest the resolver falls back to for this ecosystem, or null when it has none. */
48
51
  export function manifestCandidate(cwd, ecosystem) {
@@ -67,6 +70,30 @@ export function resolveVersionSource(cwd, ecosystem) {
67
70
  function manifestInstances(versions) {
68
71
  return [...versions].map(([name, version]) => ({ name, version }));
69
72
  }
73
+ /**
74
+ * A manifest read with no lockfile beside it. Only an exact pin names the
75
+ * version that gets installed; any other requirement ("^1.2.0", "0.8") is
76
+ * kept in `ranges`, and its floor in `versions` is a label, not an install.
77
+ * nushell's samples/wasm/Cargo.toml (no Cargo.lock) was scanned as if its
78
+ * floors were installed and produced 2 false findings (2026-10-10).
79
+ */
80
+ function declaredRanges(declared, exact, file) {
81
+ const versions = new Map();
82
+ const ranges = new Map();
83
+ for (const [name, spec] of declared) {
84
+ // "0.3" and "1" are ranges too: their floor is 0.3.0 / 1.0.0.
85
+ const partial = /(\d+)(?:\.(\d+))?/.exec(spec);
86
+ const version = extractVersionFromSpec(spec) ?? (partial ? `${partial[1]}.${partial[2] ?? "0"}.0` : null);
87
+ if (!version)
88
+ continue;
89
+ versions.set(name, version);
90
+ if (!exact(spec))
91
+ ranges.set(name, spec);
92
+ }
93
+ // Only exact pins are installs; ranged names are not instances at all.
94
+ const pinned = new Map([...versions].filter(([name]) => !ranges.has(name)));
95
+ return found(versions, manifestInstances(pinned), file, "declared_ranges", { ranges });
96
+ }
70
97
  // =============================================================================
71
98
  // npm: package-lock.json > npm-shrinkwrap.json > pnpm-lock.yaml > yarn.lock > bun.lock > package.json ranges
72
99
  // =============================================================================
@@ -125,21 +152,20 @@ function resolveNpm(cwd) {
125
152
  }
126
153
  catch { /* fall through */ }
127
154
  }
128
- // Fallback: extract version floors from package.json ranges
155
+ // No lockfile: package.json declares ranges, not installed versions.
129
156
  const pkgPath = path.join(cwd, "package.json");
130
157
  if (fs.existsSync(pkgPath)) {
131
- const versions = new Map();
158
+ const declared = new Map();
132
159
  try {
133
160
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
134
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
135
- for (const [name, spec] of Object.entries(allDeps)) {
136
- const version = extractVersionFromSpec(spec);
137
- if (version)
138
- versions.set(name, version);
161
+ for (const [name, spec] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {
162
+ if (typeof spec === "string")
163
+ declared.set(name, spec);
139
164
  }
140
165
  }
141
166
  catch { /* skip */ }
142
- return found(versions, manifestInstances(versions), pkgPath, "manifest");
167
+ // npm installs exactly "1.2.3" (or "=1.2.3"); anything else is a range.
168
+ return declaredRanges(declared, (spec) => /^=?v?\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(spec.trim()), pkgPath);
143
169
  }
144
170
  return emptySource();
145
171
  }
@@ -259,25 +285,32 @@ function resolveRust(cwd) {
259
285
  return found(versions, instances, lockPath, "lockfile");
260
286
  }
261
287
  function resolveRustFromManifest(cwd) {
262
- const versions = new Map();
288
+ const declared = new Map();
263
289
  const cargoPath = path.join(cwd, "Cargo.toml");
264
290
  if (!fs.existsSync(cargoPath))
265
291
  return emptySource();
266
292
  try {
267
- const content = fs.readFileSync(cargoPath, "utf-8");
268
- // name = "1.0" or name = { version = "1.0", ... }
269
- const depRegex = /^([a-zA-Z_][\w-]*)\s*=\s*(?:"([^"]+)"|.*?version\s*=\s*"([^"]+)")/gm;
270
- let match;
271
- while ((match = depRegex.exec(content)) !== null) {
272
- const name = match[1];
273
- const version = match[2] || match[3];
274
- if (version && /^\d/.test(version)) {
275
- versions.set(name, version);
293
+ // name = "1.0" or name = { version = "1.0", ... }, inside dependency tables
294
+ // only: [package]'s own `version = "0.1.0"` and `edition = "2021"` were read
295
+ // as crates named "version" and "edition".
296
+ let inDeps = false;
297
+ for (const line of fs.readFileSync(cargoPath, "utf-8").split(/\r?\n/)) {
298
+ const header = /^\s*\[([^\]]+)\]\s*$/.exec(line);
299
+ if (header) {
300
+ inDeps = /(?:^|\.)(?:dev-|build-)?dependencies$/.test(header[1].trim());
301
+ continue;
276
302
  }
303
+ if (!inDeps)
304
+ continue;
305
+ const match = /^\s*([a-zA-Z_][\w-]*)\s*=\s*(?:"([^"]+)"|.*?\bversion\s*=\s*"([^"]+)")/.exec(line);
306
+ const spec = match?.[2] ?? match?.[3];
307
+ if (match && spec && /^[=^~]?\s*\d/.test(spec))
308
+ declared.set(match[1], spec);
277
309
  }
278
310
  }
279
311
  catch { /* skip */ }
280
- return found(versions, manifestInstances(versions), cargoPath, "manifest");
312
+ // Cargo reads "1.0" as "^1.0": only "=1.2.3" names one version.
313
+ return declaredRanges(declared, (spec) => /^=\s*\d+\.\d+\.\d+/.test(spec.trim()), cargoPath);
281
314
  }
282
315
  // =============================================================================
283
316
  // Helpers
@@ -16,9 +16,18 @@ export interface VersionSource {
16
16
  instances: PackageInstance[];
17
17
  /** Absolute path of the file the versions were read from, or null when none exists. */
18
18
  source: string | null;
19
- /** `lockfile` = exact versions; `manifest` = read from a manifest (pins or specifier floors). */
20
- kind: "lockfile" | "manifest" | null;
19
+ /**
20
+ * `lockfile` = exact versions; `manifest` = exact pins from a requirements
21
+ * file; `declared_ranges` = a manifest with no lockfile (package.json,
22
+ * Cargo.toml): its versions are the floors of declared ranges, not installs.
23
+ */
24
+ kind: SourceKind | null;
25
+ /** Every further file read for this ecosystem in this directory (Python reads several). */
26
+ extraSources?: string[];
27
+ /** declared_ranges: the declared requirement per name ("^1.2.0"), for names whose version is only a floor. */
28
+ ranges?: Map<string, string>;
21
29
  }
30
+ export type SourceKind = "lockfile" | "manifest" | "declared_ranges";
22
31
  export declare function emptySource(): VersionSource;
23
32
  /**
24
33
  * Collects instances deduplicated by name@version. A copy seen as runtime
@@ -31,4 +40,4 @@ export declare class InstanceSet {
31
40
  get size(): number;
32
41
  toArray(): PackageInstance[];
33
42
  }
34
- export declare function found(versions: Map<string, string>, instances: InstanceSet | PackageInstance[], source: string, kind: "lockfile" | "manifest"): VersionSource;
43
+ export declare function found(versions: Map<string, string>, instances: InstanceSet | PackageInstance[], source: string, kind: SourceKind, extra?: Pick<VersionSource, "extraSources" | "ranges">): VersionSource;
@@ -32,11 +32,12 @@ export class InstanceSet {
32
32
  return [...this.byKey.values()];
33
33
  }
34
34
  }
35
- export function found(versions, instances, source, kind) {
35
+ export function found(versions, instances, source, kind, extra = {}) {
36
36
  return {
37
37
  versions,
38
38
  instances: Array.isArray(instances) ? instances : instances.toArray(),
39
39
  source,
40
40
  kind,
41
+ ...extra,
41
42
  };
42
43
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * OSV affected ranges, read as version spans, and the smallest version that
3
+ * is clean against EVERY known advisory of a package.
4
+ *
5
+ * A fix target used to be the highest per-advisory fix among the advisories
6
+ * that affect the INSTALLED version. That target can itself be vulnerable to
7
+ * an advisory the installed version predates: openssl 0.10.38 was sent to
8
+ * 0.10.79, which GHSA-phqj-4mhp-q6mq affects (0.10.50 up to 0.10.80), so the
9
+ * recommended upgrade swapped one advisory for another (fix-path oracle,
10
+ * 2026-10-10). The target is now checked against all of the package's
11
+ * advisories, not only the ones the installed version has.
12
+ */
13
+ import type { OsvVulnerability } from "./types.js";
14
+ /**
15
+ * One affected span: [introduced, fixed), or [introduced, lastAffected]
16
+ * when OSV gives a `last_affected` event; both null = still open.
17
+ */
18
+ export interface AffectedSpan {
19
+ introduced: string;
20
+ fixed: string | null;
21
+ lastAffected: string | null;
22
+ }
23
+ /** What one advisory says about one package: its spans plus any versions it lists explicitly. */
24
+ export interface AdvisoryRanges {
25
+ id: string;
26
+ spans: AffectedSpan[];
27
+ versions: string[];
28
+ }
29
+ /**
30
+ * Flatten the matching package's version ranges into spans. OSV sorts events
31
+ * within a range; each `introduced` opens a span and the next `fixed` or
32
+ * `last_affected` closes it. GIT ranges hold commit hashes, not versions,
33
+ * and are skipped.
34
+ */
35
+ export declare function collectAffectedSpans(affected: OsvVulnerability["affected"] | undefined, packageName: string, ecosystem: string): AffectedSpan[];
36
+ /** One advisory's ranges for one package; null when the advisory is withdrawn. */
37
+ export declare function advisoryRanges(vuln: OsvVulnerability, packageName: string, ecosystem: string): AdvisoryRanges | null;
38
+ /** True when `version` lies in a span (or the explicit list) of this advisory. */
39
+ export declare function advisoryAffects(advisory: AdvisoryRanges, version: string, ecosystem: string): boolean;
40
+ /** Whether a version is a prerelease in its ecosystem's own terms. */
41
+ export declare function isPrereleaseVersion(version: string, ecosystem: string): boolean;
42
+ export interface CleanTarget {
43
+ /** The smallest candidate at or above every per-advisory fix that no fixable advisory affects; null when none is clean. */
44
+ target: string | null;
45
+ /** Where the candidates came from. */
46
+ basis: "registry" | "advisory_fixes";
47
+ /** The highest per-advisory fix. */
48
+ floor: string;
49
+ /** Advisories that affect the floor (why the target can be above it). */
50
+ floorAffectedBy: string[];
51
+ /**
52
+ * Advisories that still affect the returned target (only unfixable ones:
53
+ * every candidate has them), or, when `target` is null, the floor.
54
+ */
55
+ stillAffectedBy: string[];
56
+ /**
57
+ * Advisories no release fixes: they affect the newest candidate too
58
+ * (braces GHSA-vfj7-8cjw-p6xm, last_affected 3.0.3 = the newest release).
59
+ * No target can clear them, so they do not disqualify one; they are
60
+ * reported instead.
61
+ */
62
+ unfixable: string[];
63
+ }
64
+ /**
65
+ * The smallest version at or above the highest per-advisory fix that none
66
+ * of the package's fixable advisories affects.
67
+ *
68
+ * Candidates are the package's published releases when known (prereleases
69
+ * only when the floor is one), otherwise every fix event the advisories
70
+ * name. An advisory that affects the newest candidate has no fixed release
71
+ * and does not disqualify a target (it is listed in `unfixable`). Null when
72
+ * `fixes` is empty.
73
+ */
74
+ export declare function smallestCleanVersion(input: {
75
+ ecosystem: string;
76
+ fixes: string[];
77
+ advisories: AdvisoryRanges[];
78
+ published: string[] | null;
79
+ }): CleanTarget | null;
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * OSV affected ranges, read as version spans, and the smallest version that
4
+ * is clean against EVERY known advisory of a package.
5
+ *
6
+ * A fix target used to be the highest per-advisory fix among the advisories
7
+ * that affect the INSTALLED version. That target can itself be vulnerable to
8
+ * an advisory the installed version predates: openssl 0.10.38 was sent to
9
+ * 0.10.79, which GHSA-phqj-4mhp-q6mq affects (0.10.50 up to 0.10.80), so the
10
+ * recommended upgrade swapped one advisory for another (fix-path oracle,
11
+ * 2026-10-10). The target is now checked against all of the package's
12
+ * advisories, not only the ones the installed version has.
13
+ */
14
+ import { compareVersions, isComparable, parsePep440, samePackageName } from "./version-compare.js";
15
+ import { parseSemverPrecedence } from "./semver-precedence.js";
16
+ /**
17
+ * Flatten the matching package's version ranges into spans. OSV sorts events
18
+ * within a range; each `introduced` opens a span and the next `fixed` or
19
+ * `last_affected` closes it. GIT ranges hold commit hashes, not versions,
20
+ * and are skipped.
21
+ */
22
+ export function collectAffectedSpans(affected, packageName, ecosystem) {
23
+ const spans = [];
24
+ for (const a of affected ?? []) {
25
+ // Names compare the registry's way: PEP 503 for PyPI (`Jinja2` is
26
+ // `jinja2`), `-`/`_` folding for crates.
27
+ if (a.package.ecosystem !== ecosystem || !samePackageName(a.package.name, packageName, ecosystem))
28
+ continue;
29
+ for (const range of a.ranges || []) {
30
+ if (range.type === "GIT")
31
+ continue;
32
+ let open = null;
33
+ for (const event of range.events || []) {
34
+ if (event.introduced !== undefined) {
35
+ if (open)
36
+ spans.push(open);
37
+ open = { introduced: event.introduced, fixed: null, lastAffected: null };
38
+ }
39
+ else if (event.fixed !== undefined || event.last_affected !== undefined) {
40
+ const close = { fixed: event.fixed ?? null, lastAffected: event.fixed === undefined ? (event.last_affected ?? null) : null };
41
+ if (open) {
42
+ spans.push({ ...open, ...close });
43
+ open = null;
44
+ }
45
+ else {
46
+ // A closing event with no preceding `introduced`: affected since
47
+ // inception ("0" = OSV's since-the-beginning sentinel).
48
+ spans.push({ introduced: "0", ...close });
49
+ }
50
+ }
51
+ }
52
+ if (open)
53
+ spans.push(open);
54
+ }
55
+ }
56
+ return spans;
57
+ }
58
+ /** Versions an advisory lists explicitly for this package (`affected[].versions`). */
59
+ function listedVersions(affected, packageName, ecosystem) {
60
+ const out = [];
61
+ for (const a of affected ?? []) {
62
+ if (a.package.ecosystem !== ecosystem || !samePackageName(a.package.name, packageName, ecosystem))
63
+ continue;
64
+ out.push(...(a.versions ?? []));
65
+ }
66
+ return out;
67
+ }
68
+ /** One advisory's ranges for one package; null when the advisory is withdrawn. */
69
+ export function advisoryRanges(vuln, packageName, ecosystem) {
70
+ if (vuln.withdrawn)
71
+ return null;
72
+ return {
73
+ id: vuln.id,
74
+ spans: collectAffectedSpans(vuln.affected, packageName, ecosystem),
75
+ versions: listedVersions(vuln.affected, packageName, ecosystem),
76
+ };
77
+ }
78
+ /** True when `version` lies in a span (or the explicit list) of this advisory. */
79
+ export function advisoryAffects(advisory, version, ecosystem) {
80
+ if (advisory.versions.includes(version))
81
+ return true;
82
+ const cmp = (a, b) => compareVersions(a, b, ecosystem);
83
+ return advisory.spans.some((s) => cmp(version, s.introduced) >= 0 &&
84
+ (s.fixed !== null ? cmp(version, s.fixed) < 0 : s.lastAffected !== null ? cmp(version, s.lastAffected) <= 0 : true));
85
+ }
86
+ /** Whether a version is a prerelease in its ecosystem's own terms. */
87
+ export function isPrereleaseVersion(version, ecosystem) {
88
+ if (ecosystem === "PyPI") {
89
+ const p = parsePep440(version);
90
+ return p ? p.pre !== null || p.dev !== null : false;
91
+ }
92
+ const p = parseSemverPrecedence(version.replace(/^v(?=\d)/, ""));
93
+ return p ? p.prerelease.length > 0 : false;
94
+ }
95
+ /**
96
+ * The smallest version at or above the highest per-advisory fix that none
97
+ * of the package's fixable advisories affects.
98
+ *
99
+ * Candidates are the package's published releases when known (prereleases
100
+ * only when the floor is one), otherwise every fix event the advisories
101
+ * name. An advisory that affects the newest candidate has no fixed release
102
+ * and does not disqualify a target (it is listed in `unfixable`). Null when
103
+ * `fixes` is empty.
104
+ */
105
+ export function smallestCleanVersion(input) {
106
+ const { ecosystem } = input;
107
+ const fixes = input.fixes.filter((f) => isComparable(f, ecosystem));
108
+ if (fixes.length === 0)
109
+ return null;
110
+ const cmp = (a, b) => compareVersions(a, b, ecosystem);
111
+ const floor = fixes.reduce((max, v) => (cmp(v, max) > 0 ? v : max), fixes[0]);
112
+ const allowPre = isPrereleaseVersion(floor, ecosystem);
113
+ const fromRegistry = input.published !== null && input.published.length > 0;
114
+ const pool = fromRegistry
115
+ ? input.published
116
+ : [floor, ...input.advisories.flatMap((a) => a.spans.map((s) => s.fixed).filter((f) => f !== null))];
117
+ const candidates = [...new Set(pool)]
118
+ .filter((v) => isComparable(v, ecosystem) && cmp(v, floor) >= 0 && (allowPre || !isPrereleaseVersion(v, ecosystem)))
119
+ .sort(cmp);
120
+ const basis = fromRegistry ? "registry" : "advisory_fixes";
121
+ const newest = candidates[candidates.length - 1];
122
+ const unfixable = newest ? input.advisories.filter((a) => advisoryAffects(a, newest, ecosystem)) : [];
123
+ const fixable = input.advisories.filter((a) => !unfixable.includes(a));
124
+ const unfixableIds = [...new Set(unfixable.map((a) => a.id))];
125
+ const affecting = (v) => [...new Set(input.advisories.filter((a) => advisoryAffects(a, v, ecosystem)).map((a) => a.id))];
126
+ // Clean against everything if any candidate is; otherwise against every advisory a release fixes.
127
+ for (const pool of [input.advisories, fixable]) {
128
+ const candidate = candidates.find((v) => !pool.some((a) => advisoryAffects(a, v, ecosystem)));
129
+ if (candidate)
130
+ return { target: candidate, basis, floor, floorAffectedBy: affecting(floor), stillAffectedBy: affecting(candidate), unfixable: unfixableIds };
131
+ }
132
+ return { target: null, basis, floor, floorAffectedBy: affecting(floor), stillAffectedBy: affecting(floor), unfixable: unfixableIds };
133
+ }
@@ -22,6 +22,7 @@ export declare class OsvScanner {
22
22
  * real advisory is never silently dropped just because its detail 404'd.
23
23
  */
24
24
  private hydrateDetails;
25
+ /** One advisory's record; retried once after a throttle, a server error or a network failure. Null when it cannot be had. */
25
26
  private fetchDetail;
26
27
  }
27
28
  /**