@effected/workspaces 0.8.0 → 0.9.1

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.
@@ -103,7 +103,11 @@ const CHECKED = [
103
103
  "bun.lock",
104
104
  "bun.lockb",
105
105
  "yarn.lock",
106
- "package.json#workspaces"
106
+ "package.json#workspaces",
107
+ "pnpm-lock.yaml",
108
+ "package-lock.json",
109
+ "package.json#packageManager",
110
+ "package.json#devEngines.packageManager"
107
111
  ];
108
112
  /**
109
113
  * Detects which package manager owns a workspace root.
@@ -217,7 +221,8 @@ var PackageManagerDetector = class PackageManagerDetector extends Context.Servic
217
221
  version: versionFor(hints, "pnpm"),
218
222
  runtime: "node"
219
223
  });
220
- if (((yield* has(root, "bun.lock")) || (yield* has(root, "bun.lockb"))) && namesManager(hints, "bun")) return DetectedPackageManager.make({
224
+ const bunLock = (yield* has(root, "bun.lock")) || (yield* has(root, "bun.lockb"));
225
+ if (bunLock && namesManager(hints, "bun")) return DetectedPackageManager.make({
221
226
  name: "bun",
222
227
  version: versionFor(hints, "bun"),
223
228
  runtime: "bun"
@@ -233,6 +238,35 @@ var PackageManagerDetector = class PackageManagerDetector extends Context.Servic
233
238
  version: versionFor(hints, "npm"),
234
239
  runtime: "node"
235
240
  });
241
+ if (yield* has(root, "pnpm-lock.yaml")) return DetectedPackageManager.make({
242
+ name: "pnpm",
243
+ version: versionFor(hints, "pnpm"),
244
+ runtime: "node"
245
+ });
246
+ if (bunLock && namesManager(hints, "bun")) return DetectedPackageManager.make({
247
+ name: "bun",
248
+ version: versionFor(hints, "bun"),
249
+ runtime: "bun"
250
+ });
251
+ if ((yield* has(root, "yarn.lock")) && namesManager(hints, "yarn")) return DetectedPackageManager.make({
252
+ name: "yarn",
253
+ version: versionFor(hints, "yarn"),
254
+ runtime: "node"
255
+ });
256
+ if (yield* has(root, "package-lock.json")) return DetectedPackageManager.make({
257
+ name: "npm",
258
+ version: versionFor(hints, "npm"),
259
+ runtime: "node"
260
+ });
261
+ const declared = declaredName(hints);
262
+ if (Option.isSome(declared)) {
263
+ const name = declared.value;
264
+ if (name === "pnpm" || name === "npm" || name === "yarn" || name === "bun") return DetectedPackageManager.make({
265
+ name,
266
+ version: versionFor(hints, name),
267
+ runtime: name === "bun" ? "bun" : "node"
268
+ });
269
+ }
236
270
  return yield* Effect.fail(new PackageManagerDetectionError({
237
271
  root,
238
272
  checked: CHECKED
@@ -242,6 +276,58 @@ var PackageManagerDetector = class PackageManagerDetector extends Context.Servic
242
276
  });
243
277
  /** The live layer. */
244
278
  static layer = Layer.effect(PackageManagerDetector, PackageManagerDetector.make);
279
+ /**
280
+ * The sanctioned in-memory double.
281
+ *
282
+ * @remarks
283
+ * **`detect` has no honest default, so an unstubbed call dies** — the
284
+ * `WorkspaceDiscovery.info` posture, for the same reason. A stand-in that
285
+ * answered `"pnpm"` would hand a consumer a fact nothing established, and it
286
+ * would contradict the very service it stands in for: the live detector's
287
+ * defining property is that it [refuses to
288
+ * guess](https://github.com/spencerbeggs/effected) when no evidence matches.
289
+ * A double that guesses is worse than no double.
290
+ *
291
+ * Failing typed would be the subtler mistake: `PackageManagerDetectionError`
292
+ * reads as a legitimate "no manager here" answer, so a consumer would branch
293
+ * on it and proceed, never learning that the test simply forgot to stub.
294
+ *
295
+ * The defect is also not absorbed by `Effect.catch` or any typed-error
296
+ * handler — deliberately, so code under test with a best-effort `catch`
297
+ * around detection cannot make the mandatory stub look optional; the
298
+ * unstubbed call still fails the test.
299
+ *
300
+ * @param overrides - Members to supply; anything omitted dies on use.
301
+ *
302
+ * @example
303
+ * ```ts
304
+ * import { DetectedPackageManager, PackageManagerDetector } from "@effected/workspaces";
305
+ * import { Effect, Option } from "effect";
306
+ *
307
+ * const TestDetector = PackageManagerDetector.layerTest({
308
+ * detect: () =>
309
+ * Effect.succeed(
310
+ * DetectedPackageManager.make({ name: "pnpm", version: Option.none(), runtime: "node" }),
311
+ * ),
312
+ * });
313
+ * ```
314
+ */
315
+ static makeTest = (overrides = {}) => ({
316
+ detect: () => Effect.die(/* @__PURE__ */ new Error("PackageManagerDetector.makeTest: detect() was called but not stubbed — no honest default DetectedPackageManager exists for a test double; pass a `detect` override.")),
317
+ ...overrides
318
+ });
319
+ /**
320
+ * {@link PackageManagerDetector.makeTest} behind `Layer.succeed`.
321
+ *
322
+ * @remarks
323
+ * A parameterized layer factory mints a **fresh reference per call**, and
324
+ * layers memoize by reference — bind the result to a `const` and reuse it
325
+ * rather than calling `layerTest(...)` at each composition site.
326
+ *
327
+ * Pairs with `WorkspaceRoot.layerTest` and `WorkspaceDiscovery.layerTest` to
328
+ * stand up the whole discovery path with no filesystem at all.
329
+ */
330
+ static layerTest = (overrides = {}) => Layer.succeed(PackageManagerDetector, PackageManagerDetector.makeTest(overrides));
245
331
  };
246
332
 
247
333
  //#endregion
package/Publishability.js CHANGED
@@ -84,9 +84,35 @@ var PublishTarget = class extends Schema.Class("PublishTarget")({
84
84
  *
85
85
  * @public
86
86
  */
87
- var PublishabilityDetector = class PublishabilityDetector extends Context.Service()("@effected/workspaces/PublishabilityDetector") {
88
- /** Standard npm publishing semantics. Pure — no filesystem, no platform services. */
89
- static layer = Layer.succeed(PublishabilityDetector, { detect: (pkg) => Effect.sync(() => {
87
+ var PublishabilityDetector = class extends Context.Service()("@effected/workspaces/PublishabilityDetector") {
88
+ /**
89
+ * Standard npm publishing semantics, **as a value**. Pure no filesystem,
90
+ * no platform services.
91
+ *
92
+ * @remarks
93
+ * Exposed as a shape and not only as a layer, because a consumer composing
94
+ * *around* these rules cannot reach them through a layer without re-entering
95
+ * the very tag it is replacing. `@savvy-web/silk-effects` had to write
96
+ * `Effect.provide(PublishabilityDetector, PublishabilityDetector.layer)`
97
+ * **inside its own implementation of that tag** to get at this function for
98
+ * its pass-through branch; with the value exposed that becomes
99
+ * `PublishabilityDetector.npm.detect(pkg)`.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * import { PublishabilityDetector } from "@effected/workspaces";
104
+ * import { Effect, Layer } from "effect";
105
+ *
106
+ * // A policy that defers to npm semantics for everything it does not veto.
107
+ * const withVeto = Layer.succeed(PublishabilityDetector, {
108
+ * detect: (pkg) =>
109
+ * pkg.name.endsWith("-private")
110
+ * ? Effect.succeed([])
111
+ * : PublishabilityDetector.npm.detect(pkg),
112
+ * });
113
+ * ```
114
+ */
115
+ static npm = { detect: (pkg) => Effect.sync(() => {
90
116
  const config = pkg.publishConfig;
91
117
  const access = config?.access;
92
118
  if (pkg.private && access === void 0) return [];
@@ -97,7 +123,40 @@ var PublishabilityDetector = class PublishabilityDetector extends Context.Servic
97
123
  access: access ?? "public",
98
124
  provenance: false
99
125
  })];
100
- }) });
126
+ }) };
127
+ /** Nothing publishes. */
128
+ static none = { detect: () => Effect.succeed([]) };
129
+ /**
130
+ * {@link PublishabilityDetector.npm} as a layer.
131
+ *
132
+ * @remarks
133
+ * Named for its policy rather than called `layer`, deliberately. **No
134
+ * composite in this package provides a publishability detector**: a
135
+ * `Workspaces.layer()` that quietly supplied npm semantics made the choice
136
+ * invisible, and worse, made a naively-ordered override lose to it in
137
+ * silence — `Layer.mergeAll(myDetector, Workspaces.layer())` resolved to the
138
+ * default, because `mergeAll` is last-wins. For a service that decides
139
+ * whether a package publishes and to which registry, that silent revert was
140
+ * the worst available failure.
141
+ *
142
+ * The composites do not *require* a detector either — nothing inside them
143
+ * asks a publishability question, so their `R` stays `FileSystem | Path`.
144
+ * The requirement instead surfaces in the `R` of each operation that asks
145
+ * (`VersioningStrategy.detect`, e.g.): a program that asks and never wires
146
+ * a detector fails to compile where that operation's `R` must close — which
147
+ * can be far from the layer-wiring site — and a program that never asks
148
+ * never supplies a publish policy at all.
149
+ */
150
+ static layerNpm = Layer.succeed(this, this.npm);
151
+ /**
152
+ * {@link PublishabilityDetector.none} as a layer: a workspace where nothing
153
+ * publishes.
154
+ *
155
+ * @remarks
156
+ * For dry runs, and for a release tool whose configuration disables
157
+ * publishing wholesale — silk's changeset `mode: "none"` is exactly this.
158
+ */
159
+ static layerNone = Layer.succeed(this, this.none);
101
160
  };
102
161
 
103
162
  //#endregion
package/README.md CHANGED
@@ -232,7 +232,9 @@ A name miss in the derived `getPackage` fails with the service's own typed `Pack
232
232
  - `WorkspaceCatalogs` — pnpm catalog assembly and `catalog:` resolution, on pnpm's own catalog packages; `releaseAgeGate()` assembles the effective `@effected/npm` `ReleaseAgeGate` from inline `pnpm-workspace.yaml` release-age keys and replayed hook contributions, strictest-wins, in the same pass as the catalogs.
233
233
  - `LockfileReader` — locate and parse the workspace's lockfile through `@effected/lockfiles`.
234
234
  - `ChangeDetector` — git-range change detection over `@effected/git`'s `Git` service; swap the layer to mock it with no repository.
235
- - `PublishabilityDetector` — whether a package publishes and to where, as a `PublishTarget` (registry, directory, access, provenance). The default layer implements npm's semantics; swap the layer if yours differ.
235
+ - `PublishabilityDetector` — whether a package publishes and to where, as a `PublishTarget` (registry, directory, access, provenance). No composite provides one: pick `PublishabilityDetector.layerNpm` (standard npm semantics) or `.layerNone` (nothing publishes) and provide it explicitly.
236
+ - `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.
237
+ - `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`.
236
238
  - `findWorkspaceRootSync` / `getWorkspacePackagesSync` — the synchronous escape hatch for config-time callers that cannot await, over file and path operations you supply.
237
239
  - `@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.
238
240
 
package/ReleaseTag.js ADDED
@@ -0,0 +1,260 @@
1
+ import { Schema } from "effect";
2
+
3
+ //#region src/ReleaseTag.ts
4
+ /**
5
+ * Whether one shared tag names a whole release, or one tag names each package.
6
+ *
7
+ * @remarks
8
+ * `single` is the shape of a single-package repo and of a monorepo whose
9
+ * publishable packages all version in lockstep; `scoped` is the shape of
10
+ * independent versioning, where a shared tag would be ambiguous.
11
+ *
12
+ * @public
13
+ */
14
+ const TagStyle = Schema.Literals(["single", "scoped"]);
15
+ /** Digits only, and no leading zeros beyond `0` itself — SemVer's numeric identifier. */
16
+ const NUMERIC_IDENTIFIER = /^(?:0|[1-9]\d*)$/;
17
+ /**
18
+ * Read a version's numeric core, or nothing when it is not `X.Y.Z[-pre][+build]`.
19
+ *
20
+ * Build metadata is stripped **before** the prerelease test: `+build` carries
21
+ * no precedence meaning in SemVer, so `1.2.3+sha.abc` is the stable `1.2.3`,
22
+ * and treating any `-`-or-`+` suffix as a prerelease is the obvious wrong
23
+ * reading.
24
+ */
25
+ const versionCore = (version) => {
26
+ const withoutBuild = version.split("+", 1)[0] ?? "";
27
+ const dash = withoutBuild.indexOf("-");
28
+ const prerelease = dash !== -1;
29
+ const segments = (prerelease ? withoutBuild.slice(0, dash) : withoutBuild).split(".");
30
+ if (segments.length !== 3) return void 0;
31
+ if (!segments.every((segment) => NUMERIC_IDENTIFIER.test(segment))) return void 0;
32
+ return {
33
+ major: Number(segments[0]),
34
+ minor: Number(segments[1]),
35
+ prerelease
36
+ };
37
+ };
38
+ /**
39
+ * A floating alias tag — `v1`, `v1.2` — that a repo re-points at its newest
40
+ * matching release.
41
+ *
42
+ * @remarks
43
+ * This is the GitHub Actions distribution convention: a consumer writes
44
+ * `uses: owner/repo@v1` and receives whatever 1.x the repo last pointed `v1` at.
45
+ *
46
+ * **Deliberately not SemVer, and deliberately not a {@link (TagStyle:variable)}.**
47
+ * A release tag names one immutable version; a tracking tag is an alias derived
48
+ * *from* a version, carrying a truncated number that is not a version at all.
49
+ * Folding it into `ReleaseTag` as a third style would put a mutable pointer and
50
+ * an immutable name behind one type.
51
+ *
52
+ * Everything here is derivation, formatting and parsing. **Actually moving a
53
+ * git tag is not this package's business** — a consumer does that through git,
54
+ * and the deliberate omission is what keeps this module a pure leaf.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * import { TrackingTag } from "@effected/workspaces";
59
+ *
60
+ * TrackingTag.forVersion("1.2.3").map((t) => t.value); // ["v1", "v1.2"]
61
+ * TrackingTag.forVersion("1.0.0-beta.3"); // [] — never float onto a beta
62
+ * TrackingTag.forVersion("1.2.3", { packageName: "@acme/cli" });
63
+ * // ["@acme/cli@v1", "@acme/cli@v1.2"]
64
+ * ```
65
+ *
66
+ * @public
67
+ */
68
+ var TrackingTag = class TrackingTag extends Schema.Class("TrackingTag")({
69
+ /** The tag string exactly as it appears in git. */
70
+ value: Schema.NonEmptyString,
71
+ /** The package the alias namespaces; absent on a bare `v1`. */
72
+ packageName: Schema.optionalKey(Schema.NonEmptyString),
73
+ /** The major version the alias tracks. */
74
+ major: Schema.Int,
75
+ /** The minor version, on a `v1.2`-precision alias; absent on `v1`. */
76
+ minor: Schema.optionalKey(Schema.Int)
77
+ }) {
78
+ /** Whether this alias tracks a whole major line, or one minor line inside it. */
79
+ get precision() {
80
+ return this.minor === void 0 ? "major" : "minor";
81
+ }
82
+ /**
83
+ * The tracking tags a release of `version` should be pointed at.
84
+ *
85
+ * @remarks
86
+ * **A prerelease derives nothing.** Anyone depending on `owner/repo@v1` is
87
+ * asking for the newest *stable* 1.x, so re-pointing that alias at
88
+ * `1.0.0-beta.3` would ship a prerelease to every such consumer with no
89
+ * signal at all. `includePrerelease` exists for callers who genuinely mean
90
+ * it — a prerelease-only distribution channel — and should be rare.
91
+ *
92
+ * **Total, never throwing.** A version that is not `X.Y.Z` derives nothing
93
+ * rather than failing: this is a query about a version, not a validation of
94
+ * one, and `WorkspacePackage.version` is deliberately tolerant, so odd
95
+ * versions reach here routinely.
96
+ *
97
+ * 0.x versions DO derive aliases. Floating `v0` across 0.x minors is a real
98
+ * hazard, but which aliases to publish is the caller's policy, decided where
99
+ * the tags are moved — not something a derivation should quietly withhold.
100
+ *
101
+ * @param version - The version being released.
102
+ * @param options - Package prefix, precision and the prerelease override.
103
+ * @returns The aliases, broadest first; empty when none apply.
104
+ */
105
+ static forVersion(version, options) {
106
+ const core = versionCore(version);
107
+ if (core === void 0) return [];
108
+ if (core.prerelease && options?.includePrerelease !== true) return [];
109
+ const packageName = options?.packageName;
110
+ const prefix = packageName === void 0 ? "" : `${packageName}@`;
111
+ const named = (suffix, fields) => TrackingTag.make({
112
+ value: `${prefix}v${suffix}`,
113
+ ...packageName !== void 0 && { packageName },
114
+ major: fields.major,
115
+ ...fields.minor !== void 0 && { minor: fields.minor }
116
+ });
117
+ const major = named(`${core.major}`, { major: core.major });
118
+ if (options?.precision === "major") return [major];
119
+ return [major, named(`${core.major}.${core.minor}`, {
120
+ major: core.major,
121
+ minor: core.minor
122
+ })];
123
+ }
124
+ };
125
+ /** A tag string split into an optional package prefix and the version part. */
126
+ const splitTag = (tag) => {
127
+ const at = tag.lastIndexOf("@");
128
+ if (at === -1) return { rest: tag };
129
+ const packageName = tag.slice(0, at);
130
+ const rest = tag.slice(at + 1);
131
+ if (packageName === "" || rest === "") return void 0;
132
+ return {
133
+ packageName,
134
+ rest
135
+ };
136
+ };
137
+ /**
138
+ * Decide whether a tag string is a release tag, a tracking alias, or neither.
139
+ *
140
+ * @remarks
141
+ * The two families are told apart by **segment count**, not by the `v` prefix:
142
+ * three numeric segments is a version (so `1.0.0` and `v1.0.0` are both release
143
+ * tags), while one or two segments is a truncated alias. The `v` *is* required
144
+ * on an alias — a bare `1` is neither valid SemVer nor the tracking convention,
145
+ * and accepting it would make this function guess.
146
+ *
147
+ * The package prefix splits at the **last** `@`, so a leading npm scope
148
+ * survives: `@scope/pkg@1.0.0` is package `@scope/pkg` at version `1.0.0`.
149
+ *
150
+ * Round-tripping is a tested property: every tag {@link ReleaseTag} and
151
+ * {@link TrackingTag} format classifies back to the family that produced it,
152
+ * with its fields intact.
153
+ *
154
+ * @param tag - Any tag string.
155
+ * @returns The classification.
156
+ *
157
+ * @public
158
+ */
159
+ const classifyTag = (tag) => {
160
+ const split = splitTag(tag);
161
+ if (split === void 0) return { kind: "unrecognized" };
162
+ const { packageName, rest } = split;
163
+ const withoutV = rest.startsWith("v") ? rest.slice(1) : rest;
164
+ const hasV = rest.startsWith("v");
165
+ if (versionCore(withoutV) !== void 0) return {
166
+ kind: "release",
167
+ tag: ReleaseTag.make({
168
+ value: tag,
169
+ ...packageName !== void 0 && { packageName },
170
+ version: withoutV,
171
+ style: packageName === void 0 ? "single" : "scoped"
172
+ })
173
+ };
174
+ if (!hasV) return { kind: "unrecognized" };
175
+ const segments = withoutV.split(".");
176
+ if (segments.length > 2 || !segments.every((segment) => NUMERIC_IDENTIFIER.test(segment))) return { kind: "unrecognized" };
177
+ const major = Number(segments[0]);
178
+ const minor = segments.length === 2 ? Number(segments[1]) : void 0;
179
+ return {
180
+ kind: "tracking",
181
+ tag: TrackingTag.make({
182
+ value: tag,
183
+ ...packageName !== void 0 && { packageName },
184
+ major,
185
+ ...minor !== void 0 && { minor }
186
+ })
187
+ };
188
+ };
189
+ /**
190
+ * A git tag naming a release, and the parts it was built from.
191
+ *
192
+ * @remarks
193
+ * `value` is the tag exactly as it appears in git; `version` stays **bare**
194
+ * even when `value` carries a prefix, so a consumer comparing versions never
195
+ * has to strip one back off.
196
+ *
197
+ * Formatting is **total**: there is no error channel, because the only failure
198
+ * v3 modelled — an empty version — is caught by `Schema.NonEmptyString` when
199
+ * the value is constructed. A bad version reaching these statics is developer
200
+ * wiring rather than untrusted input, so it dies as a defect, the same posture
201
+ * as an uncompilable glob literal in `WorkspacePackage.matchesDependency`.
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * import { ReleaseTag } from "@effected/workspaces";
206
+ *
207
+ * ReleaseTag.single("1.2.3").value; // "1.2.3"
208
+ * ReleaseTag.scoped("@acme/cli", "1.2.3").value; // "@acme/cli@1.2.3"
209
+ * ReleaseTag.scoped("cli", "1.2.3").value; // "cli@1.2.3"
210
+ * ReleaseTag.scoped("cli", "1.2.3", { versionPrefix: "v" }).value; // "cli@v1.2.3"
211
+ * ```
212
+ *
213
+ * @public
214
+ */
215
+ var ReleaseTag = class ReleaseTag extends Schema.Class("ReleaseTag")({
216
+ /** The tag string exactly as it appears in git. */
217
+ value: Schema.NonEmptyString,
218
+ /** The package the tag names; absent on a workspace-wide single tag. */
219
+ packageName: Schema.optionalKey(Schema.NonEmptyString),
220
+ /** The version the tag names, without any prefix. */
221
+ version: Schema.NonEmptyString,
222
+ /** Which style produced it. */
223
+ style: TagStyle
224
+ }) {
225
+ /**
226
+ * One shared tag for a whole release: `1.2.3`.
227
+ *
228
+ * @param version - The version being released. Must not be empty.
229
+ * @param options - Formatting overrides.
230
+ */
231
+ static single(version, options) {
232
+ const prefix = options?.versionPrefix ?? "";
233
+ return ReleaseTag.make({
234
+ value: `${prefix}${version}`,
235
+ version,
236
+ style: "single"
237
+ });
238
+ }
239
+ /**
240
+ * A per-package tag: `<packageName>@<version>` — `@scope/pkg@1.2.3` for a
241
+ * scoped name, `pkg@1.2.3` for an unscoped one, uniformly, unless
242
+ * `options.versionPrefix` says otherwise.
243
+ *
244
+ * @param packageName - The package being released. Must not be empty.
245
+ * @param version - The version being released. Must not be empty.
246
+ * @param options - Formatting overrides.
247
+ */
248
+ static scoped(packageName, version, options) {
249
+ const prefix = options?.versionPrefix ?? "";
250
+ return ReleaseTag.make({
251
+ value: `${packageName}@${prefix}${version}`,
252
+ packageName,
253
+ version,
254
+ style: "scoped"
255
+ });
256
+ }
257
+ };
258
+
259
+ //#endregion
260
+ export { ReleaseTag, TagStyle, TrackingTag, classifyTag };
@@ -0,0 +1,122 @@
1
+ import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
2
+ import { PublishabilityDetector } from "./Publishability.js";
3
+ import { ReleaseTag } from "./ReleaseTag.js";
4
+ import { Effect, Schema } from "effect";
5
+
6
+ //#region src/VersioningStrategy.ts
7
+ /**
8
+ * How a workspace assigns versions across its publishable packages.
9
+ *
10
+ * @remarks
11
+ * - `single` — zero or one publishable package, so one tag names the release.
12
+ * - `fixed-group` — every publishable package sits inside one group that
13
+ * versions in lockstep, so one tag still names the release.
14
+ * - `independent` — publishable packages version separately, so a shared tag
15
+ * would be ambiguous and each package needs its own.
16
+ *
17
+ * @public
18
+ */
19
+ const VersioningStrategyType = Schema.Literals([
20
+ "single",
21
+ "fixed-group",
22
+ "independent"
23
+ ]);
24
+ /**
25
+ * How a workspace versions, and the tagging that follows from it.
26
+ *
27
+ * @remarks
28
+ * Built either purely with {@link VersioningStrategy.classify}, or from a live
29
+ * workspace with {@link VersioningStrategy.detect}.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * import { VersioningStrategy } from "@effected/workspaces";
34
+ * import { Effect } from "effect";
35
+ *
36
+ * const program = Effect.gen(function* () {
37
+ * const strategy = yield* VersioningStrategy.detect({ fixedGroups });
38
+ * return strategy.tagsFor(released).map((tag) => tag.value);
39
+ * });
40
+ * ```
41
+ *
42
+ * @public
43
+ */
44
+ var VersioningStrategy = class VersioningStrategy extends Schema.Class("VersioningStrategy")({
45
+ /** The classification. */
46
+ type: VersioningStrategyType,
47
+ /** The groups classification was performed against, as supplied. */
48
+ fixedGroups: Schema.Array(Schema.Array(Schema.String)),
49
+ /** The publishable package names, sorted and de-duplicated. */
50
+ publishablePackages: Schema.Array(Schema.String)
51
+ }) {
52
+ /**
53
+ * Whether a release needs one tag per package rather than one shared tag.
54
+ */
55
+ get perPackageTags() {
56
+ return this.type === "independent";
57
+ }
58
+ /** The tag style this strategy implies. */
59
+ get tagStyle() {
60
+ return this.perPackageTags ? "scoped" : "single";
61
+ }
62
+ /**
63
+ * Classify a workspace from its publishable package names and fixed groups.
64
+ *
65
+ * @remarks
66
+ * Pure and total — no IO, no error channel. `packages` is sorted and
67
+ * de-duplicated first, so a name listed twice cannot inflate a one-package
68
+ * repo into an independent one.
69
+ */
70
+ static classify(options) {
71
+ const fixedGroups = options.fixedGroups ?? [];
72
+ const packages = [...new Set(options.packages)].sort();
73
+ const type = packages.length <= 1 ? "single" : fixedGroups.some((group) => packages.every((name) => group.includes(name))) ? "fixed-group" : "independent";
74
+ return VersioningStrategy.make({
75
+ type,
76
+ fixedGroups,
77
+ publishablePackages: packages
78
+ });
79
+ }
80
+ /**
81
+ * Classify the ambient workspace: enumerate its packages, keep the ones the
82
+ * {@link PublishabilityDetector} says publish somewhere, and classify those.
83
+ *
84
+ * @remarks
85
+ * The publishability question is asked through the service precisely so a
86
+ * consumer with its own rules — honouring a release tool's ignore list, say —
87
+ * swaps the layer instead of filtering afterwards.
88
+ */
89
+ static detect = Effect.fn("VersioningStrategy.detect")(function* (options) {
90
+ const discovery = yield* WorkspaceDiscovery;
91
+ const publishability = yield* PublishabilityDetector;
92
+ const packages = yield* discovery.listPackages();
93
+ const publishable = [];
94
+ for (const candidate of packages) if ((yield* publishability.detect(candidate)).length > 0) publishable.push(candidate.name);
95
+ return VersioningStrategy.classify({
96
+ packages: publishable,
97
+ ...options?.fixedGroups !== void 0 && { fixedGroups: options.fixedGroups }
98
+ });
99
+ });
100
+ /**
101
+ * The tags a release of `releases` produces under this strategy.
102
+ *
103
+ * @remarks
104
+ * Under `independent` this is one {@link ReleaseTag} per release, in the
105
+ * order given. Under `single` and `fixed-group` it is exactly one shared tag
106
+ * carrying the **first** release's version — every release in a lockstep
107
+ * batch shares a version by construction, so the choice is only visible on a
108
+ * batch that should not exist. Whether a batch actually agreed is a property
109
+ * of that batch rather than of the workspace, so it stays the caller's
110
+ * one-line check rather than a field here.
111
+ *
112
+ * An empty batch produces no tags under either style.
113
+ */
114
+ tagsFor(releases, options) {
115
+ if (releases.length === 0) return [];
116
+ if (this.perPackageTags) return releases.map((release) => ReleaseTag.scoped(release.name, release.version, options));
117
+ return [ReleaseTag.single(releases[0].version, options)];
118
+ }
119
+ };
120
+
121
+ //#endregion
122
+ export { VersioningStrategy, VersioningStrategyType };
@@ -326,7 +326,10 @@ var WorkspaceDiscovery = class WorkspaceDiscovery extends Context.Service()("@ef
326
326
  * (a fabricated root path would leak into consumer path logic), so an
327
327
  * unstubbed `info()` call is a test-wiring mistake and fails loudly as a
328
328
  * defect rather than succeeding with a lie or failing with a dishonest
329
- * typed error.
329
+ * typed error. A defect is not absorbed by `Effect.catch` or any
330
+ * typed-error handler — deliberately, so code under test with a
331
+ * best-effort `catch` cannot make the mandatory stub look optional; the
332
+ * unstubbed call still fails the test.
330
333
  *
331
334
  * @example
332
335
  * ```ts
@@ -175,7 +175,7 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
175
175
  return {
176
176
  at: Effect.fn("WorkspaceSnapshots.at")(function* (ref) {
177
177
  const root = yield* Effect.suspend(() => roots.find(options?.cwd ?? process.cwd()));
178
- const key = `${root}${ref}`;
178
+ const key = `${root}\0${ref}`;
179
179
  let memo = atCaches.get(key);
180
180
  if (memo === void 0) {
181
181
  const [resolveOnce, invalidate] = yield* Effect.cachedInvalidateWithTTL(computeAt(root, ref), Duration.infinity);
@@ -233,6 +233,13 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
233
233
  * test-wiring mistake fails loudly as a defect rather than succeeding with a
234
234
  * lie.
235
235
  *
236
+ * **A defect is not absorbed by `Effect.catch` or any typed-error handler**,
237
+ * and that is the point: code under test with a best-effort `catch` around
238
+ * its snapshot reads cannot make a mandatory stub look optional — the
239
+ * unstubbed call still fails the test instead of quietly taking the catch
240
+ * branch. Only defect-level combinators (`Effect.catchDefect`,
241
+ * `Effect.exit`) would see it.
242
+ *
236
243
  * @example
237
244
  * ```ts
238
245
  * import { CatalogSet, WorkspaceSnapshots, WorkspaceStateSnapshot } from "@effected/workspaces";