@effected/workspaces 0.7.0 → 0.9.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/LockfileReader.js CHANGED
@@ -27,6 +27,8 @@ var LockfileReadError = class extends Schema.TaggedErrorClass()("LockfileReadErr
27
27
  return `Cannot read ${this.format} lockfile at ${this.lockfilePath}`;
28
28
  }
29
29
  };
30
+ /** A defect naming the unstubbed test-double method — a test-wiring mistake, not a typed failure. */
31
+ const unstubbed = (method) => Effect.die(/* @__PURE__ */ new Error(`LockfileReader.makeTest: ${method}() was called but not stubbed — pass a \`${method}\` override.`));
30
32
  /**
31
33
  * Reads and parses the workspace's lockfile.
32
34
  *
@@ -112,6 +114,74 @@ var LockfileReader = class LockfileReader extends Context.Service()("@effected/w
112
114
  * `const` and reuse it.
113
115
  */
114
116
  static layer = (options) => Layer.effect(LockfileReader, LockfileReader.make(options));
117
+ /**
118
+ * A test double satisfying the full {@link LockfileReaderShape} with no
119
+ * filesystem, root walk, or package-manager detection.
120
+ *
121
+ * @remarks
122
+ * There is **no honest default lockfile**: an empty one that looks like a
123
+ * legitimate answer is indistinguishable from "this workspace resolves
124
+ * nothing" — the silent-empty failure class this package documents on the
125
+ * live paths — so `read` **dies** with an instructive defect until stubbed.
126
+ *
127
+ * The one derivation mirrors `WorkspaceDiscovery.makeTest`'s
128
+ * derived-from-the-primary rule: when a `read` override is supplied,
129
+ * `resolvedVersion` answers as the live service does — the **first** entry
130
+ * of `lockfile.packagesNamed(name)` in lockfile order, `Option.none()` on a
131
+ * miss — so the two stay consistent by construction. `integrity` is **not**
132
+ * derivable: the live method compares the lockfile against the workspace
133
+ * manifests discovery enumerates, and the double has no discovery to ask, so
134
+ * it dies unless stubbed.
135
+ *
136
+ * `refresh` defaults to `Effect.void` honestly: the live contract is "drop
137
+ * the memoized read so the next call re-reads", and this double memoizes
138
+ * nothing — every `read()` call re-invokes the override — so there is
139
+ * nothing to drop and the no-op is truthful, the same reasoning as
140
+ * `WorkspaceDiscovery.makeTest`'s `refresh`.
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * import { Lockfile } from "@effected/lockfiles";
145
+ * import { LockfileReader } from "@effected/workspaces";
146
+ * import { Effect } from "effect";
147
+ *
148
+ * const double = LockfileReader.makeTest({
149
+ * read: () =>
150
+ * Effect.succeed(
151
+ * Lockfile.make({ format: "pnpm", lockfileVersion: "9.0", packages: [], workspaceDependencies: [] }),
152
+ * ),
153
+ * });
154
+ * // `resolvedVersion` now answers consistently from that lockfile.
155
+ * ```
156
+ */
157
+ static makeTest = (overrides = {}) => {
158
+ const read = overrides.read;
159
+ return {
160
+ read: () => unstubbed("read"),
161
+ resolvedVersion: read !== void 0 ? (packageName) => Effect.map(read(), (lockfile) => Option.fromUndefinedOr(lockfile.packagesNamed(packageName)[0])) : () => unstubbed("resolvedVersion"),
162
+ integrity: () => unstubbed("integrity"),
163
+ refresh: () => Effect.void,
164
+ ...overrides
165
+ };
166
+ };
167
+ /**
168
+ * The test layer: {@link LockfileReader.makeTest} behind `Layer.succeed`, so
169
+ * a suite provides only the methods it exercises.
170
+ *
171
+ * @remarks
172
+ * A parameterized layer factory mints a **fresh reference per call**, and
173
+ * layers memoize by reference — bind the result to a `const` and reuse it
174
+ * rather than calling `layerTest(...)` at each composition site.
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * import { LockfileReader } from "@effected/workspaces";
179
+ *
180
+ * const TestLockfiles = LockfileReader.layerTest();
181
+ * // program.pipe(Effect.provide(TestLockfiles)) — dies loudly if touched.
182
+ * ```
183
+ */
184
+ static layerTest = (overrides = {}) => Layer.succeed(LockfileReader, LockfileReader.makeTest(overrides));
115
185
  };
116
186
 
117
187
  //#endregion
@@ -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,53 @@ 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
+ * @param overrides - Members to supply; anything omitted dies on use.
296
+ *
297
+ * @example
298
+ * ```ts
299
+ * import { DetectedPackageManager, PackageManagerDetector } from "@effected/workspaces";
300
+ * import { Effect, Option } from "effect";
301
+ *
302
+ * const TestDetector = PackageManagerDetector.layerTest({
303
+ * detect: () =>
304
+ * Effect.succeed(
305
+ * DetectedPackageManager.make({ name: "pnpm", version: Option.none(), runtime: "node" }),
306
+ * ),
307
+ * });
308
+ * ```
309
+ */
310
+ static makeTest = (overrides = {}) => ({
311
+ 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.")),
312
+ ...overrides
313
+ });
314
+ /**
315
+ * {@link PackageManagerDetector.makeTest} behind `Layer.succeed`.
316
+ *
317
+ * @remarks
318
+ * A parameterized layer factory mints a **fresh reference per call**, and
319
+ * layers memoize by reference — bind the result to a `const` and reuse it
320
+ * rather than calling `layerTest(...)` at each composition site.
321
+ *
322
+ * Pairs with `WorkspaceRoot.layerTest` and `WorkspaceDiscovery.layerTest` to
323
+ * stand up the whole discovery path with no filesystem at all.
324
+ */
325
+ static layerTest = (overrides = {}) => Layer.succeed(PackageManagerDetector, PackageManagerDetector.makeTest(overrides));
245
326
  };
246
327
 
247
328
  //#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,33 @@ 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. The requirement now sits in `R`, so the
141
+ * choice is made once, explicitly, and unmade wiring does not compile.
142
+ */
143
+ static layerNpm = Layer.succeed(this, this.npm);
144
+ /**
145
+ * {@link PublishabilityDetector.none} as a layer: a workspace where nothing
146
+ * publishes.
147
+ *
148
+ * @remarks
149
+ * For dry runs, and for a release tool whose configuration disables
150
+ * publishing wholesale — silk's changeset `mode: "none"` is exactly this.
151
+ */
152
+ static layerNone = Layer.succeed(this, this.none);
101
153
  };
102
154
 
103
155
  //#endregion
package/README.md CHANGED
@@ -134,7 +134,21 @@ A specifier the workspace cannot answer fails typed as `UnresolvedDependencyErro
134
134
 
135
135
  ## The synchronous escape hatch
136
136
 
137
- Vitest's config-time project discovery cannot await. Two functions exist for exactly that case, and they run synchronously over file and path operations you supply the module itself imports nothing platform-shaped, and Node's built-ins satisfy the operations one-liner each:
137
+ Vitest's config-time project discovery cannot await. Two functions exist for exactly that case, and they run synchronously over file and path operations you supply. On Node you do not have to write them: the `@effected/workspaces/node-sync` subpath exports `nodeSyncOps`, the ready-made `node:fs` and `node:path` bindings, so adopting the sync path is one extra import.
138
+
139
+ ```ts
140
+ import { findWorkspaceRootSync, getWorkspacePackagesSync } from "@effected/workspaces";
141
+ import { nodeSyncOps } from "@effected/workspaces/node-sync";
142
+
143
+ const root = findWorkspaceRootSync(process.cwd(), nodeSyncOps);
144
+ const packages = root === null ? [] : getWorkspacePackagesSync(root, nodeSyncOps);
145
+ // root: the workspace root path, or null when none is found above the cwd
146
+ // packages: the discovered workspace packages, empty when there is no root
147
+ ```
148
+
149
+ Both entry points take their path positionally, so the bag usually passes through verbatim; spread it to add `getWorkspacePackagesSync`'s traversal extras — `{ ...nodeSyncOps, maxDepth }`. The bindings are a separate subpath deliberately: the main entry imports nothing platform-shaped, and re-exporting them from it would drag `node:*` into every consumer, including the ones supplying their own operations. `nodePath` is the running platform's `node:path`, so on Windows the paths handed back are win32 paths.
150
+
151
+ Write the operations yourself when Node's built-ins are not the platform you mean — a Bun or Deno binding, a test fake, or `node:path/win32` to pin a dialect rather than follow the running platform. Each one is a one-liner:
138
152
 
139
153
  ```ts
140
154
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
@@ -157,7 +171,7 @@ const packages = root === null ? [] : getWorkspacePackagesSync(root, options);
157
171
  // packages: the discovered workspace packages, empty when there is no root
158
172
  ```
159
173
 
160
- Windows correctness is the operations you pass `node:path` on Windows is already win32-appropriate. 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.
174
+ 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.
161
175
 
162
176
  ## Error handling
163
177
 
@@ -220,6 +234,7 @@ A name miss in the derived `getPackage` fails with the service's own typed `Pack
220
234
  - `ChangeDetector` — git-range change detection over `@effected/git`'s `Git` service; swap the layer to mock it with no repository.
221
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.
222
236
  - `findWorkspaceRootSync` / `getWorkspacePackagesSync` — the synchronous escape hatch for config-time callers that cannot await, over file and path operations you supply.
237
+ - `@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.
223
238
 
224
239
  ## License
225
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 };