@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 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
@@ -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 every parent's requirement already admits a clean version. */
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
- /** The refresh command for one lockfile format. */
92
- export declare function refreshCommand(format: LockfileFormat, name: string, installed: string, target: string): string;
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. Null when the
96
- * lockfile or a requirement cannot be read (the caller keeps "waiting on
97
- * upstream", the conservative answer).
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
  /**
@@ -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 { readLockfileParents } from "./lockfile-parents.js";
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, npmRangeAdmits } from "./range-satisfies.js";
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
- /** The refresh command for one lockfile format. */
306
- export function refreshCommand(format, name, installed, target) {
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 `delete the ${name} entries from bun.lock, then run bun install`;
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. Null when the
325
- * lockfile or a requirement cannot be read (the caller keeps "waiting on
326
- * upstream", the conservative answer).
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 admits = (range, version) => (isCargo ? cargoReqAdmits(range, version) : npmRangeAdmits(range, version));
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
- return null;
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 matching = all.filter((req) => admits(req, installed) === true);
346
- const reqs = matching.length > 0 ? [...new Set(matching)] : [...new Set(all)];
347
- for (const req of reqs) {
348
- if (isCargo) {
349
- // `cargo update --precise` moves to exactly the clean target, if every requirement admits it.
350
- const ok = admits(req, target);
351
- if (ok === null)
352
- return null;
353
- if (!ok)
354
- blocking.push({ parent: edge.parent, parentVersion: edge.parentVersion, requirement: req });
355
- else
356
- resolvesTo = target;
357
- continue;
358
- }
359
- // npm, pnpm, yarn and bun resolve each requirement to its newest match.
360
- if (releases.length > 0) {
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) => admits(req, v) === true)
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
- blocking.push({ parent: edge.parent, parentVersion: edge.parentVersion, requirement: req });
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
- let command = refreshCommand(parents.format, name, installed, target);
381
- if (isCargo && blocking.length === 0) {
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: blocking.length === 0,
436
+ refreshFixes,
437
+ inferred: rule.inferred,
397
438
  format: parents.format,
398
439
  command,
399
440
  blocking,
400
- resolvesTo: blocking.length === 0 ? resolvesTo : null,
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
- let entry = null; // the packages/snapshots entry whose fields are being read
143
- const finish = () => {
144
- const id = entry ? pnpmEntryIdentity(entry) : null;
145
- if (id) {
146
- transitive.push(id);
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
- if (indent === 2 && text.endsWith(":")) {
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 pendingImporterDep = false;
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 = parsePnpmKey(text.slice(0, -1));
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]) === name && exactVersion(m[4]) === version) {
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
- pendingImporterDep = !!m && (m[1] ?? m[2] ?? m[3]) === name;
214
+ pendingDep = m ? m[1] ?? m[2] ?? m[3] : null;
201
215
  importerSpecifier = null;
202
- if (pendingImporterDep && m?.[4] && exactVersion(m[4]) === version) {
203
- add({ parent: importer, parentVersion: null, range: null, local: true });
204
- pendingImporterDep = false;
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 && pendingImporterDep) {
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 && exactVersion(ver[1]) === version) {
213
- add({ parent: importer, parentVersion: null, range: importerSpecifier, local: true });
214
- pendingImporterDep = false;
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 name = /^"?(@?[^@\s"]+)@/.exec(header)?.[1];
230
- if (!name || name.startsWith("__"))
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.version);
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
- if (dep !== name)
273
- continue;
274
- const range = rawRange.replace(/^npm:/, "");
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
- edges.push({ parent: b.name, parentVersion: b.version, range, local: false });
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, alias, tag) or the
17
- * version is unreadable.
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, alias, tag) or the
134
- * version is unreadable.
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 ("npm:other@^1") names another package.
141
- const text = range.trim().replace(/^npm:(?=[\^~<>=\dvxX*\s]|$)/, "");
142
- // Protocols (git+https:, file:, link:, workspace:, npm:alias@), paths, GitHub shorthand.
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 0;
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;
@@ -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; `waiting_on_upstream` = transitive, and a
61
- * parent's requirement excludes every clean version (or could not be
62
- * read); `reinstall` = the lockfile's version is fine
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.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",