@4da/mcp-server 6.2.4 → 6.2.5

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 +484 -0
  2. package/README.md +22 -6
  3. package/dist/db.d.ts +38 -0
  4. package/dist/db.js +78 -23
  5. package/dist/doctor.js +6 -3
  6. package/dist/embeddings.d.ts +1 -1
  7. package/dist/embeddings.js +16 -14
  8. package/dist/index.js +73 -103
  9. package/dist/live/advisory-groups.d.ts +68 -0
  10. package/dist/live/advisory-groups.js +131 -0
  11. package/dist/live/cache.js +11 -3
  12. package/dist/live/cargo-platform.d.ts +6 -0
  13. package/dist/live/cargo-platform.js +8 -2
  14. package/dist/live/changelog.js +38 -6
  15. package/dist/live/dependency-set.d.ts +9 -1
  16. package/dist/live/dependency-set.js +10 -2
  17. package/dist/live/fix-paths.d.ts +258 -9
  18. package/dist/live/fix-paths.js +790 -45
  19. package/dist/live/github-releases.js +3 -0
  20. package/dist/live/held-dependencies.d.ts +93 -0
  21. package/dist/live/held-dependencies.js +212 -0
  22. package/dist/live/held-targets.d.ts +30 -0
  23. package/dist/live/held-targets.js +107 -0
  24. package/dist/live/http-utils.js +13 -3
  25. package/dist/live/index.d.ts +7 -4
  26. package/dist/live/index.js +24 -19
  27. package/dist/live/install-state.js +6 -2
  28. package/dist/live/js-lockfile-readers.d.ts +8 -1
  29. package/dist/live/js-lockfile-readers.js +43 -6
  30. package/dist/live/lockfile-parents.d.ts +15 -0
  31. package/dist/live/lockfile-parents.js +85 -10
  32. package/dist/live/lockfile-parsers-pygo.d.ts +47 -2
  33. package/dist/live/lockfile-parsers-pygo.js +230 -12
  34. package/dist/live/lockfile-parsers-rbphp.d.ts +38 -0
  35. package/dist/live/lockfile-parsers-rbphp.js +123 -0
  36. package/dist/live/lockfile-parsers.d.ts +6 -0
  37. package/dist/live/lockfile-parsers.js +116 -4
  38. package/dist/live/lockfile-types.d.ts +28 -1
  39. package/dist/live/maintenance.d.ts +20 -4
  40. package/dist/live/maintenance.js +31 -2
  41. package/dist/live/malware.d.ts +20 -0
  42. package/dist/live/malware.js +31 -0
  43. package/dist/live/npm-packument.d.ts +4 -1
  44. package/dist/live/npm-packument.js +5 -2
  45. package/dist/live/offered-release.d.ts +53 -0
  46. package/dist/live/offered-release.js +117 -0
  47. package/dist/live/osv-ranges.d.ts +111 -0
  48. package/dist/live/osv-ranges.js +173 -14
  49. package/dist/live/osv-scanner.d.ts +87 -5
  50. package/dist/live/osv-scanner.js +310 -59
  51. package/dist/live/pinned-advisories.d.ts +2 -2
  52. package/dist/live/pinned-advisories.js +3 -3
  53. package/dist/live/pypi-advice.d.ts +36 -0
  54. package/dist/live/pypi-advice.js +158 -0
  55. package/dist/live/rate-limiter.d.ts +7 -0
  56. package/dist/live/rate-limiter.js +22 -1
  57. package/dist/live/release-metadata.d.ts +5 -0
  58. package/dist/live/resolution.d.ts +3 -0
  59. package/dist/live/resolution.js +6 -3
  60. package/dist/live/scan-coverage.d.ts +26 -0
  61. package/dist/live/scan-coverage.js +70 -0
  62. package/dist/live/semver-utils.d.ts +8 -0
  63. package/dist/live/semver-utils.js +26 -3
  64. package/dist/live/severity-scope.d.ts +26 -1
  65. package/dist/live/severity-scope.js +32 -0
  66. package/dist/live/shell-safe.d.ts +19 -0
  67. package/dist/live/shell-safe.js +36 -0
  68. package/dist/live/types.d.ts +134 -8
  69. package/dist/live/version-compare.d.ts +49 -0
  70. package/dist/live/version-compare.js +131 -0
  71. package/dist/live/withdrawn-releases.d.ts +50 -0
  72. package/dist/live/withdrawn-releases.js +125 -0
  73. package/dist/project-manifests.d.ts +7 -0
  74. package/dist/project-manifests.js +45 -5
  75. package/dist/project-scanner.js +6 -4
  76. package/dist/project-tree.d.ts +56 -0
  77. package/dist/project-tree.js +174 -5
  78. package/dist/schema-registry.d.ts +47 -4
  79. package/dist/schema-registry.js +76 -9
  80. package/dist/server-instructions.js +4 -2
  81. package/dist/setup.d.ts +8 -0
  82. package/dist/setup.js +21 -2
  83. package/dist/tool-dispatch.js +3 -3
  84. package/dist/tools/agent-memory.d.ts +5 -0
  85. package/dist/tools/agent-memory.js +9 -0
  86. package/dist/tools/briefing-task-scope.js +5 -2
  87. package/dist/tools/decision-memory.js +2 -0
  88. package/dist/tools/dependency-check-held.d.ts +44 -0
  89. package/dist/tools/dependency-check-held.js +98 -0
  90. package/dist/tools/dependency-check-signals.d.ts +19 -2
  91. package/dist/tools/dependency-check-signals.js +84 -13
  92. package/dist/tools/dependency-check-versions.d.ts +37 -4
  93. package/dist/tools/dependency-check-versions.js +48 -13
  94. package/dist/tools/dependency-check.d.ts +11 -1
  95. package/dist/tools/dependency-check.js +81 -29
  96. package/dist/tools/dependency-health.js +209 -45
  97. package/dist/tools/get-context.d.ts +3 -0
  98. package/dist/tools/get-context.js +4 -0
  99. package/dist/tools/project-scope.d.ts +8 -0
  100. package/dist/tools/project-scope.js +21 -0
  101. package/dist/tools/upgrade-impact-features.d.ts +37 -0
  102. package/dist/tools/upgrade-impact-features.js +176 -0
  103. package/dist/tools/upgrade-impact-literals.js +26 -4
  104. package/dist/tools/upgrade-impact-report.d.ts +5 -1
  105. package/dist/tools/upgrade-impact-report.js +26 -5
  106. package/dist/tools/upgrade-impact.js +10 -3
  107. package/dist/tools/upgrade-planner-fixpaths.d.ts +162 -5
  108. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  109. package/dist/tools/upgrade-planner.d.ts +59 -4
  110. package/dist/tools/upgrade-planner.js +237 -61
  111. package/dist/tools/vulnerability-scan-concise.d.ts +47 -2
  112. package/dist/tools/vulnerability-scan-concise.js +37 -3
  113. package/dist/tools/vulnerability-scan-format.d.ts +126 -24
  114. package/dist/tools/vulnerability-scan-format.js +193 -26
  115. package/dist/tools/vulnerability-scan.d.ts +224 -63
  116. package/dist/tools/vulnerability-scan.js +50 -15
  117. package/dist/tools/what-should-i-know.d.ts +6 -1
  118. package/dist/tools/what-should-i-know.js +72 -15
  119. package/package.json +2 -2
@@ -23,28 +23,37 @@
23
23
  * package's own registry; package names and versions only. Every lookup is
24
24
  * cached, and any failure degrades to the old answer, never to a guess.
25
25
  */
26
+ import { isShellSafeCargoTarget, isShellSafeName, isShellSafeVersion } from "./shell-safe.js";
26
27
  import { fetchWithTimeout } from "./http-utils.js";
27
28
  import { escapeModulePath } from "./go-registry.js";
28
- import { advisoryRanges, advisoryAffects, isPrereleaseVersion, smallestCleanVersion } from "./osv-ranges.js";
29
+ import { advisoryRanges, advisoryAffects, installableFix, remainsText, isPrereleaseVersion, smallestCleanVersion } from "./osv-ranges.js";
29
30
  import * as fs from "node:fs";
30
31
  import * as path from "node:path";
31
32
  import { lockfileFormat, readLockfileParents } from "./lockfile-parents.js";
32
33
  import { parseCargoLockPackages } from "./lockfile-parsers.js";
33
- import { cargoReqAdmits } from "./range-satisfies.js";
34
+ import { compiledRequirements, confinedInclude, requirementOptions } from "./lockfile-parsers-pygo.js";
35
+ import { cargoReqAdmits, npmRangeAdmits } from "./range-satisfies.js";
34
36
  import { refreshRule, requirementAdmits } from "./refresh-rule.js";
35
- import { compareVersions } from "./version-compare.js";
37
+ import { compareVersions, maxVersion } from "./version-compare.js";
36
38
  import { CRATES_USER_AGENT } from "./crates-versions.js";
39
+ import { isNpmDeprecated, npmExcludedVersions, sayWithdrawn, withdrawalText, withheldNote } from "./withdrawn-releases.js";
40
+ import { heldSpecsOf, heldText } from "./held-dependencies.js";
41
+ import { withHeldTargets } from "./held-targets.js";
37
42
  const TIMEOUT_MS = 15_000;
38
43
  const ADVISORY_TTL = 6 * 3600;
39
44
  const VERSIONS_TTL = 6 * 3600;
40
45
  /** A published version's manifest never changes. */
41
46
  const VERSION_DEPS_TTL = 30 * 86_400;
42
47
  const MAX_OSV_PAGES = 10;
43
- /** Bump when the shape of anything this module caches changes. */
44
- const CACHE_VERSION = 3;
48
+ /** Bump when the shape of anything this module caches changes (10: npm facts carry `heldSpecs`, audit 6.2.5 D-4). */
49
+ const CACHE_VERSION = 10;
50
+ /** How long, and how many, npm documents are kept in memory between the lookups of one plan. */
51
+ const NPM_DOC_MEMORY_MS = 120_000;
52
+ const NPM_DOCS_KEPT = 16;
45
53
  export class FixPathSources {
46
54
  cache;
47
55
  rateLimiter;
56
+ npmDocs = new Map();
48
57
  constructor(cache, rateLimiter) {
49
58
  this.cache = cache;
50
59
  this.rateLimiter = rateLimiter;
@@ -91,11 +100,11 @@ export class FixPathSources {
91
100
  async publishedVersions(ecosystem, name) {
92
101
  switch (ecosystem) {
93
102
  case "npm":
94
- return this.npmVersions(name);
103
+ return (await this.npmFacts(name))?.versions ?? null;
95
104
  case "crates.io":
96
105
  return (await this.cratesSparse(name))?.versions ?? null;
97
106
  case "PyPI":
98
- return this.pypiVersions(name);
107
+ return (await this.pypiFacts(name))?.versions ?? null;
99
108
  case "Go":
100
109
  return this.goVersions(name);
101
110
  default:
@@ -125,7 +134,9 @@ export class FixPathSources {
125
134
  if (!res.ok)
126
135
  return null;
127
136
  const doc = (await res.json());
128
- deps = { ...doc.peerDependencies, ...doc.optionalDependencies, ...doc.dependencies };
137
+ // Not peerDependencies, as in the 4DA app: a peer is resolved from whoever provides it
138
+ // higher in the tree, so the parent's peer range does not decide which copy a refresh picks.
139
+ deps = { ...doc.optionalDependencies, ...doc.dependencies };
129
140
  this.cache.set(key, deps, "npm", VERSION_DEPS_TTL);
130
141
  }
131
142
  catch {
@@ -134,26 +145,145 @@ export class FixPathSources {
134
145
  }
135
146
  return typeof deps[dep] === "string" ? [deps[dep]] : [];
136
147
  }
137
- async npmVersions(name) {
138
- const key = `fixpath:v${CACHE_VERSION}:npm-versions:${name}`;
148
+ /**
149
+ * What every published release of `parent` requires of `dep`, from the
150
+ * registry: version -> requirements (`reqs: []` when that release does not
151
+ * depend on it). npm reads dependencies and optionalDependencies of the
152
+ * abbreviated document (as `parentRequirements`), and, when neither holds
153
+ * it, peerDependencies, flagged `peer`: npm 7+ installs a missing peer, so
154
+ * moving a package into peerDependencies is not dropping it (readiness
155
+ * review 2026-10-17, finding 10). crates.io: the sparse index. Null when
156
+ * unknown or unsupported. Used to find the smallest parent release that
157
+ * admits a clean version (audit H1).
158
+ */
159
+ async requirementHistory(ecosystem, parent, dep) {
160
+ if (ecosystem === "crates.io") {
161
+ const sparse = await this.cratesSparse(parent);
162
+ if (!sparse)
163
+ return null;
164
+ return Object.fromEntries(Object.entries(sparse.reqs).map(([v, deps]) => [v, { reqs: (deps[dep] ?? []).map((d) => d.req), peer: false }]));
165
+ }
166
+ if (ecosystem !== "npm")
167
+ return null;
168
+ const key = `fixpath:v${CACHE_VERSION}:npm-requirement-history:${parent}:${dep}`;
139
169
  const hit = this.cache.get(key);
140
170
  if (hit)
141
171
  return hit;
142
- if (!this.rateLimiter.consume("fixpath-registry"))
172
+ const doc = await this.npmDocument(parent);
173
+ if (!doc)
143
174
  return null;
144
- try {
145
- // The abbreviated document: versions without READMEs.
146
- const res = await fetchWithTimeout(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { headers: { Accept: "application/vnd.npm.install-v1+json" } }, TIMEOUT_MS);
147
- if (!res.ok)
175
+ const out = {};
176
+ for (const [version, m] of Object.entries(doc.versions ?? {})) {
177
+ const req = m?.dependencies?.[dep] ?? m?.optionalDependencies?.[dep];
178
+ const peer = m?.peerDependencies?.[dep];
179
+ out[version] =
180
+ typeof req === "string" ? { reqs: [req], peer: false } : typeof peer === "string" ? { reqs: [peer], peer: true } : { reqs: [], peer: false };
181
+ }
182
+ this.cache.set(key, out, "npm", VERSIONS_TTL);
183
+ return out;
184
+ }
185
+ /**
186
+ * One npm abbreviated document per package, shared by the release facts and the requirement
187
+ * history of a plan (a parent's document was downloaded twice: NodeGoat's mongodb, ~600
188
+ * releases). Kept in memory for a short while, at most a few at a time; a failure is not kept.
189
+ */
190
+ npmDocument(name) {
191
+ const now = Date.now();
192
+ for (const [n, e] of this.npmDocs)
193
+ if (now - e.at > NPM_DOC_MEMORY_MS)
194
+ this.npmDocs.delete(n);
195
+ const hit = this.npmDocs.get(name);
196
+ if (hit)
197
+ return hit.doc;
198
+ if (!this.rateLimiter.consume("fixpath-registry"))
199
+ return Promise.resolve(null);
200
+ const doc = (async () => {
201
+ try {
202
+ const res = await fetchWithTimeout(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { headers: { Accept: "application/vnd.npm.install-v1+json" } }, TIMEOUT_MS);
203
+ if (!res.ok)
204
+ return null;
205
+ return (await res.json());
206
+ }
207
+ catch {
148
208
  return null;
149
- const doc = (await res.json());
150
- const versions = Object.keys(doc.versions ?? {});
151
- this.cache.set(key, versions, "npm", VERSIONS_TTL);
152
- return versions;
209
+ }
210
+ })();
211
+ this.npmDocs.set(name, { at: now, doc });
212
+ void doc.then((d) => {
213
+ if (d === null)
214
+ this.npmDocs.delete(name);
215
+ });
216
+ while (this.npmDocs.size > NPM_DOCS_KEPT)
217
+ this.npmDocs.delete(this.npmDocs.keys().next().value);
218
+ return doc;
219
+ }
220
+ /**
221
+ * Release facts for a parent or a fix target: what is installable, what is
222
+ * deprecated or yanked, and the latest release. Null when the registry
223
+ * could not be read or is not supported.
224
+ */
225
+ async releaseFacts(ecosystem, name) {
226
+ if (ecosystem === "npm")
227
+ return this.npmFacts(name);
228
+ const highest = (versions) => versions.filter((v) => !isPrereleaseVersion(v, ecosystem)).sort((a, b) => compareVersions(a, b, ecosystem)).pop() ?? null;
229
+ if (ecosystem === "crates.io") {
230
+ const sparse = await this.cratesSparse(name);
231
+ if (!sparse)
232
+ return null;
233
+ const yanked = sparse.yanked ?? [];
234
+ const withdrawnWhy = Object.fromEntries(yanked.map((v) => [v, withdrawalText("crates.io", v)]));
235
+ return { versions: sparse.versions, withdrawn: yanked, withdrawnWhy, latest: highest(sparse.versions), deprecatedLatest: null };
153
236
  }
154
- catch {
237
+ if (ecosystem === "PyPI") {
238
+ const facts = await this.pypiFacts(name);
239
+ return facts ? { ...facts, latest: highest(facts.versions), deprecatedLatest: null } : null;
240
+ }
241
+ // Go: `retract` lives in the newest version's go.mod, which is not read (as in the 4DA app).
242
+ const versions = await this.publishedVersions(ecosystem, name);
243
+ if (!versions)
155
244
  return null;
245
+ return { versions, withdrawn: [], withdrawnWhy: {}, latest: highest(versions), deprecatedLatest: null };
246
+ }
247
+ /**
248
+ * npm's abbreviated document: versions without READMEs, with each
249
+ * version's `deprecated` message. A withdrawn release is never offered as a
250
+ * fix target: lodash 4.18.0 ("Bad release. Please use lodash@4.17.21
251
+ * instead.") was recommended by vulnerability_scan (audit B-8, 2026-10-12).
252
+ * Withdrawn means the app's rule (withdrawn-releases.ts): a deprecated
253
+ * release with a later clean one on its caret line. A whole-major notice
254
+ * (glob, tar 6.x) withdraws nothing, or glob 10.4.5 is sent to 12.0.0.
255
+ */
256
+ async npmFacts(name) {
257
+ const key = `fixpath:v${CACHE_VERSION}:npm-facts:${name}`;
258
+ const hit = this.cache.get(key);
259
+ if (hit)
260
+ return hit;
261
+ const doc = await this.npmDocument(name);
262
+ if (!doc)
263
+ return null;
264
+ const versions = doc.versions ?? {};
265
+ const latest = doc["dist-tags"]?.latest ?? null;
266
+ const withdrawn = npmExcludedVersions(Object.fromEntries(Object.entries(versions).map(([v, m]) => [v, m?.deprecated])), latest);
267
+ const out = new Set(withdrawn);
268
+ const message = (v) => (typeof versions[v]?.deprecated === "string" ? versions[v].deprecated : null);
269
+ const noticed = Object.keys(versions).filter((v) => !out.has(v) && isNpmDeprecated(versions[v]?.deprecated));
270
+ const heldSpecs = {};
271
+ for (const [v, m] of Object.entries(versions)) {
272
+ const held = heldSpecsOf(m?.dependencies, m?.optionalDependencies);
273
+ if (Object.keys(held).length > 0)
274
+ heldSpecs[v] = held;
156
275
  }
276
+ const facts = {
277
+ versions: Object.keys(versions).filter((v) => !out.has(v)),
278
+ withdrawn,
279
+ withdrawnWhy: Object.fromEntries(withdrawn.map((v) => [v, withdrawalText("npm", v, message(v))])),
280
+ latest,
281
+ deprecatedLatest: latest && isNpmDeprecated(versions[latest]?.deprecated) ? (message(latest) ?? "deprecated") : null,
282
+ ...(noticed.length > 0 ? { lineNotices: Object.fromEntries(noticed.map((v) => [v, message(v) ?? "deprecated"])) } : {}),
283
+ heldSpecs,
284
+ };
285
+ this.cache.set(key, facts, "npm", VERSIONS_TTL);
286
+ return facts;
157
287
  }
158
288
  /** A crate's sparse-index record: versions and per-version dependency requirements. */
159
289
  async cratesSparse(name) {
@@ -167,7 +297,7 @@ export class FixPathSources {
167
297
  const res = await fetchWithTimeout(sparseIndexUrl(name), { headers: { "User-Agent": CRATES_USER_AGENT } }, TIMEOUT_MS);
168
298
  if (!res.ok)
169
299
  return null;
170
- const out = { versions: [], reqs: {} };
300
+ const out = { versions: [], yanked: [], reqs: {} };
171
301
  for (const line of (await res.text()).split("\n")) {
172
302
  if (!line.trim())
173
303
  continue;
@@ -175,6 +305,8 @@ export class FixPathSources {
175
305
  const entry = JSON.parse(line);
176
306
  if (!entry.yanked)
177
307
  out.versions.push(entry.vers);
308
+ else
309
+ out.yanked.push(entry.vers);
178
310
  const reqs = {};
179
311
  for (const d of entry.deps ?? []) {
180
312
  if (d.kind === "dev")
@@ -195,8 +327,12 @@ export class FixPathSources {
195
327
  return null;
196
328
  }
197
329
  }
198
- async pypiVersions(name) {
199
- const key = `fixpath:v${CACHE_VERSION}:pypi-versions:${name}`;
330
+ /**
331
+ * PyPI's JSON API: every release with files; one whose every file is yanked is withdrawn
332
+ * (PEP 592, the app's `parse_pypi`). A release with no files cannot be installed and is not listed.
333
+ */
334
+ async pypiFacts(name) {
335
+ const key = `fixpath:v${CACHE_VERSION}:pypi-facts:${name}`;
200
336
  const hit = this.cache.get(key);
201
337
  if (hit)
202
338
  return hit;
@@ -207,12 +343,20 @@ export class FixPathSources {
207
343
  if (!res.ok)
208
344
  return null;
209
345
  const doc = (await res.json());
210
- // A release with files, not all of them yanked.
211
- const versions = Object.entries(doc.releases ?? {})
212
- .filter(([, files]) => files.length > 0 && files.some((f) => !f.yanked))
213
- .map(([v]) => v);
214
- this.cache.set(key, versions, "pypi", VERSIONS_TTL);
215
- return versions;
346
+ const out = { versions: [], withdrawn: [], withdrawnWhy: {} };
347
+ for (const [version, files] of Object.entries(doc.releases ?? {})) {
348
+ if (!Array.isArray(files) || files.length === 0)
349
+ continue;
350
+ if (files.every((f) => f.yanked === true)) {
351
+ out.withdrawn.push(version);
352
+ out.withdrawnWhy[version] = withdrawalText("PyPI", version, files.find((f) => f.yanked_reason)?.yanked_reason ?? null);
353
+ }
354
+ else {
355
+ out.versions.push(version);
356
+ }
357
+ }
358
+ this.cache.set(key, out, "pypi", VERSIONS_TTL);
359
+ return out;
216
360
  }
217
361
  catch {
218
362
  return null;
@@ -248,26 +392,165 @@ function sparseIndexUrl(name) {
248
392
  default: return `https://index.crates.io/${n.slice(0, 2)}/${n.slice(2, 4)}/${n}`;
249
393
  }
250
394
  }
251
- /** The minimal clean target for one installed copy, or null when the advisories could not be read. */
252
- export async function cleanFixTarget(sources, ecosystem, name, perAdvisoryFixes) {
253
- if (perAdvisoryFixes.length === 0)
395
+ /**
396
+ * Whether a checked target may be reported as checked against every advisory (fix_checked
397
+ * `all_advisories`): its own advisories were read, and, for npm, what it installs at a fixed
398
+ * version was judged clean (audit 6.2.5 D-4).
399
+ */
400
+ export function targetCheckedAll(clean) {
401
+ return !clean.advisoriesUnread && !clean.heldBlocked && clean.heldUnchecked === undefined;
402
+ }
403
+ /** The words for a target's held dependencies (FixTargetResult.heldFrom / heldBlocked / heldUnchecked), or null when there is nothing to say. */
404
+ export function heldNote(clean) {
405
+ if (clean.heldFrom && clean.heldPast && clean.heldPast.length > 0 && clean.target) {
406
+ const [first, ...rest] = clean.heldPast;
407
+ const names = [...new Set(rest.flatMap((p) => p.affected.map((d) => d.name)))];
408
+ const later = rest.length === 0
409
+ ? ""
410
+ : `; ${rest.length === 1 ? rest[0].version : `${rest.length} later releases (${rest[0].version} to ${rest[rest.length - 1].version})`} still bring${rest.length === 1 ? "s" : ""} in an affected ${names.join(", ")}`;
411
+ return `${heldText(first.version, first.affected)}${later}; so ${clean.target}, the smallest later release on its line whose own advisories and held dependencies are clean`;
412
+ }
413
+ if (clean.heldBlocked && clean.target) {
414
+ const last = clean.heldBlocked.searched[clean.heldBlocked.searched.length - 1];
415
+ const upTo = last && last !== clean.target ? `up to ${last} ` : "";
416
+ return `${heldText(clean.target, clean.heldBlocked.affected)}; no release on its line ${upTo}that its own advisories leave clean clears ${clean.heldBlocked.affected.length === 1 ? "it" : "them"}, so ${clean.target} is NOT a clean fix: confirm a release with dependency_check before pinning it`;
417
+ }
418
+ if (clean.heldUnchecked !== undefined && clean.target) {
419
+ return `whether the dependencies ${clean.target} exact-pins or narrowly ranges are affected was NOT CHECKED (${clean.heldUnchecked}): confirm it with dependency_check before pinning it`;
420
+ }
421
+ return null;
422
+ }
423
+ /**
424
+ * The minimal clean target for one installed copy, or null when neither the
425
+ * advisories nor anything that changes the answer could be read.
426
+ *
427
+ * Advisories unreadable, registry readable (audit H5: OSV /v1/query down):
428
+ * a deprecated or yanked release is still never the target. lodash 4.17.20
429
+ * went to 4.18.0 ("Bad release") because the whole check was skipped; the
430
+ * registry facts alone move it to 4.18.1, flagged `advisoriesUnread`.
431
+ *
432
+ * With no per-advisory fix at all (advisories that give only `last_affected`,
433
+ * audit A6), and the installed version known, the registry is asked for the
434
+ * first published release above the installed one that no known advisory
435
+ * affects: a fix candidate the advisories never named, flagged
436
+ * `noAdvisoryFix`, instead of "no fix".
437
+ */
438
+ export async function cleanFixTarget(sources, ecosystem, name, perAdvisoryFixes, installed) {
439
+ if (perAdvisoryFixes.length === 0 && !installed)
254
440
  return null;
255
- const [advisories, published] = await Promise.all([
441
+ const [advisories, facts] = await Promise.all([
256
442
  sources.packageAdvisories(ecosystem, name),
257
- sources.publishedVersions(ecosystem, name),
443
+ sources.releaseFacts(ecosystem, name),
258
444
  ]);
445
+ if (!advisories && !facts && perAdvisoryFixes.length > 0 && WITHDRAWALS_READ.has(ecosystem)) {
446
+ // Neither OSV's list nor the registry answered: the highest per-advisory fix stands, checked
447
+ // against the installed version's advisories only, and the answer says the withdrawal check
448
+ // did not run (lodash 4.18.0, "Bad release", went out with no word of it: final audit 7a).
449
+ const floor = maxVersion(perAdvisoryFixes, ecosystem) ?? perAdvisoryFixes[0];
450
+ return { target: floor, basis: "advisory_fixes", floor, floorAffectedBy: [], stillAffectedBy: [], unfixable: [], withheld: [], advisoriesUnread: true, registryUnread: true };
451
+ }
259
452
  if (!advisories)
453
+ return perAdvisoryFixes.length > 0 && facts ? registryOnlyTarget(ecosystem, perAdvisoryFixes, facts) : null;
454
+ // Every release the registry lists, the withdrawn ones included so the result can name them;
455
+ // `excluded` keeps them from ever being the target (FX-H5).
456
+ const published = facts ? [...facts.versions, ...facts.withdrawn] : null;
457
+ const excluded = facts?.withdrawn ?? [];
458
+ const malware = advisories.filter((a) => a.malware).map((a) => a.id);
459
+ if (perAdvisoryFixes.length > 0) {
460
+ const clean = smallestCleanVersion({ ecosystem, fixes: perAdvisoryFixes, advisories, published, excluded, installed });
461
+ if (!clean)
462
+ return null;
463
+ if (malware.length > 0)
464
+ Object.assign(clean, { malware });
465
+ if (clean.target === null && clean.maliciousLanding)
466
+ Object.assign(clean, { landingNote: malwareText(name, clean.maliciousLanding) });
467
+ return withRegistryFacts({ ...clean, ...(facts?.withdrawn.includes(clean.floor) ? { floorWithdrawn: true } : {}) }, ecosystem, perAdvisoryFixes, advisories, facts);
468
+ }
469
+ // Only a registry list can show a release past a last_affected bound.
470
+ if (!published || published.length === 0)
260
471
  return null;
261
- return smallestCleanVersion({ ecosystem, fixes: perAdvisoryFixes, advisories, published });
472
+ const clean = smallestCleanVersion({ ecosystem, fixes: [installed], advisories, published, excluded, installed });
473
+ if (!clean || clean.target === null || clean.target === installed || clean.stillAffectedBy.length > 0)
474
+ return null;
475
+ return withRegistryFacts({ ...clean, noAdvisoryFix: true }, ecosystem, [], advisories, facts);
476
+ }
477
+ /**
478
+ * The registry side of a target: that the registry was not read (so withdrawn releases were
479
+ * not checked), or the words for each withdrawn release and the installable fix for each
480
+ * per-advisory fix the registry withdrew (the app's `installable_fix`: the lowest allowed
481
+ * release above it that the advisories fixed at it do not affect).
482
+ */
483
+ function withRegistryFacts(clean, ecosystem, fixes, advisories, facts) {
484
+ // Only the registries whose withdrawals are read (as in the app: Go `retract` and the RubyGems /
485
+ // Packagist readers are not); for the rest there was nothing to check, so nothing to say.
486
+ if (!facts)
487
+ return WITHDRAWALS_READ.has(ecosystem) ? { ...clean, registryUnread: true } : clean;
488
+ const lineFixes = {};
489
+ const published = [...facts.versions, ...facts.withdrawn];
490
+ for (const fix of new Set(fixes)) {
491
+ if (!facts.withdrawn.includes(fix))
492
+ continue;
493
+ const fixedHere = advisories.filter((a) => a.spans.some((s) => s.fixed !== null && compareVersions(s.fixed, fix, ecosystem) === 0));
494
+ lineFixes[fix] = installableFix({ ecosystem, fix, advisories: fixedHere, published, excluded: facts.withdrawn });
495
+ }
496
+ const named = new Set([...clean.withheld, ...Object.values(lineFixes).flatMap((l) => l.withheld)]);
497
+ const withheldWhy = Object.fromEntries([...named].map((v) => [v, facts.withdrawnWhy[v] ?? withdrawalText(ecosystem, v)]));
498
+ const notice = clean.target ? facts.lineNotices?.[clean.target] : undefined;
499
+ return {
500
+ ...clean,
501
+ ...(named.size > 0 ? { withheldWhy } : {}),
502
+ ...(Object.keys(lineFixes).length > 0 ? { lineFixes } : {}),
503
+ ...(notice ? { targetNotice: lineNoticeText(clean.target, notice) } : {}),
504
+ };
505
+ }
506
+ /**
507
+ * With no advisory list: the smallest installable release at or above the
508
+ * highest per-advisory fix, only when that fix itself is withdrawn or not
509
+ * published (otherwise the unchecked answer stands as it is). Null when
510
+ * nothing changes or no installable release qualifies.
511
+ */
512
+ function registryOnlyTarget(ecosystem, fixes, facts) {
513
+ if (facts.versions.length === 0)
514
+ return null;
515
+ const published = [...facts.versions, ...facts.withdrawn];
516
+ const clean = smallestCleanVersion({ ecosystem, fixes, advisories: [], published, excluded: facts.withdrawn });
517
+ if (!clean || (clean.target !== null && clean.target === clean.floor))
518
+ return null;
519
+ if (clean.target === null && clean.withheld.length === 0)
520
+ return null;
521
+ return withRegistryFacts({ ...clean, floorWithdrawn: facts.withdrawn.includes(clean.floor), advisoriesUnread: true }, ecosystem, fixes, [], facts);
522
+ }
523
+ /** #981 (b): an advisory whose ranges cannot be read leaves the release unknown, never skipped silently. */
524
+ export function unreadableText(ids) {
525
+ return `the affected ranges of ${ids.join(", ")} cannot be read, so whether any release clears ${ids.length === 1 ? "it" : "them"} is unknown: no release is named`;
526
+ }
527
+ /** The 4DA lead's rule (2026-10-18): no release is offered that brings in an advisory the installed version does not have. */
528
+ export function introducesText(ids) {
529
+ return `every fixing release brings in ${ids.join(", ")}, which the installed version does not have, so no release is named`;
530
+ }
531
+ /** #981 round 3: an installed copy a malicious advisory affects names no release; the advice is to remove or replace it. */
532
+ export function malwareText(name, ids) {
533
+ return `${name} is flagged malicious (${ids.join(", ")}): remove or replace ${name}`;
534
+ }
535
+ /** The words for a target that carries a line-wide deprecation notice (FixTargetResult.targetNotice). */
536
+ export function lineNoticeText(version, message) {
537
+ const quoted = message.length > 120 ? `${message.slice(0, 117)}...` : message;
538
+ return `${version} is deprecated on npm ("${quoted}"), a notice on its whole line rather than a withdrawn release (no later release on the line is free of it), so it stays the smallest fix without a major upgrade; dependency_check says review for it: tell the user it is deprecated`;
539
+ }
540
+ /** The ecosystems whose registry withdrawals (yanked, deprecated) are read. */
541
+ const WITHDRAWALS_READ = new Set(["npm", "crates.io", "PyPI"]);
542
+ /** `withheldNote` for a checked target: the withdrawn releases passed, in plain words; null when none. */
543
+ export function withheldText(clean, ecosystem) {
544
+ return withheldNote(clean.withheld, clean.target, clean.withheldWhy ?? {}, ecosystem, clean.stillAffectedBy.length === 0);
262
545
  }
263
546
  /**
264
547
  * Verified targets for many installed copies at once, within a time budget:
265
548
  * the copies that could not be checked in time (or offline) are simply
266
549
  * absent from the result, and the caller says so. Keyed by `key`.
267
550
  */
268
- export async function verifyFixTargets(sources, items, budgetMs, concurrency = 12) {
551
+ export async function verifyFixTargets(sources, items, budgetMs, concurrency = 12, options = {}) {
269
552
  const out = new Map();
270
- const todo = items.filter((i) => i.fixes.length > 0);
553
+ const todo = items.filter((i) => i.fixes.length > 0 || i.installed);
271
554
  if (!sources || todo.length === 0)
272
555
  return out;
273
556
  const deadline = Date.now() + budgetMs;
@@ -280,7 +563,7 @@ export async function verifyFixTargets(sources, items, budgetMs, concurrency = 1
280
563
  timer = setTimeout(() => resolve(null), Math.max(0, deadline - Date.now()));
281
564
  });
282
565
  try {
283
- const clean = await Promise.race([cleanFixTarget(sources, item.ecosystem, item.name, item.fixes).catch(() => null), late]);
566
+ const clean = await Promise.race([cleanFixTarget(sources, item.ecosystem, item.name, item.fixes, item.installed).catch(() => null), late]);
284
567
  if (clean)
285
568
  out.set(item.key, clean);
286
569
  }
@@ -291,19 +574,102 @@ export async function verifyFixTargets(sources, items, budgetMs, concurrency = 1
291
574
  }
292
575
  };
293
576
  await Promise.all(Array.from({ length: Math.min(concurrency, todo.length) }, worker));
577
+ if (options.heldQuery) {
578
+ // npm: what each target installs at a fixed version, in one advisory lookup for all of them
579
+ // (audit 6.2.5 D-4). It gets at least HELD_FLOOR_MS even when the targets used the budget up:
580
+ // without it a cold scan would downgrade every npm target that holds a dependency.
581
+ const npm = todo.filter((i) => i.ecosystem === "npm" && out.has(i.key)).map((i) => ({ key: i.key, name: i.name, installed: i.installed, clean: out.get(i.key) }));
582
+ const held = await withHeldTargets(sources, options.heldQuery, npm, Math.max(deadline, Date.now() + HELD_FLOOR_MS));
583
+ for (const [key, clean] of held)
584
+ out.set(key, clean);
585
+ }
294
586
  return out;
295
587
  }
296
- /** One clause for a target that is not the highest per-advisory fix, or null when it is. */
297
- export function targetNote(clean) {
588
+ /** The least time the held-dependency check of a scan's npm targets gets (verifyFixTargets). */
589
+ const HELD_FLOOR_MS = 3_000;
590
+ /** Said whenever the registry could not be read: withdrawn releases are then not ruled out, never silently. */
591
+ export const REGISTRY_UNREAD_NOTE = "the registry could not be read, so whether it yanked or deprecated this release was not checked";
592
+ /**
593
+ * One clause for a target that is not the highest per-advisory fix, or null when it is.
594
+ * Withdrawn releases passed on the way are named in the registry's own words (FX-H5).
595
+ */
596
+ export function targetNote(clean, ecosystem = "") {
597
+ if (clean.heldFrom) {
598
+ // The advisory-side reasoning is about the release the advisories pointed at; the held step follows it.
599
+ const { heldFrom, heldPast: _past, targetNotice, ...rest } = clean;
600
+ const base = targetNote({ ...rest, target: heldFrom }, ecosystem);
601
+ return [base, heldNote(clean), targetNotice ?? null].filter((p) => Boolean(p)).join("; ") || null;
602
+ }
603
+ const held = heldNote(clean);
604
+ if (held) {
605
+ const { heldBlocked: _b, heldUnchecked: _u, ...rest } = clean;
606
+ return [targetNote(rest, ecosystem), held].filter((p) => Boolean(p)).join("; ");
607
+ }
608
+ const withheld = withheldText(clean, ecosystem);
609
+ if (clean.advisoriesUnread) {
610
+ const unchecked = "not checked against the package's other advisories (OSV.dev did not answer)";
611
+ if (clean.registryUnread)
612
+ return `${clean.target} is the highest per-advisory fix, ${unchecked}; ${REGISTRY_UNREAD_NOTE}`;
613
+ // Not "clean": nothing but the installed version's advisories was checked.
614
+ if (withheld && clean.target)
615
+ return `${withheld.replace(/ is the smallest clean release$/, " is the smallest installable release past it")}; ${unchecked}`;
616
+ if (withheld)
617
+ return `${withheld}; ${unchecked}`;
618
+ return `${clean.target} is the smallest installable release at or above ${clean.floor}, the highest per-advisory fix, which ${clean.floorWithdrawn ? "is deprecated or yanked on the registry" : "is not a published release"}; ${unchecked}`;
619
+ }
298
620
  const parts = [];
299
- if (clean.target && clean.target !== clean.floor) {
300
- parts.push(`${clean.target} is the smallest version clear of every advisory a release fixes; ${clean.floor}, the highest per-advisory fix, ${clean.floorAffectedBy.length > 0 ? `is itself affected by ${clean.floorAffectedBy.join(", ")}` : "is not a published release"}`);
621
+ if (clean.noAdvisoryFix) {
622
+ parts.push(`no advisory names a fixed version (they give only a last affected one); ${clean.target} is the first published release above the installed version that no known advisory affects`);
623
+ if (withheld)
624
+ parts.push(withheld);
625
+ }
626
+ else if (clean.target === null) {
627
+ if (withheld)
628
+ parts.push(withheld);
629
+ }
630
+ else if (clean.target !== clean.floor) {
631
+ if (clean.floorAffectedBy.length > 0) {
632
+ parts.push(`${clean.target} is the smallest version clear of every advisory a release fixes; ${clean.floor}, the highest per-advisory fix, is itself affected by ${clean.floorAffectedBy.join(", ")}`);
633
+ if (withheld)
634
+ parts.push(withheld);
635
+ }
636
+ else if (withheld) {
637
+ parts.push(withheld);
638
+ }
639
+ else {
640
+ parts.push(`${clean.target} is the smallest version clear of every advisory a release fixes; ${clean.floor}, the highest per-advisory fix, ${clean.floorWithdrawn ? "is deprecated or yanked on the registry" : "is not a published release"}`);
641
+ }
301
642
  }
302
643
  if (clean.target && clean.stillAffectedBy.length > 0) {
303
- parts.push(`${clean.stillAffectedBy.join(", ")} also affect${clean.stillAffectedBy.length === 1 ? "s" : ""} it and every newer release (no fix published)`);
644
+ parts.push(`${clean.target} ${remainsText(clean.stillAffectedBy)}: ${clean.stillAffectedBy.length === 1 ? "it affects" : "they affect"} every newer release too`);
304
645
  }
646
+ if (clean.target === null && clean.unreadable && clean.unreadable.length > 0)
647
+ parts.push(unreadableText(clean.unreadable));
648
+ if (clean.target === null && clean.landingNote)
649
+ parts.push(clean.landingNote);
650
+ if (clean.target === null && clean.introduces && clean.introduces.length > 0)
651
+ parts.push(introducesText(clean.introduces));
652
+ if (clean.registryUnread)
653
+ parts.push(REGISTRY_UNREAD_NOTE);
654
+ if (clean.targetNotice)
655
+ parts.push(clean.targetNotice);
305
656
  return parts.length > 0 ? parts.join("; ") : null;
306
657
  }
658
+ /**
659
+ * A finding's own fix as the registry still offers it (the app's `installable_fix`): the
660
+ * advisory's fixed version, or, when the registry withdrew it, the release to take instead
661
+ * (null: none past it is known), with the withdrawn ones and the plain-words note.
662
+ */
663
+ export function rowFix(fixed, clean, ecosystem) {
664
+ const line = fixed && clean?.lineFixes?.[fixed];
665
+ if (!fixed || !line)
666
+ return { fixed, withheld: [], note: null };
667
+ const said = sayWithdrawn(line.withheld, clean.withheldWhy ?? {}, ecosystem);
668
+ const note = line.fix
669
+ ? `${said}; ${line.fix} is the smallest release past it that fixes this advisory`
670
+ : `No installable fix for this advisory: ${said}, and no later release is known to fix it`;
671
+ return { fixed: line.fix, withheld: line.withheld, note };
672
+ }
307
673
  /**
308
674
  * The command that refreshes one transitive copy in place, for the tools it
309
675
  * can be named for (4DA AD-055 ¶4); null for yarn 1 and bun, which have none
@@ -317,6 +683,9 @@ export function targetNote(clean) {
317
683
  * pnpm 8.15 to 10.34 also moved a root project's transitive copy without it.
318
684
  */
319
685
  export function refreshCommand(format, name, installed, target, workspaceRoot = false) {
686
+ const ecosystem = format === "cargo" ? "crates.io" : "npm";
687
+ if (!isShellSafeName(name, ecosystem) || !isShellSafeVersion(installed) || !isShellSafeVersion(target))
688
+ return null;
320
689
  switch (format) {
321
690
  case "package-lock":
322
691
  return `npm update ${name}`;
@@ -331,8 +700,381 @@ export function refreshCommand(format, name, installed, target, workspaceRoot =
331
700
  return `cargo update -p ${name}@${installed} --precise ${target}`;
332
701
  }
333
702
  }
703
+ /**
704
+ * Whether an override (npm `overrides`, pnpm `pnpm.overrides`, yarn `resolutions`) can reach the copy
705
+ * these blocking parents hold: never when one of them ships it bundled (rule vectors rv-066..rv-068).
706
+ */
707
+ export function overrideReaches(blocking) {
708
+ return !blocking.some((b) => b.bundled === true);
709
+ }
710
+ /** The verified in-range refresh of a DECLARED npm-family dependency (DirectFix refresh). */
711
+ function npmDirectRefresh(manager, name) {
712
+ switch (manager) {
713
+ case "npm":
714
+ return `npm update ${name}`;
715
+ case "pnpm":
716
+ return `pnpm update ${name} --no-save`;
717
+ case "yarn1":
718
+ return `yarn upgrade ${name}`;
719
+ case "yarn-berry":
720
+ return `yarn up -R ${name}`;
721
+ }
722
+ }
723
+ /** How far up from a project directory a workspace lockfile is looked for (the app's MAX_LOCKFILE_ASCENT). */
724
+ const MAX_LOCKFILE_ASCENT = 6;
725
+ /** The npm-family tool whose lockfile holds the project at `dir`, nearest directory first (yarn, pnpm, npm, as the app). */
726
+ function npmManager(dir) {
727
+ let here = path.resolve(dir);
728
+ for (let i = 0; i <= MAX_LOCKFILE_ASCENT; i++) {
729
+ if (fs.existsSync(path.join(here, "yarn.lock"))) {
730
+ let berry = false;
731
+ try {
732
+ berry = fs.readFileSync(path.join(here, "yarn.lock"), "utf-8").split("\n").slice(0, 12).some((l) => l.startsWith("__metadata:"));
733
+ }
734
+ catch {
735
+ berry = false;
736
+ }
737
+ return berry ? "yarn-berry" : "yarn1";
738
+ }
739
+ if (fs.existsSync(path.join(here, "pnpm-lock.yaml")))
740
+ return "pnpm";
741
+ if (fs.existsSync(path.join(here, "package-lock.json")))
742
+ return "npm";
743
+ const up = path.dirname(here);
744
+ if (up === here)
745
+ break;
746
+ here = up;
747
+ }
748
+ return null;
749
+ }
750
+ function readText(file) {
751
+ try {
752
+ return fs.readFileSync(file, "utf-8");
753
+ }
754
+ catch {
755
+ return null;
756
+ }
757
+ }
758
+ /** The spec `name` is declared with, when exactly one of dependencies / devDependencies / optionalDependencies holds it. */
759
+ export function npmDeclared(packageJson, name) {
760
+ let pkg;
761
+ try {
762
+ pkg = JSON.parse(packageJson);
763
+ }
764
+ catch {
765
+ return null;
766
+ }
767
+ const found = ["dependencies", "devDependencies", "optionalDependencies"]
768
+ .map((s) => pkg?.[s]?.[name])
769
+ .filter((v) => typeof v === "string");
770
+ return found.length === 1 ? found[0] : null;
771
+ }
772
+ const PRERELEASE_BASE = /^\d+\.\d+\.\d+-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/;
773
+ const FULL_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
774
+ /** The operator a declared npm spec carries: "" for an exact pin, "^" or "~"; null for anything else. */
775
+ function npmOperator(declared) {
776
+ const d = declared.trim();
777
+ const digits = (s) => {
778
+ const parts = s.split(".");
779
+ return parts.length >= 1 && parts.length <= 3 && parts.every((p) => /^\d+$/.test(p));
780
+ };
781
+ // The 4DA lead (2026-10-19): a `^` or `~` on a PRERELEASE base (`^2.0.0-rc.4`, `~1.2.3-beta.1`)
782
+ // is bounded too: a full X.Y.Z with prerelease identifiers, no build metadata.
783
+ const base = (s) => digits(s) || PRERELEASE_BASE.test(s);
784
+ if (d.startsWith("^"))
785
+ return base(d.slice(1)) ? "^" : null;
786
+ if (d.startsWith("~"))
787
+ return base(d.slice(1)) ? "~" : null;
788
+ const exact = (d.startsWith("=") ? d.slice(1) : d).replace(/^v+/, "");
789
+ return FULL_SEMVER.test(exact) ? "" : null;
790
+ }
791
+ /** The command that moves a declared npm-family dependency, keeping its operator (quoted: `^` is cmd.exe's escape). */
792
+ export function npmDirectCommand(manager, declared, name, target) {
793
+ if (!isShellSafeName(name, "npm") || !isShellSafeVersion(target))
794
+ return null;
795
+ const op = npmOperator(declared);
796
+ if (op === null)
797
+ return null;
798
+ const spec = op === "" ? `${name}@${target}` : `"${name}@${op}${target}"`;
799
+ switch (manager) {
800
+ case "npm":
801
+ return op === "" ? `npm install ${spec} --save-exact` : `npm install ${spec}`;
802
+ case "pnpm":
803
+ return `pnpm up ${spec}`;
804
+ case "yarn1":
805
+ return `yarn upgrade ${spec}`;
806
+ case "yarn-berry":
807
+ return `yarn up ${spec}`;
808
+ }
809
+ }
810
+ const unquote = (s) => s.trim().replace(/^["']+|["']+$/g, "");
811
+ const crateKey = (name) => name.trim().toLowerCase().replace(/_/g, "-");
812
+ /** `[dependencies]`, `[dev-dependencies.foo]`, `[target.'cfg(unix)'.build-dependencies]`, ... */
813
+ function cargoTable(header) {
814
+ const inner = header.replace(/^\[+/, "").replace(/\]+$/, "").trim();
815
+ let target = null;
816
+ let rest = inner;
817
+ if (inner.startsWith("target.")) {
818
+ const after = inner.slice("target.".length);
819
+ if (after.startsWith("'") || after.startsWith('"')) {
820
+ const q = after.slice(1);
821
+ const close = q.search(/['"]/);
822
+ if (close < 0)
823
+ return null;
824
+ target = q.slice(0, close);
825
+ rest = q.slice(close + 1).replace(/^\.+/, "");
826
+ }
827
+ else {
828
+ const dot = after.indexOf(".");
829
+ if (dot < 0)
830
+ return null;
831
+ target = after.slice(0, dot);
832
+ rest = after.slice(dot + 1);
833
+ }
834
+ }
835
+ const dot = rest.indexOf(".");
836
+ const section = dot < 0 ? rest : rest.slice(0, dot);
837
+ const key = dot < 0 ? null : unquote(rest.slice(dot + 1));
838
+ const kind = section === "dependencies" ? "normal" : section === "dev-dependencies" ? "dev" : section === "build-dependencies" ? "build" : null;
839
+ return kind ? { kind, target, key } : null;
840
+ }
841
+ /** Every declaration of `crate` in a Cargo.toml (by crate name, so a renamed dependency is found through `package = ...`). */
842
+ export function cargoDeclarations(manifest, crate) {
843
+ const want = crateKey(crate);
844
+ const found = [];
845
+ let table = null;
846
+ const set = (f, field, value) => {
847
+ if (field === "version")
848
+ f.req = unquote(value);
849
+ else if (field === "package")
850
+ f.pkg = unquote(value);
851
+ else if (field === "workspace")
852
+ f.workspace ||= value.trim() === "true";
853
+ else if (field === "git" || field === "path" || field === "registry")
854
+ f.otherSource = true;
855
+ };
856
+ for (const raw of manifest.split("\n")) {
857
+ const line = (raw.split("#")[0] ?? "").trim();
858
+ if (line.startsWith("[")) {
859
+ table = cargoTable(line);
860
+ continue;
861
+ }
862
+ const eq = line.indexOf("=");
863
+ if (!table || eq < 0)
864
+ continue;
865
+ const k = unquote(line.slice(0, eq));
866
+ const v = line.slice(eq + 1);
867
+ let key;
868
+ let field;
869
+ if (table.key !== null) {
870
+ key = table.key;
871
+ field = k;
872
+ }
873
+ else {
874
+ const dot = k.indexOf(".");
875
+ if (dot >= 0) {
876
+ key = unquote(k.slice(0, dot));
877
+ field = k.slice(dot + 1).trim();
878
+ }
879
+ else {
880
+ key = k;
881
+ field = null;
882
+ }
883
+ }
884
+ let slot = found.find((d) => d.kind === table.kind && d.target === table.target && d.key === key);
885
+ if (!slot) {
886
+ slot = { kind: table.kind, target: table.target, key, f: { req: null, pkg: null, workspace: false, otherSource: false } };
887
+ found.push(slot);
888
+ }
889
+ if (field !== null)
890
+ set(slot.f, field, v);
891
+ else if (v.trimStart().startsWith("{")) {
892
+ const body = v.trim().replace(/^\{/, "").replace(/\}$/, "");
893
+ for (const kv of body.split(",")) {
894
+ const at = kv.indexOf("=");
895
+ if (at >= 0)
896
+ set(slot.f, kv.slice(0, at).trim(), kv.slice(at + 1).trim());
897
+ }
898
+ }
899
+ else
900
+ slot.f.req = unquote(v);
901
+ }
902
+ return found
903
+ .filter((d) => crateKey(d.f.pkg ?? d.key) === want)
904
+ .map((d) => ({ kind: d.kind, target: d.target, key: d.key, req: d.f.req, workspace: d.f.workspace, otherSource: d.f.otherSource }));
905
+ }
906
+ /** The fix for one Cargo declaration. */
907
+ function cargoDirect(decl, crate, target) {
908
+ if (decl.otherSource)
909
+ return null;
910
+ if (decl.workspace) {
911
+ return {
912
+ instruction: `bump ${crate} to ${target} in the workspace root's [workspace.dependencies] (it is inherited here; \`cargo add\` in this crate would replace the inheritance with a local pin)`,
913
+ };
914
+ }
915
+ // Every value spliced below comes from the project's own Cargo.toml or lockfile (C-1).
916
+ const keyRenamed = crateKey(decl.key) !== crateKey(crate);
917
+ if (!isShellSafeName(crate, "crates.io") ||
918
+ !isShellSafeVersion(target) ||
919
+ (decl.target !== null && !isShellSafeCargoTarget(decl.target)) ||
920
+ (keyRenamed && !isShellSafeName(decl.key, "crates.io"))) {
921
+ return { instruction: `bump the ${isShellSafeName(crate, "crates.io") ? crate : "dependency"} requirement in Cargo.toml so it admits the fixed release (no command is named: the declaration holds characters that are unsafe in a command)` };
922
+ }
923
+ const r = decl.req?.trim() ?? "";
924
+ const op = r.startsWith("=") ? "=" : r.startsWith("~") ? "~" : r.startsWith("^") ? "^" : /^\d/.test(r) ? "" : null;
925
+ if (op === null)
926
+ return null;
927
+ let cmd = `cargo add ${op === "" ? `${crate}@${target}` : `"${crate}@${op}${target}"`}`;
928
+ if (decl.kind === "dev")
929
+ cmd += " --dev";
930
+ if (decl.kind === "build")
931
+ cmd += " --build";
932
+ // Double quotes: cmd.exe keeps single quotes as part of the argument.
933
+ if (decl.target)
934
+ cmd += ` --target "${decl.target}"`;
935
+ if (crateKey(decl.key) !== crateKey(crate))
936
+ cmd += ` --rename ${decl.key}`;
937
+ return { command: cmd };
938
+ }
939
+ /** `go get <module>@v<target>` when go.mod requires the module directly at `installed` and no `replace` points it elsewhere. */
940
+ export function goDirectCommand(goMod, module, installed, target) {
941
+ if (!isShellSafeName(module, "Go") || !isShellSafeVersion(installed) || !isShellSafeVersion(target))
942
+ return null;
943
+ const v = (s) => (s.startsWith("v") ? s : `v${s}`);
944
+ let inRequire = false;
945
+ let inReplace = false;
946
+ let requiredAt = null;
947
+ for (const raw of goMod.split("\n")) {
948
+ const line = raw.trim();
949
+ if (line === "require (")
950
+ inRequire = true;
951
+ else if (line === "replace (")
952
+ inReplace = true;
953
+ else if (line === ")")
954
+ inRequire = inReplace = false;
955
+ const replaced = inReplace ? line : line.startsWith("replace ") ? line.slice("replace ".length) : null;
956
+ if (replaced !== null && replaced.split(/\s+/)[0] === module)
957
+ return null;
958
+ const entry = inRequire ? line : line.startsWith("require ") ? line.slice("require ".length) : null;
959
+ if (entry !== null && !entry.includes("// indirect")) {
960
+ const parts = entry.split(/\s+/);
961
+ if (parts[0] === module)
962
+ requiredAt = parts[1] ?? null;
963
+ }
964
+ }
965
+ return requiredAt !== null && requiredAt === v(installed) ? `go get ${module}@${v(target)}` : null;
966
+ }
967
+ /** The edit of the requirements line that pins the package with `==` at `installed` (markers, extras kept). */
968
+ export function pypiDirectInstruction(requirements, file, name, installed, target) {
969
+ const lines = requirements.split("\n");
970
+ // Compiled output (pip-compile, and `uv pip compile`, whose header the old check missed, or any
971
+ // file with `# via` annotations) is regenerated from its input: a hand edit is reverted, and an
972
+ // indirect pin conflicts with its parent (audit 6.2.5 A-4). pypi-advice.ts words the re-compile.
973
+ if (compiledRequirements(requirements))
974
+ return null;
975
+ const norm = (s) => s.toLowerCase().replace(/[_.]/g, "-");
976
+ const want = norm(name);
977
+ for (const [i, raw] of lines.entries()) {
978
+ const line = raw.trim();
979
+ const at = line.indexOf("==");
980
+ if (at < 0)
981
+ continue;
982
+ const head = line.slice(0, at);
983
+ const rest = line.slice(at + 2);
984
+ if (norm((head.split("[")[0] ?? head).trim()) !== want)
985
+ continue;
986
+ const endAt = rest.search(/[;\\#\s]/);
987
+ const end = endAt < 0 ? rest.length : endAt;
988
+ const version = rest.slice(0, end);
989
+ const tail = rest.slice(end);
990
+ const continued = line.endsWith("\\") || line.includes("--hash") || (lines[i + 1]?.trimStart().startsWith("--hash") ?? false);
991
+ if (continued || version !== installed)
992
+ continue;
993
+ return `change \`${line}\` to \`${head}==${target}${tail}\` in ${file}`;
994
+ }
995
+ return null;
996
+ }
997
+ /**
998
+ * The fix for `name` at `installed`, declared in the project at `projectDir`, read from its own
999
+ * manifest; null when it cannot be named with certainty (see DirectFix).
1000
+ */
1001
+ export function directFix(projectDir, ecosystem, name, installed, target) {
1002
+ const read = (file) => readText(path.join(projectDir, file));
1003
+ switch (ecosystem) {
1004
+ case "npm": {
1005
+ const manager = npmManager(projectDir);
1006
+ if (!manager)
1007
+ return null;
1008
+ // yarn's up / upgrade act on the whole workspace: from a member they rewrite siblings too.
1009
+ if ((manager === "yarn1" || manager === "yarn-berry") && !fs.existsSync(path.join(projectDir, "yarn.lock")))
1010
+ return null;
1011
+ const json = read("package.json");
1012
+ const declared = json === null ? null : npmDeclared(json, name);
1013
+ if (declared === null || npmRangeAdmits(declared, installed) !== true)
1014
+ return null;
1015
+ // In an npm workspace member too, run in the member's directory: on lockfiles npm wrote,
1016
+ // `npm update ms` there moved a member's own ^2.1.1 copy (hoisted and nested layouts, npm
1017
+ // 10.9.2, 2026-10-18). An earlier "it does not move" came from a hand-edited fixture lockfile
1018
+ // that carried 1.2.8's integrity under version 1.2.5.
1019
+ // Only a range bounded below the next major (`^`, `~`) is refreshed: a refresh installs the
1020
+ // NEWEST release in range, so a `>=` / `||` / `*` / x-range could jump a major or land on a
1021
+ // withdrawn or held release, and names nothing (fc-059, the app's npm_direct_fix).
1022
+ const op = npmOperator(declared);
1023
+ const bounded = op === "^" || op === "~";
1024
+ if (bounded && npmRangeAdmits(declared, target) === true)
1025
+ return isShellSafeName(name, "npm") ? { command: npmDirectRefresh(manager, name), refresh: true } : null;
1026
+ const command = npmDirectCommand(manager, declared, name, target);
1027
+ return command ? { command } : null;
1028
+ }
1029
+ case "crates.io": {
1030
+ const manifest = read("Cargo.toml");
1031
+ if (manifest === null)
1032
+ return null;
1033
+ const decls = cargoDeclarations(manifest, name);
1034
+ if (decls.length !== 1)
1035
+ return null;
1036
+ const [only] = decls;
1037
+ const admits = only.req !== null ? cargoReqAdmits(only.req, installed) === true : only.workspace;
1038
+ // A workspace-inherited, git, path or alternate-registry entry is never refreshed this way.
1039
+ if (admits && only.req !== null && !only.workspace && !only.otherSource && cargoReqAdmits(only.req, target) === true) {
1040
+ const command = refreshCommand("cargo", name, installed, target);
1041
+ return command ? { command, refresh: true } : null;
1042
+ }
1043
+ return admits ? cargoDirect(only, name, target) : null;
1044
+ }
1045
+ case "Go": {
1046
+ const goMod = read("go.mod");
1047
+ const command = goMod === null ? null : goDirectCommand(goMod, name, installed, target);
1048
+ return command ? { command } : null;
1049
+ }
1050
+ case "PyPI": {
1051
+ // A pin in a file included with `-r` is declared too, and edited there (audit 7g).
1052
+ const files = ["requirements.txt", "requirements-dev.txt", "requirements/base.txt"];
1053
+ for (let i = 0; i < files.length && i < 16; i++) {
1054
+ const body = read(files[i]);
1055
+ if (body === null)
1056
+ continue;
1057
+ const instruction = pypiDirectInstruction(body, files[i], name, installed, target);
1058
+ if (instruction)
1059
+ return { instruction };
1060
+ for (const { option, target: inc } of requirementOptions(body)) {
1061
+ const included = option === "r" ? confinedInclude(path.dirname(path.join(projectDir, files[i])), inc, projectDir) : null;
1062
+ const rel = included ? path.relative(projectDir, included).replace(/\\/g, "/") : null;
1063
+ if (rel && !files.includes(rel))
1064
+ files.push(rel);
1065
+ }
1066
+ }
1067
+ return null;
1068
+ }
1069
+ default:
1070
+ return null;
1071
+ }
1072
+ }
334
1073
  /** For a lockfile with no in-place refresh command (yarn 1, bun): the steps that move the copy. */
335
1074
  export function manualRefreshSteps(format, name) {
1075
+ // The steps are read and followed; a name a shell would read specially is not echoed into them (C-1).
1076
+ if (!isShellSafeName(name, "npm"))
1077
+ return `refresh this package's ${format === "yarn" ? "yarn.lock" : "bun.lock"} entries by hand: its name contains characters that are unsafe in a command`;
336
1078
  // Yarn 1 keys a block by every spec that resolved to it, and an alias block's
337
1079
  // first key names the alias ("string-width-cjs@npm:string-width@^4.2.0"), so
338
1080
  // name both forms. Deleting the entries and reinstalling is what moved the
@@ -371,7 +1113,8 @@ export async function refreshVerdict(sources, ecosystem, lockfile, name, install
371
1113
  return null;
372
1114
  const isCargo = parents.format === "cargo";
373
1115
  const dialect = isCargo ? "cargo" : "npm";
374
- const clean = (v) => !advisories || !advisories.some((a) => advisoryAffects(a, v, ecosystem));
1116
+ // A maintenance notice never makes a copy unclean (#994 review).
1117
+ const clean = (v) => !advisories || !advisories.some((a) => !a.maintenance && advisoryAffects(a, v, ecosystem));
375
1118
  const releases = (published ?? []).filter((v) => !isPrereleaseVersion(v, ecosystem));
376
1119
  const edgeReqs = await Promise.all(parents.edges.map((edge) => requirementsOf(sources, ecosystem, edge, name)));
377
1120
  const reqs = [];
@@ -392,7 +1135,7 @@ export async function refreshVerdict(sources, ecosystem, lockfile, name, install
392
1135
  if (rule.verdict === "unknown")
393
1136
  return null;
394
1137
  const blocking = [];
395
- const block = (r) => blocking.push({ parent: r.edge.parent, parentVersion: r.edge.parentVersion, requirement: r.text ?? "" });
1138
+ const block = (r) => blocking.push({ parent: r.edge.parent, parentVersion: r.edge.parentVersion, requirement: r.text ?? "", ...(r.edge.bundled ? { bundled: true } : {}) });
396
1139
  for (const r of reqs)
397
1140
  if (requirementAdmits(r, target) === false)
398
1141
  block(r);
@@ -429,6 +1172,8 @@ export async function refreshVerdict(sources, ecosystem, lockfile, name, install
429
1172
  const prereqs = await cargoPrecisePrerequisites(sources, content, name, installed, target);
430
1173
  if (prereqs === null)
431
1174
  return null;
1175
+ if (prereqs.some((p) => !/^[A-Za-z0-9_-]+(?:@[0-9A-Za-z.+_-]+)?$/.test(p)))
1176
+ return null;
432
1177
  if (prereqs.length > 0)
433
1178
  command = `cargo update ${prereqs.map((p) => `-p ${p}`).join(" ")} && ${command}`;
434
1179
  }