rolldown-pnpm-config 0.4.1 → 0.5.0

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.
@@ -12,7 +12,7 @@ const root = Command.make("rolldown-pnpm-config").pipe(Command.withSubcommands([
12
12
  exportCommand,
13
13
  previewCommand
14
14
  ]));
15
- Command.run(root, { version: "0.4.1" }).pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);
15
+ Command.run(root, { version: "0.5.0" }).pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);
16
16
 
17
17
  //#endregion
18
18
  export { };
package/catalogs.js CHANGED
@@ -2,9 +2,11 @@
2
2
  /**
3
3
  * Normalize declarative catalog input into the resolved `{ catalog → pkg → range }`
4
4
  * map consumed by the runtime. Pure: the base catalog uses each package's
5
- * `range` (or bare string); a `<name>Peers` catalog is emitted only for packages
6
- * carrying a materialized `peer`, using that value verbatim. `strategy` is
7
- * CLI-only and ignored here.
5
+ * `range` (or bare string); a peers catalog is emitted only for packages
6
+ * carrying a materialized `peer`, using that value verbatim. The peers map is
7
+ * emitted under BOTH `<name>Peers` (camelCase, legacy) and `<name>:peers`
8
+ * (colon-delimited, preferred) during this transition; the camelCase form is
9
+ * removed in a later branch. `strategy` is CLI-only and ignored here.
8
10
  *
9
11
  * @internal
10
12
  */
@@ -18,7 +20,10 @@ function normalizeCatalogs(input) {
18
20
  if (typeof spec === "object" && spec.peer !== void 0) peers[pkg] = spec.peer;
19
21
  }
20
22
  out[name] = base;
21
- if (Object.keys(peers).length > 0) out[`${name}Peers`] = peers;
23
+ if (Object.keys(peers).length > 0) {
24
+ out[`${name}Peers`] = peers;
25
+ out[`${name}:peers`] = peers;
26
+ }
22
27
  }
23
28
  return out;
24
29
  }
@@ -11,6 +11,7 @@ import { evaluatePluginConfig } from "../evaluate.js";
11
11
  import { findConfigFiles, pickConfigCandidate } from "../select-file.js";
12
12
  import { toAnsi } from "../ui/ansi.js";
13
13
  import { detectCapabilities } from "../ui/env.js";
14
+ import { legendLines } from "../ui/legend.js";
14
15
  import { canonicalize, findWorkspaceFile, parseWorkspace, renderWorkspace } from "../workspace-file.js";
15
16
  import { overlayWorkspace } from "../workspace-overlay.js";
16
17
  import { Data, Effect, Option } from "effect";
@@ -139,8 +140,8 @@ const exportCommand = Command.make("export", {
139
140
  if (dryRun) {
140
141
  const caps = detectCapabilities();
141
142
  process.stdout.write(`${result.path} (dry run — not written)\n\n`);
142
- process.stdout.write(`${toAnsi(result.diff, { color: caps.color })}\n`);
143
- process.stdout.write("\n+ added ~ changed - removed (local) local override (unmanaged) not managed\n");
143
+ const legend = caps.color ? `${toAnsi(legendLines(), { color: caps.color })}\n\n` : "";
144
+ process.stdout.write(`${legend}${toAnsi(result.diff, { color: caps.color })}\n`);
144
145
  } else process.stdout.write(`Exported to ${result.path}\n`);
145
146
  for (const k of result.report.staleEntries) process.stderr.write(`warning: patch entry "${k}" has no file on disk\n`);
146
147
  for (const k of result.report.keyMismatches) process.stderr.write(`warning: patch entry "${k}" does not match its filename\n`);
@@ -4,6 +4,7 @@ import { evaluatePluginConfig } from "../evaluate.js";
4
4
  import { findConfigFiles, pickConfigCandidate } from "../select-file.js";
5
5
  import { toAnsi } from "../ui/ansi.js";
6
6
  import { detectCapabilities } from "../ui/env.js";
7
+ import { legendLines } from "../ui/legend.js";
7
8
  import { findWorkspaceFile, parseWorkspace } from "../workspace-file.js";
8
9
  import { WORKSPACE_FIELDS } from "./export.js";
9
10
  import { buildPreviewViews } from "../preview-views.js";
@@ -66,7 +67,10 @@ const previewCommand = Command.make("preview", { path: pathArg }, ({ path }) =>
66
67
  });
67
68
  const caps = detectCapabilities();
68
69
  if (caps.interactive) yield* runPreview(views);
69
- else yield* Effect.sync(() => process.stdout.write(`${toAnsi(views.changes, { color: caps.color })}\n`));
70
+ else yield* Effect.sync(() => {
71
+ const legend = caps.color ? `${toAnsi(legendLines(), { color: caps.color })}\n\n` : "";
72
+ process.stdout.write(`${legend}${toAnsi(views.changes, { color: caps.color })}\n`);
73
+ });
70
74
  })).pipe(Command.withDescription("Interactively preview how pnpm-workspace.yaml would change"));
71
75
 
72
76
  //#endregion
@@ -5,7 +5,8 @@ import { detectCapabilities } from "../ui/env.js";
5
5
  import { derivePeerRange } from "../peer-range.js";
6
6
  import { detectPeerDrift } from "../drift.js";
7
7
  import { buildEdits } from "../edits.js";
8
- import { buildInteropEdits, capVersions, interopEntryChanged, reentryCandidates, runInterop } from "../interop.js";
8
+ import { buildInteropEdits, interopEntryChanged, runInterop } from "../interop.js";
9
+ import { buildGroupModel, computeGroupPeers } from "../interop-live.js";
9
10
  import { planEntry } from "../plan.js";
10
11
  import { combineReleaseAge, filterByReleaseAge, parsePnpmGate, readConfigReleaseAge } from "../release-age.js";
11
12
  import { RegistryResolver, RegistryResolverLive } from "../resolve.js";
@@ -256,19 +257,6 @@ function applyInteropAndDecisions(file, source, nonInteropEdits, interopEdits) {
256
257
  });
257
258
  }
258
259
  /**
259
- * Filter walk items down to the ones the interactive table should show: a row
260
- * is actionable when it is anything but up-to-date (a range bump, a peer
261
- * drift resync, or a peer materialization), unless `--full` asks for every
262
- * row including inert up-to-date ones. Parity with the old walk's
263
- * `nextActionable` auto-skip, now applied as an upfront filter instead of a
264
- * per-step cursor advance.
265
- *
266
- * @internal
267
- */
268
- function actionableWalkItems(items, full) {
269
- return full ? [...items] : items.filter((i) => !i.upToDate);
270
- }
271
- /**
272
260
  * The message printed instead of entering the interactive table when nothing
273
261
  * is actionable: either no catalog packages were discovered at all, or every
274
262
  * discovered package is already up to date.
@@ -420,13 +408,11 @@ const upgradeCommand = Command.make("upgrade", {
420
408
  yield* Effect.sync(() => process.stdout.write(`${text}${warn}\n\n${note}\n`));
421
409
  return;
422
410
  }
423
- const actionable = actionableWalkItems(items, full);
424
- if (actionable.length === 0) {
411
+ if (items.length === 0) {
425
412
  const warn = versions.unresolved.length > 0 ? `⚠ ${unresolvedMessage(versions.unresolved)}\n\n` : "";
426
- yield* Effect.sync(() => process.stdout.write(`${warn}${nothingToUpgradeMessage(items.length)}`));
413
+ yield* Effect.sync(() => process.stdout.write(`${warn}${nothingToUpgradeMessage(0)}`));
427
414
  return;
428
415
  }
429
- const decisions = yield* runWalk(actionable, dryRun, versions.unresolved);
430
416
  const interopByCatalog = /* @__PURE__ */ new Map();
431
417
  for (const e of entries) {
432
418
  if (e.strategy !== "interop") continue;
@@ -434,67 +420,64 @@ const upgradeCommand = Command.make("upgrade", {
434
420
  list.push(e);
435
421
  interopByCatalog.set(e.catalog, list);
436
422
  }
423
+ const peerCache = /* @__PURE__ */ new Map();
424
+ const fetchPeer = (pkg, v) => {
425
+ const k = `${pkg}@${v}`;
426
+ const cached = peerCache.get(k);
427
+ if (cached !== void 0) return Effect.succeed(cached);
428
+ return resolver.peerDependencies(pkg, v).pipe(Effect.catch(() => Effect.succeed({})), Effect.map((deps) => {
429
+ peerCache.set(k, deps);
430
+ return deps;
431
+ }));
432
+ };
433
+ if (interopByCatalog.size > 0) yield* Effect.sync(() => process.stderr.write("Resolving peer dependencies…\n"));
434
+ const interopModels = /* @__PURE__ */ new Map();
435
+ for (const [catalog, group] of interopByCatalog) {
436
+ const candByPkg = /* @__PURE__ */ new Map();
437
+ for (const e of group) {
438
+ const it = items.find((i) => i.entry.catalog === catalog && i.entry.pkg === e.pkg);
439
+ candByPkg.set(e.pkg, it ? it.candidates.map((c) => c.version) : [e.currentRange.replace(/^[\^~]/, "")]);
440
+ }
441
+ interopModels.set(catalog, yield* buildGroupModel(candByPkg, fetchPeer));
442
+ }
443
+ const decisions = yield* runWalk(items, dryRun, versions.unresolved, interopModels);
437
444
  const nonInteropDecisions = decisions.filter((d) => d.item.entry.strategy !== "interop");
438
445
  const interopEdits = [];
439
- const adjustments = [];
440
446
  const allConflicts = [];
441
447
  let interopChanged = 0;
442
- for (const [, group] of interopByCatalog) {
443
- const pickOf = (pkg) => {
444
- const d = decisions.find((dd) => dd.item.entry.pkg === pkg);
445
- if (d) return d.chosen.version;
446
- const ge = group.find((g) => g.pkg === pkg);
447
- return ge ? ge.currentRange.replace(/^[\^~]/, "") : "";
448
- };
449
- let members = group.map((e) => ({
450
- pkg: e.pkg,
451
- ceiling: pickOf(e.pkg),
452
- candidates: versions.gated.get(e.pkg) ?? []
453
- }));
454
- const originalPick = new Map(members.map((m) => [m.pkg, m.ceiling]));
455
- const peerCache = /* @__PURE__ */ new Map();
456
- let result = yield* runInterop(members, resolver, peerCache);
457
- for (let round = 0; round < members.length + 1; round++) {
458
- const reentry = reentryCandidates(members, result);
459
- if (reentry.length === 0) break;
460
- const capEntries = group.filter((e) => reentry.some((rc) => rc.pkg === e.pkg));
461
- const cappedVersions = /* @__PURE__ */ new Map();
462
- for (const rc of reentry) {
463
- const all = versions.gated.get(rc.pkg) ?? [];
464
- cappedVersions.set(rc.pkg, rc.cap === null ? all : yield* capVersions(all, rc.cap));
465
- }
466
- const reDecisions = yield* runWalk(yield* buildWalkItems(capEntries, cappedVersions).pipe(Effect.catch((err) => Effect.fail(new UpgradeError({ message: err.message })))));
467
- const before = new Map(members.map((m) => [m.pkg, m.ceiling]));
468
- members = members.map((m) => {
469
- const rd = reDecisions.find((d) => d.item.entry.pkg === m.pkg);
470
- return rd ? {
471
- ...m,
472
- ceiling: rd.chosen.version
473
- } : m;
474
- });
475
- if (!members.some((m) => before.get(m.pkg) !== m.ceiling)) break;
476
- result = yield* runInterop(members, resolver, peerCache);
448
+ for (const [catalog, group] of interopByCatalog) {
449
+ const model = interopModels.get(catalog);
450
+ if (model === void 0) continue;
451
+ const selected = /* @__PURE__ */ new Map();
452
+ for (const e of group) {
453
+ const d = decisions.find((dd) => dd.item.entry.catalog === catalog && dd.item.entry.pkg === e.pkg);
454
+ selected.set(e.pkg, d ? d.chosen.version : e.currentRange.replace(/^[\^~]/, ""));
477
455
  }
478
- interopEdits.push(...buildInteropEdits(group, result));
479
- allConflicts.push(...result.conflicts);
456
+ const { peer, conflict } = computeGroupPeers(model, selected);
457
+ interopEdits.push(...buildInteropEdits(group, {
458
+ resolved: selected,
459
+ peers: peer,
460
+ conflicts: [],
461
+ peerDepsOf: () => ({})
462
+ }));
463
+ for (const [pkg, blockedBy] of conflict) allConflicts.push({
464
+ pkg,
465
+ ceiling: selected.get(pkg) ?? "",
466
+ blockedBy
467
+ });
480
468
  for (const e of group) {
481
- if (interopEntryChanged(e, result)) interopChanged++;
482
- const version = result.resolved.get(e.pkg);
483
- const original = originalPick.get(e.pkg);
484
- if (version === void 0 || original === void 0 || version === original) continue;
485
- adjustments.push({
486
- catalog: e.catalog,
487
- pkg: e.pkg,
488
- from: `${e.operator}${original}`,
489
- to: `${e.operator}${version}`,
490
- peer: result.peers.get(e.pkg) ?? `^${version}`
491
- });
469
+ const version = selected.get(e.pkg);
470
+ if (version === void 0) continue;
471
+ const rangeChanged = `${e.operator}${version}` !== e.currentRange;
472
+ const newPeer = peer.get(e.pkg);
473
+ const peerChanged = newPeer !== void 0 && (e.peer ? newPeer !== e.peer.value : true);
474
+ if (rangeChanged || peerChanged) interopChanged++;
492
475
  }
493
476
  }
494
477
  const { accepted, rejected } = yield* validateEdits(buildEdits(nonInteropDecisions), versions.raw);
495
478
  const acceptedPkgs = new Set(accepted.map((e) => e.pkg));
496
479
  yield* Effect.sync(() => process.stdout.write(`${renderSummary(decisions, {
497
- adjustments,
480
+ adjustments: [],
498
481
  conflicts: allConflicts
499
482
  }, { color: caps.color }, rejected)}\n`));
500
483
  if (!dryRun) yield* applyInteropAndDecisions(file, source, accepted, interopEdits);
@@ -504,4 +487,4 @@ const upgradeCommand = Command.make("upgrade", {
504
487
  }).pipe(Effect.provide(RegistryResolverLive), Effect.provide(NodeServices.layer))).pipe(Command.withDescription("Upgrade catalog versions in a config file"));
505
488
 
506
489
  //#endregion
507
- export { UpgradeError, actionableWalkItems, applyInteropAndDecisions, computeGate, countChangedDecisions, nothingToUpgradeMessage, projectDecisions, resolveGatedVersions, resolveTargetFile, runUpgrade, runUpgradePreview, unresolvedMessage, upgradeCommand, writeResolveProgress };
490
+ export { UpgradeError, applyInteropAndDecisions, computeGate, countChangedDecisions, nothingToUpgradeMessage, projectDecisions, resolveGatedVersions, resolveTargetFile, runUpgrade, runUpgradePreview, unresolvedMessage, upgradeCommand, writeResolveProgress };
@@ -15,12 +15,13 @@ const STYLE = {
15
15
  function scalarText(v) {
16
16
  return typeof v === "string" ? v : JSON.stringify(v);
17
17
  }
18
- function flatten(node, depth) {
18
+ function flatten(node, depth, inheritedUnmanaged = false) {
19
19
  const indent = depth;
20
20
  const gutter = GUTTER[node.kind];
21
- const style = STYLE[node.kind];
22
21
  const tag = node.tag;
23
22
  const changed = node.kind !== "unchanged";
23
+ const unmanaged = inheritedUnmanaged || node.tag === "unmanaged" && node.kind === "unchanged";
24
+ const style = node.kind === "unchanged" && unmanaged ? "unmanaged" : STYLE[node.kind];
24
25
  const isArrayEl = node.arrayElement === true;
25
26
  if (node.children) return [{
26
27
  line: {
@@ -33,7 +34,7 @@ function flatten(node, depth) {
33
34
  ...tag ? { tag } : {}
34
35
  },
35
36
  changed
36
- }, ...node.children.flatMap((c) => flatten(c, depth + 1))];
37
+ }, ...node.children.flatMap((c) => flatten(c, depth + 1, unmanaged))];
37
38
  let text;
38
39
  if (node.kind === "changed") text = `${node.key}: ${scalarText(node.before)} → ${scalarText(node.after)}`;
39
40
  else if (isArrayEl) text = `- ${node.key}`;
@@ -0,0 +1,117 @@
1
+ import "./interop.js";
2
+ import { Effect } from "effect";
3
+ import { Range, SemVer } from "@effected/semver";
4
+
5
+ //#region src/cli/interop-live.ts
6
+ /** Strip a range operator to its bare version digits (e.g. `^3.17.0` → `3.17.0`). */
7
+ function floorOf(range) {
8
+ return range.replace(/^[\^~>=\s]+/, "").split(/\s/)[0] ?? range;
9
+ }
10
+ /**
11
+ * Recompute each member's peer floor and conflict for the current selection —
12
+ * synchronously, from a pre-built {@link GroupModel}. Mirrors `deriveFloors`
13
+ * (peer floor is the LOWEST floor any in-group member declares for a package,
14
+ * else `^<its selected version>`) and `violations` (a member conflicts when its
15
+ * selected version's in-group peer requirement is not satisfied by the current
16
+ * pick of that dep). Never changes a selection — detection only.
17
+ *
18
+ * @internal
19
+ */
20
+ function computeGroupPeers(model, selected) {
21
+ const declaredFloors = /* @__PURE__ */ new Map();
22
+ for (const m of model.members) {
23
+ const v = selected.get(m);
24
+ if (v === void 0) continue;
25
+ for (const { dep, range } of model.peerReqs.get(`${m}@${v}`) ?? []) {
26
+ const list = declaredFloors.get(dep) ?? [];
27
+ list.push(floorOf(range));
28
+ declaredFloors.set(dep, list);
29
+ }
30
+ }
31
+ const peer = /* @__PURE__ */ new Map();
32
+ for (const m of model.members) {
33
+ const v = selected.get(m);
34
+ if (v === void 0) continue;
35
+ const declared = declaredFloors.get(m);
36
+ if (declared?.length) {
37
+ let minStr = declared[0];
38
+ let minSv = model.ver.get(minStr) ?? null;
39
+ for (const f of declared.slice(1)) {
40
+ const sv = model.ver.get(f) ?? null;
41
+ if (sv && (minSv === null || sv.compare(minSv) < 0)) {
42
+ minSv = sv;
43
+ minStr = f;
44
+ }
45
+ }
46
+ peer.set(m, `^${minStr}`);
47
+ } else peer.set(m, `^${v}`);
48
+ }
49
+ const conflict = /* @__PURE__ */ new Map();
50
+ for (const m of model.members) {
51
+ const v = selected.get(m);
52
+ if (v === void 0) continue;
53
+ const bad = [];
54
+ for (const { dep, range } of model.peerReqs.get(`${m}@${v}`) ?? []) {
55
+ const dv = selected.get(dep);
56
+ if (dv === void 0) continue;
57
+ const r = model.rng.get(range) ?? null;
58
+ const sv = model.ver.get(dv) ?? null;
59
+ if (!(r && sv && r.test(sv))) bad.push(`${dep} ${range}`);
60
+ }
61
+ if (bad.length) conflict.set(m, bad.join(", "));
62
+ }
63
+ return {
64
+ peer,
65
+ conflict
66
+ };
67
+ }
68
+ /**
69
+ * Build a {@link GroupModel} for one interop catalog group: fetch the
70
+ * peerDependencies of every member's candidate version (in-group deps only) via
71
+ * the shared memoized `fetchPeer`, then pre-parse every version and range string
72
+ * so the table can recompute floors/conflicts without further Effects.
73
+ *
74
+ * @internal
75
+ */
76
+ function buildGroupModel(candidatesByPkg, fetchPeer) {
77
+ return Effect.gen(function* () {
78
+ const members = new Set(candidatesByPkg.keys());
79
+ const peerReqs = /* @__PURE__ */ new Map();
80
+ const verStrings = /* @__PURE__ */ new Set();
81
+ const rngStrings = /* @__PURE__ */ new Set();
82
+ const pairs = [];
83
+ for (const [pkg, versions] of candidatesByPkg) for (const v of versions) pairs.push([pkg, v]);
84
+ const fetched = yield* Effect.forEach(pairs, ([pkg, v]) => fetchPeer(pkg, v).pipe(Effect.map((deps) => [
85
+ pkg,
86
+ v,
87
+ deps
88
+ ])), { concurrency: 8 });
89
+ for (const [pkg, v, deps] of fetched) {
90
+ verStrings.add(v);
91
+ const reqs = [];
92
+ for (const [dep, range] of Object.entries(deps)) {
93
+ if (!members.has(dep)) continue;
94
+ reqs.push({
95
+ dep,
96
+ range
97
+ });
98
+ rngStrings.add(range);
99
+ verStrings.add(floorOf(range));
100
+ }
101
+ peerReqs.set(`${pkg}@${v}`, reqs);
102
+ }
103
+ const ver = /* @__PURE__ */ new Map();
104
+ for (const s of verStrings) ver.set(s, yield* SemVer.parse(s).pipe(Effect.catch(() => Effect.succeed(null))));
105
+ const rng = /* @__PURE__ */ new Map();
106
+ for (const s of rngStrings) rng.set(s, yield* Range.parse(s).pipe(Effect.catch(() => Effect.succeed(null))));
107
+ return {
108
+ members,
109
+ peerReqs,
110
+ ver,
111
+ rng
112
+ };
113
+ });
114
+ }
115
+
116
+ //#endregion
117
+ export { buildGroupModel, computeGroupPeers };
package/cli/interop.js CHANGED
@@ -190,35 +190,6 @@ function runInterop(members, resolver, cache = /* @__PURE__ */ new Map()) {
190
190
  };
191
191
  });
192
192
  }
193
- /**
194
- * The members to re-prompt in the interactive re-entry: each downgraded or
195
- * conflicted dependent (capped at its resolved version) PLUS the in-group peer
196
- * targets those dependents depend on (uncapped, so the user can RAISE the anchor
197
- * instead of accepting the downgrade). `cap` is the version to cap candidates at,
198
- * or null for an uncapped anchor.
199
- *
200
- * @internal
201
- */
202
- function reentryCandidates(members, result) {
203
- const memberSet = new Set(members.map((m) => m.pkg));
204
- const byPkg = new Map(members.map((m) => [m.pkg, m]));
205
- const conflicted = new Set(result.conflicts.map((c) => c.pkg));
206
- const out = /* @__PURE__ */ new Map();
207
- for (const m of members) {
208
- const r = result.resolved.get(m.pkg);
209
- if (r === void 0) continue;
210
- if (r !== m.ceiling || conflicted.has(m.pkg)) out.set(m.pkg, r);
211
- }
212
- for (const pkg of [...out.keys()]) {
213
- const m = byPkg.get(pkg);
214
- if (!m) continue;
215
- for (const dep of Object.keys(result.peerDepsOf(pkg, m.ceiling))) if (memberSet.has(dep) && !out.has(dep)) out.set(dep, null);
216
- }
217
- return [...out].map(([pkg, cap]) => ({
218
- pkg,
219
- cap
220
- }));
221
- }
222
193
  /** True when an interop member's resolved version/peer differs from what's in source. @internal */
223
194
  function interopEntryChanged(e, result) {
224
195
  const version = result.resolved.get(e.pkg);
@@ -261,25 +232,6 @@ function buildInteropEdits(entries, result) {
261
232
  }
262
233
  return edits;
263
234
  }
264
- /**
265
- * Keep only the versions less than or equal to `max` (SemVer comparison). Used
266
- * to cap the candidate list of a re-prompted interop member. An unparseable
267
- * `max` leaves the list unchanged.
268
- *
269
- * @internal
270
- */
271
- function capVersions(list, max) {
272
- return Effect.gen(function* () {
273
- const mv = yield* SemVer.parse(max).pipe(Effect.catch(() => Effect.succeed(null)));
274
- if (!mv) return [...list];
275
- const out = [];
276
- for (const v of list) {
277
- const sv = yield* SemVer.parse(v).pipe(Effect.catch(() => Effect.succeed(null)));
278
- if (sv && sv.compare(mv) <= 0) out.push(v);
279
- }
280
- return out;
281
- });
282
- }
283
235
 
284
236
  //#endregion
285
- export { buildInteropEdits, capVersions, deriveFloors, interopEntryChanged, reentryCandidates, resolveGroup, runInterop };
237
+ export { buildInteropEdits, deriveFloors, interopEntryChanged, resolveGroup, runInterop };
package/cli/plan.js CHANGED
@@ -30,6 +30,7 @@ function planEntry(entry, versions) {
30
30
  parsed.sort((a, b) => a.compare(b));
31
31
  const maxOf = (list) => list.length ? list[list.length - 1] : null;
32
32
  const inRangeMax = range ? maxOf(parsed.filter((v) => range.test(v))) : null;
33
+ const sameMajorMax = maxOf(parsed.filter((v) => v.major === currentMajor));
33
34
  const overallMax = maxOf(parsed);
34
35
  const withPeer = (version) => entry.strategy && entry.strategy !== "interop" ? derivePeerRange(`${entry.operator}${version}`, entry.strategy).pipe(Effect.map((d) => d.range)) : Effect.succeed(void 0);
35
36
  const candidates = [];
@@ -44,6 +45,22 @@ function planEntry(entry, versions) {
44
45
  ...peerRange ? { peerRange } : {}
45
46
  });
46
47
  }
48
+ if (sameMajorMax !== null) {
49
+ const beatsCurrent = current === null || sameMajorMax.gt(current);
50
+ const beatsInRange = inRangeMax === null || sameMajorMax.gt(inRangeMax);
51
+ const belowOverall = overallMax?.gt(sameMajorMax) ?? false;
52
+ if (beatsCurrent && beatsInRange && belowOverall) {
53
+ const version = sameMajorMax.toString();
54
+ const peerRange = yield* withPeer(version);
55
+ candidates.push({
56
+ kind: "minor",
57
+ range: `${entry.operator}${version}`,
58
+ version,
59
+ isMajor: sameMajorMax.major > currentMajor,
60
+ ...peerRange ? { peerRange } : {}
61
+ });
62
+ }
63
+ }
47
64
  if (overallMax && (current === null || overallMax.gt(current)) && (!inRangeMax || overallMax.gt(inRangeMax))) {
48
65
  const version = overallMax.toString();
49
66
  const peerRange = yield* withPeer(version);
@@ -4,12 +4,14 @@ import { effectiveManaged, vanillaManaged } from "./effective.js";
4
4
  import { canonicalize } from "./workspace-file.js";
5
5
  import { overlayWorkspace } from "./workspace-overlay.js";
6
6
  import { WORKSPACE_FIELDS } from "./commands/export.js";
7
+ import { renderSimulated } from "./simulated-view.js";
7
8
 
8
9
  //#region src/cli/preview-views.ts
9
10
  /**
10
- * Build the three preview views as styled lines: Changes (parsed→merged, with
11
- * local + preserve + excludeByRepo), Full (same tree, full verbosity), and
12
- * Simulated (parsed→vanilla fresh-consumer output, no local/overlay).
11
+ * Build the three preview views as styled lines: Changes (parsed→merged diff,
12
+ * with local + preserve + excludeByRepo), Full (same tree, full verbosity), and
13
+ * Simulated (the fresh-consumer output rendered as a plain calculated file — not
14
+ * a diff — with per-field merge/overwrite + enforcement annotations).
13
15
  *
14
16
  * @internal
15
17
  */
@@ -20,13 +22,11 @@ function buildPreviewViews(input) {
20
22
  };
21
23
  const merged = overlayWorkspace(effectiveManaged(input.managed, input.local, input.parsed, input.manifest, input.rootName), input.parsed);
22
24
  const vanilla = vanillaManaged(input.managed, input.manifest, input.rootName);
23
- const before = canonicalize(input.parsed);
24
- const changesTree = buildDiff(before, canonicalize(merged), meta);
25
- const simulatedTree = buildDiff(before, canonicalize(vanilla), meta);
25
+ const changesTree = buildDiff(canonicalize(input.parsed), canonicalize(merged), meta);
26
26
  return {
27
27
  changes: renderExportDiff(changesTree, { full: false }),
28
28
  full: renderExportDiff(changesTree, { full: true }),
29
- simulated: renderExportDiff(simulatedTree, { full: false })
29
+ simulated: renderSimulated(vanilla, input.manifest)
30
30
  };
31
31
  }
32
32
 
@@ -0,0 +1,105 @@
1
+ import { canonicalize } from "./workspace-file.js";
2
+
3
+ //#region src/cli/simulated-view.ts
4
+ /**
5
+ * How each merge strategy combines a managed field with the consumer's local
6
+ * value, in the vocabulary the Simulated view shows: `merge` (values combined)
7
+ * or `overwrite` (the plugin value replaces the local one). Keyed by manifest
8
+ * strategy name; a new strategy defaults to `merge` and should be classified here.
9
+ */
10
+ const STRATEGY_VERB = {
11
+ scalar: "overwrite",
12
+ securityFlag: "overwrite",
13
+ securityMin: "overwrite",
14
+ catalogs: "merge",
15
+ mapChildWins: "merge",
16
+ arrayUnion: "merge",
17
+ arrayRecordUnion: "merge",
18
+ overrides: "merge",
19
+ peerDependencyRules: "merge",
20
+ allowBuilds: "merge"
21
+ };
22
+ /** The enforcement suffix, e.g. " · warn" / " · error"; empty when silent. */
23
+ function enforcementSegs(e) {
24
+ if (e === "warn") return [{
25
+ text: " · warn",
26
+ style: "changed"
27
+ }];
28
+ if (e === "error") return [{
29
+ text: " · error",
30
+ style: "warn"
31
+ }];
32
+ return [];
33
+ }
34
+ /** The trailing "(merge)" / "(overwrite · error)" annotation for one field. */
35
+ function annotation(entry) {
36
+ if (!entry) return [];
37
+ const verb = STRATEGY_VERB[entry.strategy] ?? "merge";
38
+ return [
39
+ {
40
+ text: " (",
41
+ style: "unchanged"
42
+ },
43
+ {
44
+ text: verb,
45
+ style: verb
46
+ },
47
+ ...enforcementSegs(entry.enforcement),
48
+ {
49
+ text: ")",
50
+ style: "unchanged"
51
+ }
52
+ ];
53
+ }
54
+ function scalarText(v) {
55
+ return typeof v === "string" ? v : JSON.stringify(v);
56
+ }
57
+ /** Flatten one key/value into YAML-shaped plain lines; `ann` annotates the head. */
58
+ function flatten(key, value, depth, ann) {
59
+ if (Array.isArray(value)) return [{
60
+ indent: depth,
61
+ gutter: " ",
62
+ segments: [{
63
+ text: `${key}:`,
64
+ style: "plain"
65
+ }, ...ann]
66
+ }, ...value.map((el) => ({
67
+ indent: depth + 1,
68
+ gutter: " ",
69
+ segments: [{
70
+ text: `- ${scalarText(el)}`,
71
+ style: "plain"
72
+ }]
73
+ }))];
74
+ if (value !== null && typeof value === "object") return [{
75
+ indent: depth,
76
+ gutter: " ",
77
+ segments: [{
78
+ text: `${key}:`,
79
+ style: "plain"
80
+ }, ...ann]
81
+ }, ...Object.entries(value).flatMap(([k, v]) => flatten(k, v, depth + 1, []))];
82
+ return [{
83
+ indent: depth,
84
+ gutter: " ",
85
+ segments: [{
86
+ text: `${key}: ${scalarText(value)}`,
87
+ style: "plain"
88
+ }, ...ann]
89
+ }];
90
+ }
91
+ /**
92
+ * Render the Simulated view: the calculated fresh-consumer config as a plain
93
+ * pnpm-workspace.yaml listing (NOT a diff against the local file — nothing is
94
+ * added or removed), each top-level field annotated with how the plugin would
95
+ * combine it (`merge`/`overwrite`) and its enforcement (`warn`/`error`).
96
+ *
97
+ * @internal
98
+ */
99
+ function renderSimulated(vanilla, manifest) {
100
+ const canon = canonicalize(vanilla);
101
+ return Object.entries(canon).flatMap(([k, v]) => flatten(k, v, 0, annotation(manifest[k])));
102
+ }
103
+
104
+ //#endregion
105
+ export { renderSimulated };
package/cli/ui/Preview.js CHANGED
@@ -1,29 +1,31 @@
1
+ import { legendLines, simulatedLegendLines } from "./legend.js";
1
2
  import { Box, Text, useApp, useInput } from "ink";
2
3
  import { createElement, useState } from "react";
3
4
  import { Tab, Tabs } from "ink-tab";
4
5
 
5
6
  //#region src/cli/ui/Preview.ts
6
- const INK_COLOR = {
7
- added: "green",
8
- removed: "red",
9
- changed: "yellow",
10
- warn: "red",
11
- local: "magenta",
12
- unmanaged: "gray",
13
- unchanged: "gray",
14
- plain: void 0
7
+ const INK_PROPS = {
8
+ added: { color: "green" },
9
+ removed: { color: "red" },
10
+ changed: { color: "yellow" },
11
+ warn: { color: "red" },
12
+ local: { color: "magenta" },
13
+ unmanaged: { color: "#585858" },
14
+ unchanged: { dimColor: true },
15
+ plain: {},
16
+ merge: { color: "cyan" },
17
+ overwrite: { color: "magenta" }
15
18
  };
16
19
  const TabC = Tab;
17
20
  const TabsC = Tabs;
18
21
  function renderLines(lines) {
19
22
  return createElement(Box, { flexDirection: "column" }, ...lines.map((l, i) => {
20
23
  const indent = " ".repeat(l.indent);
21
- const tag = l.tag ? ` (${l.tag})` : "";
24
+ const tag = l.tag === "local" ? " (local)" : "";
22
25
  const body = l.segments.map((s, j) => {
23
- const color = INK_COLOR[s.style];
24
26
  return createElement(Text, {
25
27
  key: j,
26
- ...color ? { color } : {}
28
+ ...INK_PROPS[s.style] ?? {}
27
29
  }, s.text);
28
30
  });
29
31
  return createElement(Text, { key: i }, `${l.gutter} ${indent}`, ...body, tag);
@@ -39,7 +41,7 @@ function Preview({ views, onExit }) {
39
41
  const app = useApp();
40
42
  const [active, setActive] = useState("changes");
41
43
  useInput((input, key) => {
42
- if (input === "q" || key.escape) {
44
+ if (input === "q" || key.escape || key.return) {
43
45
  onExit();
44
46
  app.exit();
45
47
  }
@@ -53,7 +55,7 @@ function Preview({ views, onExit }) {
53
55
  }, "Full"), createElement(TabC, {
54
56
  name: "simulated",
55
57
  key: "simulated"
56
- }, "Simulated")), renderLines(views[active]));
58
+ }, "Simulated")), renderLines(active === "simulated" ? simulatedLegendLines() : legendLines()), renderLines(views[active]));
57
59
  }
58
60
 
59
61
  //#endregion
package/cli/ui/Walk.js CHANGED
@@ -1,4 +1,5 @@
1
- import { cellColor, displayCandidates, initTable, peerFor, tableDecisions, tableStep } from "../walk-reducer.js";
1
+ import { computeGroupPeers } from "../interop-live.js";
2
+ import { cellColor, displayCandidates, initTable, peerFor, tableDecisions, tableStep, truncateEnd } from "../walk-reducer.js";
2
3
  import { Box, Text, useApp, useInput } from "ink";
3
4
  import { createElement, useEffect, useState } from "react";
4
5
 
@@ -15,7 +16,7 @@ const VIEWPORT = 20;
15
16
  *
16
17
  * @internal
17
18
  */
18
- function Walk({ items, onDone, dryRun = false, unresolved = [] }) {
19
+ function Walk({ items, onDone, dryRun = false, unresolved = [], interopModels = /* @__PURE__ */ new Map() }) {
19
20
  const app = useApp();
20
21
  const [state, setState] = useState(() => initTable(items));
21
22
  useEffect(() => {
@@ -43,8 +44,20 @@ function Walk({ items, onDone, dryRun = false, unresolved = [] }) {
43
44
  const cellWidth = Math.max(...items.flatMap((i) => displayCandidates(i).map((c) => c.range.length + (c.isMajor ? 8 : 0))));
44
45
  const maxCells = Math.max(...items.map((i) => displayCandidates(i).length));
45
46
  const blankCell = `${" ".repeat(2 + cellWidth)} `;
47
+ const termCols = process.stdout.columns ?? 100;
48
+ const fixedPrefix = 2 + (pkgWidth + 2) + maxCells * (cellWidth + 4) + 2;
46
49
  const start = Math.max(0, Math.min(state.cursor - Math.floor(VIEWPORT / 2), items.length - VIEWPORT));
47
50
  const visible = items.slice(Math.max(0, start), Math.max(0, start) + VIEWPORT);
51
+ const interopPeers = /* @__PURE__ */ new Map();
52
+ for (const [catalog, model] of interopModels) {
53
+ const selected = /* @__PURE__ */ new Map();
54
+ items.forEach((it, idx) => {
55
+ if (it.entry.catalog !== catalog) return;
56
+ const cand = displayCandidates(it)[state.picks[idx] ?? 0];
57
+ if (cand) selected.set(it.entry.pkg, cand.version);
58
+ });
59
+ interopPeers.set(catalog, computeGroupPeers(model, selected));
60
+ }
48
61
  const rows = [];
49
62
  let lastCatalog = null;
50
63
  visible.forEach((item, offset) => {
@@ -72,7 +85,11 @@ function Walk({ items, onDone, dryRun = false, unresolved = [] }) {
72
85
  });
73
86
  for (let ci = candidates.length; ci < maxCells; ci++) cells.push(createElement(Text, { key: `blank-${ci}` }, blankCell));
74
87
  const chosen = candidates[pick];
75
- rows.push(createElement(Box, { key: `${item.entry.catalog}/${item.entry.pkg}` }, createElement(Text, { ...onCursor ? { color: "cyan" } : {} }, onCursor ? "❯ " : " "), createElement(Text, { bold: onCursor }, item.entry.pkg.padEnd(pkgWidth + 2)), ...cells, createElement(Text, { dimColor: true }, `│ ${chosen === void 0 ? "—" : peerFor(item, chosen)}`), item.peerWarning ? createElement(Text, { color: "red" }, ` ⚠ ${item.peerWarning.message}`) : null));
88
+ const gp = interopPeers.get(item.entry.catalog);
89
+ const peerText = gp?.peer.get(item.entry.pkg) ?? (chosen === void 0 ? "—" : peerFor(item, chosen));
90
+ const conflict = gp?.conflict.get(item.entry.pkg);
91
+ const room = Math.max(8, termCols - fixedPrefix - peerText.length - 3);
92
+ rows.push(createElement(Box, { key: `${item.entry.catalog}/${item.entry.pkg}` }, createElement(Text, { ...onCursor ? { color: "cyan" } : {} }, onCursor ? "❯ " : " "), createElement(Text, { bold: onCursor }, item.entry.pkg.padEnd(pkgWidth + 2)), ...cells, createElement(Text, { dimColor: true }, `│ ${peerText}`), conflict ? createElement(Text, { color: "red" }, truncateEnd(` ⚠ needs ${conflict}`, room)) : null, item.peerWarning ? createElement(Text, { color: "red" }, truncateEnd(` ⚠ ${item.peerWarning.message}`, room)) : null));
76
93
  });
77
94
  return createElement(Box, { flexDirection: "column" }, createElement(Text, { bold: true }, dryRun ? "Enter to preview • Esc to cancel" : "Enter to update • Esc to cancel"), dryRun ? createElement(Text, { color: "yellow" }, "DRY RUN — nothing will be written to the config") : null, unresolved.length > 0 ? createElement(Text, { color: "red" }, `⚠ Could not resolve from the registry — check for a typo: ${unresolved.join(", ")}`) : null, createElement(Box, { height: 1 }), ...rows);
78
95
  }
package/cli/ui/ansi.js CHANGED
@@ -12,7 +12,7 @@ function toAnsi(lines, opts) {
12
12
  return lines.map((l) => {
13
13
  const indent = " ".repeat(l.indent);
14
14
  const body = l.segments.map((s) => paint(s.text, s.style, opts.color)).join("");
15
- return `${l.gutter} ${indent}${body}${tagSuffix(l.tag)}`;
15
+ return `${l.gutter} ${indent}${body}${tagSuffix(l.tag, opts.color)}`;
16
16
  }).join("\n");
17
17
  }
18
18
 
@@ -0,0 +1,80 @@
1
+ //#region src/cli/ui/legend.ts
2
+ /**
3
+ * The color legend for the config diff (the `preview` command and
4
+ * `export --dry-run`). A single styled line whose swatches each carry the
5
+ * matching `ChangeStyle`, so the legend automatically tracks the palette.
6
+ * Callers prepend it only when color is on — with color off the swatches are
7
+ * indistinguishable and the legend is just noise.
8
+ *
9
+ * @internal
10
+ */
11
+ function legendLines() {
12
+ return [{
13
+ indent: 0,
14
+ gutter: " ",
15
+ segments: [
16
+ {
17
+ text: "Legend: ",
18
+ style: "plain"
19
+ },
20
+ {
21
+ text: "■ added ",
22
+ style: "added"
23
+ },
24
+ {
25
+ text: "■ removed ",
26
+ style: "removed"
27
+ },
28
+ {
29
+ text: "■ modified ",
30
+ style: "changed"
31
+ },
32
+ {
33
+ text: "■ unchanged ",
34
+ style: "unchanged"
35
+ },
36
+ {
37
+ text: "■ unmanaged",
38
+ style: "unmanaged"
39
+ }
40
+ ]
41
+ }];
42
+ }
43
+ /**
44
+ * The legend for the Simulated view. That view is not a diff — nothing is added
45
+ * or removed — so it gets its own vocabulary: how each field is combined
46
+ * (`merge`/`overwrite`) and how divergence is enforced (`warn`/`error`).
47
+ *
48
+ * @internal
49
+ */
50
+ function simulatedLegendLines() {
51
+ return [{
52
+ indent: 0,
53
+ gutter: " ",
54
+ segments: [
55
+ {
56
+ text: "Legend: ",
57
+ style: "plain"
58
+ },
59
+ {
60
+ text: "■ merge ",
61
+ style: "merge"
62
+ },
63
+ {
64
+ text: "■ overwrite ",
65
+ style: "overwrite"
66
+ },
67
+ {
68
+ text: "■ warn ",
69
+ style: "changed"
70
+ },
71
+ {
72
+ text: "■ error",
73
+ style: "warn"
74
+ }
75
+ ]
76
+ }];
77
+ }
78
+
79
+ //#endregion
80
+ export { legendLines, simulatedLegendLines };
@@ -15,7 +15,7 @@ function runPreview(views) {
15
15
  render(createElement(Preview, {
16
16
  views,
17
17
  onExit: () => {}
18
- })).waitUntilExit().then(() => resume(Effect.void));
18
+ })).waitUntilExit().then(() => resume(Effect.void)).catch((err) => resume(Effect.die(err)));
19
19
  });
20
20
  }
21
21
 
@@ -11,19 +11,18 @@ import { createElement } from "react";
11
11
  *
12
12
  * @internal
13
13
  */
14
- function runWalk(items, dryRun = false, unresolved = []) {
14
+ function runWalk(items, dryRun = false, unresolved = [], interopModels = /* @__PURE__ */ new Map()) {
15
15
  return Effect.callback((resume) => {
16
16
  let collected = [];
17
17
  render(createElement(Walk, {
18
18
  items,
19
19
  dryRun,
20
20
  unresolved,
21
+ interopModels,
21
22
  onDone: (d) => {
22
23
  collected = d;
23
24
  }
24
- })).waitUntilExit().then(() => {
25
- resume(Effect.succeed([...collected]));
26
- });
25
+ })).waitUntilExit().then(() => resume(Effect.succeed([...collected]))).catch((err) => resume(Effect.die(err)));
27
26
  });
28
27
  }
29
28
 
package/cli/ui/styled.js CHANGED
@@ -6,9 +6,11 @@ const ANSI_OPEN = {
6
6
  changed: "\x1B[33m",
7
7
  warn: "\x1B[31m",
8
8
  local: "\x1B[35m",
9
- unmanaged: "\x1B[2m",
9
+ unmanaged: "\x1B[38;5;240m",
10
10
  unchanged: "\x1B[2m",
11
- plain: ""
11
+ plain: "",
12
+ merge: "\x1B[36m",
13
+ overwrite: "\x1B[35m"
12
14
  };
13
15
  const ANSI_RESET = "\x1B[0m";
14
16
  /** Apply (or omit) the SGR code for a style. @internal */
@@ -16,10 +18,17 @@ function paint(text, style, color) {
16
18
  if (!color || style === "plain" || ANSI_OPEN[style] === "") return text;
17
19
  return `${ANSI_OPEN[style]}${text}${ANSI_RESET}`;
18
20
  }
19
- /** The trailing annotation for a tag, e.g. " (local)". @internal */
20
- function tagSuffix(tag) {
21
+ /**
22
+ * The trailing annotation for a tag, e.g. " (local)". With color on, the
23
+ * `unmanaged` tag is dropped — the dedicated gray shade plus the legend already
24
+ * convey it — but it is kept when color is off, where the shade is invisible and
25
+ * the tag is the only signal (the tags-stay-meaningful-without-color contract).
26
+ *
27
+ * @internal
28
+ */
29
+ function tagSuffix(tag, color) {
21
30
  if (tag === "local") return " (local)";
22
- if (tag === "unmanaged") return " (unmanaged)";
31
+ if (tag === "unmanaged") return color ? "" : " (unmanaged)";
23
32
  return "";
24
33
  }
25
34
 
@@ -2,9 +2,22 @@
2
2
  const ORDER = {
3
3
  keep: 0,
4
4
  "in-range": 1,
5
- latest: 2
5
+ minor: 2,
6
+ latest: 3
6
7
  };
7
8
  /**
9
+ * Truncate `s` to at most `max` display columns, appending `…` when clipped.
10
+ * Keeps a long peer-conflict annotation from wrapping and breaking the table's
11
+ * column alignment. A `max` of 1 or less yields just the ellipsis.
12
+ *
13
+ * @internal
14
+ */
15
+ function truncateEnd(s, max) {
16
+ if (s.length <= max) return s;
17
+ if (max <= 1) return "…";
18
+ return `${s.slice(0, max - 1)}…`;
19
+ }
20
+ /**
8
21
  * The row's options in display order: keep first (always index 0, always the
9
22
  * default), then the in-range bump, then the latest-overall bump. `planEntry`
10
23
  * emits them in the opposite order, with keep last.
@@ -15,14 +28,18 @@ function displayCandidates(item) {
15
28
  return [...item.candidates].sort((a, b) => ORDER[a.kind] - ORDER[b.kind]);
16
29
  }
17
30
  /**
18
- * Initialize the table: cursor on the first row, keep selected on every row, so
19
- * the default state applies nothing.
31
+ * Initialize the table: keep selected on every row (so the default state applies
32
+ * nothing), and the cursor on the first actionable row. Since every discovered
33
+ * row is shown — up-to-date rows included, as context — the cursor skips past any
34
+ * leading inert keep-only rows to land where the user can act; it falls back to
35
+ * row 0 when nothing is actionable.
20
36
  *
21
37
  * @internal
22
38
  */
23
39
  function initTable(items) {
40
+ const first = items.findIndex((i) => !i.upToDate);
24
41
  return {
25
- cursor: 0,
42
+ cursor: first === -1 ? 0 : first,
26
43
  picks: items.map(() => 0),
27
44
  done: false,
28
45
  cancelled: false
@@ -113,4 +130,4 @@ function cellColor(candidate, selected) {
113
130
  }
114
131
 
115
132
  //#endregion
116
- export { cellColor, displayCandidates, initTable, peerFor, tableDecisions, tableStep };
133
+ export { cellColor, displayCandidates, initTable, peerFor, tableDecisions, tableStep, truncateEnd };
package/index.d.ts CHANGED
@@ -29,6 +29,33 @@ interface CatalogDeclaration {
29
29
  readonly packages: Record<string, CatalogPackageSpec>;
30
30
  }
31
31
  //#endregion
32
+ //#region src/plugin/allowed-versions.d.ts
33
+ /**
34
+ * A directive that derives `peerDependencyRules.allowedVersions` rules from a
35
+ * catalog. For each catalog entry with an exact pinned version, a version-
36
+ * qualified rule `"<name>@<pin>><peer>"` is emitted, valued at the `peer`
37
+ * package's own catalog version (optionally re-prefixed) — retiring the "unmet
38
+ * peer on a fast prerelease line" warning class without ever masking a
39
+ * genuinely-unmet range: pnpm applies a qualified rule only when the parent
40
+ * instance's version satisfies the exact qualifier, so a same-named satellite on
41
+ * a different version line keeps its real complaint.
42
+ *
43
+ * @public
44
+ */
45
+ interface AllowedVersionsFromCatalogs {
46
+ /** The catalog whose entries supply the rules. */
47
+ readonly catalog: string;
48
+ /** The peer package each rule targets; its own version becomes the rule value and it gets no rule of its own. */
49
+ readonly peer: string;
50
+ /**
51
+ * Operator applied to the derived peer value. Omit to use the peer's catalog
52
+ * value verbatim (`4.0.0-beta.99` stays exact, `^3.0.0` stays `^3.0.0`); set
53
+ * `"^"` / `"~"` / `">="` etc. to strip the existing operator and apply this
54
+ * one; set `null` or `""` to strip to an exact version.
55
+ */
56
+ readonly prefix?: string | null;
57
+ }
58
+ //#endregion
32
59
  //#region src/runtime/types.d.ts
33
60
  /**
34
61
  * How a field's divergences are enforced: silent, console warning, or a thrown
@@ -130,6 +157,12 @@ interface PluginConfig {
130
157
  readonly allowedVersions?: Record<string, string>;
131
158
  readonly ignoreMissing?: string[];
132
159
  readonly allowAny?: string[];
160
+ /**
161
+ * Derive version-qualified `allowedVersions` rules from a catalog (or several).
162
+ * Resolved and merged into `allowedVersions` at build time, then stripped; a
163
+ * manually authored `allowedVersions` entry wins on a key clash.
164
+ */
165
+ readonly allowedVersionsFromCatalogs?: AllowedVersionsFromCatalogs | readonly AllowedVersionsFromCatalogs[];
133
166
  }>;
134
167
  /** Whether dependency build scripts are blocked unless explicitly allowed. */
135
168
  readonly strictDepBuilds?: FieldInput<boolean>;
@@ -373,5 +406,5 @@ interface PluginConfig {
373
406
  */
374
407
  declare function PnpmConfigPlugin(config: PluginConfig): Plugin;
375
408
  //#endregion
376
- export { type CatalogDeclaration, type CatalogPackageSpec, type Enforcement, type FieldInput, type LocalDirective, type PeerStrategy, type PluginConfig, PnpmConfigPlugin };
409
+ export { type AllowedVersionsFromCatalogs, type CatalogDeclaration, type CatalogPackageSpec, type Enforcement, type FieldInput, type LocalDirective, type PeerStrategy, type PluginConfig, PnpmConfigPlugin };
377
410
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rolldown-pnpm-config",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "A dogfooding example of our plugin",
6
6
  "repository": {
@@ -35,10 +35,10 @@
35
35
  "rolldown-pnpm-config": "bin/rolldown-pnpm-config.js"
36
36
  },
37
37
  "dependencies": {
38
- "@effect/platform-node": "4.0.0-beta.98",
39
- "@effected/semver": "^0.1.0",
40
- "@effected/yaml": "^0.3.1",
41
- "effect": "4.0.0-beta.98",
38
+ "@effect/platform-node": "4.0.0-beta.99",
39
+ "@effected/semver": "^0.2.0",
40
+ "@effected/yaml": "^0.5.0",
41
+ "effect": "4.0.0-beta.99",
42
42
  "ink": "^7.1.1",
43
43
  "ink-tab": "^5.2.0",
44
44
  "oxc-parser": "^0.140.0",
@@ -0,0 +1,60 @@
1
+ //#region src/plugin/allowed-versions.ts
2
+ /** An exact version: no range operator, no wildcard — `X.Y.Z` with an optional prerelease. */
3
+ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
4
+ /** The install range of a catalog spec (bare string or `{ range }` object). */
5
+ const rangeOf = (spec) => typeof spec === "string" ? spec : spec.range;
6
+ /** Strip a leading range operator to the bare version digits (e.g. `^3.17.0` → `3.17.0`). */
7
+ const bareVersion = (range) => range.replace(/^[\^~>=<\s]+/, "").split(/\s/)[0] ?? range;
8
+ /** Apply the `prefix` transform to a peer value: verbatim, re-prefixed, or stripped to exact. */
9
+ function applyPrefix(peerValue, prefix) {
10
+ if (prefix === void 0) return peerValue;
11
+ const bare = bareVersion(peerValue);
12
+ return prefix === null || prefix === "" ? bare : `${prefix}${bare}`;
13
+ }
14
+ /** Derive the rules for one directive. Pure; throws when the catalog or peer is absent. */
15
+ function deriveOne(catalogs, { catalog, peer, prefix }) {
16
+ const decl = catalogs[catalog];
17
+ if (decl === void 0) throw new Error(`allowedVersionsFromCatalogs: catalog "${catalog}" is not declared`);
18
+ const peerSpec = decl.packages[peer];
19
+ if (peerSpec === void 0) throw new Error(`allowedVersionsFromCatalogs: peer "${peer}" is not in catalog "${catalog}"`);
20
+ const value = applyPrefix(rangeOf(peerSpec), prefix);
21
+ const table = {};
22
+ for (const [name, spec] of Object.entries(decl.packages)) {
23
+ if (name === peer) continue;
24
+ const range = rangeOf(spec);
25
+ if (!EXACT_VERSION.test(range)) continue;
26
+ table[`${name}@${range}>${peer}`] = value;
27
+ }
28
+ return table;
29
+ }
30
+ /** Merge the derived rules for one or more directives (later directives win on a key clash). */
31
+ function deriveAllowedVersions(catalogs, directives) {
32
+ const list = Array.isArray(directives) ? directives : [directives];
33
+ const out = {};
34
+ for (const d of list) Object.assign(out, deriveOne(catalogs, d));
35
+ return out;
36
+ }
37
+ /**
38
+ * Resolve a `peerDependencyRules` value carrying an `allowedVersionsFromCatalogs`
39
+ * directive: derive the rules from the catalogs, merge them into `allowedVersions`
40
+ * (an explicitly authored entry wins on a key clash), and strip the directive so
41
+ * the cleaned value passes the `peerDependencyRules` schema. Any value without the
42
+ * directive passes through untouched.
43
+ *
44
+ * @internal
45
+ */
46
+ function resolvePeerDependencyRules(value, catalogs) {
47
+ if (value === null || typeof value !== "object" || !("allowedVersionsFromCatalogs" in value)) return value;
48
+ const { allowedVersionsFromCatalogs, ...rest } = value;
49
+ const merged = {
50
+ ...deriveAllowedVersions(catalogs, allowedVersionsFromCatalogs),
51
+ ...rest.allowedVersions ?? {}
52
+ };
53
+ return {
54
+ ...rest,
55
+ ...Object.keys(merged).length > 0 ? { allowedVersions: merged } : {}
56
+ };
57
+ }
58
+
59
+ //#endregion
60
+ export { deriveAllowedVersions, resolvePeerDependencyRules };
package/plugin/freeze.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { normalizeCatalogs } from "../catalogs.js";
2
2
  import { DESCRIPTORS, deriveSchemas } from "../descriptors/index.js";
3
3
  import { FIELD_REGISTRY } from "../registry.js";
4
+ import { resolvePeerDependencyRules } from "./allowed-versions.js";
4
5
  import { Data, Effect, Schema } from "effect";
5
6
 
6
7
  //#region src/plugin/freeze.ts
@@ -60,7 +61,11 @@ function freeze(config) {
60
61
  if (raw === void 0) continue;
61
62
  const decl = normalizeField(raw);
62
63
  const schema = FIELD_SCHEMAS[field];
63
- base[field] = schema ? yield* Schema.decodeUnknownEffect(schema)(decl.value).pipe(Effect.mapError((error) => new ConfigError({ message: `Invalid ${field}: ${String(error)}` }))) : decl.value;
64
+ const value = field === "peerDependencyRules" ? yield* Effect.try({
65
+ try: () => resolvePeerDependencyRules(decl.value, config.catalogs),
66
+ catch: (error) => new ConfigError({ message: error instanceof Error ? error.message : String(error) })
67
+ }) : decl.value;
68
+ base[field] = schema ? yield* Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError((error) => new ConfigError({ message: `Invalid ${field}: ${String(error)}` }))) : value;
64
69
  manifest[field] = {
65
70
  strategy: reg.strategy,
66
71
  enforcement: decl.enforcement ?? reg.enforcement,