@4da/mcp-server 6.2.3 → 6.2.4
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 +36 -0
- package/dist/live/fix-paths.d.ts +31 -7
- package/dist/live/fix-paths.js +89 -51
- package/dist/live/range-satisfies.d.ts +4 -2
- package/dist/live/range-satisfies.js +7 -5
- package/dist/live/refresh-rule.d.ts +36 -0
- package/dist/live/refresh-rule.js +49 -0
- package/dist/live/version-compare.d.ts +2 -0
- package/dist/live/version-compare.js +22 -2
- package/dist/tools/upgrade-planner-fixpaths.d.ts +2 -0
- package/dist/tools/upgrade-planner-fixpaths.js +0 -0
- package/dist/tools/upgrade-planner.d.ts +11 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.2.4 — 2026-10-12
|
|
4
|
+
|
|
5
|
+
### Fix paths held to the 4DA rule vectors
|
|
6
|
+
|
|
7
|
+
The fix-path rules (version order, OSV range evaluation, the fix target, the
|
|
8
|
+
refresh-or-parent verdict, the refresh command, `range_check`) now run in CI
|
|
9
|
+
against the shared rule vectors of the 4DA desktop app, the same 333 vectors
|
|
10
|
+
its Rust engine runs (see `conformance/README.md`). All pass. The first run
|
|
11
|
+
found these defects:
|
|
12
|
+
|
|
13
|
+
- **pnpm workspaces: the refresh command missed members.** At a pnpm workspace
|
|
14
|
+
root, `pnpm update <pkg>` touches only the root project, so a member's
|
|
15
|
+
vulnerable transitive copy stayed (measured with pnpm 10.34.6). The command
|
|
16
|
+
is now `pnpm update -r <pkg> --depth Infinity` there, and
|
|
17
|
+
`pnpm update <pkg> --depth Infinity` elsewhere (vectors fc-005..fc-008).
|
|
18
|
+
- **A blocking parent was not named when another parent's requirement could
|
|
19
|
+
not be read.** `upgrade_planner` gave up on the whole copy and kept a
|
|
20
|
+
generic "waiting on upstream". A requirement that excludes the target now
|
|
21
|
+
decides on its own, and the reason names it (rv-048).
|
|
22
|
+
- **Unread requirements fall back to semver, and say so.** When not every
|
|
23
|
+
parent's requirement can be read (the registry did not answer) and none read
|
|
24
|
+
excludes the target, the verdict comes from semver compatibility: a target
|
|
25
|
+
in the installed version's caret range is a `lockfile_refresh` with
|
|
26
|
+
`refreshInferred: true` and a reason that says it was inferred; any other
|
|
27
|
+
target stays `waiting_on_upstream` (AD-055 ¶1; rv-002..rv-032 even ids,
|
|
28
|
+
rv-049, rv-050).
|
|
29
|
+
- **npm aliases were unreadable requirements.** `"npm:string-width@^4.2.0"`,
|
|
30
|
+
how package-lock records cliui's `string-width-cjs`, is now read as `^4.2.0`
|
|
31
|
+
on string-width (rv-047).
|
|
32
|
+
- **PEP 440 local labels did not order.** `1.0`, `1.0+abc.5`, `1.0+abc.7` and
|
|
33
|
+
`1.0+5` compared equal; they now order as PEP 440 says (vo-013..vo-015), so a
|
|
34
|
+
`last_affected` bound equal to a local-labelled install is read correctly.
|
|
35
|
+
- Yarn 1 and bun have no command that moves one transitive copy in place, so
|
|
36
|
+
none is named; the manual steps (delete the lockfile entries, reinstall) are
|
|
37
|
+
unchanged.
|
|
38
|
+
|
|
3
39
|
## 6.2.3 — 2026-10-11
|
|
4
40
|
|
|
5
41
|
### Yarn 1 refresh instruction covers alias entries
|
package/dist/live/fix-paths.d.ts
CHANGED
|
@@ -74,10 +74,17 @@ export declare function verifyFixTargets(sources: FixPathSources | null, items:
|
|
|
74
74
|
/** One clause for a target that is not the highest per-advisory fix, or null when it is. */
|
|
75
75
|
export declare function targetNote(clean: CleanTarget): string | null;
|
|
76
76
|
export interface RefreshVerdict {
|
|
77
|
-
/** True when
|
|
77
|
+
/** True when a lockfile refresh reaches a clean version (read, or inferred: see `inferred`). */
|
|
78
78
|
refreshFixes: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* True when no parent's requirement could be read (or not every one) and none
|
|
81
|
+
* excludes the target, so the verdict comes from semver compatibility
|
|
82
|
+
* (4DA AD-055 ¶1): a refresh when the target is caret-compatible with the
|
|
83
|
+
* installed version, the parent otherwise.
|
|
84
|
+
*/
|
|
85
|
+
inferred: boolean;
|
|
79
86
|
format: LockfileFormat;
|
|
80
|
-
/** The command, run in the lockfile's directory. */
|
|
87
|
+
/** The command, run in the lockfile's directory; for yarn 1 and bun, the manual steps (no command moves one copy in place). */
|
|
81
88
|
command: string;
|
|
82
89
|
/** Parent requirements that do NOT admit a clean version (why it is waiting on upstream). */
|
|
83
90
|
blocking: Array<{
|
|
@@ -88,13 +95,30 @@ export interface RefreshVerdict {
|
|
|
88
95
|
/** The version a refresh resolves to, when the registry's version list was read. */
|
|
89
96
|
resolvesTo: string | null;
|
|
90
97
|
}
|
|
91
|
-
/**
|
|
92
|
-
|
|
98
|
+
/**
|
|
99
|
+
* The command that refreshes one transitive copy in place, for the tools it
|
|
100
|
+
* can be named for (4DA AD-055 ¶4); null for yarn 1 and bun, which have none
|
|
101
|
+
* (see `manualRefreshSteps`).
|
|
102
|
+
*
|
|
103
|
+
* pnpm: at a workspace root (`pnpm-workspace.yaml`), `pnpm update <pkg>`
|
|
104
|
+
* touches only the root project's tree, so a member's transitive copy stayed
|
|
105
|
+
* where it was; `-r` reaches every member (measured with pnpm 10.34.6: a
|
|
106
|
+
* member's mkdirp -> minimist 1.2.5 moved to 1.2.8 only with `-r`). `--depth
|
|
107
|
+
* Infinity` is the form the fix-path oracle applied (2026-10-10, U04/U10);
|
|
108
|
+
* pnpm 8.15 to 10.34 also moved a root project's transitive copy without it.
|
|
109
|
+
*/
|
|
110
|
+
export declare function refreshCommand(format: LockfileFormat, name: string, installed: string, target: string, workspaceRoot?: boolean): string | null;
|
|
111
|
+
/** For a lockfile with no in-place refresh command (yarn 1, bun): the steps that move the copy. */
|
|
112
|
+
export declare function manualRefreshSteps(format: "yarn" | "bun", name: string): string;
|
|
113
|
+
/** The refresh command for the project whose lockfile is `lockfile`; null when no command is named for it. */
|
|
114
|
+
export declare function refreshCommandFor(lockfile: string, name: string, installed: string, target: string): string | null;
|
|
93
115
|
/**
|
|
94
116
|
* Whether refreshing `lockfile` moves `name@installed` to a clean version
|
|
95
|
-
* without any parent changing its declared requirement
|
|
96
|
-
*
|
|
97
|
-
*
|
|
117
|
+
* without any parent changing its declared requirement: `refreshRule` over
|
|
118
|
+
* every requirement the parents declare (read from the lockfile, else the
|
|
119
|
+
* parent's registry metadata). Null when the lockfile cannot be read or no
|
|
120
|
+
* verdict is possible (the caller keeps "waiting on upstream", the
|
|
121
|
+
* conservative answer).
|
|
98
122
|
*/
|
|
99
123
|
export declare function refreshVerdict(sources: FixPathSources, ecosystem: OsvEcosystem, lockfile: string, name: string, installed: string, target: string, advisories: AdvisoryRanges[] | null, published: string[] | null): Promise<RefreshVerdict | null>;
|
|
100
124
|
/**
|
package/dist/live/fix-paths.js
CHANGED
|
@@ -27,9 +27,11 @@ import { fetchWithTimeout } from "./http-utils.js";
|
|
|
27
27
|
import { escapeModulePath } from "./go-registry.js";
|
|
28
28
|
import { advisoryRanges, advisoryAffects, isPrereleaseVersion, smallestCleanVersion } from "./osv-ranges.js";
|
|
29
29
|
import * as fs from "node:fs";
|
|
30
|
-
import
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
import { lockfileFormat, readLockfileParents } from "./lockfile-parents.js";
|
|
31
32
|
import { parseCargoLockPackages } from "./lockfile-parsers.js";
|
|
32
|
-
import { cargoReqAdmits
|
|
33
|
+
import { cargoReqAdmits } from "./range-satisfies.js";
|
|
34
|
+
import { refreshRule, requirementAdmits } from "./refresh-rule.js";
|
|
33
35
|
import { compareVersions } from "./version-compare.js";
|
|
34
36
|
import { CRATES_USER_AGENT } from "./crates-versions.js";
|
|
35
37
|
const TIMEOUT_MS = 15_000;
|
|
@@ -302,86 +304,121 @@ export function targetNote(clean) {
|
|
|
302
304
|
}
|
|
303
305
|
return parts.length > 0 ? parts.join("; ") : null;
|
|
304
306
|
}
|
|
305
|
-
/**
|
|
306
|
-
|
|
307
|
+
/**
|
|
308
|
+
* The command that refreshes one transitive copy in place, for the tools it
|
|
309
|
+
* can be named for (4DA AD-055 ¶4); null for yarn 1 and bun, which have none
|
|
310
|
+
* (see `manualRefreshSteps`).
|
|
311
|
+
*
|
|
312
|
+
* pnpm: at a workspace root (`pnpm-workspace.yaml`), `pnpm update <pkg>`
|
|
313
|
+
* touches only the root project's tree, so a member's transitive copy stayed
|
|
314
|
+
* where it was; `-r` reaches every member (measured with pnpm 10.34.6: a
|
|
315
|
+
* member's mkdirp -> minimist 1.2.5 moved to 1.2.8 only with `-r`). `--depth
|
|
316
|
+
* Infinity` is the form the fix-path oracle applied (2026-10-10, U04/U10);
|
|
317
|
+
* pnpm 8.15 to 10.34 also moved a root project's transitive copy without it.
|
|
318
|
+
*/
|
|
319
|
+
export function refreshCommand(format, name, installed, target, workspaceRoot = false) {
|
|
307
320
|
switch (format) {
|
|
308
321
|
case "package-lock":
|
|
309
322
|
return `npm update ${name}`;
|
|
310
323
|
case "pnpm":
|
|
311
|
-
return `pnpm update ${name}`;
|
|
324
|
+
return `pnpm update ${workspaceRoot ? "-r " : ""}${name} --depth Infinity`;
|
|
312
325
|
case "yarn-berry":
|
|
313
326
|
return `yarn up -R ${name}`;
|
|
314
327
|
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
328
|
case "bun":
|
|
320
|
-
return
|
|
329
|
+
return null;
|
|
321
330
|
case "cargo":
|
|
322
331
|
return `cargo update -p ${name}@${installed} --precise ${target}`;
|
|
323
332
|
}
|
|
324
333
|
}
|
|
334
|
+
/** For a lockfile with no in-place refresh command (yarn 1, bun): the steps that move the copy. */
|
|
335
|
+
export function manualRefreshSteps(format, name) {
|
|
336
|
+
// Yarn 1 keys a block by every spec that resolved to it, and an alias block's
|
|
337
|
+
// first key names the alias ("string-width-cjs@npm:string-width@^4.2.0"), so
|
|
338
|
+
// name both forms. Deleting the entries and reinstalling is what moved the
|
|
339
|
+
// copy in the fix-path oracle (case U11).
|
|
340
|
+
return format === "yarn"
|
|
341
|
+
? `delete every yarn.lock entry that resolves to ${name} (keys "${name}@…" and alias keys "…@npm:${name}@…"), then run yarn install`
|
|
342
|
+
: `delete the ${name} entries from bun.lock, then run bun install`;
|
|
343
|
+
}
|
|
344
|
+
/** Whether a pnpm lockfile sits at a workspace root (`pnpm-workspace.yaml` beside it). */
|
|
345
|
+
function isPnpmWorkspaceRoot(lockfile) {
|
|
346
|
+
return fs.existsSync(path.join(path.dirname(lockfile), "pnpm-workspace.yaml"));
|
|
347
|
+
}
|
|
348
|
+
/** The refresh command for the project whose lockfile is `lockfile`; null when no command is named for it. */
|
|
349
|
+
export function refreshCommandFor(lockfile, name, installed, target) {
|
|
350
|
+
let content;
|
|
351
|
+
try {
|
|
352
|
+
content = fs.readFileSync(lockfile, "utf-8");
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
const format = lockfileFormat(lockfile, content);
|
|
358
|
+
return format ? refreshCommand(format, name, installed, target, format === "pnpm" && isPnpmWorkspaceRoot(lockfile)) : null;
|
|
359
|
+
}
|
|
325
360
|
/**
|
|
326
361
|
* Whether refreshing `lockfile` moves `name@installed` to a clean version
|
|
327
|
-
* without any parent changing its declared requirement
|
|
328
|
-
*
|
|
329
|
-
*
|
|
362
|
+
* without any parent changing its declared requirement: `refreshRule` over
|
|
363
|
+
* every requirement the parents declare (read from the lockfile, else the
|
|
364
|
+
* parent's registry metadata). Null when the lockfile cannot be read or no
|
|
365
|
+
* verdict is possible (the caller keeps "waiting on upstream", the
|
|
366
|
+
* conservative answer).
|
|
330
367
|
*/
|
|
331
368
|
export async function refreshVerdict(sources, ecosystem, lockfile, name, installed, target, advisories, published) {
|
|
332
369
|
const parents = readLockfileParents(lockfile, name, installed);
|
|
333
370
|
if (!parents || parents.edges.length === 0)
|
|
334
371
|
return null;
|
|
335
372
|
const isCargo = parents.format === "cargo";
|
|
336
|
-
const
|
|
373
|
+
const dialect = isCargo ? "cargo" : "npm";
|
|
337
374
|
const clean = (v) => !advisories || !advisories.some((a) => advisoryAffects(a, v, ecosystem));
|
|
338
375
|
const releases = (published ?? []).filter((v) => !isPrereleaseVersion(v, ecosystem));
|
|
339
|
-
const blocking = [];
|
|
340
|
-
let resolvesTo = null;
|
|
341
376
|
const edgeReqs = await Promise.all(parents.edges.map((edge) => requirementsOf(sources, ecosystem, edge, name)));
|
|
377
|
+
const reqs = [];
|
|
342
378
|
for (const [i, edge] of parents.edges.entries()) {
|
|
343
379
|
const all = edgeReqs[i];
|
|
344
|
-
if (all === null || all.length === 0)
|
|
345
|
-
|
|
380
|
+
if (all === null || all.length === 0) {
|
|
381
|
+
reqs.push({ parent: edge.parent, text: null, dialect, edge }); // not read: the rule cannot count it as admitting
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
346
384
|
// A parent can declare the same crate twice under different names (feature-gated
|
|
347
385
|
// `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
|
-
|
|
386
|
+
const asReq = (text) => ({ parent: edge.parent, text, dialect });
|
|
387
|
+
const matching = all.filter((text) => requirementAdmits(asReq(text), installed) === true);
|
|
388
|
+
for (const text of new Set(matching.length > 0 ? matching : all))
|
|
389
|
+
reqs.push({ ...asReq(text), edge });
|
|
390
|
+
}
|
|
391
|
+
const rule = refreshRule(installed, target, reqs);
|
|
392
|
+
if (rule.verdict === "unknown")
|
|
393
|
+
return null;
|
|
394
|
+
const blocking = [];
|
|
395
|
+
const block = (r) => blocking.push({ parent: r.edge.parent, parentVersion: r.edge.parentVersion, requirement: r.text ?? "" });
|
|
396
|
+
for (const r of reqs)
|
|
397
|
+
if (requirementAdmits(r, target) === false)
|
|
398
|
+
block(r);
|
|
399
|
+
let resolvesTo = null;
|
|
400
|
+
if (rule.verdict === "refresh" && !rule.inferred) {
|
|
401
|
+
if (isCargo) {
|
|
402
|
+
// `cargo update --precise` moves to exactly the clean target, which every requirement admits.
|
|
403
|
+
resolvesTo = target;
|
|
404
|
+
}
|
|
405
|
+
else if (releases.length > 0) {
|
|
406
|
+
// npm, pnpm, yarn and bun resolve each requirement to its newest match, which must be clean too.
|
|
407
|
+
for (const r of reqs) {
|
|
364
408
|
const newest = releases
|
|
365
|
-
.filter((v) =>
|
|
409
|
+
.filter((v) => requirementAdmits(r, v) === true)
|
|
366
410
|
.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) {
|
|
411
|
+
if (!newest || compareVersions(newest, target, ecosystem) < 0 || !clean(newest))
|
|
412
|
+
block(r);
|
|
413
|
+
else if (!resolvesTo || compareVersions(newest, resolvesTo, ecosystem) < 0)
|
|
371
414
|
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
415
|
}
|
|
381
416
|
}
|
|
382
417
|
}
|
|
383
|
-
|
|
384
|
-
|
|
418
|
+
const refreshFixes = rule.verdict === "refresh" && blocking.length === 0;
|
|
419
|
+
const named = refreshCommand(parents.format, name, installed, target, parents.format === "pnpm" && isPnpmWorkspaceRoot(lockfile));
|
|
420
|
+
let command = named ?? manualRefreshSteps(parents.format === "bun" ? "bun" : "yarn", name);
|
|
421
|
+
if (isCargo && refreshFixes) {
|
|
385
422
|
let content;
|
|
386
423
|
try {
|
|
387
424
|
content = fs.readFileSync(lockfile, "utf-8");
|
|
@@ -396,11 +433,12 @@ export async function refreshVerdict(sources, ecosystem, lockfile, name, install
|
|
|
396
433
|
command = `cargo update ${prereqs.map((p) => `-p ${p}`).join(" ")} && ${command}`;
|
|
397
434
|
}
|
|
398
435
|
return {
|
|
399
|
-
refreshFixes
|
|
436
|
+
refreshFixes,
|
|
437
|
+
inferred: rule.inferred,
|
|
400
438
|
format: parents.format,
|
|
401
439
|
command,
|
|
402
440
|
blocking,
|
|
403
|
-
resolvesTo:
|
|
441
|
+
resolvesTo: refreshFixes ? resolvesTo : null,
|
|
404
442
|
};
|
|
405
443
|
}
|
|
406
444
|
async function requirementsOf(sources, ecosystem, edge, name) {
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
/**
|
|
15
15
|
* Whether an npm range admits `version`. Null when the range is not a
|
|
16
|
-
* registry semver range (git, file, link, workspace,
|
|
17
|
-
*
|
|
16
|
+
* registry semver range (git, file, link, workspace, tag) or the version is
|
|
17
|
+
* unreadable. An npm alias ("npm:string-width@^4.2.0", the spec a lockfile
|
|
18
|
+
* records for "string-width-cjs") is read by its range: it is the parent's
|
|
19
|
+
* requirement on the aliased package.
|
|
18
20
|
*/
|
|
19
21
|
export declare function npmRangeAdmits(range: string, version: string): boolean | null;
|
|
20
22
|
/** Whether a Cargo version requirement ("0.8", "^1.2", ">=0.10, <0.11", "=1.0.5") admits `version`. Null when unreadable. */
|
|
@@ -130,16 +130,18 @@ function test(set, version) {
|
|
|
130
130
|
}
|
|
131
131
|
/**
|
|
132
132
|
* Whether an npm range admits `version`. Null when the range is not a
|
|
133
|
-
* registry semver range (git, file, link, workspace,
|
|
134
|
-
*
|
|
133
|
+
* registry semver range (git, file, link, workspace, tag) or the version is
|
|
134
|
+
* unreadable. An npm alias ("npm:string-width@^4.2.0", the spec a lockfile
|
|
135
|
+
* records for "string-width-cjs") is read by its range: it is the parent's
|
|
136
|
+
* requirement on the aliased package.
|
|
135
137
|
*/
|
|
136
138
|
export function npmRangeAdmits(range, version) {
|
|
137
139
|
const v = parseSemverPrecedence(version);
|
|
138
140
|
if (!v)
|
|
139
141
|
return null;
|
|
140
|
-
// yarn berry writes "npm:^1.2.0"; an alias
|
|
141
|
-
const text = range.trim().replace(/^npm:(?=[\^~<>=\dvxX*\s]|$)/, "");
|
|
142
|
-
// Protocols (git+https:, file:, link:, workspace
|
|
142
|
+
// yarn berry writes "npm:^1.2.0"; an alias writes "npm:<name>@<range>" (scoped names too).
|
|
143
|
+
const text = range.trim().replace(/^npm:(?:(?:@[^@\s/]+\/)?[^@\s/]+@)?(?=[\^~<>=\dvxX*\s]|$)/, "");
|
|
144
|
+
// Protocols (git+https:, file:, link:, workspace:), paths, GitHub shorthand.
|
|
143
145
|
if (/^[a-z][a-z+]*:/i.test(text) || text.includes("/"))
|
|
144
146
|
return null;
|
|
145
147
|
// A dist-tag ("latest", "next", "beta").
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE refresh-or-parent rule for a transitive copy (4DA AD-055 ¶1): given
|
|
3
|
+
* every requirement read for the parents that pull `installed` in, does a
|
|
4
|
+
* lockfile refresh reach `target`?
|
|
5
|
+
*
|
|
6
|
+
* - Any requirement that excludes the target sends the fix to that parent,
|
|
7
|
+
* even when another parent's requirement cannot be read.
|
|
8
|
+
* - Every requirement admitting it makes a refresh enough.
|
|
9
|
+
* - Nothing (or not everything) readable falls back to semver compatibility,
|
|
10
|
+
* the caret rule: same major at >= 1.0, same minor below it, any bump at
|
|
11
|
+
* 0.0.z leaves it. That answer is `inferred`, and every rendering says so.
|
|
12
|
+
*
|
|
13
|
+
* The 4DA desktop app's `osv::fix_path::refresh_verdict` is the same rule;
|
|
14
|
+
* the shared rule vectors (`refresh_verdict.jsonl`) hold both engines to it.
|
|
15
|
+
* Pure: no I/O.
|
|
16
|
+
*/
|
|
17
|
+
/** How a requirement is written: npm (a bare version is an exact pin) or Cargo (a bare version is a caret). */
|
|
18
|
+
export type RequirementDialect = "npm" | "cargo";
|
|
19
|
+
export interface ParentRequirement {
|
|
20
|
+
parent: string;
|
|
21
|
+
/** The requirement as declared; null when it could not be read at all. */
|
|
22
|
+
text: string | null;
|
|
23
|
+
dialect: RequirementDialect;
|
|
24
|
+
}
|
|
25
|
+
export interface RefreshRule {
|
|
26
|
+
verdict: "refresh" | "parent_must_move" | "unknown";
|
|
27
|
+
/** True when no requirement decided and the verdict comes from semver compatibility. */
|
|
28
|
+
inferred: boolean;
|
|
29
|
+
/** Parents whose requirement excludes the target, in input order. */
|
|
30
|
+
via: string[];
|
|
31
|
+
}
|
|
32
|
+
/** Whether a requirement admits a version; null when either cannot be read. */
|
|
33
|
+
export declare function requirementAdmits(req: ParentRequirement, version: string): boolean | null;
|
|
34
|
+
/** True when moving from `installed` to `target` leaves the caret range of `installed`; null when either is unreadable. */
|
|
35
|
+
export declare function leavesCaret(installed: string, target: string): boolean | null;
|
|
36
|
+
export declare function refreshRule(installed: string, target: string, requirements: ParentRequirement[]): RefreshRule;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* THE refresh-or-parent rule for a transitive copy (4DA AD-055 ¶1): given
|
|
4
|
+
* every requirement read for the parents that pull `installed` in, does a
|
|
5
|
+
* lockfile refresh reach `target`?
|
|
6
|
+
*
|
|
7
|
+
* - Any requirement that excludes the target sends the fix to that parent,
|
|
8
|
+
* even when another parent's requirement cannot be read.
|
|
9
|
+
* - Every requirement admitting it makes a refresh enough.
|
|
10
|
+
* - Nothing (or not everything) readable falls back to semver compatibility,
|
|
11
|
+
* the caret rule: same major at >= 1.0, same minor below it, any bump at
|
|
12
|
+
* 0.0.z leaves it. That answer is `inferred`, and every rendering says so.
|
|
13
|
+
*
|
|
14
|
+
* The 4DA desktop app's `osv::fix_path::refresh_verdict` is the same rule;
|
|
15
|
+
* the shared rule vectors (`refresh_verdict.jsonl`) hold both engines to it.
|
|
16
|
+
* Pure: no I/O.
|
|
17
|
+
*/
|
|
18
|
+
import { cargoReqAdmits, npmRangeAdmits } from "./range-satisfies.js";
|
|
19
|
+
import { parseSemverPrecedence } from "./semver-precedence.js";
|
|
20
|
+
/** Whether a requirement admits a version; null when either cannot be read. */
|
|
21
|
+
export function requirementAdmits(req, version) {
|
|
22
|
+
if (req.text === null)
|
|
23
|
+
return null;
|
|
24
|
+
return req.dialect === "cargo" ? cargoReqAdmits(req.text, version) : npmRangeAdmits(req.text, version);
|
|
25
|
+
}
|
|
26
|
+
/** True when moving from `installed` to `target` leaves the caret range of `installed`; null when either is unreadable. */
|
|
27
|
+
export function leavesCaret(installed, target) {
|
|
28
|
+
const a = parseSemverPrecedence(installed.replace(/^v(?=\d)/, ""));
|
|
29
|
+
const b = parseSemverPrecedence(target.replace(/^v(?=\d)/, ""));
|
|
30
|
+
if (!a || !b)
|
|
31
|
+
return null;
|
|
32
|
+
if (a.major !== b.major)
|
|
33
|
+
return true;
|
|
34
|
+
if (a.major === 0 && a.minor !== b.minor)
|
|
35
|
+
return true;
|
|
36
|
+
return a.major === 0 && a.minor === 0 && a.patch !== b.patch;
|
|
37
|
+
}
|
|
38
|
+
export function refreshRule(installed, target, requirements) {
|
|
39
|
+
const read = requirements.map((r) => requirementAdmits(r, target));
|
|
40
|
+
const via = requirements.filter((_, i) => read[i] === false).map((r) => r.parent);
|
|
41
|
+
if (via.length > 0)
|
|
42
|
+
return { verdict: "parent_must_move", inferred: false, via: [...new Set(via)] };
|
|
43
|
+
if (read.length > 0 && read.every((ok) => ok === true))
|
|
44
|
+
return { verdict: "refresh", inferred: false, via: [] };
|
|
45
|
+
const leaves = leavesCaret(installed, target);
|
|
46
|
+
if (leaves === null)
|
|
47
|
+
return { verdict: "unknown", inferred: true, via: [] };
|
|
48
|
+
return { verdict: leaves ? "parent_must_move" : "refresh", inferred: true, via: [] };
|
|
49
|
+
}
|
|
@@ -20,6 +20,8 @@ interface Pep440 {
|
|
|
20
20
|
pre: [number, number] | null;
|
|
21
21
|
post: number | null;
|
|
22
22
|
dev: number | null;
|
|
23
|
+
/** Local label segments ("1.0+ubuntu.1" -> ["ubuntu", 1]); empty for none. */
|
|
24
|
+
local: Array<number | string>;
|
|
23
25
|
}
|
|
24
26
|
export declare function parsePep440(version: string): Pep440 | null;
|
|
25
27
|
export declare function comparePep440(a: Pep440, b: Pep440): number;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { compareSemver, parseSemver } from "./semver-utils.js";
|
|
17
17
|
import { compareVersionPrecedence, parseSemverPrecedence } from "./semver-precedence.js";
|
|
18
|
-
const PEP440 = /^v?(?:(\d+)!)?(\d+(?:\.\d+)*)(?:[-_.]?(a|alpha|b|beta|c|rc|pre|preview)[-_.]?(\d*))?(?:(?:[-_.]?(?:post|rev|r)[-_.]?(\d*))|-(\d+))?(?:[-_.]?dev[-_.]?(\d*))?(?:\+[a-z0-9]+(?:[-_.][a-z0-9]+)*)?$/i;
|
|
18
|
+
const PEP440 = /^v?(?:(\d+)!)?(\d+(?:\.\d+)*)(?:[-_.]?(a|alpha|b|beta|c|rc|pre|preview)[-_.]?(\d*))?(?:(?:[-_.]?(?:post|rev|r)[-_.]?(\d*))|-(\d+))?(?:[-_.]?dev[-_.]?(\d*))?(?:\+([a-z0-9]+(?:[-_.][a-z0-9]+)*))?$/i;
|
|
19
19
|
export function parsePep440(version) {
|
|
20
20
|
const m = PEP440.exec(version.trim());
|
|
21
21
|
if (!m)
|
|
@@ -27,6 +27,7 @@ export function parsePep440(version) {
|
|
|
27
27
|
pre: m[3] ? [preRank[m[3].toLowerCase()], m[4] ? Number(m[4]) : 0] : null,
|
|
28
28
|
post: m[5] !== undefined ? (m[5] ? Number(m[5]) : 0) : m[6] !== undefined ? Number(m[6]) : null,
|
|
29
29
|
dev: m[7] !== undefined ? (m[7] ? Number(m[7]) : 0) : null,
|
|
30
|
+
local: m[8] ? m[8].split(/[-_.]/).map((seg) => (/^\d+$/.test(seg) ? Number(seg) : seg.toLowerCase())) : [],
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
export function comparePep440(a, b) {
|
|
@@ -55,7 +56,26 @@ export function comparePep440(a, b) {
|
|
|
55
56
|
if (ka[i] !== kb[i])
|
|
56
57
|
return ka[i] > kb[i] ? 1 : -1;
|
|
57
58
|
}
|
|
58
|
-
return
|
|
59
|
+
return compareLocal(a.local, b.local);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* PEP 440 local labels, which decide only between otherwise equal versions:
|
|
63
|
+
* none sorts below any (1.0 < 1.0+abc.5); segment by segment, numbers compare
|
|
64
|
+
* numerically and sort above strings, strings compare case-folded; a label
|
|
65
|
+
* that extends another sorts above it (1.0+abc.5 < 1.0+abc.7 < 1.0+5).
|
|
66
|
+
* Dropping the label made these compare equal, so a `last_affected` bound
|
|
67
|
+
* equal to a local-labelled install was misread.
|
|
68
|
+
*/
|
|
69
|
+
function compareLocal(a, b) {
|
|
70
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
|
71
|
+
const [x, y] = [a[i], b[i]];
|
|
72
|
+
if (x === y)
|
|
73
|
+
continue;
|
|
74
|
+
if (typeof x !== typeof y)
|
|
75
|
+
return typeof x === "number" ? 1 : -1;
|
|
76
|
+
return x > y ? 1 : -1;
|
|
77
|
+
}
|
|
78
|
+
return Math.sign(a.length - b.length);
|
|
59
79
|
}
|
|
60
80
|
// ---------------------------------------------------------------------------
|
|
61
81
|
// Dispatch
|
|
@@ -28,6 +28,8 @@ export interface FixPathStep {
|
|
|
28
28
|
dir: string;
|
|
29
29
|
command: string;
|
|
30
30
|
}>;
|
|
31
|
+
/** lockfile_refresh: true when not every parent's requirement could be read and the verdict comes from semver compatibility. */
|
|
32
|
+
refreshInferred?: boolean;
|
|
31
33
|
}
|
|
32
34
|
export interface FixPathInput {
|
|
33
35
|
step: FixPathStep;
|
|
Binary file
|
|
@@ -57,9 +57,11 @@ interface UpgradeRecommendation {
|
|
|
57
57
|
/**
|
|
58
58
|
* Direct deps you can bump yourself; `lockfile_refresh` = transitive, and
|
|
59
59
|
* every parent's declared requirement already admits a clean version, so
|
|
60
|
-
* `refreshCommands` fixes it
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* `refreshCommands` fixes it (`refreshInferred` when that is inferred from
|
|
61
|
+
* semver because a requirement could not be read); `waiting_on_upstream` =
|
|
62
|
+
* transitive, and a parent's requirement excludes every clean version (or
|
|
63
|
+
* none could be read and the target leaves the semver-compatible range);
|
|
64
|
+
* `reinstall` = the lockfile's version is fine
|
|
63
65
|
* but node_modules holds another one, so the fix is `installFix`;
|
|
64
66
|
* `no_fix_available` = vulnerable with no fixed release to move to;
|
|
65
67
|
* `not_built_on_this_host` = every advisory is against code this host never compiles.
|
|
@@ -78,6 +80,12 @@ interface UpgradeRecommendation {
|
|
|
78
80
|
dir: string;
|
|
79
81
|
command: string;
|
|
80
82
|
}>;
|
|
83
|
+
/**
|
|
84
|
+
* lockfile_refresh: true when not every parent's requirement could be read
|
|
85
|
+
* (none read excludes the target) and the target is semver-compatible with
|
|
86
|
+
* the installed version, so the refresh is inferred rather than read.
|
|
87
|
+
*/
|
|
88
|
+
refreshInferred?: boolean;
|
|
81
89
|
/** False when the dependency is gated behind a target spec not active on this host (label, never suppressed). */
|
|
82
90
|
platformActive: boolean;
|
|
83
91
|
/** npm: the version node_modules holds, when it differs from `currentVersion` (the lockfile's). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@4da/mcp-server",
|
|
3
|
-
"version": "6.2.
|
|
3
|
+
"version": "6.2.4",
|
|
4
4
|
"mcpName": "io.github.4DA-Systems/4da-mcp-server",
|
|
5
5
|
"description": "Upgrade intelligence for AI coding agents: what changes between your installed and target version (changelog, breaking changes, the files you import it from), a pre-install verdict on each bump, vulnerability scanning at osv-scanner parity across npm/pnpm/yarn, Cargo, Python and Go lockfiles, ranked upgrade plans, task-scoped briefings, decision memory. Local, no account; works standalone, and adds the 4DA desktop app's signals, knowledge gaps and stack-judged news when it is installed.",
|
|
6
6
|
"type": "module",
|