@4da/mcp-server 6.2.2 → 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 +66 -0
- package/dist/live/fix-paths.d.ts +31 -7
- package/dist/live/fix-paths.js +89 -48
- package/dist/live/js-lockfile-readers.d.ts +27 -0
- package/dist/live/js-lockfile-readers.js +64 -24
- package/dist/live/lockfile-parents.d.ts +12 -0
- package/dist/live/lockfile-parents.js +49 -25
- 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,71 @@
|
|
|
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
|
+
|
|
39
|
+
## 6.2.3 — 2026-10-11
|
|
40
|
+
|
|
41
|
+
### Yarn 1 refresh instruction covers alias entries
|
|
42
|
+
|
|
43
|
+
The yarn 1 refresh step said "delete the `<name>@` entries from yarn.lock". A
|
|
44
|
+
yarn 1 block is keyed by every spec that resolved to it, and an alias block's
|
|
45
|
+
first key names the alias (`"string-width-cjs@npm:string-width@^4.2.0"`), so
|
|
46
|
+
following the step left those blocks and kept the vulnerable version. It now
|
|
47
|
+
names both forms: keys `"<name>@…"` and alias keys `"…@npm:<name>@…"`.
|
|
48
|
+
|
|
49
|
+
### Fix-path parents name the real package
|
|
50
|
+
|
|
51
|
+
The parent reader behind the fix-path rule ("a lockfile refresh is enough"
|
|
52
|
+
vs "the parent must move") parsed lockfiles with its own code and still had
|
|
53
|
+
the two naming defects 6.2.2 fixed in the package readers. It now names
|
|
54
|
+
packages with the readers' helpers.
|
|
55
|
+
|
|
56
|
+
- **yarn (v1 and berry): npm aliases.** A parent installed under an alias was
|
|
57
|
+
reported by the alias: on hono's benchmarks/routers lockfile, string-width
|
|
58
|
+
4.2.3 and wrap-ansi 7.0.0 were named `string-width-cjs` and `wrap-ansi-cjs`.
|
|
59
|
+
An aliased dependency edge (`string-width-cjs: "npm:string-width@^4.2.0"`)
|
|
60
|
+
was never matched, so @isaacs/cliui was missing from string-width 4.2.3's
|
|
61
|
+
parents, and its range is now the real package's `^4.2.0`.
|
|
62
|
+
- **pnpm 5: entries keyed by a tarball URL.** Such an entry was dropped as a
|
|
63
|
+
parent and as a dependency: trpc's tarball-keyed @prisma/client 4.7.1 was
|
|
64
|
+
missing from prisma 4.7.1's parents, and the four workspace projects that
|
|
65
|
+
depend on it were missing from @prisma/client's. A dependency value that
|
|
66
|
+
points at another entry (that tarball key, or a pnpm alias's
|
|
67
|
+
`/real/1.2.3`) is now an edge to the package it installs.
|
|
68
|
+
|
|
3
69
|
## 6.2.2 — 2026-10-11
|
|
4
70
|
|
|
5
71
|
### Lockfile readers agree with the 4DA conformance corpus
|
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,83 +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
|
-
return `delete the ${name}@ entries from yarn.lock, then run yarn install`;
|
|
316
328
|
case "bun":
|
|
317
|
-
return
|
|
329
|
+
return null;
|
|
318
330
|
case "cargo":
|
|
319
331
|
return `cargo update -p ${name}@${installed} --precise ${target}`;
|
|
320
332
|
}
|
|
321
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
|
+
}
|
|
322
360
|
/**
|
|
323
361
|
* Whether refreshing `lockfile` moves `name@installed` to a clean version
|
|
324
|
-
* without any parent changing its declared requirement
|
|
325
|
-
*
|
|
326
|
-
*
|
|
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).
|
|
327
367
|
*/
|
|
328
368
|
export async function refreshVerdict(sources, ecosystem, lockfile, name, installed, target, advisories, published) {
|
|
329
369
|
const parents = readLockfileParents(lockfile, name, installed);
|
|
330
370
|
if (!parents || parents.edges.length === 0)
|
|
331
371
|
return null;
|
|
332
372
|
const isCargo = parents.format === "cargo";
|
|
333
|
-
const
|
|
373
|
+
const dialect = isCargo ? "cargo" : "npm";
|
|
334
374
|
const clean = (v) => !advisories || !advisories.some((a) => advisoryAffects(a, v, ecosystem));
|
|
335
375
|
const releases = (published ?? []).filter((v) => !isPrereleaseVersion(v, ecosystem));
|
|
336
|
-
const blocking = [];
|
|
337
|
-
let resolvesTo = null;
|
|
338
376
|
const edgeReqs = await Promise.all(parents.edges.map((edge) => requirementsOf(sources, ecosystem, edge, name)));
|
|
377
|
+
const reqs = [];
|
|
339
378
|
for (const [i, edge] of parents.edges.entries()) {
|
|
340
379
|
const all = edgeReqs[i];
|
|
341
|
-
if (all === null || all.length === 0)
|
|
342
|
-
|
|
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
|
+
}
|
|
343
384
|
// A parent can declare the same crate twice under different names (feature-gated
|
|
344
385
|
// `mio-0_6` / `mio-0_7` aliases): only the requirements this copy satisfies apply.
|
|
345
|
-
const
|
|
346
|
-
const
|
|
347
|
-
for (const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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) {
|
|
361
408
|
const newest = releases
|
|
362
|
-
.filter((v) =>
|
|
409
|
+
.filter((v) => requirementAdmits(r, v) === true)
|
|
363
410
|
.sort((a, b) => compareVersions(b, a, ecosystem))[0];
|
|
364
|
-
if (!newest || compareVersions(newest, target, ecosystem) < 0 || !clean(newest))
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
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)
|
|
368
414
|
resolvesTo = newest;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
else {
|
|
372
|
-
const ok = admits(req, target);
|
|
373
|
-
if (ok === null)
|
|
374
|
-
return null;
|
|
375
|
-
if (!ok)
|
|
376
|
-
blocking.push({ parent: edge.parent, parentVersion: edge.parentVersion, requirement: req });
|
|
377
415
|
}
|
|
378
416
|
}
|
|
379
417
|
}
|
|
380
|
-
|
|
381
|
-
|
|
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) {
|
|
382
422
|
let content;
|
|
383
423
|
try {
|
|
384
424
|
content = fs.readFileSync(lockfile, "utf-8");
|
|
@@ -393,11 +433,12 @@ export async function refreshVerdict(sources, ecosystem, lockfile, name, install
|
|
|
393
433
|
command = `cargo update ${prereqs.map((p) => `-p ${p}`).join(" ")} && ${command}`;
|
|
394
434
|
}
|
|
395
435
|
return {
|
|
396
|
-
refreshFixes
|
|
436
|
+
refreshFixes,
|
|
437
|
+
inferred: rule.inferred,
|
|
397
438
|
format: parents.format,
|
|
398
439
|
command,
|
|
399
440
|
blocking,
|
|
400
|
-
resolvesTo:
|
|
441
|
+
resolvesTo: refreshFixes ? resolvesTo : null,
|
|
401
442
|
};
|
|
402
443
|
}
|
|
403
444
|
async function requirementsOf(sources, ecosystem, edge, name) {
|
|
@@ -17,12 +17,30 @@
|
|
|
17
17
|
* file, link) is dropped instead of being queried as a package name.
|
|
18
18
|
*/
|
|
19
19
|
import type { InstanceSet } from "./lockfile-types.js";
|
|
20
|
+
/** An exact version with pnpm's peer suffixes removed: v6/v9 `1.2.3(react@18)`, v5 `1.2.3_react@18`. */
|
|
21
|
+
export declare function exactVersion(raw: string): string | null;
|
|
20
22
|
/**
|
|
21
23
|
* Splits one `packages:` or `snapshots:` key into [name, version]:
|
|
22
24
|
* v9 `'@scope/pkg@1.2.3(peer@4.5.6)'`, v6 `/pkg@1.2.3`, v5 `/@scope/pkg/1.2.3_peer@4.5.6`.
|
|
23
25
|
* Returns null for a key that does not pin a registry version.
|
|
24
26
|
*/
|
|
25
27
|
export declare function parsePnpmKey(rawKey: string): [string, string] | null;
|
|
28
|
+
/** A `packages:`/`snapshots:` key, or a dependency value that points at one, in one spelling: unquoted, no leading `/`. */
|
|
29
|
+
export declare function pnpmKeyRef(raw: string): string;
|
|
30
|
+
export interface PnpmEntryReader {
|
|
31
|
+
/** One line inside a `packages:` or `snapshots:` section (indent > 0). */
|
|
32
|
+
line(indent: number, text: string): void;
|
|
33
|
+
/** Ends the entry being read: call at every section change and at the end of the file. */
|
|
34
|
+
finish(): void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Reads `packages:`/`snapshots:` entries line by line and reports the
|
|
38
|
+
* registry (name, version) each installs (see pnpmEntryIdentity); entries
|
|
39
|
+
* that install no registry release are not reported.
|
|
40
|
+
*/
|
|
41
|
+
export declare function pnpmEntryReader(onEntry: (key: string, id: [string, string], dev: boolean | undefined) => void): PnpmEntryReader;
|
|
42
|
+
/** Every `packages:`/`snapshots:` entry's registry (name, version), by pnpmKeyRef of its key. */
|
|
43
|
+
export declare function pnpmEntryIdentities(content: string): Map<string, [string, string]>;
|
|
26
44
|
/**
|
|
27
45
|
* Reads a pnpm lockfile (v5.x, v6 or v9) into `versions`; the first version seen wins.
|
|
28
46
|
*
|
|
@@ -37,6 +55,15 @@ export declare function parsePnpmKey(rawKey: string): [string, string] | null;
|
|
|
37
55
|
* package key. pnpm 9 dropped that field, so its copies have unknown scope.
|
|
38
56
|
*/
|
|
39
57
|
export declare function readPnpmLock(content: string, versions: Map<string, string>, instances?: InstanceSet): void;
|
|
58
|
+
/**
|
|
59
|
+
* The registry package a yarn block installs. An npm alias
|
|
60
|
+
* (`"string-width-cjs": "npm:string-width@^4.2.0"`) keys the block by the
|
|
61
|
+
* alias, which is not a package: OSV knows `string-width`, so the alias was
|
|
62
|
+
* queried for nothing and the real package went unscanned (hono's
|
|
63
|
+
* benchmarks/routers yarn.lock, 4DA conformance corpus, 2026-10-12). berry
|
|
64
|
+
* names the real package in `resolution:`; v1 only in the alias spec.
|
|
65
|
+
*/
|
|
66
|
+
export declare function yarnPackageName(block: string, firstLine: string, headerName: string): string;
|
|
40
67
|
/**
|
|
41
68
|
* Reads a yarn lockfile (v1 or berry) into `versions`.
|
|
42
69
|
* v1: `"@scope/pkg@^1.0.0", "@scope/pkg@^1.1.0":` then ` version "1.2.3"`.
|
|
@@ -24,7 +24,7 @@ function unquote(s) {
|
|
|
24
24
|
return s.length >= 2 && (s[0] === "'" || s[0] === '"') && s[s.length - 1] === s[0] ? s.slice(1, -1) : s;
|
|
25
25
|
}
|
|
26
26
|
/** An exact version with pnpm's peer suffixes removed: v6/v9 `1.2.3(react@18)`, v5 `1.2.3_react@18`. */
|
|
27
|
-
function exactVersion(raw) {
|
|
27
|
+
export function exactVersion(raw) {
|
|
28
28
|
const version = unquote(raw.trim()).split("(")[0].split("_")[0].trim();
|
|
29
29
|
return /^\d/.test(version) ? version : null;
|
|
30
30
|
}
|
|
@@ -116,6 +116,60 @@ function pnpmEntryIdentity(entry) {
|
|
|
116
116
|
}
|
|
117
117
|
return parsed;
|
|
118
118
|
}
|
|
119
|
+
/** A `packages:`/`snapshots:` key, or a dependency value that points at one, in one spelling: unquoted, no leading `/`. */
|
|
120
|
+
export function pnpmKeyRef(raw) {
|
|
121
|
+
return unquote(raw.trim()).replace(/^\//, "");
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Reads `packages:`/`snapshots:` entries line by line and reports the
|
|
125
|
+
* registry (name, version) each installs (see pnpmEntryIdentity); entries
|
|
126
|
+
* that install no registry release are not reported.
|
|
127
|
+
*/
|
|
128
|
+
export function pnpmEntryReader(onEntry) {
|
|
129
|
+
let entry = null;
|
|
130
|
+
const finish = () => {
|
|
131
|
+
const id = entry ? pnpmEntryIdentity(entry) : null;
|
|
132
|
+
if (entry && id)
|
|
133
|
+
onEntry(entry.key, id, entry.dev);
|
|
134
|
+
entry = null;
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
line(indent, text) {
|
|
138
|
+
if (indent === 2 && text.endsWith(":")) {
|
|
139
|
+
finish();
|
|
140
|
+
entry = { key: text.slice(0, -1), inResolution: false, local: false };
|
|
141
|
+
}
|
|
142
|
+
else if (entry && indent === 4) {
|
|
143
|
+
pnpmEntryField(entry, text);
|
|
144
|
+
}
|
|
145
|
+
else if (entry?.inResolution && indent > 4) {
|
|
146
|
+
pnpmResolutionText(entry, text);
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
finish,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Every `packages:`/`snapshots:` entry's registry (name, version), by pnpmKeyRef of its key. */
|
|
153
|
+
export function pnpmEntryIdentities(content) {
|
|
154
|
+
const ids = new Map();
|
|
155
|
+
const reader = pnpmEntryReader((key, id) => ids.set(pnpmKeyRef(key), id));
|
|
156
|
+
let inEntries = false;
|
|
157
|
+
for (const line of content.split(/\r?\n/)) {
|
|
158
|
+
const text = line.trim();
|
|
159
|
+
if (text === "" || text.startsWith("#"))
|
|
160
|
+
continue;
|
|
161
|
+
const indent = line.length - line.trimStart().length;
|
|
162
|
+
if (indent === 0) {
|
|
163
|
+
reader.finish();
|
|
164
|
+
inEntries = text === "packages:" || text === "snapshots:";
|
|
165
|
+
}
|
|
166
|
+
else if (inEntries) {
|
|
167
|
+
reader.line(indent, text);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
reader.finish();
|
|
171
|
+
return ids;
|
|
172
|
+
}
|
|
119
173
|
/**
|
|
120
174
|
* Reads a pnpm lockfile (v5.x, v6 or v9) into `versions`; the first version seen wins.
|
|
121
175
|
*
|
|
@@ -139,39 +193,25 @@ export function readPnpmLock(content, versions, instances) {
|
|
|
139
193
|
let section = "";
|
|
140
194
|
let blockIndent = -1; // column of the dependency block being read, or -1
|
|
141
195
|
let pending = null; // v6/v9: a name whose `version:` line follows
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (entry.dev !== undefined)
|
|
148
|
-
devByKey.set(`${id[0]}\0${id[1]}`, entry.dev);
|
|
149
|
-
}
|
|
150
|
-
entry = null;
|
|
151
|
-
};
|
|
196
|
+
const entries = pnpmEntryReader((_key, id, dev) => {
|
|
197
|
+
transitive.push(id);
|
|
198
|
+
if (dev !== undefined)
|
|
199
|
+
devByKey.set(`${id[0]}\0${id[1]}`, dev);
|
|
200
|
+
});
|
|
152
201
|
for (const line of content.split(/\r?\n/)) {
|
|
153
202
|
const text = line.trim();
|
|
154
203
|
if (text === "" || text.startsWith("#"))
|
|
155
204
|
continue;
|
|
156
205
|
const indent = line.length - line.trimStart().length;
|
|
157
206
|
if (indent === 0) {
|
|
158
|
-
finish();
|
|
207
|
+
entries.finish();
|
|
159
208
|
section = text.endsWith(":") ? text.slice(0, -1) : "";
|
|
160
209
|
blockIndent = DEPENDENCY_BLOCKS.has(text) ? 0 : -1;
|
|
161
210
|
pending = null;
|
|
162
211
|
continue;
|
|
163
212
|
}
|
|
164
213
|
if (section === "packages" || section === "snapshots") {
|
|
165
|
-
|
|
166
|
-
finish();
|
|
167
|
-
entry = { key: text.slice(0, -1), inResolution: false, local: false };
|
|
168
|
-
}
|
|
169
|
-
else if (entry && indent === 4) {
|
|
170
|
-
pnpmEntryField(entry, text);
|
|
171
|
-
}
|
|
172
|
-
else if (entry?.inResolution && indent > 4) {
|
|
173
|
-
pnpmResolutionText(entry, text);
|
|
174
|
-
}
|
|
214
|
+
entries.line(indent, text);
|
|
175
215
|
continue;
|
|
176
216
|
}
|
|
177
217
|
if (section === "importers" && indent <= 4) {
|
|
@@ -204,7 +244,7 @@ export function readPnpmLock(content, versions, instances) {
|
|
|
204
244
|
pending = null;
|
|
205
245
|
}
|
|
206
246
|
}
|
|
207
|
-
finish();
|
|
247
|
+
entries.finish();
|
|
208
248
|
for (const [name, version] of transitive)
|
|
209
249
|
setFirst(name, version);
|
|
210
250
|
if (!instances)
|
|
@@ -224,7 +264,7 @@ export function readPnpmLock(content, versions, instances) {
|
|
|
224
264
|
* benchmarks/routers yarn.lock, 4DA conformance corpus, 2026-10-12). berry
|
|
225
265
|
* names the real package in `resolution:`; v1 only in the alias spec.
|
|
226
266
|
*/
|
|
227
|
-
function yarnPackageName(block, firstLine, headerName) {
|
|
267
|
+
export function yarnPackageName(block, firstLine, headerName) {
|
|
228
268
|
const resolution = /^\s+resolution:\s+"?((?:@[^\s/@"]+\/)?[^\s/@"]+)@npm:/m.exec(block);
|
|
229
269
|
if (resolution)
|
|
230
270
|
return resolution[1];
|
|
@@ -45,8 +45,20 @@ export declare function packageLockParents(lock: {
|
|
|
45
45
|
* dependency blocks resolve `name` to `version`. pnpm records resolved
|
|
46
46
|
* versions only, so `range` is null. Importers (the project's own manifests)
|
|
47
47
|
* become local edges with their `specifier` when the lockfile records one.
|
|
48
|
+
*
|
|
49
|
+
* Entries and dependency values are named the way the package reader names
|
|
50
|
+
* them (pnpmEntryIdentities): a pnpm 5 entry keyed by its tarball URL is the
|
|
51
|
+
* package its `name:`/`version:` fields say, and a dependency value that is a
|
|
52
|
+
* key rather than a version (that tarball key, or an alias's
|
|
53
|
+
* `/real/1.2.3`, `/real@1.2.3`, `real@1.2.3`) is an edge to the package it points at.
|
|
48
54
|
*/
|
|
49
55
|
export declare function pnpmParents(content: string, name: string, version: string): ParentEdge[];
|
|
56
|
+
/**
|
|
57
|
+
* Blocks whose dependencies resolve `name` to `version`. An npm alias edge
|
|
58
|
+
* (`string-width-cjs: "npm:string-width@^4.2.0"`) is an edge to the real
|
|
59
|
+
* package, with the real package's range; it resolves through the block whose
|
|
60
|
+
* spec is `string-width-cjs@npm:string-width@^4.2.0`.
|
|
61
|
+
*/
|
|
50
62
|
export declare function yarnParents(content: string, name: string, version: string): ParentEdge[];
|
|
51
63
|
export declare function bunParents(lock: {
|
|
52
64
|
packages?: Record<string, unknown>;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { stripTrailingCommas } from "./bun-lockfile.js";
|
|
14
|
-
import { parsePnpmKey } from "./js-lockfile-readers.js";
|
|
14
|
+
import { exactVersion, parsePnpmKey, pnpmEntryIdentities, pnpmKeyRef, yarnPackageName } from "./js-lockfile-readers.js";
|
|
15
15
|
import { packageNameFromNodeModulesPath, parseCargoLockPackages } from "./lockfile-parsers.js";
|
|
16
16
|
const DEP_FIELDS = ["dependencies", "optionalDependencies", "peerDependencies"];
|
|
17
17
|
/** The lockfile format of a path, or null for a file this module does not read. */
|
|
@@ -123,18 +123,32 @@ export function packageLockParents(lock, name, version) {
|
|
|
123
123
|
// ---------------------------------------------------------------------------
|
|
124
124
|
// pnpm-lock.yaml (v5, v6, v9)
|
|
125
125
|
// ---------------------------------------------------------------------------
|
|
126
|
-
function exactVersion(raw) {
|
|
127
|
-
const unquoted = raw.trim().replace(/^['"]|['"]$/g, "");
|
|
128
|
-
const v = unquoted.split("(")[0].split("_")[0].trim();
|
|
129
|
-
return /^\d/.test(v) ? v : null;
|
|
130
|
-
}
|
|
131
126
|
/**
|
|
132
127
|
* Parents from `packages:` (v5/v6) or `snapshots:` (v9) entries whose
|
|
133
128
|
* dependency blocks resolve `name` to `version`. pnpm records resolved
|
|
134
129
|
* versions only, so `range` is null. Importers (the project's own manifests)
|
|
135
130
|
* become local edges with their `specifier` when the lockfile records one.
|
|
131
|
+
*
|
|
132
|
+
* Entries and dependency values are named the way the package reader names
|
|
133
|
+
* them (pnpmEntryIdentities): a pnpm 5 entry keyed by its tarball URL is the
|
|
134
|
+
* package its `name:`/`version:` fields say, and a dependency value that is a
|
|
135
|
+
* key rather than a version (that tarball key, or an alias's
|
|
136
|
+
* `/real/1.2.3`, `/real@1.2.3`, `real@1.2.3`) is an edge to the package it points at.
|
|
136
137
|
*/
|
|
137
138
|
export function pnpmParents(content, name, version) {
|
|
139
|
+
const ids = pnpmEntryIdentities(content);
|
|
140
|
+
/** The (name, version) a dependency line installs: `dep: 1.2.3`, or a value that points at an entry. */
|
|
141
|
+
const installs = (dep, value) => {
|
|
142
|
+
const exact = exactVersion(value);
|
|
143
|
+
if (exact)
|
|
144
|
+
return [dep, exact];
|
|
145
|
+
const ref = pnpmKeyRef(value);
|
|
146
|
+
return ids.get(ref) ?? parsePnpmKey(ref);
|
|
147
|
+
};
|
|
148
|
+
const isTarget = (dep, value) => {
|
|
149
|
+
const id = installs(dep, value);
|
|
150
|
+
return id !== null && id[0] === name && id[1] === version;
|
|
151
|
+
};
|
|
138
152
|
const edges = [];
|
|
139
153
|
const seen = new Set();
|
|
140
154
|
let section = "";
|
|
@@ -142,7 +156,7 @@ export function pnpmParents(content, name, version) {
|
|
|
142
156
|
let importer = null;
|
|
143
157
|
let inDeps = false;
|
|
144
158
|
let depIndent = -1;
|
|
145
|
-
let
|
|
159
|
+
let pendingDep = null;
|
|
146
160
|
let importerSpecifier = null;
|
|
147
161
|
const add = (edge) => {
|
|
148
162
|
const key = `${edge.parent}\0${edge.parentVersion}`;
|
|
@@ -166,7 +180,7 @@ export function pnpmParents(content, name, version) {
|
|
|
166
180
|
}
|
|
167
181
|
if (section === "packages" || section === "snapshots") {
|
|
168
182
|
if (indent === 2 && text.endsWith(":")) {
|
|
169
|
-
parent =
|
|
183
|
+
parent = ids.get(pnpmKeyRef(text.slice(0, -1))) ?? null;
|
|
170
184
|
inDeps = false;
|
|
171
185
|
}
|
|
172
186
|
else if (indent === 4 && parent) {
|
|
@@ -174,7 +188,7 @@ export function pnpmParents(content, name, version) {
|
|
|
174
188
|
}
|
|
175
189
|
else if (indent === 6 && parent && inDeps) {
|
|
176
190
|
const m = /^(?:'([^']+)'|"([^"]+)"|([^\s:'"]+)):\s*(.+)$/.exec(text);
|
|
177
|
-
if (m && (m[1] ?? m[2] ?? m[3]
|
|
191
|
+
if (m && isTarget(m[1] ?? m[2] ?? m[3], m[4])) {
|
|
178
192
|
add({ parent: parent[0], parentVersion: parent[1], range: null, local: false });
|
|
179
193
|
}
|
|
180
194
|
}
|
|
@@ -197,21 +211,23 @@ export function pnpmParents(content, name, version) {
|
|
|
197
211
|
// A dependency of the project itself: v5 `name: 1.2.3`, v6/v9 `name:` + `specifier:` + `version:`.
|
|
198
212
|
if (indent === depIndent + 2) {
|
|
199
213
|
const m = /^(?:'([^']+)'|"([^"]+)"|([^\s:'"]+)):(?:\s+(.*))?$/.exec(text);
|
|
200
|
-
|
|
214
|
+
pendingDep = m ? m[1] ?? m[2] ?? m[3] : null;
|
|
201
215
|
importerSpecifier = null;
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
|
|
216
|
+
if (pendingDep && m?.[4]) {
|
|
217
|
+
if (isTarget(pendingDep, m[4]))
|
|
218
|
+
add({ parent: importer, parentVersion: null, range: null, local: true });
|
|
219
|
+
pendingDep = null;
|
|
205
220
|
}
|
|
206
221
|
}
|
|
207
|
-
else if (indent === depIndent + 4 &&
|
|
222
|
+
else if (indent === depIndent + 4 && pendingDep) {
|
|
208
223
|
const spec = /^specifier:\s*(.+)$/.exec(text);
|
|
209
224
|
if (spec)
|
|
210
225
|
importerSpecifier = spec[1].replace(/^['"]|['"]$/g, "");
|
|
211
226
|
const ver = /^version:\s*(.+)$/.exec(text);
|
|
212
|
-
if (ver
|
|
213
|
-
|
|
214
|
-
|
|
227
|
+
if (ver) {
|
|
228
|
+
if (isTarget(pendingDep, ver[1]))
|
|
229
|
+
add({ parent: importer, parentVersion: null, range: importerSpecifier, local: true });
|
|
230
|
+
pendingDep = null;
|
|
215
231
|
}
|
|
216
232
|
}
|
|
217
233
|
}
|
|
@@ -226,9 +242,11 @@ function parseYarnBlocks(content) {
|
|
|
226
242
|
for (const block of content.split(/\r?\n(?=\S)/)) {
|
|
227
243
|
const lines = block.split(/\r?\n/);
|
|
228
244
|
const header = lines[0];
|
|
229
|
-
const
|
|
230
|
-
if (!
|
|
245
|
+
const headerName = /^"?(@?[^@\s"]+)@/.exec(header)?.[1];
|
|
246
|
+
if (!headerName || headerName.startsWith("__"))
|
|
231
247
|
continue;
|
|
248
|
+
// An npm alias block (`string-width-cjs@npm:string-width@^4.2.0`) installs the real package.
|
|
249
|
+
const name = yarnPackageName(block, header, headerName);
|
|
232
250
|
const version = /^\s+version:?\s+"?([^"\s]+)"?\s*$/m.exec(block)?.[1];
|
|
233
251
|
if (!version)
|
|
234
252
|
continue;
|
|
@@ -260,21 +278,27 @@ function parseYarnBlocks(content) {
|
|
|
260
278
|
}
|
|
261
279
|
return blocks;
|
|
262
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Blocks whose dependencies resolve `name` to `version`. An npm alias edge
|
|
283
|
+
* (`string-width-cjs: "npm:string-width@^4.2.0"`) is an edge to the real
|
|
284
|
+
* package, with the real package's range; it resolves through the block whose
|
|
285
|
+
* spec is `string-width-cjs@npm:string-width@^4.2.0`.
|
|
286
|
+
*/
|
|
263
287
|
export function yarnParents(content, name, version) {
|
|
264
288
|
const blocks = parseYarnBlocks(content);
|
|
265
289
|
const resolved = new Map();
|
|
266
290
|
for (const b of blocks)
|
|
267
291
|
for (const spec of b.specs)
|
|
268
|
-
resolved.set(spec, b
|
|
292
|
+
resolved.set(spec, b);
|
|
269
293
|
const edges = [];
|
|
270
294
|
for (const b of blocks) {
|
|
271
295
|
for (const [dep, rawRange] of b.deps) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (resolved.get(`${name}@${range}`) !== version)
|
|
296
|
+
const spec = rawRange.replace(/^npm:/, "");
|
|
297
|
+
const target = resolved.get(`${dep}@${spec}`);
|
|
298
|
+
if (!target || target.name !== name || target.version !== version)
|
|
276
299
|
continue;
|
|
277
|
-
|
|
300
|
+
const alias = /^npm:((?:@[^\s/@]+\/)?[^\s/@]+)@(.+)$/.exec(rawRange);
|
|
301
|
+
edges.push({ parent: b.name, parentVersion: b.version, range: alias ? alias[2] : spec, local: false });
|
|
278
302
|
}
|
|
279
303
|
}
|
|
280
304
|
return edges;
|
|
@@ -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",
|