@effected/workspaces 0.25.0 → 0.27.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.
@@ -0,0 +1,199 @@
1
+ import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
2
+ import { ALL_DEPENDENCY_FIELDS } from "./internal/dependencyFields.js";
3
+ import { Effect, Graph, Option, Schema } from "effect";
4
+ import { GlobSet } from "@effected/glob";
5
+ import { DependencyField } from "@effected/npm";
6
+
7
+ //#region src/WorkspaceLayering.ts
8
+ /**
9
+ * One dependency edge between two workspace packages, in one field.
10
+ *
11
+ * @public
12
+ */
13
+ var LayerEdge = class extends Schema.Class("LayerEdge")({
14
+ /** The dependent package. */
15
+ from: Schema.String,
16
+ /** The package depended on. */
17
+ to: Schema.String,
18
+ /** The manifest map that declares it. */
19
+ field: DependencyField
20
+ }) {
21
+ /** `from -> to (field)`. */
22
+ get label() {
23
+ return `${this.from} -> ${this.to} (${this.field})`;
24
+ }
25
+ };
26
+ /**
27
+ * What a layering check found.
28
+ *
29
+ * @public
30
+ */
31
+ var LayeringReport = class extends Schema.Class("LayeringReport")({
32
+ /** Packages the policy declares more than once, or declares and also matches with an unconstrained glob. */
33
+ duplicates: Schema.Array(Schema.String),
34
+ /** Workspace packages the policy does not classify. */
35
+ unclassified: Schema.Array(Schema.String),
36
+ /** Edges that break the policy, each with why. */
37
+ offenders: Schema.Array(Schema.Struct({
38
+ edge: LayerEdge,
39
+ reason: Schema.Literals([
40
+ "upward",
41
+ "sameLayer",
42
+ "toolingReachesLayer",
43
+ "intoUnconstrained",
44
+ "intoUnclassified"
45
+ ])
46
+ })),
47
+ /** The members of every dependency cycle in the checked fields, or none. */
48
+ cycle: Schema.Option(Schema.Array(Schema.String)),
49
+ /** Packages the policy names that the workspace does not contain. */
50
+ missingDeclared: Schema.Array(Schema.String),
51
+ /** Required edges absent from the checked fields. */
52
+ missingRequiredEdges: Schema.Array(Schema.String),
53
+ /** Edges in the checked fields; `0` is itself a violation. */
54
+ edgeCount: Schema.Number
55
+ }) {
56
+ /** One line per violation: `[]` means the graph honours the policy and the check was not vacuous. */
57
+ get violations() {
58
+ return [
59
+ ...this.duplicates.map((name) => `classified more than once: ${name}`),
60
+ ...this.unclassified.map((name) => `not classified by the policy: ${name}`),
61
+ ...this.offenders.map(({ edge, reason }) => `${reason}: ${edge.label}`),
62
+ ...Option.match(this.cycle, {
63
+ onNone: () => [],
64
+ onSome: (members) => [`dependency cycle among: ${members.join(", ")}`]
65
+ }),
66
+ ...this.missingDeclared.map((name) => `declared but not in the workspace: ${name}`),
67
+ ...this.missingRequiredEdges.map((edge) => `required edge missing: ${edge}`),
68
+ ...this.edgeCount === 0 ? ["no workspace edges in the checked fields: the check is vacuous"] : []
69
+ ];
70
+ }
71
+ };
72
+ const offence = (from, to) => {
73
+ if (from.kind === "unconstrained" || from.kind === "unclassified") return void 0;
74
+ if (to.kind === "unconstrained") return "intoUnconstrained";
75
+ if (to.kind === "unclassified") return "intoUnclassified";
76
+ if (from.kind === "tooling") return to.kind === "layer" ? "toolingReachesLayer" : void 0;
77
+ if (to.kind === "tooling") return void 0;
78
+ return to.index > from.index ? void 0 : to.index === from.index ? "sameLayer" : "upward";
79
+ };
80
+ /** The sorted members of every strongly connected component larger than one (cf. `DependencyGraph.ts` `cycleMembers`). */
81
+ const cycleOf = (names, edges) => {
82
+ const sorted = [.../* @__PURE__ */ new Set([...names, ...edges.flatMap((e) => [e.from, e.to])])].sort();
83
+ const graph = Graph.directed((mutable) => {
84
+ const index = /* @__PURE__ */ new Map();
85
+ for (const name of sorted) index.set(name, Graph.addNode(mutable, name));
86
+ for (const e of edges) {
87
+ const from = index.get(e.from);
88
+ const to = index.get(e.to);
89
+ if (from !== void 0 && to !== void 0 && from !== to) Graph.addEdge(mutable, from, to, e.field);
90
+ }
91
+ });
92
+ const members = /* @__PURE__ */ new Set();
93
+ for (const component of Graph.stronglyConnectedComponents(graph)) {
94
+ if (component.length < 2) continue;
95
+ for (const index of component) {
96
+ const name = sorted[index];
97
+ if (name !== void 0) members.add(name);
98
+ }
99
+ }
100
+ return members.size === 0 ? Option.none() : Option.some([...members].sort());
101
+ };
102
+ /**
103
+ * Holds a workspace's package graph to a committed {@link LayerPolicy}.
104
+ *
105
+ * @remarks
106
+ * `check` is pure, so positive-control fixture graphs need no filesystem.
107
+ * `edgesOf` recomputes one edge per declaring field, because
108
+ * `DependencyGraph` merges the four fields into one adjacency and a policy
109
+ * may check only some of them. An edge exists wherever a dependency NAME is
110
+ * a workspace package, whatever its specifier protocol.
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * import { NodeServices } from "@effect/platform-node";
115
+ * import { Workspaces } from "@effected/workspaces";
116
+ * import { LayerPolicy, WorkspaceLayering } from "@effected/workspaces/testing";
117
+ * import { Effect, Layer } from "effect";
118
+ *
119
+ * const Live = Workspaces.layer({ cwd: "/repo" }).pipe(Layer.provideMerge(NodeServices.layer));
120
+ *
121
+ * const violations = Effect.gen(function* () {
122
+ * const policy = yield* LayerPolicy.load("/repo/layers.json");
123
+ * const report = yield* WorkspaceLayering.checkWorkspace(policy);
124
+ * return report.violations;
125
+ * }).pipe(Effect.provide(Live));
126
+ * ```
127
+ *
128
+ * @public
129
+ */
130
+ var WorkspaceLayering = class WorkspaceLayering {
131
+ constructor() {}
132
+ /** Check `graph` against `policy`, reading only the policy's fields. Pure. */
133
+ static check = (graph, policy) => {
134
+ const fields = new Set(policy.effectiveFields);
135
+ const edges = graph.edges.filter((e) => fields.has(e.field));
136
+ const unconstrained = GlobSet.make({ patterns: policy.unconstrained });
137
+ const layerOf = /* @__PURE__ */ new Map();
138
+ const declared = /* @__PURE__ */ new Map();
139
+ const count = (name) => {
140
+ declared.set(name, (declared.get(name) ?? 0) + 1);
141
+ };
142
+ policy.layers.forEach((members, index) => {
143
+ for (const name of members) {
144
+ count(name);
145
+ if (!layerOf.has(name)) layerOf.set(name, index);
146
+ }
147
+ });
148
+ for (const name of policy.tooling) count(name);
149
+ const tooling = new Set(policy.tooling);
150
+ const place = (name) => {
151
+ const index = layerOf.get(name);
152
+ if (index !== void 0) return {
153
+ kind: "layer",
154
+ index
155
+ };
156
+ if (tooling.has(name)) return { kind: "tooling" };
157
+ return unconstrained.matches(name) ? { kind: "unconstrained" } : { kind: "unclassified" };
158
+ };
159
+ const offenders = [];
160
+ for (const e of edges) {
161
+ const reason = offence(place(e.from), place(e.to));
162
+ if (reason !== void 0) offenders.push({
163
+ edge: e,
164
+ reason
165
+ });
166
+ }
167
+ const names = new Set(graph.names);
168
+ const present = new Set(edges.map((e) => `${e.from} -> ${e.to}`));
169
+ return LayeringReport.make({
170
+ duplicates: [...declared].filter(([name, times]) => times > 1 || unconstrained.matches(name)).map(([name]) => name).sort(),
171
+ unclassified: graph.names.filter((name) => place(name).kind === "unclassified").sort(),
172
+ offenders,
173
+ cycle: cycleOf(graph.names, edges),
174
+ missingDeclared: [...declared.keys()].filter((name) => !names.has(name)).sort(),
175
+ missingRequiredEdges: (policy.requiredEdges ?? []).filter((required) => !present.has(required)),
176
+ edgeCount: edges.length
177
+ });
178
+ };
179
+ /** One edge per declaring field between workspace packages; self-edges dropped. */
180
+ static edgesOf = (packages) => {
181
+ const names = new Set(packages.map((pkg) => pkg.name));
182
+ return packages.flatMap((pkg) => ALL_DEPENDENCY_FIELDS.flatMap((field) => Object.keys(pkg[field]).filter((name) => names.has(name) && name !== pkg.name).sort().map((to) => LayerEdge.make({
183
+ from: pkg.name,
184
+ to,
185
+ field
186
+ }))));
187
+ };
188
+ /** Discover the workspace and check it against `policy`. */
189
+ static checkWorkspace = Effect.fn("WorkspaceLayering.checkWorkspace")(function* (policy) {
190
+ const packages = yield* (yield* WorkspaceDiscovery).listPackages();
191
+ return WorkspaceLayering.check({
192
+ names: packages.map((pkg) => pkg.name),
193
+ edges: WorkspaceLayering.edgesOf(packages)
194
+ }, policy);
195
+ });
196
+ };
197
+
198
+ //#endregion
199
+ export { LayerEdge, LayeringReport, WorkspaceLayering };
@@ -0,0 +1,17 @@
1
+ //#region src/internal/dependencyFields.ts
2
+ /** Every dependency map a manifest declares, in manifest order. */
3
+ const ALL_DEPENDENCY_FIELDS = [
4
+ "dependencies",
5
+ "devDependencies",
6
+ "peerDependencies",
7
+ "optionalDependencies"
8
+ ];
9
+ /** The maps an installed package's own dependencies come from — never devDependencies. */
10
+ const RUNTIME_DEPENDENCY_FIELDS = [
11
+ "dependencies",
12
+ "optionalDependencies",
13
+ "peerDependencies"
14
+ ];
15
+
16
+ //#endregion
17
+ export { ALL_DEPENDENCY_FIELDS, RUNTIME_DEPENDENCY_FIELDS };
@@ -0,0 +1,239 @@
1
+ import { RUNTIME_DEPENDENCY_FIELDS } from "./dependencyFields.js";
2
+ import { Predicate, Result } from "effect";
3
+
4
+ //#region src/internal/packedInstallPlan.ts
5
+ /** Variables that carry the PARENT run's context into a child package manager. */
6
+ const TRAPS = /* @__PURE__ */ new Set([
7
+ "CI",
8
+ "INIT_CWD",
9
+ "NODE_V8_COVERAGE",
10
+ "PNPM_SCRIPT_SRC_DIR",
11
+ "PNPM_PACKAGE_NAME"
12
+ ]);
13
+ /** Prefixes of whole families of parent-run context: npm's, pnpm's config, and Yarn's. */
14
+ const TRAP_PREFIXES = /^(npm_|pnpm_config_|yarn_)/i;
15
+ /**
16
+ * `env` without undefined values and without the parent's context: every
17
+ * `npm_*` variable (a pnpm-run vitest leaks `npm_config_user_agent`), every
18
+ * `pnpm_config_*` variable plus `PNPM_SCRIPT_SRC_DIR` and `PNPM_PACKAGE_NAME`
19
+ * (a `pnpm exec` child carries all three, `pnpm_config_verify_deps_before_run`
20
+ * among them), every `YARN_*` variable (Berry lets them override
21
+ * `.yarnrc.yml`, so `YARN_NODE_LINKER=pnp` would defeat the consumer's
22
+ * `nodeLinker`), `CI` (Yarn Berry turns on immutable installs), `INIT_CWD`,
23
+ * and `NODE_V8_COVERAGE` (a spawned manager writes coverage files that race
24
+ * vitest's V8 provider).
25
+ *
26
+ * Stripping `CI` does not make the child believe it runs locally: ci-info
27
+ * also reads provider variables such as `GITHUB_ACTIONS`, which stay. That is
28
+ * harmless today, because the only CI-conditional behaviour a consumer
29
+ * install trips on (Berry's immutable installs, pnpm's frozen lockfile) keys
30
+ * on a lockfile the fresh consumer does not have, or is switched off in the
31
+ * files `consumerFiles` writes.
32
+ *
33
+ * User-level configuration is inherited by design: `HOME` stays, so each
34
+ * manager still reads the user's `~/.npmrc`, `~/.yarnrc.yml` and friends, and
35
+ * with them the registry, auth and proxy a real install on this machine
36
+ * would use.
37
+ */
38
+ const scrubEnv = (env) => {
39
+ const out = {};
40
+ for (const [key, value] of Object.entries(env)) if (value !== void 0 && !TRAP_PREFIXES.test(key) && !TRAPS.has(key)) out[key] = value;
41
+ return out;
42
+ };
43
+ /** The last version-shaped line of `--version` output, without a leading `v`. */
44
+ const versionOf = (stdout) => {
45
+ for (const line of stdout.split(/\r?\n/).reverse()) {
46
+ const match = /^v?(\d+\.\d+\.\d+\S*)$/.exec(line.trim());
47
+ if (match?.[1] !== void 0) return match[1];
48
+ }
49
+ };
50
+ /** The carrier first, then the rest of the closure; the failure names the first package the workspace lacks. */
51
+ const closureOf = (packages, carrier, closure) => {
52
+ const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
53
+ const root = byName.get(carrier);
54
+ if (root === void 0) return Result.fail(carrier);
55
+ if (closure !== "auto") {
56
+ const unknown = closure.find((name) => !byName.has(name));
57
+ if (unknown !== void 0) return Result.fail(unknown);
58
+ const rest = [...new Set(closure)].filter((name) => name !== carrier);
59
+ return Result.succeed([root, ...rest.flatMap((name) => byName.get(name) ?? [])]);
60
+ }
61
+ const ordered = [root];
62
+ const seen = /* @__PURE__ */ new Set([carrier]);
63
+ for (let head = 0; head < ordered.length; head++) {
64
+ const current = ordered[head];
65
+ if (current === void 0) continue;
66
+ for (const field of RUNTIME_DEPENDENCY_FIELDS) for (const name of Object.keys(current[field]).sort()) {
67
+ const dependency = byName.get(name);
68
+ if (dependency !== void 0 && !seen.has(name)) {
69
+ seen.add(name);
70
+ ordered.push(dependency);
71
+ }
72
+ }
73
+ }
74
+ return Result.succeed(ordered);
75
+ };
76
+ const major = (version) => Number.parseInt(version.split(".")[0] ?? "", 10);
77
+ const YARNRC = "nodeLinker: node-modules\nenableImmutableInstalls: false\nenableScripts: false\nenableTelemetry: false\n";
78
+ /** JSON strings are valid YAML double-quoted scalars. */
79
+ const pnpmWorkspaceYaml = (specs) => {
80
+ const entries = Object.entries(specs);
81
+ return entries.length === 0 ? "overrides: {}\n" : `overrides:\n${entries.map(([name, spec]) => ` ${JSON.stringify(name)}: ${JSON.stringify(spec)}`).join("\n")}\n`;
82
+ };
83
+ /**
84
+ * The files of one scratch consumer. The carrier and the caller's extra
85
+ * dependencies are its only direct dependencies; every other packed package
86
+ * is steered to its tarball through the field this manager reads:
87
+ * `overrides` (npm, bun), `resolutions` (yarn), or a settings-only
88
+ * `pnpm-workspace.yaml` (pnpm 10+ reads overrides there, and pnpm 11+ no
89
+ * longer reads `package.json#pnpm`).
90
+ *
91
+ * An extra dependency that names a packed package (the carrier or a closure
92
+ * member) is written as that package's `file:` tarball spec, whatever the
93
+ * caller passed: the packed tarball always wins. npm fails an install whose
94
+ * override differs from a direct spec for the same package (`EOVERRIDE`), and
95
+ * accepts one that is identical; and a caller's range must never silently
96
+ * replace the tarball the run exists to prove.
97
+ */
98
+ const consumerFiles = (input) => {
99
+ const specs = Object.fromEntries(Object.entries(input.overrides).map(([name, tarball]) => [name, `file:${tarball}`]));
100
+ const carrierSpec = `file:${input.carrier.tarball}`;
101
+ const extra = Object.fromEntries(Object.entries(input.dependencies).filter(([name]) => name !== input.carrier.name).map(([name, spec]) => [name, Object.hasOwn(specs, name) ? specs[name] ?? spec : spec]));
102
+ const manifest = {
103
+ name: `packed-install-${input.manager}`,
104
+ version: "0.0.0",
105
+ private: true,
106
+ ...input.manager === "pnpm" ? { devEngines: { packageManager: {
107
+ name: "pnpm",
108
+ version: input.version,
109
+ onFail: "ignore"
110
+ } } } : { packageManager: `${input.manager}@${input.version}` },
111
+ dependencies: {
112
+ [input.carrier.name]: carrierSpec,
113
+ ...extra
114
+ },
115
+ ...input.manager === "npm" || input.manager === "bun" ? { overrides: specs } : {},
116
+ ...input.manager === "yarn" ? { resolutions: specs } : {}
117
+ };
118
+ const files = [{
119
+ file: "package.json",
120
+ content: `${JSON.stringify(manifest, null, 2)}\n`
121
+ }];
122
+ if (input.manager === "pnpm") files.push({
123
+ file: "pnpm-workspace.yaml",
124
+ content: pnpmWorkspaceYaml(specs)
125
+ });
126
+ if (input.manager === "yarn" && major(input.version) >= 2) files.push({
127
+ file: ".yarnrc.yml",
128
+ content: YARNRC
129
+ });
130
+ return files;
131
+ };
132
+ /** The install argv, lifecycle scripts skipped the way this manager spells it. */
133
+ const installArgs = (manager, version) => {
134
+ switch (manager) {
135
+ case "npm": return [
136
+ "install",
137
+ "--ignore-scripts",
138
+ "--no-audit",
139
+ "--no-fund"
140
+ ];
141
+ case "pnpm": return ["install", "--config.ignore-scripts=true"];
142
+ case "yarn": return major(version) >= 2 ? ["install"] : [
143
+ "install",
144
+ "--ignore-scripts",
145
+ "--non-interactive"
146
+ ];
147
+ case "bun": return ["install", "--ignore-scripts"];
148
+ }
149
+ };
150
+ /**
151
+ * A specifier no consumer outside the workspace can resolve: `workspace:`,
152
+ * `catalog:`, `link:`, or a relative `file:` path. An absolute `file:` path
153
+ * is left alone: it resolves wherever the file exists.
154
+ */
155
+ const UNRESOLVABLE = /^(?:workspace:|catalog:|link:|file:(?!\/))/;
156
+ /** Parse a packed manifest into the facts the run checks; the failure is the parse error or a non-object. */
157
+ const readPackedManifest = (manifestJson) => {
158
+ let manifest;
159
+ try {
160
+ manifest = JSON.parse(manifestJson);
161
+ } catch (cause) {
162
+ return Result.fail(cause);
163
+ }
164
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) return Result.fail(/* @__PURE__ */ new Error("package.json is not an object"));
165
+ const record = manifest;
166
+ const name = typeof record.name === "string" ? record.name : void 0;
167
+ const unresolved = RUNTIME_DEPENDENCY_FIELDS.flatMap((field) => {
168
+ const block = record[field];
169
+ if (typeof block !== "object" || block === null) return [];
170
+ return Object.entries(block).filter(([, spec]) => typeof spec === "string" && UNRESOLVABLE.test(spec)).map(([dependency, spec]) => `${field}.${dependency}: ${String(spec)}`);
171
+ });
172
+ const bin = record.bin;
173
+ const bins = typeof bin === "string" ? name === void 0 ? [] : [name.replace(/^@[^/]+\//, "")] : typeof bin === "object" && bin !== null && !Array.isArray(bin) ? Object.keys(bin) : [];
174
+ return Result.succeed({
175
+ name,
176
+ unresolved,
177
+ bins
178
+ });
179
+ };
180
+ /**
181
+ * The file a manifest's `bin` declares for `name`: a `bin` object's entry, or
182
+ * a `bin` string when `name` is the unscoped package name. `undefined` when
183
+ * the manifest is not a JSON object or declares no such bin.
184
+ */
185
+ const binTargetOf = (manifestJson, name) => {
186
+ let manifest;
187
+ try {
188
+ manifest = JSON.parse(manifestJson);
189
+ } catch {
190
+ return;
191
+ }
192
+ if (!Predicate.isObject(manifest) || Array.isArray(manifest)) return void 0;
193
+ const bin = manifest.bin;
194
+ if (typeof bin === "string") return typeof manifest.name === "string" && manifest.name.replace(/^@[^/]+\//, "") === name ? bin : void 0;
195
+ if (!Predicate.isObject(bin) || Array.isArray(bin)) return void 0;
196
+ const target = bin[name];
197
+ return typeof target === "string" && Object.hasOwn(bin, name) ? target : void 0;
198
+ };
199
+ /**
200
+ * The first bin the carrier declares that another packed package declares
201
+ * too. Under a flat layout (npm, bun, Yarn's `node-modules` linker) either
202
+ * package can take `node_modules/.bin/<bin>`, so a bin check or a bin run
203
+ * could pass on the wrong package. `PackedInstall.run` refuses it unless the
204
+ * caller shares bin names deliberately (`allowSharedBins`).
205
+ */
206
+ const binConflict = (carrier, others) => {
207
+ for (const bin of carrier.bins) {
208
+ const other = others.find((pkg) => pkg.name !== carrier.name && pkg.bins.includes(bin));
209
+ if (other !== void 0) return {
210
+ bin,
211
+ package: other.name
212
+ };
213
+ }
214
+ };
215
+ /** A bare package name, scoped or not: an override key carrying a selector (`a>b`, `a@1`) is not one. */
216
+ const BARE_NAME = /^(?:@[^/@\s>]+\/)?[^/@\s>]+$/;
217
+ /**
218
+ * The `file:` entries of a parsed `pnpm-workspace.yaml`'s `overrides:` map,
219
+ * name to the path after `file:` (relative paths are the caller's to resolve,
220
+ * against the workspace root, as pnpm does). Entries that are not strings,
221
+ * not `file:`, or keyed by anything but a bare package name are skipped, and
222
+ * so is `__proto__`, which no npm package can be named. The map has no
223
+ * prototype, so a package named `constructor` or `prototype` is an ordinary
224
+ * own entry and a missing name never resolves to an inherited member.
225
+ */
226
+ const fileOverridesOf = (document) => {
227
+ const out = Object.create(null);
228
+ if (!Predicate.isObject(document) || !Predicate.isObject(document.overrides) || Array.isArray(document.overrides)) return out;
229
+ for (const [name, spec] of Object.entries(document.overrides)) {
230
+ if (name === "__proto__") continue;
231
+ if (typeof spec === "string" && spec.startsWith("file:") && BARE_NAME.test(name)) out[name] = spec.slice(5);
232
+ }
233
+ return out;
234
+ };
235
+ /** An `overrides` value without the `file:` prefix a caller may copy from a workspace file. */
236
+ const overridePath = (spec) => spec.startsWith("file:") ? spec.slice(5) : spec;
237
+
238
+ //#endregion
239
+ export { binConflict, binTargetOf, closureOf, consumerFiles, fileOverridesOf, installArgs, overridePath, readPackedManifest, scrubEnv, versionOf };