@4da/mcp-server 6.2.3 → 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.
- package/CHANGELOG.md +520 -0
- package/README.md +22 -6
- package/dist/db.d.ts +38 -0
- package/dist/db.js +78 -23
- package/dist/doctor.js +6 -3
- package/dist/embeddings.d.ts +1 -1
- package/dist/embeddings.js +16 -14
- package/dist/index.js +73 -103
- package/dist/live/advisory-groups.d.ts +68 -0
- package/dist/live/advisory-groups.js +131 -0
- package/dist/live/cache.js +11 -3
- package/dist/live/cargo-platform.d.ts +6 -0
- package/dist/live/cargo-platform.js +8 -2
- package/dist/live/changelog.js +38 -6
- package/dist/live/dependency-set.d.ts +9 -1
- package/dist/live/dependency-set.js +10 -2
- package/dist/live/fix-paths.d.ts +289 -16
- package/dist/live/fix-paths.js +876 -93
- package/dist/live/github-releases.js +3 -0
- package/dist/live/held-dependencies.d.ts +93 -0
- package/dist/live/held-dependencies.js +212 -0
- package/dist/live/held-targets.d.ts +30 -0
- package/dist/live/held-targets.js +107 -0
- package/dist/live/http-utils.js +13 -3
- package/dist/live/index.d.ts +7 -4
- package/dist/live/index.js +24 -19
- package/dist/live/install-state.js +6 -2
- package/dist/live/js-lockfile-readers.d.ts +8 -1
- package/dist/live/js-lockfile-readers.js +43 -6
- package/dist/live/lockfile-parents.d.ts +15 -0
- package/dist/live/lockfile-parents.js +85 -10
- package/dist/live/lockfile-parsers-pygo.d.ts +47 -2
- package/dist/live/lockfile-parsers-pygo.js +230 -12
- package/dist/live/lockfile-parsers-rbphp.d.ts +38 -0
- package/dist/live/lockfile-parsers-rbphp.js +123 -0
- package/dist/live/lockfile-parsers.d.ts +6 -0
- package/dist/live/lockfile-parsers.js +116 -4
- package/dist/live/lockfile-types.d.ts +28 -1
- package/dist/live/maintenance.d.ts +20 -4
- package/dist/live/maintenance.js +31 -2
- package/dist/live/malware.d.ts +20 -0
- package/dist/live/malware.js +31 -0
- package/dist/live/npm-packument.d.ts +4 -1
- package/dist/live/npm-packument.js +5 -2
- package/dist/live/offered-release.d.ts +53 -0
- package/dist/live/offered-release.js +117 -0
- package/dist/live/osv-ranges.d.ts +111 -0
- package/dist/live/osv-ranges.js +173 -14
- package/dist/live/osv-scanner.d.ts +87 -5
- package/dist/live/osv-scanner.js +310 -59
- package/dist/live/pinned-advisories.d.ts +2 -2
- package/dist/live/pinned-advisories.js +3 -3
- package/dist/live/pypi-advice.d.ts +36 -0
- package/dist/live/pypi-advice.js +158 -0
- package/dist/live/range-satisfies.d.ts +4 -2
- package/dist/live/range-satisfies.js +7 -5
- package/dist/live/rate-limiter.d.ts +7 -0
- package/dist/live/rate-limiter.js +22 -1
- package/dist/live/refresh-rule.d.ts +36 -0
- package/dist/live/refresh-rule.js +49 -0
- package/dist/live/release-metadata.d.ts +5 -0
- package/dist/live/resolution.d.ts +3 -0
- package/dist/live/resolution.js +6 -3
- package/dist/live/scan-coverage.d.ts +26 -0
- package/dist/live/scan-coverage.js +70 -0
- package/dist/live/semver-utils.d.ts +8 -0
- package/dist/live/semver-utils.js +26 -3
- package/dist/live/severity-scope.d.ts +26 -1
- package/dist/live/severity-scope.js +32 -0
- package/dist/live/shell-safe.d.ts +19 -0
- package/dist/live/shell-safe.js +36 -0
- package/dist/live/types.d.ts +134 -8
- package/dist/live/version-compare.d.ts +51 -0
- package/dist/live/version-compare.js +152 -1
- package/dist/live/withdrawn-releases.d.ts +50 -0
- package/dist/live/withdrawn-releases.js +125 -0
- package/dist/project-manifests.d.ts +7 -0
- package/dist/project-manifests.js +45 -5
- package/dist/project-scanner.js +6 -4
- package/dist/project-tree.d.ts +56 -0
- package/dist/project-tree.js +174 -5
- package/dist/schema-registry.d.ts +47 -4
- package/dist/schema-registry.js +76 -9
- package/dist/server-instructions.js +4 -2
- package/dist/setup.d.ts +8 -0
- package/dist/setup.js +21 -2
- package/dist/tool-dispatch.js +3 -3
- package/dist/tools/agent-memory.d.ts +5 -0
- package/dist/tools/agent-memory.js +9 -0
- package/dist/tools/briefing-task-scope.js +5 -2
- package/dist/tools/decision-memory.js +2 -0
- package/dist/tools/dependency-check-held.d.ts +44 -0
- package/dist/tools/dependency-check-held.js +98 -0
- package/dist/tools/dependency-check-signals.d.ts +19 -2
- package/dist/tools/dependency-check-signals.js +84 -13
- package/dist/tools/dependency-check-versions.d.ts +37 -4
- package/dist/tools/dependency-check-versions.js +48 -13
- package/dist/tools/dependency-check.d.ts +11 -1
- package/dist/tools/dependency-check.js +81 -29
- package/dist/tools/dependency-health.js +209 -45
- package/dist/tools/get-context.d.ts +3 -0
- package/dist/tools/get-context.js +4 -0
- package/dist/tools/project-scope.d.ts +8 -0
- package/dist/tools/project-scope.js +21 -0
- package/dist/tools/upgrade-impact-features.d.ts +37 -0
- package/dist/tools/upgrade-impact-features.js +176 -0
- package/dist/tools/upgrade-impact-literals.js +26 -4
- package/dist/tools/upgrade-impact-report.d.ts +5 -1
- package/dist/tools/upgrade-impact-report.js +26 -5
- package/dist/tools/upgrade-impact.js +10 -3
- package/dist/tools/upgrade-planner-fixpaths.d.ts +164 -5
- package/dist/tools/upgrade-planner-fixpaths.js +0 -0
- package/dist/tools/upgrade-planner.d.ts +70 -7
- package/dist/tools/upgrade-planner.js +237 -61
- package/dist/tools/vulnerability-scan-concise.d.ts +47 -2
- package/dist/tools/vulnerability-scan-concise.js +37 -3
- package/dist/tools/vulnerability-scan-format.d.ts +126 -24
- package/dist/tools/vulnerability-scan-format.js +193 -26
- package/dist/tools/vulnerability-scan.d.ts +224 -63
- package/dist/tools/vulnerability-scan.js +50 -15
- package/dist/tools/what-should-i-know.d.ts +6 -1
- package/dist/tools/what-should-i-know.js +72 -15
- package/package.json +2 -2
package/dist/live/fix-paths.js
CHANGED
|
@@ -23,26 +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
|
-
import
|
|
31
|
+
import * as path from "node:path";
|
|
32
|
+
import { lockfileFormat, readLockfileParents } from "./lockfile-parents.js";
|
|
31
33
|
import { parseCargoLockPackages } from "./lockfile-parsers.js";
|
|
34
|
+
import { compiledRequirements, confinedInclude, requirementOptions } from "./lockfile-parsers-pygo.js";
|
|
32
35
|
import { cargoReqAdmits, npmRangeAdmits } from "./range-satisfies.js";
|
|
33
|
-
import {
|
|
36
|
+
import { refreshRule, requirementAdmits } from "./refresh-rule.js";
|
|
37
|
+
import { compareVersions, maxVersion } from "./version-compare.js";
|
|
34
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";
|
|
35
42
|
const TIMEOUT_MS = 15_000;
|
|
36
43
|
const ADVISORY_TTL = 6 * 3600;
|
|
37
44
|
const VERSIONS_TTL = 6 * 3600;
|
|
38
45
|
/** A published version's manifest never changes. */
|
|
39
46
|
const VERSION_DEPS_TTL = 30 * 86_400;
|
|
40
47
|
const MAX_OSV_PAGES = 10;
|
|
41
|
-
/** Bump when the shape of anything this module caches changes. */
|
|
42
|
-
const CACHE_VERSION =
|
|
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;
|
|
43
53
|
export class FixPathSources {
|
|
44
54
|
cache;
|
|
45
55
|
rateLimiter;
|
|
56
|
+
npmDocs = new Map();
|
|
46
57
|
constructor(cache, rateLimiter) {
|
|
47
58
|
this.cache = cache;
|
|
48
59
|
this.rateLimiter = rateLimiter;
|
|
@@ -89,11 +100,11 @@ export class FixPathSources {
|
|
|
89
100
|
async publishedVersions(ecosystem, name) {
|
|
90
101
|
switch (ecosystem) {
|
|
91
102
|
case "npm":
|
|
92
|
-
return this.
|
|
103
|
+
return (await this.npmFacts(name))?.versions ?? null;
|
|
93
104
|
case "crates.io":
|
|
94
105
|
return (await this.cratesSparse(name))?.versions ?? null;
|
|
95
106
|
case "PyPI":
|
|
96
|
-
return this.
|
|
107
|
+
return (await this.pypiFacts(name))?.versions ?? null;
|
|
97
108
|
case "Go":
|
|
98
109
|
return this.goVersions(name);
|
|
99
110
|
default:
|
|
@@ -123,7 +134,9 @@ export class FixPathSources {
|
|
|
123
134
|
if (!res.ok)
|
|
124
135
|
return null;
|
|
125
136
|
const doc = (await res.json());
|
|
126
|
-
|
|
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 };
|
|
127
140
|
this.cache.set(key, deps, "npm", VERSION_DEPS_TTL);
|
|
128
141
|
}
|
|
129
142
|
catch {
|
|
@@ -132,26 +145,145 @@ export class FixPathSources {
|
|
|
132
145
|
}
|
|
133
146
|
return typeof deps[dep] === "string" ? [deps[dep]] : [];
|
|
134
147
|
}
|
|
135
|
-
|
|
136
|
-
|
|
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}`;
|
|
137
169
|
const hit = this.cache.get(key);
|
|
138
170
|
if (hit)
|
|
139
171
|
return hit;
|
|
140
|
-
|
|
172
|
+
const doc = await this.npmDocument(parent);
|
|
173
|
+
if (!doc)
|
|
141
174
|
return null;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
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 {
|
|
146
208
|
return null;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 };
|
|
151
236
|
}
|
|
152
|
-
|
|
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)
|
|
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)
|
|
153
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;
|
|
154
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;
|
|
155
287
|
}
|
|
156
288
|
/** A crate's sparse-index record: versions and per-version dependency requirements. */
|
|
157
289
|
async cratesSparse(name) {
|
|
@@ -165,7 +297,7 @@ export class FixPathSources {
|
|
|
165
297
|
const res = await fetchWithTimeout(sparseIndexUrl(name), { headers: { "User-Agent": CRATES_USER_AGENT } }, TIMEOUT_MS);
|
|
166
298
|
if (!res.ok)
|
|
167
299
|
return null;
|
|
168
|
-
const out = { versions: [], reqs: {} };
|
|
300
|
+
const out = { versions: [], yanked: [], reqs: {} };
|
|
169
301
|
for (const line of (await res.text()).split("\n")) {
|
|
170
302
|
if (!line.trim())
|
|
171
303
|
continue;
|
|
@@ -173,6 +305,8 @@ export class FixPathSources {
|
|
|
173
305
|
const entry = JSON.parse(line);
|
|
174
306
|
if (!entry.yanked)
|
|
175
307
|
out.versions.push(entry.vers);
|
|
308
|
+
else
|
|
309
|
+
out.yanked.push(entry.vers);
|
|
176
310
|
const reqs = {};
|
|
177
311
|
for (const d of entry.deps ?? []) {
|
|
178
312
|
if (d.kind === "dev")
|
|
@@ -193,8 +327,12 @@ export class FixPathSources {
|
|
|
193
327
|
return null;
|
|
194
328
|
}
|
|
195
329
|
}
|
|
196
|
-
|
|
197
|
-
|
|
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}`;
|
|
198
336
|
const hit = this.cache.get(key);
|
|
199
337
|
if (hit)
|
|
200
338
|
return hit;
|
|
@@ -205,12 +343,20 @@ export class FixPathSources {
|
|
|
205
343
|
if (!res.ok)
|
|
206
344
|
return null;
|
|
207
345
|
const doc = (await res.json());
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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;
|
|
214
360
|
}
|
|
215
361
|
catch {
|
|
216
362
|
return null;
|
|
@@ -246,26 +392,165 @@ function sparseIndexUrl(name) {
|
|
|
246
392
|
default: return `https://index.crates.io/${n.slice(0, 2)}/${n.slice(2, 4)}/${n}`;
|
|
247
393
|
}
|
|
248
394
|
}
|
|
249
|
-
/**
|
|
250
|
-
|
|
251
|
-
|
|
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)
|
|
252
440
|
return null;
|
|
253
|
-
const [advisories,
|
|
441
|
+
const [advisories, facts] = await Promise.all([
|
|
254
442
|
sources.packageAdvisories(ecosystem, name),
|
|
255
|
-
sources.
|
|
443
|
+
sources.releaseFacts(ecosystem, name),
|
|
256
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
|
+
}
|
|
257
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)
|
|
258
471
|
return null;
|
|
259
|
-
|
|
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);
|
|
260
545
|
}
|
|
261
546
|
/**
|
|
262
547
|
* Verified targets for many installed copies at once, within a time budget:
|
|
263
548
|
* the copies that could not be checked in time (or offline) are simply
|
|
264
549
|
* absent from the result, and the caller says so. Keyed by `key`.
|
|
265
550
|
*/
|
|
266
|
-
export async function verifyFixTargets(sources, items, budgetMs, concurrency = 12) {
|
|
551
|
+
export async function verifyFixTargets(sources, items, budgetMs, concurrency = 12, options = {}) {
|
|
267
552
|
const out = new Map();
|
|
268
|
-
const todo = items.filter((i) => i.fixes.length > 0);
|
|
553
|
+
const todo = items.filter((i) => i.fixes.length > 0 || i.installed);
|
|
269
554
|
if (!sources || todo.length === 0)
|
|
270
555
|
return out;
|
|
271
556
|
const deadline = Date.now() + budgetMs;
|
|
@@ -278,7 +563,7 @@ export async function verifyFixTargets(sources, items, budgetMs, concurrency = 1
|
|
|
278
563
|
timer = setTimeout(() => resolve(null), Math.max(0, deadline - Date.now()));
|
|
279
564
|
});
|
|
280
565
|
try {
|
|
281
|
-
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]);
|
|
282
567
|
if (clean)
|
|
283
568
|
out.set(item.key, clean);
|
|
284
569
|
}
|
|
@@ -289,99 +574,594 @@ export async function verifyFixTargets(sources, items, budgetMs, concurrency = 1
|
|
|
289
574
|
}
|
|
290
575
|
};
|
|
291
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
|
+
}
|
|
292
586
|
return out;
|
|
293
587
|
}
|
|
294
|
-
/**
|
|
295
|
-
|
|
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
|
+
}
|
|
296
620
|
const parts = [];
|
|
297
|
-
if (clean.
|
|
298
|
-
parts.push(
|
|
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
|
+
}
|
|
299
642
|
}
|
|
300
643
|
if (clean.target && clean.stillAffectedBy.length > 0) {
|
|
301
|
-
parts.push(`${clean.stillAffectedBy
|
|
644
|
+
parts.push(`${clean.target} ${remainsText(clean.stillAffectedBy)}: ${clean.stillAffectedBy.length === 1 ? "it affects" : "they affect"} every newer release too`);
|
|
302
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);
|
|
303
656
|
return parts.length > 0 ? parts.join("; ") : null;
|
|
304
657
|
}
|
|
305
|
-
/**
|
|
306
|
-
|
|
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
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* The command that refreshes one transitive copy in place, for the tools it
|
|
675
|
+
* can be named for (4DA AD-055 ¶4); null for yarn 1 and bun, which have none
|
|
676
|
+
* (see `manualRefreshSteps`).
|
|
677
|
+
*
|
|
678
|
+
* pnpm: at a workspace root (`pnpm-workspace.yaml`), `pnpm update <pkg>`
|
|
679
|
+
* touches only the root project's tree, so a member's transitive copy stayed
|
|
680
|
+
* where it was; `-r` reaches every member (measured with pnpm 10.34.6: a
|
|
681
|
+
* member's mkdirp -> minimist 1.2.5 moved to 1.2.8 only with `-r`). `--depth
|
|
682
|
+
* Infinity` is the form the fix-path oracle applied (2026-10-10, U04/U10);
|
|
683
|
+
* pnpm 8.15 to 10.34 also moved a root project's transitive copy without it.
|
|
684
|
+
*/
|
|
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;
|
|
307
689
|
switch (format) {
|
|
308
690
|
case "package-lock":
|
|
309
691
|
return `npm update ${name}`;
|
|
310
692
|
case "pnpm":
|
|
311
|
-
return `pnpm update ${name}`;
|
|
693
|
+
return `pnpm update ${workspaceRoot ? "-r " : ""}${name} --depth Infinity`;
|
|
312
694
|
case "yarn-berry":
|
|
313
695
|
return `yarn up -R ${name}`;
|
|
314
696
|
case "yarn":
|
|
315
|
-
// Yarn 1 has no command that refreshes a transitive copy. Its blocks are keyed
|
|
316
|
-
// by every spec that resolved to them, and an alias block's first key names
|
|
317
|
-
// the alias ("string-width-cjs@npm:string-width@^4.2.0"), so name both forms.
|
|
318
|
-
return `delete every yarn.lock entry that resolves to ${name} (keys "${name}@…" and alias keys "…@npm:${name}@…"), then run yarn install`;
|
|
319
697
|
case "bun":
|
|
320
|
-
return
|
|
698
|
+
return null;
|
|
321
699
|
case "cargo":
|
|
322
700
|
return `cargo update -p ${name}@${installed} --precise ${target}`;
|
|
323
701
|
}
|
|
324
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
|
+
}
|
|
1073
|
+
/** For a lockfile with no in-place refresh command (yarn 1, bun): the steps that move the copy. */
|
|
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`;
|
|
1078
|
+
// Yarn 1 keys a block by every spec that resolved to it, and an alias block's
|
|
1079
|
+
// first key names the alias ("string-width-cjs@npm:string-width@^4.2.0"), so
|
|
1080
|
+
// name both forms. Deleting the entries and reinstalling is what moved the
|
|
1081
|
+
// copy in the fix-path oracle (case U11).
|
|
1082
|
+
return format === "yarn"
|
|
1083
|
+
? `delete every yarn.lock entry that resolves to ${name} (keys "${name}@…" and alias keys "…@npm:${name}@…"), then run yarn install`
|
|
1084
|
+
: `delete the ${name} entries from bun.lock, then run bun install`;
|
|
1085
|
+
}
|
|
1086
|
+
/** Whether a pnpm lockfile sits at a workspace root (`pnpm-workspace.yaml` beside it). */
|
|
1087
|
+
function isPnpmWorkspaceRoot(lockfile) {
|
|
1088
|
+
return fs.existsSync(path.join(path.dirname(lockfile), "pnpm-workspace.yaml"));
|
|
1089
|
+
}
|
|
1090
|
+
/** The refresh command for the project whose lockfile is `lockfile`; null when no command is named for it. */
|
|
1091
|
+
export function refreshCommandFor(lockfile, name, installed, target) {
|
|
1092
|
+
let content;
|
|
1093
|
+
try {
|
|
1094
|
+
content = fs.readFileSync(lockfile, "utf-8");
|
|
1095
|
+
}
|
|
1096
|
+
catch {
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
const format = lockfileFormat(lockfile, content);
|
|
1100
|
+
return format ? refreshCommand(format, name, installed, target, format === "pnpm" && isPnpmWorkspaceRoot(lockfile)) : null;
|
|
1101
|
+
}
|
|
325
1102
|
/**
|
|
326
1103
|
* Whether refreshing `lockfile` moves `name@installed` to a clean version
|
|
327
|
-
* without any parent changing its declared requirement
|
|
328
|
-
*
|
|
329
|
-
*
|
|
1104
|
+
* without any parent changing its declared requirement: `refreshRule` over
|
|
1105
|
+
* every requirement the parents declare (read from the lockfile, else the
|
|
1106
|
+
* parent's registry metadata). Null when the lockfile cannot be read or no
|
|
1107
|
+
* verdict is possible (the caller keeps "waiting on upstream", the
|
|
1108
|
+
* conservative answer).
|
|
330
1109
|
*/
|
|
331
1110
|
export async function refreshVerdict(sources, ecosystem, lockfile, name, installed, target, advisories, published) {
|
|
332
1111
|
const parents = readLockfileParents(lockfile, name, installed);
|
|
333
1112
|
if (!parents || parents.edges.length === 0)
|
|
334
1113
|
return null;
|
|
335
1114
|
const isCargo = parents.format === "cargo";
|
|
336
|
-
const
|
|
337
|
-
|
|
1115
|
+
const dialect = isCargo ? "cargo" : "npm";
|
|
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));
|
|
338
1118
|
const releases = (published ?? []).filter((v) => !isPrereleaseVersion(v, ecosystem));
|
|
339
|
-
const blocking = [];
|
|
340
|
-
let resolvesTo = null;
|
|
341
1119
|
const edgeReqs = await Promise.all(parents.edges.map((edge) => requirementsOf(sources, ecosystem, edge, name)));
|
|
1120
|
+
const reqs = [];
|
|
342
1121
|
for (const [i, edge] of parents.edges.entries()) {
|
|
343
1122
|
const all = edgeReqs[i];
|
|
344
|
-
if (all === null || all.length === 0)
|
|
345
|
-
|
|
1123
|
+
if (all === null || all.length === 0) {
|
|
1124
|
+
reqs.push({ parent: edge.parent, text: null, dialect, edge }); // not read: the rule cannot count it as admitting
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
346
1127
|
// A parent can declare the same crate twice under different names (feature-gated
|
|
347
1128
|
// `mio-0_6` / `mio-0_7` aliases): only the requirements this copy satisfies apply.
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
for (const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
1129
|
+
const asReq = (text) => ({ parent: edge.parent, text, dialect });
|
|
1130
|
+
const matching = all.filter((text) => requirementAdmits(asReq(text), installed) === true);
|
|
1131
|
+
for (const text of new Set(matching.length > 0 ? matching : all))
|
|
1132
|
+
reqs.push({ ...asReq(text), edge });
|
|
1133
|
+
}
|
|
1134
|
+
const rule = refreshRule(installed, target, reqs);
|
|
1135
|
+
if (rule.verdict === "unknown")
|
|
1136
|
+
return null;
|
|
1137
|
+
const blocking = [];
|
|
1138
|
+
const block = (r) => blocking.push({ parent: r.edge.parent, parentVersion: r.edge.parentVersion, requirement: r.text ?? "", ...(r.edge.bundled ? { bundled: true } : {}) });
|
|
1139
|
+
for (const r of reqs)
|
|
1140
|
+
if (requirementAdmits(r, target) === false)
|
|
1141
|
+
block(r);
|
|
1142
|
+
let resolvesTo = null;
|
|
1143
|
+
if (rule.verdict === "refresh" && !rule.inferred) {
|
|
1144
|
+
if (isCargo) {
|
|
1145
|
+
// `cargo update --precise` moves to exactly the clean target, which every requirement admits.
|
|
1146
|
+
resolvesTo = target;
|
|
1147
|
+
}
|
|
1148
|
+
else if (releases.length > 0) {
|
|
1149
|
+
// npm, pnpm, yarn and bun resolve each requirement to its newest match, which must be clean too.
|
|
1150
|
+
for (const r of reqs) {
|
|
364
1151
|
const newest = releases
|
|
365
|
-
.filter((v) =>
|
|
1152
|
+
.filter((v) => requirementAdmits(r, v) === true)
|
|
366
1153
|
.sort((a, b) => compareVersions(b, a, ecosystem))[0];
|
|
367
|
-
if (!newest || compareVersions(newest, target, ecosystem) < 0 || !clean(newest))
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
else if (!resolvesTo || compareVersions(newest, resolvesTo, ecosystem) < 0) {
|
|
1154
|
+
if (!newest || compareVersions(newest, target, ecosystem) < 0 || !clean(newest))
|
|
1155
|
+
block(r);
|
|
1156
|
+
else if (!resolvesTo || compareVersions(newest, resolvesTo, ecosystem) < 0)
|
|
371
1157
|
resolvesTo = newest;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
else {
|
|
375
|
-
const ok = admits(req, target);
|
|
376
|
-
if (ok === null)
|
|
377
|
-
return null;
|
|
378
|
-
if (!ok)
|
|
379
|
-
blocking.push({ parent: edge.parent, parentVersion: edge.parentVersion, requirement: req });
|
|
380
1158
|
}
|
|
381
1159
|
}
|
|
382
1160
|
}
|
|
383
|
-
|
|
384
|
-
|
|
1161
|
+
const refreshFixes = rule.verdict === "refresh" && blocking.length === 0;
|
|
1162
|
+
const named = refreshCommand(parents.format, name, installed, target, parents.format === "pnpm" && isPnpmWorkspaceRoot(lockfile));
|
|
1163
|
+
let command = named ?? manualRefreshSteps(parents.format === "bun" ? "bun" : "yarn", name);
|
|
1164
|
+
if (isCargo && refreshFixes) {
|
|
385
1165
|
let content;
|
|
386
1166
|
try {
|
|
387
1167
|
content = fs.readFileSync(lockfile, "utf-8");
|
|
@@ -392,15 +1172,18 @@ export async function refreshVerdict(sources, ecosystem, lockfile, name, install
|
|
|
392
1172
|
const prereqs = await cargoPrecisePrerequisites(sources, content, name, installed, target);
|
|
393
1173
|
if (prereqs === null)
|
|
394
1174
|
return null;
|
|
1175
|
+
if (prereqs.some((p) => !/^[A-Za-z0-9_-]+(?:@[0-9A-Za-z.+_-]+)?$/.test(p)))
|
|
1176
|
+
return null;
|
|
395
1177
|
if (prereqs.length > 0)
|
|
396
1178
|
command = `cargo update ${prereqs.map((p) => `-p ${p}`).join(" ")} && ${command}`;
|
|
397
1179
|
}
|
|
398
1180
|
return {
|
|
399
|
-
refreshFixes
|
|
1181
|
+
refreshFixes,
|
|
1182
|
+
inferred: rule.inferred,
|
|
400
1183
|
format: parents.format,
|
|
401
1184
|
command,
|
|
402
1185
|
blocking,
|
|
403
|
-
resolvesTo:
|
|
1186
|
+
resolvesTo: refreshFixes ? resolvesTo : null,
|
|
404
1187
|
};
|
|
405
1188
|
}
|
|
406
1189
|
async function requirementsOf(sources, ecosystem, edge, name) {
|