@savvy-web/silk-effects 3.2.2 → 3.2.4

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/README.md CHANGED
@@ -145,7 +145,7 @@ See [Publishability](./docs/publishability.md) for the adaptive layer and the `C
145
145
 
146
146
  #### ChangesetConfig
147
147
 
148
- Typed accessor over a workspace root's `.changeset/config.json`, reading through `ChangesetConfigReader` with a per-root cache. Every accessor is total — a missing or unreadable config collapses to `mode: "none"` and empty defaults. Methods: `mode`, `versionPrivate`, `ignorePatterns`, `isIgnored`, `fixed`, plus a static `ChangesetConfig.matches(name, pattern)`.
148
+ Typed accessor over a workspace root's `.changeset/config.json`, reading through `ChangesetConfigReader` with a per-root cache. Every accessor is total — a missing or unreadable config collapses to `mode: "none"` and empty defaults. Methods: `mode`, `versionPrivate`, `ignorePatterns`, `isIgnored`, `fixed` and `refresh` (drops every cached read so a long-lived host observes on-disk config edits made since the last call), plus a static `ChangesetConfig.matches(name, pattern)`.
149
149
 
150
150
  ```typescript
151
151
  import { Effect } from "effect";
@@ -402,9 +402,14 @@ function makeShape(reader, discovery, fs) {
402
402
  const inspected = yield* inspect(cwd);
403
403
  return paths.map((p) => classifyOne(inspected, p));
404
404
  });
405
+ const refresh = () => Effect.gen(function* () {
406
+ cache.clear();
407
+ yield* discovery.refresh();
408
+ });
405
409
  return {
406
410
  inspect,
407
- classify
411
+ classify,
412
+ refresh
408
413
  };
409
414
  }
410
415
  /**
@@ -474,7 +479,8 @@ const ConfigInspectorLive = Layer.effect(ConfigInspector, Effect.gen(function* (
474
479
  function makeConfigInspectorTest(fixed) {
475
480
  return Layer.succeed(ConfigInspector, {
476
481
  inspect: () => Effect.succeed(fixed),
477
- classify: (_cwd, paths) => Effect.succeed(paths.map((p) => classifyOne(fixed, p)))
482
+ classify: (_cwd, paths) => Effect.succeed(paths.map((p) => classifyOne(fixed, p))),
483
+ refresh: () => Effect.void
478
484
  });
479
485
  }
480
486
 
@@ -5,10 +5,10 @@ import { ChangesetConfig, ChangesetConfigLive } from "../../services/ChangesetCo
5
5
  import { PublishabilityDetectorAdaptiveLive } from "../../services/SilkPublishability.js";
6
6
  import { ConfigInspector, ConfigInspectorLive } from "./config-inspector.js";
7
7
  import { computeWorkspaceDependencyDiffs } from "../utils/dep-diff.js";
8
- import { gitMergeBase } from "../utils/git.js";
8
+ import { gitListChangesetFilesAtRef, gitMergeBase } from "../utils/git.js";
9
9
  import { listPublishablePackageNames } from "../utils/publishability.js";
10
10
  import { Context, Effect, Layer, Option } from "effect";
11
- import { join, resolve } from "node:path";
11
+ import { basename, join, resolve } from "node:path";
12
12
  import { FileSystem } from "@effect/platform";
13
13
  import { PointInTimeWorkspace, PointInTimeWorkspaceLive, PublishabilityDetector, WorkspaceDiscovery, WorkspaceDiscoveryLive, WorkspaceRootLive } from "workspaces-effect";
14
14
 
@@ -30,6 +30,15 @@ import { PointInTimeWorkspace, PointInTimeWorkspaceLive, PublishabilityDetector,
30
30
  * first, then deleting the stale pure-dependency ones (so an interrupted
31
31
  * run loses nothing and is safely re-runnable).
32
32
  *
33
+ * A pure dependency changeset is only ever a delete candidate when BOTH:
34
+ * its package is in scope AND actually produced a fresh diff this run
35
+ * (present in {@link RegenPlan.toWrite}), and the file was authored on
36
+ * this branch rather than already committed at the merge-base ref. Either
37
+ * gap silently destroyed a still-relevant release note before this was
38
+ * fixed (#258) — a devDependency-only diff drops all its rows and was
39
+ * never rewritten, and a changeset an earlier, already-merged PR
40
+ * committed was swept away by an unrelated branch's regen run.
41
+ *
33
42
  * This is the single source of truth for regen/detect: the CLI commands
34
43
  * and MCP tools are thin adapters over this service.
35
44
  *
@@ -250,7 +259,8 @@ function makeShape(pit, inspector, discovery, detector, config, fs) {
250
259
  const plan = (options) => Effect.gen(function* () {
251
260
  const resolvedCwd = resolve(options.cwd);
252
261
  const changesetDir = join(resolvedCwd, ".changeset");
253
- yield* discovery.refresh();
262
+ yield* inspector.refresh();
263
+ yield* config.refresh();
254
264
  let fromRef = options.from;
255
265
  if (!fromRef) {
256
266
  let baseBranch = options.base;
@@ -287,7 +297,10 @@ function makeShape(pit, inspector, discovery, detector, config, fs) {
287
297
  }
288
298
  const existingPure = yield* findPureDependencyChangesets(fs, changesetDir);
289
299
  const skippedMixed = yield* findMixedDependencyChangesets(fs, changesetDir);
290
- const toDelete = existingPure.filter((p) => inScopeFor(p.package));
300
+ const rewrittenPackages = new Set(resolved.map((d) => d.package));
301
+ const atMergeBase = yield* gitListChangesetFilesAtRef(resolvedCwd, fromRef);
302
+ const authoredOnBranch = (file) => !atMergeBase.has(basename(file));
303
+ const toDelete = existingPure.filter((p) => inScopeFor(p.package) && rewrittenPackages.has(p.package) && authoredOnBranch(p.file));
291
304
  const chosenFilenames = /* @__PURE__ */ new Set();
292
305
  const toWrite = [];
293
306
  for (const diff of resolved) {
@@ -1,5 +1,6 @@
1
1
  import { GitError } from "../errors.js";
2
2
  import { Effect } from "effect";
3
+ import { basename } from "node:path";
3
4
  import { execFileSync } from "node:child_process";
4
5
 
5
6
  //#region src/changesets/utils/git.ts
@@ -46,6 +47,48 @@ function gitMergeBase(cwd, base) {
46
47
  }
47
48
  });
48
49
  }
50
+ /**
51
+ * List the basenames of `.changeset/*.md` files tracked at `ref` (e.g. the
52
+ * merge base), via `git ls-tree -r --name-only`. Used by `DepsRegen.plan()`
53
+ * to protect changesets authored by already-merged PRs from being deleted by
54
+ * an unrelated branch's regen run (#258).
55
+ *
56
+ * @remarks
57
+ * Deliberately tolerant, unlike {@link gitMergeBase}: `cwd` may not be a git
58
+ * repository at all (many `DepsRegen` unit tests pass synthetic refs like
59
+ * `"BEFORE"`/`"AFTER"` against a bare tmpdir), and an unresolvable ref is a
60
+ * plausible caller mistake rather than a fatal condition. Either failure mode
61
+ * resolves to an empty set — "nothing protected" — rather than propagating a
62
+ * {@link GitError}, so a missing/invalid git context degrades the
63
+ * authorship filter to a no-op instead of blocking the whole plan.
64
+ *
65
+ * @internal
66
+ */
67
+ function gitListChangesetFilesAtRef(cwd, ref) {
68
+ return Effect.sync(() => {
69
+ try {
70
+ const out = execFileSync("git", [
71
+ "ls-tree",
72
+ "-r",
73
+ "--name-only",
74
+ ref,
75
+ "--",
76
+ ".changeset"
77
+ ], {
78
+ cwd,
79
+ encoding: "utf8",
80
+ stdio: [
81
+ "ignore",
82
+ "pipe",
83
+ "pipe"
84
+ ]
85
+ });
86
+ return new Set(out.split(/\r?\n/).filter((line) => line.trim().length > 0).map((path) => basename(path)));
87
+ } catch {
88
+ return /* @__PURE__ */ new Set();
89
+ }
90
+ });
91
+ }
49
92
 
50
93
  //#endregion
51
- export { gitMergeBase };
94
+ export { gitListChangesetFilesAtRef, gitMergeBase };
package/index.d.ts CHANGED
@@ -3002,6 +3002,17 @@ interface ConfigInspectorShape {
3002
3002
  * input path, in the same order
3003
3003
  */
3004
3004
  readonly classify: (cwd: string, paths: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<Classification>, ConfigurationError>;
3005
+ /**
3006
+ * Drop the cached {@link InspectedConfig} for every previously-inspected
3007
+ * root, and refresh the underlying `WorkspaceDiscovery` snapshot. Callers
3008
+ * that hold this service across multiple logical operations in a single
3009
+ * process (e.g. a long-lived MCP server) must call this before an
3010
+ * operation that needs to observe on-disk edits made since the last
3011
+ * `inspect`/`classify` call — the cache never expires on its own.
3012
+ *
3013
+ * @returns An Effect that clears the cache and succeeds with `void`.
3014
+ */
3015
+ readonly refresh: () => Effect.Effect<void>;
3005
3016
  }
3006
3017
  /**
3007
3018
  * Base class for {@link ConfigInspector}.
@@ -3697,6 +3708,14 @@ declare const ChangesetConfig_base: Context.TagClass<ChangesetConfig, "@savvy-we
3697
3708
  readonly ignorePatterns: (root: string) => Effect.Effect<ReadonlyArray<string>>;
3698
3709
  readonly isIgnored: (name: string, root: string) => Effect.Effect<boolean>;
3699
3710
  readonly fixed: (root: string) => Effect.Effect<ReadonlyArray<ReadonlyArray<string>>>;
3711
+ /**
3712
+ * Drop the cached read for every previously-read root. Callers that hold
3713
+ * this service across multiple logical operations in a single process
3714
+ * (e.g. a long-lived MCP server) must call this before an operation that
3715
+ * needs to observe an on-disk edit made since the last accessor call —
3716
+ * the cache never expires on its own.
3717
+ */
3718
+ readonly refresh: () => Effect.Effect<void>;
3700
3719
  }>;
3701
3720
  /**
3702
3721
  * Accessor service over a workspace root's `.changeset/config.json`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/silk-effects",
3
- "version": "3.2.2",
3
+ "version": "3.2.4",
4
4
  "private": false,
5
5
  "description": "Shared Effect library for Silk Suite conventions",
6
6
  "homepage": "https://github.com/savvy-web/systems/tree/main/packages/silk-effects",
@@ -36,7 +36,7 @@
36
36
  "jsonc-effect": "^0.3.1",
37
37
  "mdast-util-heading-range": "^4.0.0",
38
38
  "mdast-util-to-string": "^4.0.0",
39
- "prettier": "^3.9.4",
39
+ "prettier": "^3.9.5",
40
40
  "remark-gfm": "^4.0.1",
41
41
  "remark-parse": "^11.0.0",
42
42
  "remark-stringify": "^11.0.0",
@@ -72,7 +72,8 @@ const ChangesetConfigLive = Layer.effect(ChangesetConfig, Effect.gen(function* (
72
72
  fixed: (root) => read(root).pipe(Effect.map(Option.match({
73
73
  onNone: () => [],
74
74
  onSome: (cfg) => cfg.fixed ?? []
75
- })))
75
+ }))),
76
+ refresh: () => Effect.sync(() => cache.clear())
76
77
  };
77
78
  }));
78
79