@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.
- package/PackageManagerName.js +88 -2
- package/Publishability.js +63 -4
- package/README.md +3 -1
- package/ReleaseTag.js +260 -0
- package/VersioningStrategy.js +122 -0
- package/WorkspaceDiscovery.js +4 -1
- package/WorkspaceSnapshots.js +8 -1
- package/Workspaces.js +227 -148
- package/index.d.ts +704 -15
- package/index.js +3 -1
- package/package.json +7 -6
package/PackageManagerName.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
88
|
-
/**
|
|
89
|
-
|
|
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).
|
|
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 };
|
package/WorkspaceDiscovery.js
CHANGED
|
@@ -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
|
package/WorkspaceSnapshots.js
CHANGED
|
@@ -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}
|
|
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";
|