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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/rolldown-pnpm-config.js +21 -0
- package/catalogs.js +27 -0
- package/cli/commands/export.js +118 -0
- package/cli/commands/preview.js +73 -0
- package/cli/commands/upgrade.js +437 -0
- package/cli/diff/build.js +122 -0
- package/cli/diff/render.js +103 -0
- package/cli/discover.js +128 -0
- package/cli/drift.js +22 -0
- package/cli/edits.js +41 -0
- package/cli/effective.js +42 -0
- package/cli/evaluate.js +91 -0
- package/cli/interop.js +285 -0
- package/cli/local-merge.js +75 -0
- package/cli/peer-range.js +34 -0
- package/cli/plan.js +66 -0
- package/cli/preview-views.js +34 -0
- package/cli/release-age.js +74 -0
- package/cli/resolve.js +109 -0
- package/cli/rewrite.js +22 -0
- package/cli/select-file.js +64 -0
- package/cli/summary.js +137 -0
- package/cli/ui/Preview.js +60 -0
- package/cli/ui/Walk.js +55 -0
- package/cli/ui/ansi.js +20 -0
- package/cli/ui/env.js +20 -0
- package/cli/ui/run-preview.js +23 -0
- package/cli/ui/run-walk.js +29 -0
- package/cli/ui/styled.js +27 -0
- package/cli/walk-plan.js +35 -0
- package/cli/walk-reducer.js +61 -0
- package/cli/workspace-file.js +58 -0
- package/cli/workspace-overlay.js +21 -0
- package/descriptors/build.js +248 -0
- package/descriptors/hoisting.js +175 -0
- package/descriptors/index.js +38 -0
- package/descriptors/lockfile.js +117 -0
- package/descriptors/misc.js +144 -0
- package/descriptors/network.js +108 -0
- package/descriptors/resolution.js +250 -0
- package/descriptors/runtime-cfg.js +90 -0
- package/descriptors/schemas.js +26 -0
- package/descriptors/workspace.js +116 -0
- package/index.d.ts +363 -0
- package/index.js +3 -0
- package/package.json +60 -0
- package/plugin/freeze.js +79 -0
- package/plugin/index.js +48 -0
- package/plugin/serialize.js +26 -0
- package/registry.js +8 -0
- package/runtime/ctx.js +39 -0
- package/runtime/enforcement.js +36 -0
- package/runtime/strategies/arrays.js +37 -0
- package/runtime/strategies/catalogs.js +36 -0
- package/runtime/strategies/maps.js +46 -0
- package/runtime/strategies/overrides.js +57 -0
- package/runtime/strategies/scalar.js +57 -0
- package/runtime/strategies/table.js +27 -0
- package/runtime/warnings.js +61 -0
- package/runtime.d.ts +111 -0
- package/runtime.js +54 -0
- package/tsdoc-metadata.json +11 -0
- package/virtual.d.ts +18 -0
package/cli/discover.js
ADDED
|
@@ -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 };
|
package/cli/effective.js
ADDED
|
@@ -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 };
|
package/cli/evaluate.js
ADDED
|
@@ -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 };
|