rolldown-pnpm-config 0.1.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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/bin/rolldown-pnpm-config.js +21 -0
  4. package/catalogs.js +27 -0
  5. package/cli/commands/export.js +118 -0
  6. package/cli/commands/preview.js +73 -0
  7. package/cli/commands/upgrade.js +437 -0
  8. package/cli/diff/build.js +122 -0
  9. package/cli/diff/render.js +103 -0
  10. package/cli/discover.js +128 -0
  11. package/cli/drift.js +22 -0
  12. package/cli/edits.js +41 -0
  13. package/cli/effective.js +42 -0
  14. package/cli/evaluate.js +91 -0
  15. package/cli/interop.js +285 -0
  16. package/cli/local-merge.js +75 -0
  17. package/cli/peer-range.js +34 -0
  18. package/cli/plan.js +66 -0
  19. package/cli/preview-views.js +34 -0
  20. package/cli/release-age.js +74 -0
  21. package/cli/resolve.js +109 -0
  22. package/cli/rewrite.js +22 -0
  23. package/cli/select-file.js +64 -0
  24. package/cli/summary.js +137 -0
  25. package/cli/ui/Preview.js +60 -0
  26. package/cli/ui/Walk.js +55 -0
  27. package/cli/ui/ansi.js +20 -0
  28. package/cli/ui/env.js +20 -0
  29. package/cli/ui/run-preview.js +23 -0
  30. package/cli/ui/run-walk.js +29 -0
  31. package/cli/ui/styled.js +27 -0
  32. package/cli/walk-plan.js +35 -0
  33. package/cli/walk-reducer.js +61 -0
  34. package/cli/workspace-file.js +58 -0
  35. package/cli/workspace-overlay.js +21 -0
  36. package/descriptors/build.js +248 -0
  37. package/descriptors/hoisting.js +175 -0
  38. package/descriptors/index.js +38 -0
  39. package/descriptors/lockfile.js +117 -0
  40. package/descriptors/misc.js +144 -0
  41. package/descriptors/network.js +108 -0
  42. package/descriptors/resolution.js +250 -0
  43. package/descriptors/runtime-cfg.js +90 -0
  44. package/descriptors/schemas.js +26 -0
  45. package/descriptors/workspace.js +116 -0
  46. package/index.d.ts +363 -0
  47. package/index.js +3 -0
  48. package/package.json +60 -0
  49. package/plugin/freeze.js +79 -0
  50. package/plugin/index.js +48 -0
  51. package/plugin/serialize.js +26 -0
  52. package/registry.js +8 -0
  53. package/runtime/ctx.js +39 -0
  54. package/runtime/enforcement.js +36 -0
  55. package/runtime/strategies/arrays.js +37 -0
  56. package/runtime/strategies/catalogs.js +36 -0
  57. package/runtime/strategies/maps.js +46 -0
  58. package/runtime/strategies/overrides.js +57 -0
  59. package/runtime/strategies/scalar.js +57 -0
  60. package/runtime/strategies/table.js +27 -0
  61. package/runtime/warnings.js +61 -0
  62. package/runtime.d.ts +111 -0
  63. package/runtime.js +54 -0
  64. package/tsdoc-metadata.json +11 -0
  65. package/virtual.d.ts +18 -0
@@ -0,0 +1,128 @@
1
+ import { Data } from "effect";
2
+ import { parseSync } from "oxc-parser";
3
+
4
+ //#region src/cli/discover.ts
5
+ /**
6
+ * Typed failure raised when the config source cannot be parsed.
7
+ *
8
+ * @internal
9
+ */
10
+ var DiscoverError = class extends Data.TaggedError("DiscoverError") {};
11
+ /** Matches a simple-operator range we can safely rewrite (`^x`, `~x`, or bare `x`). */
12
+ const SIMPLE_RANGE_RE = /^(\^|~|)(\d[\w.+-]*)$/;
13
+ function operatorOf(range) {
14
+ if (range.startsWith("^")) return "^";
15
+ if (range.startsWith("~")) return "~";
16
+ return "";
17
+ }
18
+ /**
19
+ * Find a property value by key name in an ObjectExpression node.
20
+ * Handles both Identifier keys (unquoted) and Literal keys (quoted).
21
+ */
22
+ function prop(obj, key) {
23
+ const properties = obj.properties ?? [];
24
+ for (const p of properties) {
25
+ if (p.type !== "Property") continue;
26
+ const k = p.key;
27
+ if ((k.type === "Identifier" ? k.name : k.type === "Literal" ? String(k.value) : void 0) === key) return p.value;
28
+ }
29
+ }
30
+ /** Find the first `PnpmConfigPlugin(...)` CallExpression's first argument object. */
31
+ function findPluginArg(program) {
32
+ let found;
33
+ const visit = (node) => {
34
+ if (found || node === null || typeof node !== "object") return;
35
+ const n = node;
36
+ if (n.type === "CallExpression") {
37
+ const callee = n.callee;
38
+ if (callee?.type === "Identifier" && callee.name === "PnpmConfigPlugin") {
39
+ const args = n.arguments;
40
+ if (args?.[0]?.type === "ObjectExpression") {
41
+ found = args[0];
42
+ return;
43
+ }
44
+ }
45
+ }
46
+ for (const value of Object.values(n)) if (Array.isArray(value)) value.forEach(visit);
47
+ else if (value && typeof value === "object") visit(value);
48
+ };
49
+ visit(program);
50
+ return found;
51
+ }
52
+ /**
53
+ * Statically discover the catalog version literals in a config source. Locates
54
+ * the single `PnpmConfigPlugin(...)` call and walks `.catalogs.<name>.packages`.
55
+ * Each package whose range is a simple-operator string literal yields a
56
+ * CatalogEntry with byte-offset spans; anything else (computed value, complex
57
+ * range) is reported in `skipped` as `<catalog>.<pkg>` and never throws.
58
+ *
59
+ * @internal
60
+ */
61
+ function discoverCatalogEntries(source, filename) {
62
+ const result = parseSync(filename, source);
63
+ if (result.errors.length > 0) throw new DiscoverError({ message: result.errors.map((e) => e.message).join("; ") });
64
+ const program = result.program;
65
+ const entries = [];
66
+ const skipped = [];
67
+ const arg = findPluginArg(program);
68
+ if (!arg) return {
69
+ entries,
70
+ skipped
71
+ };
72
+ const catalogs = prop(arg, "catalogs");
73
+ if (catalogs?.type !== "ObjectExpression") return {
74
+ entries,
75
+ skipped
76
+ };
77
+ for (const catProp of catalogs.properties ?? []) {
78
+ if (catProp.type !== "Property") continue;
79
+ const catKey = catProp.key;
80
+ const catalog = catKey.type === "Identifier" ? catKey.name : String(catKey.value);
81
+ const decl = catProp.value;
82
+ if (decl.type !== "ObjectExpression") continue;
83
+ const packages = prop(decl, "packages");
84
+ if (packages?.type !== "ObjectExpression") continue;
85
+ for (const pkgProp of packages.properties ?? []) {
86
+ if (pkgProp.type !== "Property") continue;
87
+ const pkgKey = pkgProp.key;
88
+ const pkg = pkgKey.type === "Identifier" ? pkgKey.name : String(pkgKey.value);
89
+ const value = pkgProp.value;
90
+ let rangeNode;
91
+ let peerNode;
92
+ let strategy;
93
+ if (value.type === "Literal" && typeof value.value === "string") rangeNode = value;
94
+ else if (value.type === "ObjectExpression") {
95
+ const r = prop(value, "range");
96
+ if (r?.type === "Literal" && typeof r.value === "string") rangeNode = r;
97
+ const p = prop(value, "peer");
98
+ if (p?.type === "Literal" && typeof p.value === "string") peerNode = p;
99
+ const s = prop(value, "strategy");
100
+ if (s?.type === "Literal" && (s.value === "lock" || s.value === "lock-minor" || s.value === "interop")) strategy = s.value;
101
+ }
102
+ if (!rangeNode || !SIMPLE_RANGE_RE.test(rangeNode.value)) {
103
+ skipped.push(`${catalog}.${pkg}`);
104
+ continue;
105
+ }
106
+ const currentRange = rangeNode.value;
107
+ entries.push({
108
+ catalog,
109
+ pkg,
110
+ currentRange,
111
+ operator: operatorOf(currentRange),
112
+ rangeSpan: [rangeNode.start, rangeNode.end],
113
+ ...peerNode ? { peer: {
114
+ value: peerNode.value,
115
+ span: [peerNode.start, peerNode.end]
116
+ } } : {},
117
+ ...strategy ? { strategy } : {}
118
+ });
119
+ }
120
+ }
121
+ return {
122
+ entries,
123
+ skipped
124
+ };
125
+ }
126
+
127
+ //#endregion
128
+ export { DiscoverError, discoverCatalogEntries };
package/cli/drift.js ADDED
@@ -0,0 +1,22 @@
1
+ import { derivePeerRange } from "./peer-range.js";
2
+ import { Effect } from "effect";
3
+
4
+ //#region src/cli/drift.ts
5
+ /**
6
+ * Detect whether an entry's materialized peer range has drifted from what its
7
+ * strategy would produce from the CURRENT range. Returns the resync target (the
8
+ * up-to-date peer range) on drift, or null when in sync or not applicable
9
+ * (missing peer or strategy).
10
+ *
11
+ * @internal
12
+ */
13
+ function detectPeerDrift(entry) {
14
+ return Effect.gen(function* () {
15
+ if (!entry.peer || !entry.strategy) return null;
16
+ const expected = yield* derivePeerRange(entry.currentRange, entry.strategy);
17
+ return expected === entry.peer.value ? null : expected;
18
+ });
19
+ }
20
+
21
+ //#endregion
22
+ export { detectPeerDrift };
package/cli/edits.js ADDED
@@ -0,0 +1,41 @@
1
+ //#region src/cli/edits.ts
2
+ /**
3
+ * Convert resolved decisions into span edits. A chosen upgrade rewrites the
4
+ * range literal (and the existing peer literal when the candidate carries a
5
+ * recomputed peerRange). A keep with peer drift rewrites only the peer literal
6
+ * to the resync target.
7
+ *
8
+ * @internal
9
+ */
10
+ function buildEdits(decisions) {
11
+ const edits = [];
12
+ for (const { item, chosen } of decisions) {
13
+ const { entry } = item;
14
+ const insertAt = entry.rangeSpan[1];
15
+ if (chosen.kind !== "keep") {
16
+ edits.push({
17
+ span: entry.rangeSpan,
18
+ text: JSON.stringify(chosen.range)
19
+ });
20
+ if (entry.peer && chosen.peerRange) edits.push({
21
+ span: entry.peer.span,
22
+ text: JSON.stringify(chosen.peerRange)
23
+ });
24
+ else if (!entry.peer && entry.strategy && chosen.peerRange) edits.push({
25
+ span: [insertAt, insertAt],
26
+ text: `, peer: ${JSON.stringify(chosen.peerRange)}`
27
+ });
28
+ } else if (entry.peer && item.driftPeer) edits.push({
29
+ span: entry.peer.span,
30
+ text: JSON.stringify(item.driftPeer)
31
+ });
32
+ else if (!entry.peer && item.materializePeer) edits.push({
33
+ span: [insertAt, insertAt],
34
+ text: `, peer: ${JSON.stringify(item.materializePeer)}`
35
+ });
36
+ }
37
+ return edits;
38
+ }
39
+
40
+ //#endregion
41
+ export { buildEdits };
@@ -0,0 +1,42 @@
1
+ import { excludeByRepo } from "../runtime/ctx.js";
2
+ import { applyLocalDirective } from "./local-merge.js";
3
+
4
+ //#region src/cli/effective.ts
5
+ /** Apply the manifest's excludeByRepo refine to publicHoistPattern, if present. */
6
+ function applyExcludeByRepo(out, manifest, rootName) {
7
+ const byRepo = manifest.publicHoistPattern?.options?.excludeByRepo;
8
+ const phl = out.publicHoistPattern;
9
+ if (byRepo && typeof byRepo === "object" && Array.isArray(phl)) out.publicHoistPattern = excludeByRepo(phl, { rootName }, byRepo);
10
+ }
11
+ /**
12
+ * Compute the effective workspace fields for THIS repo: managed base, then
13
+ * excludeByRepo on publicHoistPattern, then per-field local directives.
14
+ * `overrides` always runs (default file-protocol preserve), even with no local.
15
+ *
16
+ * @internal
17
+ */
18
+ function effectiveManaged(managed, local, parsed, manifest, rootName) {
19
+ const out = { ...managed };
20
+ applyExcludeByRepo(out, manifest, rootName);
21
+ const fields = /* @__PURE__ */ new Set(["overrides", ...Object.keys(local ?? {})]);
22
+ for (const field of fields) {
23
+ const next = applyLocalDirective(out[field], local?.[field], parsed[field], field);
24
+ if (next === void 0) delete out[field];
25
+ else out[field] = next;
26
+ }
27
+ return out;
28
+ }
29
+ /**
30
+ * The fresh-consumer ("vanilla") workspace fields: managed base + excludeByRepo
31
+ * only — no local overlay and no preserve.
32
+ *
33
+ * @internal
34
+ */
35
+ function vanillaManaged(managed, manifest, rootName) {
36
+ const out = { ...managed };
37
+ applyExcludeByRepo(out, manifest, rootName);
38
+ return out;
39
+ }
40
+
41
+ //#endregion
42
+ export { effectiveManaged, vanillaManaged };
@@ -0,0 +1,91 @@
1
+ import { parseSync } from "oxc-parser";
2
+
3
+ //#region src/cli/evaluate.ts
4
+ /** Find the first `PnpmConfigPlugin(...)` call's first argument (an object literal). */
5
+ function findPluginArg(program) {
6
+ let found;
7
+ const visit = (node) => {
8
+ if (found || node === null || typeof node !== "object") return;
9
+ const n = node;
10
+ if (n.type === "CallExpression") {
11
+ const callee = n.callee;
12
+ if (callee?.type === "Identifier" && callee.name === "PnpmConfigPlugin") {
13
+ const args = n.arguments;
14
+ if (args?.[0]?.type === "ObjectExpression") {
15
+ found = args[0];
16
+ return;
17
+ }
18
+ }
19
+ }
20
+ for (const value of Object.values(n)) if (Array.isArray(value)) value.forEach(visit);
21
+ else if (value && typeof value === "object") visit(value);
22
+ };
23
+ visit(program);
24
+ return found;
25
+ }
26
+ /** Evaluate a literal AST node into a plain JS value; unsupported nodes push to `errors`. */
27
+ function evalNode(node, path, errors) {
28
+ switch (node.type) {
29
+ case "Literal": return node.value;
30
+ case "ArrayExpression": {
31
+ const out = [];
32
+ for (const [i, el] of (node.elements ?? []).entries()) {
33
+ if (el === null) {
34
+ errors.push(`${path}[${i}]: holes are not supported`);
35
+ continue;
36
+ }
37
+ const val = evalNode(el, `${path}[${i}]`, errors);
38
+ if (val !== void 0) out.push(val);
39
+ }
40
+ return out;
41
+ }
42
+ case "ObjectExpression": {
43
+ const out = {};
44
+ for (const prop of node.properties ?? []) {
45
+ if (prop.type !== "Property") {
46
+ errors.push(`${path}: spread/getter is not supported`);
47
+ continue;
48
+ }
49
+ const key = prop.key;
50
+ const name = key.type === "Identifier" ? key.name : key.type === "Literal" ? String(key.value) : void 0;
51
+ if (name === void 0) {
52
+ errors.push(`${path}: computed key is not supported`);
53
+ continue;
54
+ }
55
+ const value = evalNode(prop.value, `${path}.${name}`, errors);
56
+ if (value !== void 0) out[name] = value;
57
+ }
58
+ return out;
59
+ }
60
+ default:
61
+ errors.push(`${path}: ${node.type} is not a literal; inline a concrete value`);
62
+ return;
63
+ }
64
+ }
65
+ /**
66
+ * Statically evaluate the single `PnpmConfigPlugin(...)` call's object-literal
67
+ * argument into a plain config object. No module execution. Non-literal values
68
+ * are reported in `errors` and omitted; `config` is null when no call is found.
69
+ *
70
+ * @internal
71
+ */
72
+ function evaluatePluginConfig(source, filename) {
73
+ const errors = [];
74
+ const result = parseSync(filename, source);
75
+ if (result.errors.length > 0) return {
76
+ config: null,
77
+ errors: result.errors.map((e) => e.message)
78
+ };
79
+ const arg = findPluginArg(result.program);
80
+ if (!arg) return {
81
+ config: null,
82
+ errors
83
+ };
84
+ return {
85
+ config: evalNode(arg, "config", errors),
86
+ errors
87
+ };
88
+ }
89
+
90
+ //#endregion
91
+ export { evaluatePluginConfig };
package/cli/interop.js ADDED
@@ -0,0 +1,285 @@
1
+ import { Effect } from "effect";
2
+ import { Range, SemVer } from "semver-effect";
3
+
4
+ //#region src/cli/interop.ts
5
+ /** Maximum number of concurrent peerDependencies fetches inside runInterop. @internal */
6
+ const INTEROP_PEER_CONCURRENCY = 8;
7
+ /** Strip a range operator to its bare version digits (e.g. `^3.17.0` → `3.17.0`). */
8
+ function floorOf(range) {
9
+ return range.replace(/^[\^~>=\s]+/, "").split(/\s/)[0] ?? range;
10
+ }
11
+ /**
12
+ * Derive each member's caret-capped peer floor: the lowest floor any in-group
13
+ * member declares for it, or `^<its resolved version>` when no member peers on
14
+ * it.
15
+ *
16
+ * @internal
17
+ */
18
+ function deriveFloors(resolved, fetchPeer) {
19
+ return Effect.gen(function* () {
20
+ const floors = /* @__PURE__ */ new Map();
21
+ for (const [pkg, version] of resolved) {
22
+ const peers = yield* fetchPeer(pkg, version);
23
+ for (const [dep, range] of Object.entries(peers)) {
24
+ if (!resolved.has(dep)) continue;
25
+ const list = floors.get(dep) ?? [];
26
+ list.push(floorOf(range));
27
+ floors.set(dep, list);
28
+ }
29
+ }
30
+ const out = /* @__PURE__ */ new Map();
31
+ for (const [pkg, version] of resolved) {
32
+ const declared = floors.get(pkg);
33
+ if (declared?.length) {
34
+ const valid = (yield* Effect.forEach(declared, (f) => SemVer.parse(f).pipe(Effect.map((sv) => ({
35
+ f,
36
+ sv
37
+ })), Effect.catchAll(() => Effect.succeed(null))))).filter((x) => x !== null);
38
+ valid.sort((a, b) => a.sv.compare(b.sv));
39
+ out.set(pkg, `^${valid[0]?.f ?? version}`);
40
+ } else out.set(pkg, `^${version}`);
41
+ }
42
+ return out;
43
+ });
44
+ }
45
+ /** Does `version` satisfy `range`? Unparseable input is treated as not-satisfied. */
46
+ function satisfies(version, range) {
47
+ return Effect.gen(function* () {
48
+ const r = yield* Range.parse(range).pipe(Effect.catchAll(() => Effect.succeed(null)));
49
+ if (!r) return false;
50
+ const v = yield* SemVer.parse(version).pipe(Effect.catchAll(() => Effect.succeed(null)));
51
+ return v ? r.test(v) : false;
52
+ });
53
+ }
54
+ /**
55
+ * In-group peers of (pkg@version) that the current resolution violates, as
56
+ * "dep@range" strings. Uses the Effectful `fetchPeer` so it fetches on-demand
57
+ * only when a candidate version's peer-deps have not yet been cached.
58
+ */
59
+ function violations(pkg, version, resolved, memberSet, fetchPeer) {
60
+ return Effect.gen(function* () {
61
+ const out = [];
62
+ for (const [dep, range] of Object.entries(yield* fetchPeer(pkg, version))) {
63
+ if (!memberSet.has(dep)) continue;
64
+ const rv = resolved.get(dep);
65
+ if (rv === void 0) continue;
66
+ if (!(yield* satisfies(rv, range))) out.push(`${dep}@${range}`);
67
+ }
68
+ return out;
69
+ });
70
+ }
71
+ /**
72
+ * Reconcile a group's chosen versions against their cross-peerDependencies by
73
+ * downgrading dependents only. Ceilings are never raised and peer targets are
74
+ * never downgraded; unsatisfiable members become conflicts.
75
+ *
76
+ * @internal
77
+ */
78
+ function resolveGroup(members, fetchPeer) {
79
+ return Effect.gen(function* () {
80
+ const memberSet = new Set(members.map((m) => m.pkg));
81
+ const resolved = new Map(members.map((m) => [m.pkg, m.ceiling]));
82
+ const ceilingOf = new Map(members.map((m) => [m.pkg, m.ceiling]));
83
+ const leq = (a, b) => Effect.gen(function* () {
84
+ const av = yield* SemVer.parse(a).pipe(Effect.catchAll(() => Effect.succeed(null)));
85
+ const bv = yield* SemVer.parse(b).pipe(Effect.catchAll(() => Effect.succeed(null)));
86
+ return av && bv ? av.compare(bv) <= 0 : false;
87
+ });
88
+ const maxIter = members.reduce((n, m) => n + m.candidates.length, 0) + members.length + 1;
89
+ for (let i = 0; i < maxIter; i++) {
90
+ let changed = false;
91
+ for (const m of members) {
92
+ const cur = resolved.get(m.pkg);
93
+ if ((yield* violations(m.pkg, cur, resolved, memberSet, fetchPeer)).length === 0) continue;
94
+ const ceiling = ceilingOf.get(m.pkg);
95
+ const eligible = [];
96
+ for (const c of m.candidates) if (yield* leq(c, ceiling)) eligible.push(c);
97
+ const sorted = yield* sortDesc(eligible);
98
+ let pick = null;
99
+ for (const c of sorted) if ((yield* violations(m.pkg, c, resolved, memberSet, fetchPeer)).length === 0) {
100
+ pick = c;
101
+ break;
102
+ }
103
+ if (pick !== null && pick !== cur) {
104
+ resolved.set(m.pkg, pick);
105
+ changed = true;
106
+ }
107
+ }
108
+ if (!changed) break;
109
+ }
110
+ const conflicts = [];
111
+ for (const m of members) {
112
+ const cur = resolved.get(m.pkg);
113
+ const v = yield* violations(m.pkg, cur, resolved, memberSet, fetchPeer);
114
+ if (v.length > 0) conflicts.push({
115
+ pkg: m.pkg,
116
+ ceiling: m.ceiling,
117
+ blockedBy: v.join(", ")
118
+ });
119
+ }
120
+ for (const c of conflicts) resolved.set(c.pkg, ceilingOf.get(c.pkg));
121
+ return {
122
+ resolved,
123
+ conflicts
124
+ };
125
+ });
126
+ }
127
+ /** Sort version strings descending; unparseable ones sink to the end. */
128
+ function sortDesc(versions) {
129
+ return Effect.gen(function* () {
130
+ const parsed = yield* Effect.forEach(versions, (v) => SemVer.parse(v).pipe(Effect.map((sv) => ({
131
+ v,
132
+ sv
133
+ })), Effect.catchAll(() => Effect.succeed({
134
+ v,
135
+ sv: null
136
+ }))));
137
+ parsed.sort((a, b) => a.sv && b.sv ? b.sv.compare(a.sv) : a.sv ? -1 : 1);
138
+ return parsed.map((p) => p.v);
139
+ });
140
+ }
141
+ /**
142
+ * Fetch the peerDependencies needed to reconcile one catalog interop group,
143
+ * then run the pure resolve + floor derivation. Failures degrade to empty
144
+ * peerDeps (the member resolves at its ceiling).
145
+ *
146
+ * Phase 1 now prefetches only the ceiling version of each member (one
147
+ * `pnpm view` call per member) instead of every candidate version ≤ ceiling.
148
+ * `resolveGroup` / `violations` fetch lower versions on-demand via the shared
149
+ * `fetchPeer` Effectful memoized lookup — they are only consulted when a
150
+ * downgrade search probes them, which for real-world interop groups (where most
151
+ * members are compatible at their ceilings) reduces the total call count from
152
+ * O(N × |candidates|) to O(N + |downgraded members| × depth).
153
+ *
154
+ * A `(pkg, version)` peerDeps lookup is immutable, so the optional `cache` may
155
+ * be shared across the interactive re-entry rounds: each round only fetches the
156
+ * keys a prior round did not, sparing the sequential `pnpm view` calls for
157
+ * versions already seen. Omitting it yields a fresh per-call cache.
158
+ *
159
+ * @internal
160
+ */
161
+ function runInterop(members, resolver, cache = /* @__PURE__ */ new Map()) {
162
+ return Effect.gen(function* () {
163
+ const key = (pkg, v) => `${pkg}@${v}`;
164
+ const fetchPeer = (pkg, v) => {
165
+ const k = key(pkg, v);
166
+ const cached = cache.get(k);
167
+ if (cached !== void 0) return Effect.succeed(cached);
168
+ return resolver.peerDependencies(pkg, v).pipe(Effect.catchAll(() => Effect.succeed({}))).pipe(Effect.map((deps) => {
169
+ cache.set(k, deps);
170
+ return deps;
171
+ }));
172
+ };
173
+ const seen = /* @__PURE__ */ new Set();
174
+ const toFetch = [];
175
+ for (const m of members) {
176
+ const k = key(m.pkg, m.ceiling);
177
+ if (seen.has(k) || cache.has(k)) continue;
178
+ seen.add(k);
179
+ toFetch.push([m.pkg, m.ceiling]);
180
+ }
181
+ yield* Effect.forEach(toFetch, ([pkg, v]) => fetchPeer(pkg, v), { concurrency: 8 });
182
+ const { resolved, conflicts } = yield* resolveGroup(members, fetchPeer);
183
+ const peers = yield* deriveFloors(resolved, fetchPeer);
184
+ const peerDepsOf = (pkg, v) => cache.get(key(pkg, v)) ?? {};
185
+ return {
186
+ resolved,
187
+ peers,
188
+ conflicts,
189
+ peerDepsOf
190
+ };
191
+ });
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
+ /** True when an interop member's resolved version/peer differs from what's in source. @internal */
223
+ function interopEntryChanged(e, result) {
224
+ const version = result.resolved.get(e.pkg);
225
+ if (version === void 0) return false;
226
+ if (`${e.operator}${version}` !== e.currentRange) return true;
227
+ const peer = result.peers.get(e.pkg);
228
+ if (peer === void 0) return false;
229
+ if (e.peer) return peer !== e.peer.value;
230
+ return true;
231
+ }
232
+ /**
233
+ * Build the span edits for one interop catalog group from its resolution: a
234
+ * range edit when the resolved version differs, and a peer edit that rewrites an
235
+ * existing `peer` literal or inserts `, peer: "^..."` at the range-span end.
236
+ *
237
+ * @internal
238
+ */
239
+ function buildInteropEdits(entries, result) {
240
+ const edits = [];
241
+ for (const e of entries) {
242
+ const version = result.resolved.get(e.pkg);
243
+ if (version === void 0) continue;
244
+ const peer = result.peers.get(e.pkg);
245
+ const newRange = `${e.operator}${version}`;
246
+ if (newRange !== e.currentRange) edits.push({
247
+ span: e.rangeSpan,
248
+ text: JSON.stringify(newRange)
249
+ });
250
+ const at = e.rangeSpan[1];
251
+ if (peer !== void 0) {
252
+ if (e.peer && peer !== e.peer.value) edits.push({
253
+ span: e.peer.span,
254
+ text: JSON.stringify(peer)
255
+ });
256
+ else if (!e.peer) edits.push({
257
+ span: [at, at],
258
+ text: `, peer: ${JSON.stringify(peer)}`
259
+ });
260
+ }
261
+ }
262
+ return edits;
263
+ }
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.catchAll(() => 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.catchAll(() => Effect.succeed(null)));
278
+ if (sv && sv.compare(mv) <= 0) out.push(v);
279
+ }
280
+ return out;
281
+ });
282
+ }
283
+
284
+ //#endregion
285
+ export { buildInteropEdits, capVersions, deriveFloors, interopEntryChanged, reentryCandidates, resolveGroup, runInterop };
@@ -0,0 +1,75 @@
1
+ //#region src/cli/local-merge.ts
2
+ /** Default protocols whose existing-file override entries are preserved. @internal */
3
+ const DEFAULT_PRESERVE = [
4
+ "file",
5
+ "link",
6
+ "workspace",
7
+ "portal"
8
+ ];
9
+ const DIRECTIVE_KEYS = /* @__PURE__ */ new Set([
10
+ "preserve",
11
+ "value",
12
+ "strategy"
13
+ ]);
14
+ /**
15
+ * True when `v` is the `{ preserve?, value?, strategy? }` directive form: a
16
+ * non-array object whose keys are a non-empty subset of the directive keys.
17
+ * A record with any foreign key (e.g. a real override entry) is a bare value.
18
+ *
19
+ * @internal
20
+ */
21
+ function isLocalDirective(v) {
22
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
23
+ const keys = Object.keys(v);
24
+ return keys.length > 0 && keys.every((k) => DIRECTIVE_KEYS.has(k));
25
+ }
26
+ function isRecord(v) {
27
+ return v !== null && typeof v === "object" && !Array.isArray(v);
28
+ }
29
+ /** Union/difference two records or two arrays; managed is the left operand. */
30
+ function combine(managed, value, strategy) {
31
+ if (Array.isArray(managed) || Array.isArray(value)) {
32
+ const m = Array.isArray(managed) ? managed : [];
33
+ const v = Array.isArray(value) ? value : [];
34
+ if (strategy === "union") return [.../* @__PURE__ */ new Set([...m, ...v])];
35
+ const drop = new Set(v.map((x) => JSON.stringify(x)));
36
+ return m.filter((x) => !drop.has(JSON.stringify(x)));
37
+ }
38
+ const m = isRecord(managed) ? managed : {};
39
+ const v = isRecord(value) ? value : {};
40
+ if (strategy === "union") return {
41
+ ...m,
42
+ ...v
43
+ };
44
+ const out = { ...m };
45
+ for (const k of Object.keys(v)) delete out[k];
46
+ return out;
47
+ }
48
+ /**
49
+ * Compute the effective value of one field from the managed value, the
50
+ * `config.local[field]` directive (or bare value / undefined), and the parsed
51
+ * existing-file value. For `overrides`, file-protocol entries are preserved
52
+ * from `parsed` (default list unless the directive sets `preserve`).
53
+ *
54
+ * @internal
55
+ */
56
+ function applyLocalDirective(managed, raw, parsed, field) {
57
+ const directive = isLocalDirective(raw) ? raw : { value: raw };
58
+ let result;
59
+ if (directive.strategy && directive.value !== void 0) result = combine(managed, directive.value, directive.strategy);
60
+ else if (directive.value !== void 0) result = directive.value;
61
+ else result = managed;
62
+ if (field === "overrides") {
63
+ const protocols = directive.preserve ?? DEFAULT_PRESERVE;
64
+ const base = isRecord(result) ? { ...result } : {};
65
+ if (isRecord(parsed)) {
66
+ for (const [k, val] of Object.entries(parsed)) if (typeof val === "string" && protocols.some((p) => val.startsWith(`${p}:`))) base[k] = val;
67
+ }
68
+ if (Object.keys(base).length === 0 && managed === void 0) return void 0;
69
+ return base;
70
+ }
71
+ return result;
72
+ }
73
+
74
+ //#endregion
75
+ export { DEFAULT_PRESERVE, applyLocalDirective, isLocalDirective };