@effected/workspaces 0.25.0 → 0.26.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/LayerPolicy.js ADDED
@@ -0,0 +1,133 @@
1
+ import { ALL_DEPENDENCY_FIELDS } from "./internal/dependencyFields.js";
2
+ import { Effect, FileSystem, Result, Schema } from "effect";
3
+ import { GlobSet } from "@effected/glob";
4
+ import { DependencyField } from "@effected/npm";
5
+
6
+ //#region src/LayerPolicy.ts
7
+ const REQUIRED_EDGE = /^\S+ -> \S+$/;
8
+ /** `input` without `keys`, when it is a plain object; anything else passes through for the schema to reject. */
9
+ const withoutKeys = (input, keys) => {
10
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return input;
11
+ return Object.fromEntries(Object.entries(input).filter(([key]) => !keys.includes(key)));
12
+ };
13
+ /**
14
+ * Raised when a layer policy cannot be read, parsed or decoded.
15
+ *
16
+ * @public
17
+ */
18
+ var LayerPolicyError = class extends Schema.TaggedError()("LayerPolicyError", {
19
+ /** `read`: the file could not be read. `json`: it is not JSON. `decode`: it is not a `LayerPolicy`. */
20
+ reason: Schema.Literals([
21
+ "read",
22
+ "json",
23
+ "decode"
24
+ ]),
25
+ /** The file, when the policy came from one. */
26
+ path: Schema.optionalKey(Schema.String),
27
+ /** The originating failure. */
28
+ cause: Schema.Defect()
29
+ }) {
30
+ /** Renders the failure kind and the file into one line. */
31
+ get message() {
32
+ const at = this.path === void 0 ? "" : ` at ${this.path}`;
33
+ switch (this.reason) {
34
+ case "read": return `Could not read the layer policy${at}`;
35
+ case "json": return `The layer policy${at} is not valid JSON`;
36
+ default: return `The layer policy${at} does not match the LayerPolicy shape${this.cause instanceof Error && this.cause.message !== "" ? `: ${this.cause.message}` : ""}`;
37
+ }
38
+ }
39
+ };
40
+ /**
41
+ * A committed dependency-layering policy (`layers.json`).
42
+ *
43
+ * @remarks
44
+ * `layers` is top-down: an edge may only point from a layer to one BELOW it
45
+ * (a higher index), or into `tooling`. `tooling` packages may depend on each
46
+ * other but never reach a layer. `unconstrained` holds globs (through
47
+ * `@effected/glob`) for packages whose own edges are not checked (a test
48
+ * harness, the private root). `fields` narrows which dependency maps count,
49
+ * defaulting to all four. `requiredEdges` (`"a -> b"`) are edges that must
50
+ * exist in a checked field: the non-vacuity guard against a discovery that
51
+ * quietly drops real edges.
52
+ *
53
+ * Every entry matches a package's `name`, never its `relativePath`: `layers`
54
+ * and `tooling` list exact names, and `unconstrained` globs match names. A
55
+ * private root named `my-monorepo` at `relativePath` `"."` is classified as
56
+ * `"my-monorepo"`; an entry of `"."` or `"packages/*"` classifies nothing.
57
+ *
58
+ * Decoding is strict: a key the policy does not model fails `decode`, and the
59
+ * message names every such key. A typo on an optional key would otherwise be
60
+ * silently dropped: `requiredEdge` would remove the non-vacuity guard and
61
+ * leave the report green. `$schema` is always accepted. A file that carries
62
+ * keys of its own, such as systems' `harness`, passes them in `allowKeys`.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * import { NodeServices } from "@effect/platform-node";
67
+ * import { LayerPolicy } from "@effected/workspaces/testing";
68
+ * import { Effect } from "effect";
69
+ *
70
+ * const policy = LayerPolicy.load("/repo/layers.json").pipe(Effect.provide(NodeServices.layer));
71
+ * ```
72
+ *
73
+ * @public
74
+ */
75
+ var LayerPolicy = class LayerPolicy extends Schema.Class("LayerPolicy")({
76
+ /** Exact package names (never relative paths) per layer, top layer first. */
77
+ layers: Schema.Array(Schema.Array(Schema.String)),
78
+ /** Exact package names (never relative paths) any layer may depend on that never depend on a layer. */
79
+ tooling: Schema.Array(Schema.String),
80
+ /** Globs matched against package names (never relative paths) for packages whose own edges are not checked. */
81
+ unconstrained: Schema.Array(Schema.String).check(Schema.makeFilter((patterns) => Result.isSuccess(GlobSet.compileResult(patterns)), { title: "compilable glob patterns" })),
82
+ /** The dependency maps to check. Absent means all four. */
83
+ fields: Schema.optionalKey(Schema.Array(DependencyField)),
84
+ /** Edges that must exist, written `"a -> b"`. */
85
+ requiredEdges: Schema.optionalKey(Schema.Array(Schema.String.check(Schema.isPattern(REQUIRED_EDGE))))
86
+ }) {
87
+ /** The dependency maps a check reads: `fields`, or all four. */
88
+ get effectiveFields() {
89
+ return this.fields ?? ALL_DEPENDENCY_FIELDS;
90
+ }
91
+ /**
92
+ * Decode a policy from a parsed JSON value.
93
+ *
94
+ * @remarks
95
+ * Every key the policy does not model fails, except `$schema` and the
96
+ * keys named in `options.allowKeys`, which are dropped before decoding.
97
+ * `options.path` names the file in the error.
98
+ */
99
+ static decode = Effect.fn("LayerPolicy.decode")((input, options) => {
100
+ const path = options?.path;
101
+ return Schema.decodeUnknownEffect(LayerPolicy)(withoutKeys(input, ["$schema", ...options?.allowKeys ?? []]), {
102
+ onExcessProperty: "error",
103
+ errors: "all"
104
+ }).pipe(Effect.mapError((cause) => new LayerPolicyError({
105
+ reason: "decode",
106
+ cause,
107
+ ...path === void 0 ? {} : { path }
108
+ })));
109
+ });
110
+ /** Read, parse and decode the policy file at `path`; `options.allowKeys` is passed to `decode`. */
111
+ static load = Effect.fn("LayerPolicy.load")(function* (path, options) {
112
+ const text = yield* (yield* FileSystem.FileSystem).readFileString(path).pipe(Effect.mapError((cause) => new LayerPolicyError({
113
+ reason: "read",
114
+ path,
115
+ cause
116
+ })));
117
+ const json = yield* Effect.try({
118
+ try: () => JSON.parse(text),
119
+ catch: (cause) => new LayerPolicyError({
120
+ reason: "json",
121
+ path,
122
+ cause
123
+ })
124
+ });
125
+ return yield* LayerPolicy.decode(json, {
126
+ path,
127
+ allowKeys: options?.allowKeys
128
+ });
129
+ });
130
+ };
131
+
132
+ //#endregion
133
+ export { LayerPolicy, LayerPolicyError };
@@ -0,0 +1,282 @@
1
+ import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
2
+ import { PackageManagerName } from "./PackageManagerName.js";
3
+ import { closureOf, consumerFiles, installArgs, scrubEnv, unresolvedSpecifiers, versionOf } from "./internal/packedInstallPlan.js";
4
+ import { Duration, Effect, FileSystem, Option, Path, Result, Schema } from "effect";
5
+ import { Run } from "@effected/commands";
6
+ import { ChildProcess } from "effect/unstable/process";
7
+
8
+ //#region src/PackedInstall.ts
9
+ /**
10
+ * Why a packed install could not be proven.
11
+ *
12
+ * @public
13
+ */
14
+ var PackedInstallError = class extends Schema.TaggedError()("PackedInstallError", {
15
+ /** What failed. */
16
+ reason: Schema.Literals([
17
+ "UnsupportedPlatform",
18
+ "NoManagerAvailable",
19
+ "ManagerUnavailable",
20
+ "UnknownPackage",
21
+ "PackSourceMissing",
22
+ "PackFailed",
23
+ "UnresolvedProtocol",
24
+ "InstallFailed",
25
+ "MissingBin",
26
+ "Discovery",
27
+ "Io"
28
+ ]),
29
+ /** One line, naming the package or manager involved. */
30
+ message: Schema.String,
31
+ /** The package manager involved. */
32
+ manager: Schema.optionalKey(PackageManagerName),
33
+ /** The workspace package involved. */
34
+ package: Schema.optionalKey(Schema.String),
35
+ /** The tail of the failing command's output. */
36
+ output: Schema.optionalKey(Schema.String),
37
+ /** The originating failure. */
38
+ cause: Schema.optionalKey(Schema.Defect())
39
+ }) {};
40
+ /**
41
+ * One scratch project, outside the workspace, with the carrier installed.
42
+ *
43
+ * @public
44
+ */
45
+ var InstalledConsumer = class extends Schema.Class("InstalledConsumer")({
46
+ /** The package manager that installed it. */
47
+ manager: PackageManagerName,
48
+ /** The version it reported and was pinned to. */
49
+ managerVersion: Schema.String,
50
+ /** The consumer project directory (realpath'd). */
51
+ directory: Schema.String
52
+ }) {
53
+ /** The installed bin `name`, in `node_modules/.bin`. POSIX: `PackedInstall` runs only there. */
54
+ binPath(name) {
55
+ return `${this.directory}/node_modules/.bin/${name}`;
56
+ }
57
+ };
58
+ /**
59
+ * What a packed install produced.
60
+ *
61
+ * @public
62
+ */
63
+ var PackedInstallResult = class extends Schema.Class("PackedInstallResult")({
64
+ /** One consumer per available manager, in the order requested. */
65
+ consumers: Schema.Array(InstalledConsumer),
66
+ /** The requested managers that did not answer `--version`. */
67
+ unavailable: Schema.Array(PackageManagerName),
68
+ /** Every packed package: name to absolute tarball path. */
69
+ tarballs: Schema.Record(Schema.String, Schema.String)
70
+ }) {};
71
+ const DEFAULT_PACK_FROM = { directory: "dist/prod/npm/pkg" };
72
+ const WORKSPACE_PROTOCOL_NOT_INSTALLED = "ERR_PNPM_CANNOT_RESOLVE_WORKSPACE_PROTOCOL";
73
+ const TAIL = 2e3;
74
+ const tail = (text) => text.length <= TAIL ? text : text.slice(-2e3);
75
+ const DEFAULT_INSTALL_TIMEOUT = "4 minutes";
76
+ /** A ceiling as a person reads it: `"4m"` for `"4 minutes"`, the raw input if it does not decode. */
77
+ const describeDuration = (input) => Option.match(Duration.fromInput(input), {
78
+ onNone: () => String(input),
79
+ onSome: Duration.format
80
+ });
81
+ const failure = (reason, message, extra = {}) => new PackedInstallError({
82
+ reason,
83
+ message,
84
+ ...extra
85
+ });
86
+ /**
87
+ * Prove a carrier's bins reach a consumer that is not part of the workspace,
88
+ * once per available package manager.
89
+ *
90
+ * @remarks
91
+ * Packs the carrier and its closure into a scoped, realpath'd scratch
92
+ * directory (so `file:` specs and the install cwd agree on macOS). Then, for
93
+ * each manager that answers `--version` from that directory (so a corepack
94
+ * pin in the repo cannot refuse it), it writes a consumer whose only direct
95
+ * dependency is the carrier tarball, steers the closure to its tarballs
96
+ * through the manager's own override field, pins the probed version (as
97
+ * `packageManager`, or for pnpm as `devEngines.packageManager` with `onFail:
98
+ * "ignore"`, since pnpm resolves a `packageManager` pin from the registry even
99
+ * when it names the running version), installs with lifecycle scripts skipped,
100
+ * and checks every
101
+ * expected bin is present and executable. A packed manifest that still
102
+ * carries `workspace:`, `catalog:`, `link:` or a relative `file:` specifier
103
+ * fails `UnresolvedProtocol` before any install.
104
+ *
105
+ * It asserts nothing about what the bins DO: run them from the test through
106
+ * `InstalledConsumer.binPath`, for an MCP bin with `McpProbe` from
107
+ * `@effected/mcp/testing`, inside the same scope, because the scratch
108
+ * directory is removed when the scope closes. POSIX only.
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * import { NodeServices } from "@effect/platform-node";
113
+ * import { Run } from "@effected/commands";
114
+ * import { Workspaces } from "@effected/workspaces";
115
+ * import { PackedInstall } from "@effected/workspaces/testing";
116
+ * import { Effect, Layer } from "effect";
117
+ * import { ChildProcess } from "effect/unstable/process";
118
+ *
119
+ * const Live = Workspaces.layer({ cwd: "/repo" }).pipe(Layer.provideMerge(NodeServices.layer));
120
+ *
121
+ * // Use the consumers INSIDE the scope: closing it removes the scratch
122
+ * // directory, so a binPath returned out of Effect.scoped points at nothing.
123
+ * const program = Effect.gen(function* () {
124
+ * const result = yield* PackedInstall.run({
125
+ * carrier: "my-tool",
126
+ * closure: "auto",
127
+ * managers: ["npm", "pnpm"],
128
+ * bins: ["my-tool"],
129
+ * env: process.env,
130
+ * });
131
+ * const env = PackedInstall.scrubEnv(process.env);
132
+ * for (const consumer of result.consumers) {
133
+ * const version = yield* Run.text(
134
+ * ChildProcess.make(consumer.binPath("my-tool"), ["--version"], { env, extendEnv: false }),
135
+ * );
136
+ * console.log(consumer.manager, version.trim());
137
+ * }
138
+ * }).pipe(Effect.scoped, Effect.provide(Live));
139
+ * ```
140
+ *
141
+ * @public
142
+ */
143
+ var PackedInstall = class {
144
+ constructor() {}
145
+ /** `env` without undefined values or the parent manager's context: the environment the installs run under. Reuse it to run the installed bins. */
146
+ static scrubEnv = scrubEnv;
147
+ /** Pack, then install under every available manager. */
148
+ static run = Effect.fn("PackedInstall.run")(function* (options) {
149
+ const fs = yield* FileSystem.FileSystem;
150
+ const path = yield* Path.Path;
151
+ if (path.sep !== "/") return yield* failure("UnsupportedPlatform", "PackedInstall drives POSIX .bin shims and tar; run it on macOS or Linux");
152
+ const managers = [...new Set(options.managers)];
153
+ if (managers.length === 0) return yield* failure("NoManagerAvailable", "managers is empty: name at least one package manager to install with");
154
+ const env = scrubEnv(options.env);
155
+ const io = (message) => (cause) => failure("Io", message, { cause });
156
+ const command = (executable, args, cwd) => ChildProcess.make(executable, args, {
157
+ cwd,
158
+ env,
159
+ extendEnv: false,
160
+ stdin: "ignore"
161
+ });
162
+ const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "effected-packed-install-" }).pipe(Effect.flatMap((directory) => fs.realPath(directory)), Effect.mapError(io("could not create the scratch directory")));
163
+ const probes = yield* Effect.forEach(managers, (manager) => Run.collect(command(manager, ["--version"], scratch), { timeout: "30 seconds" }).pipe(Effect.map((output) => output.succeeded ? versionOf(output.stdout) : void 0), Effect.catch(() => Effect.succeed(void 0)), Effect.map((version) => ({
164
+ manager,
165
+ version
166
+ }))));
167
+ const available = probes.flatMap(({ manager, version }) => version === void 0 ? [] : [{
168
+ manager,
169
+ version
170
+ }]);
171
+ const unavailable = probes.flatMap(({ manager, version }) => version === void 0 ? [manager] : []);
172
+ if (available.length === 0) return yield* failure("NoManagerAvailable", `none of ${managers.join(", ")} answered --version from ${scratch}`);
173
+ const missing = unavailable[0];
174
+ if (options.require === "all" && missing !== void 0) return yield* failure("ManagerUnavailable", `${missing} is required but did not answer --version`, { manager: missing });
175
+ const packages = yield* (yield* WorkspaceDiscovery).listPackages().pipe(Effect.mapError((cause) => failure("Discovery", "workspace discovery failed", { cause })));
176
+ const closure = closureOf(packages, options.carrier, options.closure);
177
+ if (Result.isFailure(closure)) return yield* failure("UnknownPackage", `${closure.failure} is not a package of this workspace`, { package: closure.failure });
178
+ const source = options.packFrom ?? DEFAULT_PACK_FROM;
179
+ const pack = (pkg, destination) => Effect.gen(function* () {
180
+ yield* fs.makeDirectory(destination, { recursive: true }).pipe(Effect.mapError(io(`could not create ${destination}`)));
181
+ const cwd = source === "source" ? pkg.path : path.join(pkg.path, source.directory);
182
+ if (source !== "source") {
183
+ if (!(yield* fs.exists(path.join(cwd, "package.json")).pipe(Effect.mapError(io(`could not inspect ${cwd}`))))) return yield* failure("PackSourceMissing", `${cwd} has no package.json; build ${pkg.name} before packing it`, { package: pkg.name });
184
+ }
185
+ const packer = source === "source" ? "pnpm" : "npm";
186
+ const args = source === "source" ? [
187
+ "pack",
188
+ "--pack-destination",
189
+ destination,
190
+ "--config.ignore-scripts=true"
191
+ ] : [
192
+ "pack",
193
+ "--ignore-scripts",
194
+ "--pack-destination",
195
+ destination
196
+ ];
197
+ const output = yield* Run.collect(command(packer, args, cwd), { timeout: "2 minutes" }).pipe(Effect.mapError((cause) => failure("PackFailed", `${packer} pack could not run for ${pkg.name}`, {
198
+ package: pkg.name,
199
+ cause
200
+ })));
201
+ if (!output.succeeded) {
202
+ const notInstalled = source === "source" && `${output.stdout}${output.stderr}`.includes(WORKSPACE_PROTOCOL_NOT_INSTALLED);
203
+ return yield* failure("PackFailed", notInstalled ? `pnpm pack could not rewrite the workspace: specifiers of ${pkg.name} because the workspace is not installed; run pnpm install in the workspace first` : `${packer} pack exited ${output.exitCode} for ${pkg.name}`, {
204
+ package: pkg.name,
205
+ output: tail(`${output.stdout}\n${output.stderr}`)
206
+ });
207
+ }
208
+ const found = (yield* fs.readDirectory(destination).pipe(Effect.mapError(io(`could not list ${destination}`)))).filter((name) => name.endsWith(".tgz"));
209
+ const [only] = found;
210
+ if (only === void 0 || found.length !== 1) return yield* failure("PackFailed", `expected exactly one tarball for ${pkg.name} in ${destination}, found ${found.length}`, { package: pkg.name });
211
+ const tarball = path.join(destination, only);
212
+ const manifest = yield* Run.text(command("tar", [
213
+ "-xzOf",
214
+ tarball,
215
+ "package/package.json"
216
+ ], destination), { timeout: "30 seconds" }).pipe(Effect.mapError((cause) => failure("PackFailed", `could not read package/package.json out of ${tarball}`, {
217
+ package: pkg.name,
218
+ cause
219
+ })));
220
+ const unresolved = unresolvedSpecifiers(manifest);
221
+ if (Result.isFailure(unresolved)) return yield* failure("PackFailed", `the packed package.json of ${pkg.name} is not a JSON object`, {
222
+ package: pkg.name,
223
+ cause: unresolved.failure
224
+ });
225
+ if (unresolved.success.length > 0) return yield* failure("UnresolvedProtocol", `${pkg.name}'s packed manifest still carries ${unresolved.success.join(", ")}, which no consumer outside the workspace can resolve; pack from "source" so pnpm rewrites workspace: and catalog:, or fix the build`, { package: pkg.name });
226
+ return tarball;
227
+ });
228
+ const packed = [];
229
+ for (const [index, pkg] of closure.success.entries()) packed.push({
230
+ name: pkg.name,
231
+ tarball: yield* pack(pkg, path.join(scratch, "tarballs", String(index)))
232
+ });
233
+ const [carrier, ...rest] = packed;
234
+ if (carrier === void 0) return yield* failure("UnknownPackage", `${options.carrier} was not packed`, { package: options.carrier });
235
+ const overrides = Object.fromEntries(rest.map(({ name, tarball }) => [name, tarball]));
236
+ const consumers = yield* Effect.forEach(available, ({ manager, version }) => Effect.gen(function* () {
237
+ const directory = path.join(scratch, `consumer-${manager}`);
238
+ yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.mapError(io(`could not create ${directory}`)));
239
+ for (const { file, content } of consumerFiles({
240
+ manager,
241
+ version,
242
+ carrier,
243
+ overrides,
244
+ dependencies: options.consumerDependencies ?? {}
245
+ })) yield* fs.writeFileString(path.join(directory, file), content).pipe(Effect.mapError(io(`could not write ${file}`)));
246
+ const installTimeout = options.installTimeout ?? DEFAULT_INSTALL_TIMEOUT;
247
+ const output = yield* Run.collect(command(manager, installArgs(manager, version), directory), { timeout: installTimeout }).pipe(Effect.mapError((cause) => failure("InstallFailed", cause._tag === "CommandFailedError" && cause.kind === "timeout" ? `${manager} install timed out after ${describeDuration(installTimeout)}` : `${manager} install could not run`, {
248
+ manager,
249
+ cause
250
+ })));
251
+ if (!output.succeeded) return yield* failure("InstallFailed", `${manager} install exited ${output.exitCode}`, {
252
+ manager,
253
+ output: tail(`${output.stdout}\n${output.stderr}`)
254
+ });
255
+ const consumer = InstalledConsumer.make({
256
+ manager,
257
+ managerVersion: version,
258
+ directory
259
+ });
260
+ for (const bin of options.bins) {
261
+ const info = yield* Effect.option(fs.stat(consumer.binPath(bin)));
262
+ if (Option.isNone(info) || (info.value.mode & 73) === 0) {
263
+ const listing = yield* fs.readDirectory(path.join(directory, "node_modules", ".bin")).pipe(Effect.map((names) => names.length === 0 ? "node_modules/.bin is empty" : `node_modules/.bin holds: ${[...names].sort().join(", ")}`), Effect.catch(() => Effect.succeed("node_modules/.bin does not exist or cannot be listed")));
264
+ return yield* failure("MissingBin", `${manager} installed ${options.carrier} but node_modules/.bin/${bin} is missing or not executable; ${listing}`, {
265
+ manager,
266
+ package: options.carrier,
267
+ output: listing
268
+ });
269
+ }
270
+ }
271
+ return consumer;
272
+ }));
273
+ return PackedInstallResult.make({
274
+ consumers,
275
+ unavailable,
276
+ tarballs: Object.fromEntries(packed.map(({ name, tarball }) => [name, tarball]))
277
+ });
278
+ });
279
+ };
280
+
281
+ //#endregion
282
+ export { InstalledConsumer, PackedInstall, PackedInstallError, PackedInstallResult };
package/README.md CHANGED
@@ -75,6 +75,22 @@ Effect.runPromise(program.pipe(Effect.provide(WorkspacesLayer))).then(console.lo
75
75
  `toMermaid()` renders the same graph for a job summary, an issue or a design doc. It is total, deterministic (nodes and edges both in sorted order) and safe for scoped names, which appear only inside quoted labels:
76
76
 
77
77
  ```ts
78
+ import { DependencyGraph, WorkspacePackage } from "@effected/workspaces";
79
+
80
+ const member = (name: string, dependencies: Record<string, string> = {}) =>
81
+ WorkspacePackage.make({
82
+ name,
83
+ version: "1.0.0",
84
+ path: `/repo/packages/${name.slice(6)}`,
85
+ packageJsonPath: `/repo/packages/${name.slice(6)}/package.json`,
86
+ relativePath: `packages/${name.slice(6)}`,
87
+ workspaceRoot: "/repo",
88
+ dependencies,
89
+ });
90
+
91
+ const graph = DependencyGraph.make({
92
+ packages: [member("@acme/app", { "@acme/utils": "workspace:^" }), member("@acme/utils")],
93
+ });
78
94
  console.log(graph.toMermaid());
79
95
  // flowchart TD
80
96
  // 0["@acme/app"]
@@ -239,6 +255,183 @@ For a test fake, `@effected/memfs`' `MemoryFileSystem.syncFileSystem(volume)` sa
239
255
 
240
256
  Windows correctness is therefore the operations you pass, and nothing else. Both entry points drive one traversal state machine (the same dequeue order, depth rule, visit budget and `node_modules` prune), so the sync and Effect surfaces can never disagree about what a pattern means. The one deliberate difference is at a bound: the Effect enumerator fails typed, the sync one truncates. Prefer the Effect API everywhere you can run one.
241
257
 
258
+ ## Repo-shape checks (`@effected/workspaces/testing`)
259
+
260
+ A monorepo's shape drifts in ways no unit test sees: a package that should stay platform-free starts reading `process`, a low-level package grows an edge up into an application, or a bin resolves fine inside the workspace and is missing once installed from its tarball. The `@effected/workspaces/testing` subpath holds three checks you run from your own test suite to catch each one. The main entry never re-exports it, so a consumer of `.` never loads a scanner or package-manager orchestration it does not use.
261
+
262
+ Every check refuses to pass vacuously, so every example below pairs "nothing wrong" with "something was checked".
263
+
264
+ ### Layering
265
+
266
+ `WorkspaceLayering` holds the discovered package graph to a committed `layers.json`. `layers` is top-down: a package may depend only on a layer below its own, or on `tooling`. `unconstrained` globs name packages whose own edges are not checked, such as the private root, which must be classified like any other package. Every entry matches a package's `name`, never its `relativePath`: `layers` and `tooling` list exact names, and `unconstrained` globs match names. So a private root named `my-monorepo` at `relativePath` `.` is classified as `"my-monorepo"`, and an entry of `"."` or `"packages/*"` classifies nothing. `fields` picks the dependency maps that count, and `requiredEdges` lists edges that must exist, so a discovery that silently drops edges fails. Decoding is strict: any other key fails `LayerPolicyError` naming the key, so a typo such as `requiredEdge` cannot silently drop that guard. `$schema` is always accepted, and keys a policy file carries for its own use go in `LayerPolicy.load(path, { allowKeys: [...] })`.
267
+
268
+ ```json
269
+ {
270
+ "layers": [["my-tool"], ["my-tool-cli", "my-tool-mcp"], ["my-tool-engine"]],
271
+ "tooling": [],
272
+ "unconstrained": ["my-monorepo"],
273
+ "fields": ["dependencies", "peerDependencies", "optionalDependencies"],
274
+ "requiredEdges": ["my-tool-mcp -> my-tool-engine"]
275
+ }
276
+ ```
277
+
278
+ ```ts
279
+ // __test__/layering.test.ts, one level below the workspace root
280
+ import { dirname, join, resolve } from "node:path";
281
+ import { fileURLToPath } from "node:url";
282
+ import { NodeServices } from "@effect/platform-node";
283
+ import { assert, describe, layer } from "@effect/vitest";
284
+ import { Workspaces } from "@effected/workspaces";
285
+ import { LayerPolicy, WorkspaceLayering } from "@effected/workspaces/testing";
286
+ import { Effect, Layer } from "effect";
287
+
288
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
289
+ const Live = Workspaces.layer({ cwd: ROOT }).pipe(Layer.provideMerge(NodeServices.layer));
290
+
291
+ describe("package layering", () => {
292
+ layer(Live)((it) => {
293
+ it.effect("the workspace honours layers.json, over real edges", () =>
294
+ Effect.gen(function* () {
295
+ const policy = yield* LayerPolicy.load(join(ROOT, "layers.json"));
296
+ const report = yield* WorkspaceLayering.checkWorkspace(policy);
297
+ assert.deepStrictEqual(report.violations, []);
298
+ assert.isAbove(report.edgeCount, 0, "the check read real edges");
299
+ }),
300
+ );
301
+ });
302
+ });
303
+ ```
304
+
305
+ An edge is drawn wherever a dependency's name is a workspace package, whatever its specifier, and once per declaring field. A cycle closed only by a devDependency is therefore invisible to a policy that checks runtime fields only: pin acyclicity across every field separately, with `DependencyGraph.make({ packages }).hasCycle`.
306
+
307
+ ### Source boundaries
308
+
309
+ `SourceBoundary.scan` walks a source tree and reports every read of the global `process`, forbidden import, `stdout.write` or console write. It lexes each file first, so the word `process` in a comment, a string, template text or a regex is never a read, and a `/*` inside a string never hides the code after it. Assert `verifyFixtures()` beside your scan: it proves the scanner still flags and spares what its shipped fixtures say.
310
+
311
+ ```ts
312
+ // __test__/boundary.test.ts, one level below the package root
313
+ import { dirname, join } from "node:path";
314
+ import { fileURLToPath } from "node:url";
315
+ import { NodeServices } from "@effect/platform-node";
316
+ import { assert, describe, layer } from "@effect/vitest";
317
+ import { SourceBoundary } from "@effected/workspaces/testing";
318
+ import { Effect } from "effect";
319
+
320
+ const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src");
321
+
322
+ describe("source boundary", () => {
323
+ layer(NodeServices.layer)((it) => {
324
+ it.effect("the scanner still flags and spares what its fixtures say", () =>
325
+ Effect.sync(() => assert.deepStrictEqual(SourceBoundary.verifyFixtures(), [])),
326
+ );
327
+
328
+ it.effect("no module reads process, writes to the console, or imports node: or a platform package", () =>
329
+ Effect.gen(function* () {
330
+ const scan = yield* SourceBoundary.scan({
331
+ root: SRC,
332
+ rules: ["process", "node:process", "console", { forbidImports: ["node:*", "@effect/platform*"] }],
333
+ });
334
+ assert.isNotEmpty(scan.files, "the scan read the tree");
335
+ assert.deepStrictEqual(scan.violations, []);
336
+ }),
337
+ );
338
+ });
339
+ });
340
+ ```
341
+
342
+ | Rule | Flags |
343
+ | --- | --- |
344
+ | `"process"` | a read of the global `process`; `process.env.__PACKAGE_VERSION__` is exempt |
345
+ | `"node:process"` | an import of `node:process` or `process` |
346
+ | `"stdout-write"` | a `stdout.write`, or a `stdout.end` passing a final chunk, on anything named `stdout`; a renamed receiver is left to `"process"` |
347
+ | `"console"` | any reference to the global `console` |
348
+ | `"console-stdout"` | a reference to the global `console`, except a member access to a method Node writes to stderr: `error`, `warn`, `trace`, `assert`. A bare or aliased reference (`const c = console`, `f(console)`, `console[m]`) is flagged, since it can reach `log` |
349
+ | `{ forbidImports }` | an import equal to an entry, a subpath of one, or starting with an entry's text before a trailing `*` |
350
+
351
+ Neither console rule flags core's `Console` service. `"console-stdout"` suits a stdio server that must keep stdout for its protocol but may log to stderr.
352
+
353
+ `allow` globs exempt a file from every rule, and the file is listed in `scan.allowed`. To exempt a file from one rule only, use `allowRules`, keyed by the rule (`"forbidImports"` covers every `{ forbidImports }` rule). The file is still checked against every other rule, and each offence a per-rule glob waives lands in `scan.waived` rather than disappearing, so a stale or over-broad waiver fails an assertion:
354
+
355
+ ```ts
356
+ const scan = yield* SourceBoundary.scan({
357
+ root: SRC,
358
+ rules: ["process", "stdout-write"],
359
+ // main.ts hands process.stdout to a transport, but must never write to it itself.
360
+ allowRules: { process: ["main.ts"] },
361
+ });
362
+ assert.deepStrictEqual(scan.violations, []);
363
+ assert.deepStrictEqual([...new Set(scan.waived.map((offence) => offence.file))], ["main.ts"]);
364
+ ```
365
+
366
+ Both kinds of glob compile with `@effected/glob` and match the scan's relative, `/`-separated path; an uncompilable one fails the scan with `GlobPatternError`.
367
+
368
+ The scanner is a lexer, not a type checker. Its known limits:
369
+
370
+ - There is no scope analysis, so any local binding named `process` or `console` is flagged like the global: a parameter (`(process: Handle) => process.kill()`), a variable, a label, or an unannotated class field (`process = 1`). An annotated class field (`process: T`) reads as a type member and is spared. Prefer renaming the binding; failing that, waive that one rule for the file with `allowRules` and assert `scan.waived` names exactly it.
371
+ - `globalThis["process"]` and `const { process: p } = globalThis` are not seen.
372
+ - A regex literal directly after a block-closing `}` reads as a division.
373
+ - JSX text reads as code, which is why `.tsx` and `.jsx` are not scanned by default.
374
+ - `forbidImports: ["node:*"]` matches only the `node:` spelling, not a bare `"fs"`. To forbid both, spread Node's list in the test file: `{ forbidImports: ["node:*", ...builtinModules] }`, with `builtinModules` from `node:module`. That also forbids npm packages named like a built-in (`events`, `buffer`).
375
+
376
+ ### Packed install
377
+
378
+ `PackedInstall.run` packs a carrier and every workspace package it needs, then installs the carrier into a fresh project outside the workspace under each package manager that answers `--version`, and checks every expected bin is linked and executable. It carries the traps each manager sets: the parent run's `npm_*` and `pnpm_config_*` context is scrubbed, pnpm's overrides go in `pnpm-workspace.yaml`, Yarn Berry gets the `node-modules` linker, `packageManager` is pinned to the probed version, and lifecycle scripts are skipped. By default it packs each package's built `dist/prod/npm/pkg`, the artifact a release publishes; build first.
379
+
380
+ It does not run the bins; the test does, inside the same scope, because the scratch directory is removed when the scope closes. For an MCP server bin, `McpProbe` from `@effected/mcp/testing` is the proof:
381
+
382
+ ```ts
383
+ // __test__/e2e/packed-install.e2e.test.ts, two levels below the workspace root
384
+ import { dirname, resolve } from "node:path";
385
+ import { fileURLToPath } from "node:url";
386
+ import { NodeServices } from "@effect/platform-node";
387
+ import { assert, describe, layer } from "@effect/vitest";
388
+ import { McpProbe } from "@effected/mcp/testing";
389
+ import { Workspaces } from "@effected/workspaces";
390
+ import { PackedInstall } from "@effected/workspaces/testing";
391
+ import { Effect, Layer } from "effect";
392
+ import { ChildProcess } from "effect/unstable/process";
393
+
394
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
395
+ const Live = Workspaces.layer({ cwd: ROOT }).pipe(Layer.provideMerge(NodeServices.layer));
396
+
397
+ describe("packed install", () => {
398
+ // The real clock: installs and the probe are real processes.
399
+ layer(Live, { excludeTestServices: true })((it) => {
400
+ it.effect(
401
+ "my-tool's MCP bin boots from a packed install under every available manager",
402
+ () =>
403
+ Effect.gen(function* () {
404
+ const result = yield* PackedInstall.run({
405
+ carrier: "my-tool",
406
+ closure: "auto",
407
+ managers: ["npm", "pnpm", "yarn", "bun"],
408
+ bins: ["my-tool-mcp"],
409
+ env: process.env,
410
+ installTimeout: "2 minutes",
411
+ });
412
+ assert.isAbove(result.consumers.length, 0, `nothing installed; unavailable: ${result.unavailable.join(", ")}`);
413
+ const env = PackedInstall.scrubEnv(process.env);
414
+ for (const consumer of result.consumers) {
415
+ const bin = ChildProcess.make(consumer.binPath("my-tool-mcp"), [], {
416
+ cwd: consumer.directory,
417
+ env,
418
+ extendEnv: false,
419
+ });
420
+ const { response, stderr, exitCode } = yield* McpProbe.initialize(bin).pipe(Effect.timeout("30 seconds"));
421
+ assert.isUndefined(response.error, `${consumer.manager}: initialize was refused`);
422
+ assert.strictEqual(stderr, "", `${consumer.manager}: stderr`);
423
+ assert.strictEqual(exitCode, 0, `${consumer.manager}: exit code`);
424
+ }
425
+ // The installs run one after another: the guard covers 4 managers x installTimeout, plus pack and probes.
426
+ }).pipe(Effect.timeout("12 minutes")),
427
+ 780_000,
428
+ );
429
+ });
430
+ });
431
+ ```
432
+
433
+ Pass `process.env` in: nothing in the subpath reads `process` itself. Run the bins under `PackedInstall.scrubEnv(process.env)`, the same environment the installs ran under. A requested manager that is not installed lands in `result.unavailable`; pass `require: "all"` to make that a failure instead. Declare in `consumerDependencies` every package the consumer's own code imports besides the carrier: pnpm links only declared dependencies at a project's top level. An entry that names a packed package is written as its `file:` tarball whatever spec you pass, so any range will do; npm fails `EOVERRIDE` when a direct spec differs from its override. The installs run one after another, so an outer `Effect.timeout` must be at least the number of managers times `installTimeout` (four minutes by default), plus the pack and the probes; a tighter guard fires first, as a `TimeoutError` that names no manager. `PackedInstall` is POSIX-only and fails `UnsupportedPlatform` elsewhere.
434
+
242
435
  ## Error handling
243
436
 
244
437
  Every failure is a `Schema.TaggedError` with structured fields you can branch on, not a prose string:
@@ -278,6 +471,7 @@ const TestDiscovery = WorkspaceDiscovery.layerTest({
278
471
  path: "/repo/packages/utils",
279
472
  packageJsonPath: "/repo/packages/utils/package.json",
280
473
  relativePath: "packages/utils",
474
+ workspaceRoot: "/repo",
281
475
  }),
282
476
  ]),
283
477
  });
@@ -305,6 +499,7 @@ A name miss in the derived `getPackage` fails with the service's own typed `Pack
305
499
  - `ReleaseTag` / `TrackingTag` — release-tag formatting (`ReleaseTag.single` / `.scoped`, strict SemVer by default with no `v` prefix) and the floating major/minor alias derivation GitHub Actions-style consumers expect (`v1`, `v1.2`), plus `classifyTag` to tell a release tag from a tracking alias.
306
500
  - `VersioningStrategy` — classify a workspace as `single`, `fixed-group` or `independent` from package names and fixed groups, or detect it live against `PublishabilityDetector`, and produce the release tags for a batch with `tagsFor`.
307
501
  - `findWorkspaceRootSync` / `getWorkspacePackagesSync` — the synchronous escape hatch for config-time callers that cannot await, over file and path operations you supply.
502
+ - `@effected/workspaces/testing` — a third entry point holding the repo-shape checks: `WorkspaceLayering` and `LayerPolicy` (the package graph against a committed `layers.json`), `SourceBoundary` (a lexer-backed scanner for `process` reads, forbidden imports and console writes, with shipped positive controls) and `PackedInstall` (the carrier's bins installed from packed tarballs under every available package manager).
308
503
  - `@effected/workspaces/node-sync` — a second entry point holding the Node bindings for those operations (`nodeFileSystem`, `nodePath` and the `nodeSyncOps` bag), kept off the main entry so `node:*` never reaches a consumer that supplies its own. `nodeFileSystem` implements the optional `readDirectoryWithTypes` fast path, so the bindings enumerate a workspace in one `readdirSync` per directory.
309
504
 
310
505
  ## License