@savvy-web/silk-effects 1.6.0 → 2.0.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.
@@ -3,9 +3,8 @@ import { ChangelogTransformer } from "../api/transformer.js";
3
3
  import { ConfigInspector } from "./config-inspector.js";
4
4
  import { VersionFiles } from "../utils/version-files.js";
5
5
  import { Context, Effect, Layer } from "effect";
6
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
7
6
  import { isAbsolute, join, relative } from "node:path";
8
- import { tmpdir } from "node:os";
7
+ import { FileSystem } from "@effect/platform";
9
8
  import applyReleasePlan from "@changesets/apply-release-plan";
10
9
  import { read } from "@changesets/config";
11
10
  import getReleasePlan from "@changesets/get-release-plan";
@@ -37,8 +36,8 @@ const _tag = Context.Tag("ReleasePlanner");
37
36
  const ReleasePlannerBase = _tag();
38
37
  /** Effect service tag for the release planner. @public */
39
38
  var ReleasePlanner = class extends ReleasePlannerBase {};
40
- /** Build the service shape over a resolved {@link ConfigInspector}. */
41
- function makeShape(inspector) {
39
+ /** Build the service shape over a resolved {@link ConfigInspector} and {@link FileSystem.FileSystem}. */
40
+ function makeShape(inspector, fs) {
42
41
  const plan = (root) => Effect.tryPromise({
43
42
  try: () => getReleasePlan(root),
44
43
  catch: (e) => new ReleasePlanError({
@@ -46,23 +45,17 @@ function makeShape(inspector) {
46
45
  reason: errMsg(e)
47
46
  })
48
47
  });
49
- const preview = (root) => Effect.tryPromise({
50
- try: () => previewImpl(root),
51
- catch: (e) => new ReleasePlanError({
52
- phase: "preview",
53
- reason: errMsg(e)
54
- })
55
- });
56
- const apply = (root, options) => applyEffect(root, options?.dryRun ?? false, inspector);
48
+ const preview = (root) => previewEffect(root, fs);
49
+ const apply = (root, options) => applyEffect(root, options?.dryRun ?? false, inspector, fs);
57
50
  return {
58
51
  plan,
59
52
  preview,
60
53
  apply
61
54
  };
62
55
  }
63
- /** Production layer. Requires {@link ConfigInspector} (used by `apply`). @public */
56
+ /** Production layer. Requires {@link ConfigInspector} (used by `apply`) and `FileSystem`. @public */
64
57
  const ReleasePlannerLive = Layer.effect(ReleasePlanner, Effect.gen(function* () {
65
- return makeShape(yield* ConfigInspector);
58
+ return makeShape(yield* ConfigInspector, yield* FileSystem.FileSystem);
66
59
  }));
67
60
  /**
68
61
  * Test factory — supply fixed results for any subset of methods. Unsupplied
@@ -93,31 +86,52 @@ function extractVersionBlock(changelog, version) {
93
86
  }
94
87
  return lines.slice(start, end).join("\n").trim();
95
88
  }
96
- async function previewImpl(root) {
97
- const [plan, packages] = await Promise.all([getReleasePlan(root), buildPackages(root)]);
98
- const config = await read(root, packages);
99
- const preMode = plan.preState ? plan.preState.mode : null;
100
- const changesets = plan.changesets.map((cs) => ({
101
- id: cs.id,
102
- summary: cs.summary,
103
- releases: cs.releases.filter((r) => r.type !== "none").map((r) => ({
104
- name: r.name,
105
- type: r.type
106
- }))
107
- }));
108
- const releasesToRender = plan.releases.filter((r) => r.type !== "none");
109
- if (releasesToRender.length === 0) return {
110
- preMode,
111
- releases: [],
112
- changesets
113
- };
114
- const tempRoot = mkdtempSync(join(tmpdir(), "silk-preview-"));
115
- try {
89
+ /**
90
+ * Render a non-destructive preview by redirecting every write into a
91
+ * scope-managed temp directory (cleaned up automatically when the scope
92
+ * closes) and reading the generated CHANGELOG blocks back.
93
+ */
94
+ function previewEffect(root, fs) {
95
+ const program = Effect.gen(function* () {
96
+ const [plan, packages] = yield* Effect.tryPromise({
97
+ try: () => Promise.all([getReleasePlan(root), buildPackages(root)]),
98
+ catch: (e) => new ReleasePlanError({
99
+ phase: "preview",
100
+ reason: errMsg(e)
101
+ })
102
+ });
103
+ const config = yield* Effect.tryPromise({
104
+ try: () => read(root, packages),
105
+ catch: (e) => new ReleasePlanError({
106
+ phase: "preview",
107
+ reason: errMsg(e)
108
+ })
109
+ });
110
+ const preMode = plan.preState ? plan.preState.mode : null;
111
+ const changesets = plan.changesets.map((cs) => ({
112
+ id: cs.id,
113
+ summary: cs.summary,
114
+ releases: cs.releases.filter((r) => r.type !== "none").map((r) => ({
115
+ name: r.name,
116
+ type: r.type
117
+ }))
118
+ }));
119
+ const releasesToRender = plan.releases.filter((r) => r.type !== "none");
120
+ if (releasesToRender.length === 0) return {
121
+ preMode,
122
+ releases: [],
123
+ changesets
124
+ };
125
+ const tempRoot = yield* fs.makeTempDirectoryScoped({ prefix: "silk-preview-" });
116
126
  const mapDir = (dir) => {
117
127
  const rel = relative(packages.root.dir, dir);
118
- if (rel.startsWith("..") || isAbsolute(rel)) throw new Error(`Package directory is outside the workspace root: ${dir}`);
119
- return join(tempRoot, rel);
128
+ if (rel.startsWith("..") || isAbsolute(rel)) return Effect.fail(new ReleasePlanError({
129
+ phase: "preview",
130
+ reason: `Package directory is outside the workspace root: ${dir}`
131
+ }));
132
+ return Effect.succeed(join(tempRoot, rel));
120
133
  };
134
+ const tempDirs = yield* Effect.forEach(packages.packages, (p) => mapDir(p.dir));
121
135
  const tempPackages = {
122
136
  tool: packages.tool,
123
137
  root: {
@@ -125,26 +139,33 @@ async function previewImpl(root) {
125
139
  dir: tempRoot,
126
140
  packageJson: structuredClone(packages.root.packageJson)
127
141
  },
128
- packages: packages.packages.map((p) => ({
142
+ packages: packages.packages.map((p, i) => ({
129
143
  ...p,
130
- dir: mapDir(p.dir),
144
+ dir: tempDirs[i],
131
145
  packageJson: structuredClone(p.packageJson)
132
146
  }))
133
147
  };
134
- mkdirSync(join(tempRoot, ".changeset"), { recursive: true });
135
- cpSync(join(root, "package.json"), join(tempRoot, "package.json"));
148
+ yield* fs.makeDirectory(join(tempRoot, ".changeset"), { recursive: true });
149
+ yield* fs.copyFile(join(root, "package.json"), join(tempRoot, "package.json"));
136
150
  const preJson = join(root, ".changeset", "pre.json");
137
- if (existsSync(preJson)) cpSync(preJson, join(tempRoot, ".changeset", "pre.json"));
138
- for (const p of packages.packages) {
139
- const tDir = mapDir(p.dir);
140
- mkdirSync(tDir, { recursive: true });
141
- cpSync(join(p.dir, "package.json"), join(tDir, "package.json"));
151
+ if (yield* fs.exists(preJson)) yield* fs.copyFile(preJson, join(tempRoot, ".changeset", "pre.json"));
152
+ for (let i = 0; i < packages.packages.length; i++) {
153
+ const p = packages.packages[i];
154
+ const tDir = tempDirs[i];
155
+ yield* fs.makeDirectory(tDir, { recursive: true });
156
+ yield* fs.copyFile(join(p.dir, "package.json"), join(tDir, "package.json"));
142
157
  const realCl = join(p.dir, "CHANGELOG.md");
143
- if (existsSync(realCl)) cpSync(realCl, join(tDir, "CHANGELOG.md"));
158
+ if (yield* fs.exists(realCl)) yield* fs.copyFile(realCl, join(tDir, "CHANGELOG.md"));
144
159
  }
145
160
  const rootCl = join(packages.root.dir, "CHANGELOG.md");
146
- if (existsSync(rootCl)) cpSync(rootCl, join(tempRoot, "CHANGELOG.md"));
147
- await applyReleasePlan(plan, tempPackages, config, void 0, root);
161
+ if (yield* fs.exists(rootCl)) yield* fs.copyFile(rootCl, join(tempRoot, "CHANGELOG.md"));
162
+ yield* Effect.tryPromise({
163
+ try: () => applyReleasePlan(plan, tempPackages, config, void 0, root),
164
+ catch: (e) => new ReleasePlanError({
165
+ phase: "preview",
166
+ reason: errMsg(e)
167
+ })
168
+ });
148
169
  const dirByName = /* @__PURE__ */ new Map();
149
170
  for (const p of tempPackages.packages) dirByName.set(p.packageJson.name, p.dir);
150
171
  if (tempPackages.root.packageJson.name) dirByName.set(tempPackages.root.packageJson.name, tempRoot);
@@ -153,9 +174,15 @@ async function previewImpl(root) {
153
174
  const dir = dirByName.get(r.name);
154
175
  if (!dir) continue;
155
176
  const clPath = join(dir, "CHANGELOG.md");
156
- if (!existsSync(clPath)) continue;
157
- ChangelogTransformer.transformFile(clPath);
158
- const content = readFileSync(clPath, "utf-8");
177
+ if (!(yield* fs.exists(clPath))) continue;
178
+ yield* Effect.try({
179
+ try: () => ChangelogTransformer.transformFile(clPath),
180
+ catch: (e) => new ReleasePlanError({
181
+ phase: "preview",
182
+ reason: errMsg(e)
183
+ })
184
+ });
185
+ const content = yield* fs.readFileString(clPath);
159
186
  releases.push({
160
187
  name: r.name,
161
188
  type: r.type,
@@ -170,22 +197,17 @@ async function previewImpl(root) {
170
197
  releases,
171
198
  changesets
172
199
  };
173
- } finally {
174
- rmSync(tempRoot, {
175
- recursive: true,
176
- force: true
177
- });
178
- }
200
+ });
201
+ return Effect.scoped(program).pipe(Effect.mapError((e) => e instanceof ReleasePlanError ? e : new ReleasePlanError({
202
+ phase: "preview",
203
+ reason: errMsg(e)
204
+ })));
179
205
  }
180
- /** Re-read a package's version from disk (post-bump) to feed versionFiles. */
181
- function diskVersion(workspaceDir, fallback) {
182
- try {
183
- return JSON.parse(readFileSync(join(workspaceDir, "package.json"), "utf-8")).version ?? fallback;
184
- } catch {
185
- return fallback;
186
- }
206
+ /** Re-read a package's version from disk (post-bump) to feed versionFiles; unreadable/unparseable falls back. */
207
+ function diskVersion(workspaceDir, fallback, fs) {
208
+ return fs.readFileString(join(workspaceDir, "package.json")).pipe(Effect.flatMap((raw) => Effect.try(() => JSON.parse(raw).version ?? fallback)), Effect.orElseSucceed(() => fallback));
187
209
  }
188
- function applyEffect(root, dryRun, inspector) {
210
+ function applyEffect(root, dryRun, inspector, fs) {
189
211
  return Effect.gen(function* () {
190
212
  const { plan, packages, config } = yield* Effect.tryPromise({
191
213
  try: async () => {
@@ -222,22 +244,24 @@ function applyEffect(root, dryRun, inspector) {
222
244
  const newVersionByName = new Map(plan.releases.map((r) => [r.name, r.newVersion]));
223
245
  const inspected = yield* inspector.inspect(root).pipe(Effect.catchAll((error) => Effect.logWarning(`Skipping versionFiles update: ${errMsg(error)}`).pipe(Effect.as(null))));
224
246
  let versionFileUpdates = [];
225
- if (inspected) versionFileUpdates = yield* Effect.try({
226
- try: () => {
227
- const scopes = inspected.packages.filter((p) => p.versionFiles.length > 0).map((p) => {
228
- const fresh = dryRun ? newVersionByName.get(p.name) ?? p.version : diskVersion(p.workspaceDir, p.version);
229
- return fresh !== p.version ? {
230
- ...p,
231
- version: fresh
232
- } : p;
233
- });
234
- return scopes.length > 0 ? VersionFiles.processResolvedVersionFiles(scopes, dryRun) : [];
235
- },
236
- catch: (e) => new ReleasePlanError({
237
- phase: "apply",
238
- reason: errMsg(e)
239
- })
240
- });
247
+ if (inspected) {
248
+ const candidates = inspected.packages.filter((p) => p.versionFiles.length > 0);
249
+ const freshVersions = yield* Effect.forEach(candidates, (p) => dryRun ? Effect.succeed(newVersionByName.get(p.name) ?? p.version) : diskVersion(p.workspaceDir, p.version, fs));
250
+ const scopes = candidates.map((p, i) => {
251
+ const fresh = freshVersions[i];
252
+ return fresh !== p.version ? {
253
+ ...p,
254
+ version: fresh
255
+ } : p;
256
+ });
257
+ versionFileUpdates = yield* Effect.try({
258
+ try: () => scopes.length > 0 ? VersionFiles.processResolvedVersionFiles(scopes, dryRun) : [],
259
+ catch: (e) => new ReleasePlanError({
260
+ phase: "apply",
261
+ reason: errMsg(e)
262
+ })
263
+ });
264
+ }
241
265
  return {
242
266
  dryRun,
243
267
  touchedFiles,
@@ -1,6 +1,35 @@
1
1
  import { sortDependencyRows } from "./dependency-table.js";
2
+ import { Option } from "effect";
2
3
 
3
4
  //#region src/changesets/utils/dep-diff.ts
5
+ /**
6
+ * Compute per-workspace-package dependency-table rows from two
7
+ * {@link WorkspaceStateSnapshot}s, resolving `catalog:` / `workspace:`
8
+ * specifiers against each side's own catalogs and package versions BEFORE
9
+ * comparing.
10
+ *
11
+ * @remarks
12
+ * Operates on declared dependencies only (the `dependencies` /
13
+ * `devDependencies` / `peerDependencies` / `optionalDependencies` fields
14
+ * of each workspace's `package.json`). Lockfile-only movements
15
+ * (resolved versions changing while declared ranges stay put) are
16
+ * intentionally excluded — those happen on every `pnpm install` and
17
+ * would generate constant noise.
18
+ *
19
+ * Each side carries its own catalogs and package versions, so a specifier
20
+ * is resolved against the snapshot it belongs to: `catalog:silk` resolves
21
+ * to that ref's `silk` catalog entry, `workspace:*` to that ref's target
22
+ * package version. A row is emitted iff the two RESOLVED values differ (or
23
+ * the dependency was added/removed) — a package that merely adopted a
24
+ * `catalog:` specifier without changing the concrete version produces NO
25
+ * row. When a side cannot resolve a specifier (no matching catalog entry,
26
+ * plain range, etc.) it falls back to the raw specifier string.
27
+ *
28
+ * @see {@link DependencyTableRow} for the row schema
29
+ * @see {@link WorkspaceStateSnapshot} for the input shape
30
+ *
31
+ */
32
+ /** The em-dash sentinel (U+2014) used for added ("from") / removed ("to") cells. */
4
33
  const EM_DASH = "—";
5
34
  const DEP_TYPE_MAP = [
6
35
  ["dependencies", "dependency"],
@@ -8,65 +37,70 @@ const DEP_TYPE_MAP = [
8
37
  ["peerDependencies", "peerDependency"],
9
38
  ["optionalDependencies", "optionalDependency"]
10
39
  ];
11
- function diffOneRecord(before, after, type) {
12
- const rows = [];
13
- const seen = /* @__PURE__ */ new Set();
14
- for (const [name, beforeVersion] of Object.entries(before)) {
15
- seen.add(name);
16
- const afterVersion = after[name];
17
- if (afterVersion === void 0) rows.push({
18
- dependency: name,
19
- type,
20
- action: "removed",
21
- from: beforeVersion,
22
- to: EM_DASH
23
- });
24
- else if (afterVersion !== beforeVersion) rows.push({
25
- dependency: name,
26
- type,
27
- action: "updated",
28
- from: beforeVersion,
29
- to: afterVersion
30
- });
31
- }
32
- for (const [name, afterVersion] of Object.entries(after)) {
33
- if (seen.has(name)) continue;
34
- rows.push({
35
- dependency: name,
36
- type,
37
- action: "added",
38
- from: EM_DASH,
39
- to: afterVersion
40
- });
41
- }
42
- return rows;
43
- }
44
40
  /**
45
- * Diff two workspace snapshots and return per-package dependency-table rows.
41
+ * Resolve a specifier against the snapshot it belongs to, falling back to
42
+ * the raw specifier string when the snapshot cannot resolve it.
43
+ */
44
+ const resolveOrRaw = (snapshot, dep, spec) => Option.getOrElse(snapshot.resolve(dep, spec), () => spec);
45
+ /**
46
+ * Diff two workspace snapshots and return per-package dependency-table rows,
47
+ * comparing already-resolved specifier values per side.
46
48
  *
47
- * @param before - Snapshot at the older ref (typically the merge base). Pass
48
- * `null` for workspace packages that did not exist at the older ref — every
49
- * declared dep is then reported as `"added"`.
49
+ * @param before - Snapshot at the older ref (typically the merge base). A
50
+ * workspace package absent here reports every declared dep as `"added"`.
50
51
  * @param after - Snapshot at the newer ref (typically the working tree).
51
52
  * @returns One {@link WorkspaceDependencyDiff} entry per workspace package
52
- * that has at least one row. Packages with no changes are omitted.
53
+ * that has at least one row. Packages with no resolved-value changes are
54
+ * omitted.
53
55
  *
54
56
  * @public
55
57
  */
56
- function computeWorkspaceDependencyDiffs(beforeSnapshots, afterSnapshots) {
57
- const beforeByName = new Map(beforeSnapshots.map((s) => [s.name, s]));
58
+ function computeWorkspaceDependencyDiffs(before, after) {
58
59
  const result = [];
59
- for (const after of afterSnapshots) {
60
- const before = beforeByName.get(after.name);
60
+ for (const afterPkg of after.packages) {
61
+ const beforePkg = Option.getOrNull(before.package(afterPkg.name));
61
62
  const rows = [];
62
63
  for (const [field, type] of DEP_TYPE_MAP) {
63
- const beforeRecord = before?.[field] ?? {};
64
- const afterRecord = after[field];
65
- rows.push(...diffOneRecord(beforeRecord, afterRecord, type));
64
+ const beforeRecord = beforePkg?.[field] ?? {};
65
+ const afterRecord = afterPkg[field];
66
+ const seen = /* @__PURE__ */ new Set();
67
+ for (const [name, beforeSpec] of Object.entries(beforeRecord)) {
68
+ seen.add(name);
69
+ const from = resolveOrRaw(before, name, beforeSpec);
70
+ const afterSpec = afterRecord[name];
71
+ if (afterSpec === void 0) {
72
+ rows.push({
73
+ dependency: name,
74
+ type,
75
+ action: "removed",
76
+ from,
77
+ to: EM_DASH
78
+ });
79
+ continue;
80
+ }
81
+ const to = resolveOrRaw(after, name, afterSpec);
82
+ if (from !== to) rows.push({
83
+ dependency: name,
84
+ type,
85
+ action: "updated",
86
+ from,
87
+ to
88
+ });
89
+ }
90
+ for (const [name, afterSpec] of Object.entries(afterRecord)) {
91
+ if (seen.has(name)) continue;
92
+ rows.push({
93
+ dependency: name,
94
+ type,
95
+ action: "added",
96
+ from: EM_DASH,
97
+ to: resolveOrRaw(after, name, afterSpec)
98
+ });
99
+ }
66
100
  }
67
101
  if (rows.length > 0) result.push({
68
- package: after.name,
69
- relativePath: after.relativePath,
102
+ package: afterPkg.name,
103
+ relativePath: afterPkg.relativePath,
70
104
  rows: sortDependencyRows(rows)
71
105
  });
72
106
  }
@@ -0,0 +1,51 @@
1
+ import { GitError } from "../errors.js";
2
+ import { Effect } from "effect";
3
+ import { execFileSync } from "node:child_process";
4
+
5
+ //#region src/changesets/utils/git.ts
6
+ /**
7
+ * Git helpers for the changesets deps regen/detect orchestration.
8
+ *
9
+ * @remarks
10
+ * `PointInTimeWorkspace` (from `workspaces-effect`) reads both sides of a
11
+ * dependency diff over `CommandExecutor`. The one git operation it does not
12
+ * cover is resolving the default `--from` ref — the merge-base with the base
13
+ * branch — which stays here as a synchronous `execFileSync` shell-out.
14
+ *
15
+ * @internal
16
+ */
17
+ /**
18
+ * Run `git merge-base <base> HEAD`, returning the SHA. Errors propagate
19
+ * as {@link GitError}.
20
+ *
21
+ * @internal
22
+ */
23
+ function gitMergeBase(cwd, base) {
24
+ return Effect.try({
25
+ try: () => execFileSync("git", [
26
+ "merge-base",
27
+ base,
28
+ "HEAD"
29
+ ], {
30
+ cwd,
31
+ encoding: "utf8",
32
+ stdio: [
33
+ "ignore",
34
+ "pipe",
35
+ "pipe"
36
+ ]
37
+ }).trim(),
38
+ catch: (error) => {
39
+ const stderr = error.stderr;
40
+ const text = typeof stderr === "string" ? stderr : stderr?.toString() ?? "";
41
+ return new GitError({
42
+ command: `git merge-base ${base} HEAD`,
43
+ cwd,
44
+ reason: text.trim() || (error.message ?? String(error))
45
+ });
46
+ }
47
+ });
48
+ }
49
+
50
+ //#endregion
51
+ export { gitMergeBase };
@@ -20,16 +20,28 @@ import { PublishabilityDetector } from "workspaces-effect";
20
20
  * Uses the currently-active {@link SilkPublishability} — wire the
21
21
  * {@link SilkPublishabilityDetectorLive} layer to get silk semantics.
22
22
  *
23
+ * `root` is passed through verbatim to `detector.detect(pkg, root)` for every
24
+ * package — it must be the project root (the directory containing
25
+ * `.changeset/`), NOT the individual package's directory. The vanilla
26
+ * `PublishabilityDetectorLive` and plain `SilkPublishabilityDetectorLive`
27
+ * both ignore this argument, but the ignore/mode-aware
28
+ * `PublishabilityDetectorAdaptiveLive` reads `.changeset/config.json`
29
+ * relative to it, so passing a package subdirectory silently makes every
30
+ * package resolve to "not publishable". Mirrors
31
+ * {@link SilkPublishability.listPublishable}, which takes the same
32
+ * single-root parameter for the same reason.
33
+ *
23
34
  * @param packages - The workspace packages to evaluate
35
+ * @param root - Absolute path to the project root containing `.changeset/`
24
36
  * @returns An Effect yielding a `Set` of publishable package names
25
37
  *
26
38
  * @public
27
39
  */
28
- function listPublishablePackageNames(packages) {
40
+ function listPublishablePackageNames(packages, root) {
29
41
  return Effect.gen(function* () {
30
42
  const detector = yield* PublishabilityDetector;
31
43
  const names = /* @__PURE__ */ new Set();
32
- for (const pkg of packages) if ((yield* detector.detect(pkg, pkg.path)).length > 0) names.add(pkg.name);
44
+ for (const pkg of packages) if ((yield* detector.detect(pkg, root)).length > 0) names.add(pkg.name);
33
45
  return names;
34
46
  });
35
47
  }