@4da/mcp-server 6.2.3 → 6.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.2.4 — 2026-10-12
4
+
5
+ ### Fix paths held to the 4DA rule vectors
6
+
7
+ The fix-path rules (version order, OSV range evaluation, the fix target, the
8
+ refresh-or-parent verdict, the refresh command, `range_check`) now run in CI
9
+ against the shared rule vectors of the 4DA desktop app, the same 333 vectors
10
+ its Rust engine runs (see `conformance/README.md`). All pass. The first run
11
+ found these defects:
12
+
13
+ - **pnpm workspaces: the refresh command missed members.** At a pnpm workspace
14
+ root, `pnpm update <pkg>` touches only the root project, so a member's
15
+ vulnerable transitive copy stayed (measured with pnpm 10.34.6). The command
16
+ is now `pnpm update -r <pkg> --depth Infinity` there, and
17
+ `pnpm update <pkg> --depth Infinity` elsewhere (vectors fc-005..fc-008).
18
+ - **A blocking parent was not named when another parent's requirement could
19
+ not be read.** `upgrade_planner` gave up on the whole copy and kept a
20
+ generic "waiting on upstream". A requirement that excludes the target now
21
+ decides on its own, and the reason names it (rv-048).
22
+ - **Unread requirements fall back to semver, and say so.** When not every
23
+ parent's requirement can be read (the registry did not answer) and none read
24
+ excludes the target, the verdict comes from semver compatibility: a target
25
+ in the installed version's caret range is a `lockfile_refresh` with
26
+ `refreshInferred: true` and a reason that says it was inferred; any other
27
+ target stays `waiting_on_upstream` (AD-055 ¶1; rv-002..rv-032 even ids,
28
+ rv-049, rv-050).
29
+ - **npm aliases were unreadable requirements.** `"npm:string-width@^4.2.0"`,
30
+ how package-lock records cliui's `string-width-cjs`, is now read as `^4.2.0`
31
+ on string-width (rv-047).
32
+ - **PEP 440 local labels did not order.** `1.0`, `1.0+abc.5`, `1.0+abc.7` and
33
+ `1.0+5` compared equal; they now order as PEP 440 says (vo-013..vo-015), so a
34
+ `last_affected` bound equal to a local-labelled install is read correctly.
35
+ - Yarn 1 and bun have no command that moves one transitive copy in place, so
36
+ none is named; the manual steps (delete the lockfile entries, reinstall) are
37
+ unchanged.
38
+
3
39
  ## 6.2.3 — 2026-10-11
4
40
 
5
41
  ### Yarn 1 refresh instruction covers alias entries
@@ -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,86 +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
- // Yarn 1 has no command that refreshes a transitive copy. Its blocks are keyed
316
- // by every spec that resolved to them, and an alias block's first key names
317
- // the alias ("string-width-cjs@npm:string-width@^4.2.0"), so name both forms.
318
- return `delete every yarn.lock entry that resolves to ${name} (keys "${name}@…" and alias keys "…@npm:${name}@…"), then run yarn install`;
319
328
  case "bun":
320
- return `delete the ${name} entries from bun.lock, then run bun install`;
329
+ return null;
321
330
  case "cargo":
322
331
  return `cargo update -p ${name}@${installed} --precise ${target}`;
323
332
  }
324
333
  }
334
+ /** For a lockfile with no in-place refresh command (yarn 1, bun): the steps that move the copy. */
335
+ export function manualRefreshSteps(format, name) {
336
+ // Yarn 1 keys a block by every spec that resolved to it, and an alias block's
337
+ // first key names the alias ("string-width-cjs@npm:string-width@^4.2.0"), so
338
+ // name both forms. Deleting the entries and reinstalling is what moved the
339
+ // copy in the fix-path oracle (case U11).
340
+ return format === "yarn"
341
+ ? `delete every yarn.lock entry that resolves to ${name} (keys "${name}@…" and alias keys "…@npm:${name}@…"), then run yarn install`
342
+ : `delete the ${name} entries from bun.lock, then run bun install`;
343
+ }
344
+ /** Whether a pnpm lockfile sits at a workspace root (`pnpm-workspace.yaml` beside it). */
345
+ function isPnpmWorkspaceRoot(lockfile) {
346
+ return fs.existsSync(path.join(path.dirname(lockfile), "pnpm-workspace.yaml"));
347
+ }
348
+ /** The refresh command for the project whose lockfile is `lockfile`; null when no command is named for it. */
349
+ export function refreshCommandFor(lockfile, name, installed, target) {
350
+ let content;
351
+ try {
352
+ content = fs.readFileSync(lockfile, "utf-8");
353
+ }
354
+ catch {
355
+ return null;
356
+ }
357
+ const format = lockfileFormat(lockfile, content);
358
+ return format ? refreshCommand(format, name, installed, target, format === "pnpm" && isPnpmWorkspaceRoot(lockfile)) : null;
359
+ }
325
360
  /**
326
361
  * Whether refreshing `lockfile` moves `name@installed` to a clean version
327
- * without any parent changing its declared requirement. Null when the
328
- * lockfile or a requirement cannot be read (the caller keeps "waiting on
329
- * 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).
330
367
  */
331
368
  export async function refreshVerdict(sources, ecosystem, lockfile, name, installed, target, advisories, published) {
332
369
  const parents = readLockfileParents(lockfile, name, installed);
333
370
  if (!parents || parents.edges.length === 0)
334
371
  return null;
335
372
  const isCargo = parents.format === "cargo";
336
- const admits = (range, version) => (isCargo ? cargoReqAdmits(range, version) : npmRangeAdmits(range, version));
373
+ const dialect = isCargo ? "cargo" : "npm";
337
374
  const clean = (v) => !advisories || !advisories.some((a) => advisoryAffects(a, v, ecosystem));
338
375
  const releases = (published ?? []).filter((v) => !isPrereleaseVersion(v, ecosystem));
339
- const blocking = [];
340
- let resolvesTo = null;
341
376
  const edgeReqs = await Promise.all(parents.edges.map((edge) => requirementsOf(sources, ecosystem, edge, name)));
377
+ const reqs = [];
342
378
  for (const [i, edge] of parents.edges.entries()) {
343
379
  const all = edgeReqs[i];
344
- if (all === null || all.length === 0)
345
- 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
+ }
346
384
  // A parent can declare the same crate twice under different names (feature-gated
347
385
  // `mio-0_6` / `mio-0_7` aliases): only the requirements this copy satisfies apply.
348
- const matching = all.filter((req) => admits(req, installed) === true);
349
- const reqs = matching.length > 0 ? [...new Set(matching)] : [...new Set(all)];
350
- for (const req of reqs) {
351
- if (isCargo) {
352
- // `cargo update --precise` moves to exactly the clean target, if every requirement admits it.
353
- const ok = admits(req, target);
354
- if (ok === null)
355
- return null;
356
- if (!ok)
357
- blocking.push({ parent: edge.parent, parentVersion: edge.parentVersion, requirement: req });
358
- else
359
- resolvesTo = target;
360
- continue;
361
- }
362
- // npm, pnpm, yarn and bun resolve each requirement to its newest match.
363
- 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) {
364
408
  const newest = releases
365
- .filter((v) => admits(req, v) === true)
409
+ .filter((v) => requirementAdmits(r, v) === true)
366
410
  .sort((a, b) => compareVersions(b, a, ecosystem))[0];
367
- if (!newest || compareVersions(newest, target, ecosystem) < 0 || !clean(newest)) {
368
- blocking.push({ parent: edge.parent, parentVersion: edge.parentVersion, requirement: req });
369
- }
370
- else if (!resolvesTo || compareVersions(newest, resolvesTo, ecosystem) < 0) {
411
+ if (!newest || compareVersions(newest, target, ecosystem) < 0 || !clean(newest))
412
+ block(r);
413
+ else if (!resolvesTo || compareVersions(newest, resolvesTo, ecosystem) < 0)
371
414
  resolvesTo = newest;
372
- }
373
- }
374
- else {
375
- const ok = admits(req, target);
376
- if (ok === null)
377
- return null;
378
- if (!ok)
379
- blocking.push({ parent: edge.parent, parentVersion: edge.parentVersion, requirement: req });
380
415
  }
381
416
  }
382
417
  }
383
- let command = refreshCommand(parents.format, name, installed, target);
384
- 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) {
385
422
  let content;
386
423
  try {
387
424
  content = fs.readFileSync(lockfile, "utf-8");
@@ -396,11 +433,12 @@ export async function refreshVerdict(sources, ecosystem, lockfile, name, install
396
433
  command = `cargo update ${prereqs.map((p) => `-p ${p}`).join(" ")} && ${command}`;
397
434
  }
398
435
  return {
399
- refreshFixes: blocking.length === 0,
436
+ refreshFixes,
437
+ inferred: rule.inferred,
400
438
  format: parents.format,
401
439
  command,
402
440
  blocking,
403
- resolvesTo: blocking.length === 0 ? resolvesTo : null,
441
+ resolvesTo: refreshFixes ? resolvesTo : null,
404
442
  };
405
443
  }
406
444
  async function requirementsOf(sources, ecosystem, edge, name) {
@@ -13,8 +13,10 @@
13
13
  */
14
14
  /**
15
15
  * Whether an npm range admits `version`. Null when the range is not a
16
- * registry semver range (git, file, link, workspace, 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.3",
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",