@savvy-web/silk-effects 3.0.2 → 3.1.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.
@@ -237,15 +237,16 @@ function makeShape(inspector) {
237
237
  path,
238
238
  status: "added"
239
239
  }));
240
+ const isOwnChangeset = (path) => path.startsWith(".changeset/") && path.endsWith(".md");
240
241
  const seen = /* @__PURE__ */ new Set();
241
242
  const rawEntries = [];
242
243
  for (const e of diffEntries) {
243
- if (seen.has(e.path)) continue;
244
+ if (seen.has(e.path) || isOwnChangeset(e.path)) continue;
244
245
  seen.add(e.path);
245
246
  rawEntries.push(e);
246
247
  }
247
248
  for (const e of untrackedEntries) {
248
- if (seen.has(e.path)) continue;
249
+ if (seen.has(e.path) || isOwnChangeset(e.path)) continue;
249
250
  seen.add(e.path);
250
251
  rawEntries.push(e);
251
252
  }
@@ -257,7 +257,8 @@ function makeShape(pit, inspector, discovery, detector, config, fs) {
257
257
  fromRef = yield* gitMergeBase(resolvedCwd, baseBranch);
258
258
  }
259
259
  const rawDiffs = computeWorkspaceDependencyDiffs(yield* pit.at(fromRef, { cwd: resolvedCwd }), options.to ? yield* pit.at(options.to, { cwd: resolvedCwd }) : yield* pit.worktree({ cwd: resolvedCwd }));
260
- const targetPkg = options.package;
260
+ const explicitTargets = /* @__PURE__ */ new Set([...options.packages ?? [], ...options.package ? [options.package] : []]);
261
+ const excluded = new Set(options.exclude ?? []);
261
262
  const livePackages = yield* discovery.listPackages(resolvedCwd);
262
263
  const publishable = yield* listPublishablePackageNames(livePackages, resolvedCwd).pipe(Effect.provide(provideDetector));
263
264
  const versionPrivate = yield* config.versionPrivate(resolvedCwd);
@@ -266,9 +267,15 @@ function makeShape(pit, inspector, discovery, detector, config, fs) {
266
267
  if (yield* config.isIgnored(pkg.name, resolvedCwd)) continue;
267
268
  if (publishable.has(pkg.name) || versionPrivate) inScope.add(pkg.name);
268
269
  }
269
- const targetIgnored = targetPkg ? yield* config.isIgnored(targetPkg, resolvedCwd) : false;
270
+ const activeTargets = /* @__PURE__ */ new Set();
271
+ for (const name of explicitTargets) {
272
+ if (excluded.has(name)) continue;
273
+ if (yield* config.isIgnored(name, resolvedCwd)) continue;
274
+ activeTargets.add(name);
275
+ }
276
+ const inScopeFor = (name) => explicitTargets.size > 0 ? activeTargets.has(name) : inScope.has(name) && !excluded.has(name);
270
277
  const keepDevDeps = options.includeDevDeps === true;
271
- const scoped = targetPkg ? targetIgnored ? [] : rawDiffs.filter((d) => d.package === targetPkg) : rawDiffs.filter((d) => inScope.has(d.package));
278
+ const scoped = rawDiffs.filter((d) => inScopeFor(d.package));
272
279
  const resolved = [];
273
280
  for (const diff of scoped) {
274
281
  const rows = keepDevDeps ? [...diff.rows] : diff.rows.filter((r) => r.type !== "devDependency");
@@ -279,7 +286,7 @@ function makeShape(pit, inspector, discovery, detector, config, fs) {
279
286
  }
280
287
  const existingPure = yield* findPureDependencyChangesets(fs, changesetDir);
281
288
  const skippedMixed = yield* findMixedDependencyChangesets(fs, changesetDir);
282
- const toDelete = targetPkg ? targetIgnored ? [] : existingPure.filter((p) => p.package === targetPkg) : existingPure.filter((p) => inScope.has(p.package));
289
+ const toDelete = existingPure.filter((p) => inScopeFor(p.package));
283
290
  const chosenFilenames = /* @__PURE__ */ new Set();
284
291
  const toWrite = [];
285
292
  for (const diff of resolved) {
@@ -43,6 +43,32 @@ const DEP_TYPE_MAP = [
43
43
  */
44
44
  const resolveOrRaw = (snapshot, dep, spec) => Option.getOrElse(snapshot.resolve(dep, spec), () => spec);
45
45
  /**
46
+ * Drop no-net-change field moves: the same dependency removed from one field
47
+ * and added to another with an equal resolved version (e.g. a dep promoted
48
+ * from `devDependencies` to `dependencies`). A field reclassification is a
49
+ * contract change worth release-note prose, not a version movement, so it
50
+ * must not surface as an unrelated removed row plus an added row. Moves that
51
+ * also change the resolved version keep both rows (the movement is real).
52
+ */
53
+ const collapseFieldMoves = (rows) => {
54
+ const dropped = /* @__PURE__ */ new Set();
55
+ const byName = /* @__PURE__ */ new Map();
56
+ for (const row of rows) {
57
+ const group = byName.get(row.dependency);
58
+ if (group) group.push(row);
59
+ else byName.set(row.dependency, [row]);
60
+ }
61
+ for (const group of byName.values()) for (const removed of group) {
62
+ if (removed.action !== "removed" || dropped.has(removed)) continue;
63
+ const added = group.find((r) => r.action === "added" && !dropped.has(r) && r.type !== removed.type && r.to === removed.from);
64
+ if (added) {
65
+ dropped.add(removed);
66
+ dropped.add(added);
67
+ }
68
+ }
69
+ return rows.filter((r) => !dropped.has(r));
70
+ };
71
+ /**
46
72
  * Diff two workspace snapshots and return per-package dependency-table rows,
47
73
  * comparing already-resolved specifier values per side.
48
74
  *
@@ -98,10 +124,11 @@ function computeWorkspaceDependencyDiffs(before, after) {
98
124
  });
99
125
  }
100
126
  }
101
- if (rows.length > 0) result.push({
127
+ const collapsed = collapseFieldMoves(rows);
128
+ if (collapsed.length > 0) result.push({
102
129
  package: afterPkg.name,
103
130
  relativePath: afterPkg.relativePath,
104
- rows: sortDependencyRows(rows)
131
+ rows: sortDependencyRows(collapsed)
105
132
  });
106
133
  }
107
134
  return result;
@@ -69,23 +69,46 @@ function parseJsonPath(path) {
69
69
  * @internal
70
70
  */
71
71
  function jsonPathGet(obj, path) {
72
+ return walkJsonPath(obj, path).map((entry) => entry.node);
73
+ }
74
+ /**
75
+ * Shared breadth-first traversal behind {@link jsonPathGet} and
76
+ * {@link jsonPathResolve}: each segment fans out the current set of matched
77
+ * entries, carrying both the node and the concrete path taken to reach it.
78
+ * The two public functions differ only in which half of the entry they keep.
79
+ */
80
+ function walkJsonPath(obj, path) {
72
81
  const segments = parseJsonPath(path);
73
- let current = [obj];
82
+ let current = [{
83
+ node: obj,
84
+ path: []
85
+ }];
74
86
  for (const segment of segments) {
75
87
  const next = [];
76
- for (const node of current) {
88
+ for (const { node, path: nodePath } of current) {
77
89
  if (node === null || node === void 0 || typeof node !== "object") continue;
78
90
  switch (segment.type) {
79
91
  case "property": {
80
92
  const value = node[segment.key];
81
- if (value !== void 0) next.push(value);
93
+ if (value !== void 0) next.push({
94
+ node: value,
95
+ path: [...nodePath, segment.key]
96
+ });
82
97
  break;
83
98
  }
84
99
  case "index":
85
- if (Array.isArray(node) && segment.index < node.length) next.push(node[segment.index]);
100
+ if (Array.isArray(node) && segment.index < node.length) next.push({
101
+ node: node[segment.index],
102
+ path: [...nodePath, segment.index]
103
+ });
86
104
  break;
87
105
  case "wildcard":
88
- if (Array.isArray(node)) next.push(...node);
106
+ if (Array.isArray(node)) node.forEach((element, index) => {
107
+ next.push({
108
+ node: element,
109
+ path: [...nodePath, index]
110
+ });
111
+ });
89
112
  break;
90
113
  }
91
114
  }
@@ -94,82 +117,38 @@ function jsonPathGet(obj, path) {
94
117
  return current;
95
118
  }
96
119
  /**
97
- * Mutate all matching locations in an object in-place.
120
+ * Resolve a JSONPath expression to the concrete paths of every existing match.
98
121
  *
99
122
  * @remarks
100
- * Walks to the parent(s) of the final segment, then sets the value
101
- * at each matching location. Only updates existing keys/indices;
102
- * does not create new properties or extend arrays. Returns the count
103
- * of locations actually updated.
123
+ * Uses the same breadth-first expansion as {@link jsonPathGet}, but instead of
124
+ * collecting the matched *values* it records the concrete `(string | number)[]`
125
+ * path taken to reach each one. Wildcards and indices are materialized into the
126
+ * numeric array index actually traversed, so the returned paths are directly
127
+ * consumable by structural editors such as `jsonc-effect`'s `modify`, which
128
+ * require a fully concrete path (no wildcards).
129
+ *
130
+ * Only existing locations are returned; nothing is created. A path with no
131
+ * matches yields an empty array, and the empty path (`"$."`) yields a single
132
+ * empty concrete path (the document root).
104
133
  *
105
- * @param obj - The object to modify in-place
134
+ * @param obj - The object to query
106
135
  * @param path - JSONPath string (e.g., `"$.packages[*].version"`)
107
- * @param value - The value to set at each matching location
108
- * @returns The number of locations updated (0 if no matches or empty path)
136
+ * @returns Array of concrete paths, each an array of string keys / numeric indices
109
137
  *
110
138
  * @example
111
139
  * ```typescript
112
- * import { jsonPathSet } from "../utils/jsonpath.js";
140
+ * import { jsonPathResolve } from "../utils/jsonpath.js";
113
141
  *
114
- * const obj = { version: "1.0.0" };
115
- * const count = jsonPathSet(obj, "$.version", "2.0.0");
116
- * // count === 1, obj.version === "2.0.0"
142
+ * const obj = { packages: [{ version: "1.0.0" }, { version: "2.0.0" }] };
143
+ * const paths = jsonPathResolve(obj, "$.packages[*].version");
144
+ * // [["packages", 0, "version"], ["packages", 1, "version"]]
117
145
  * ```
118
146
  *
119
147
  * @internal
120
148
  */
121
- function jsonPathSet(obj, path, value) {
122
- const segments = parseJsonPath(path);
123
- if (segments.length === 0) return 0;
124
- const lastSegment = segments[segments.length - 1];
125
- const parentSegments = segments.slice(0, -1);
126
- let parents = [obj];
127
- for (const segment of parentSegments) {
128
- const next = [];
129
- for (const node of parents) {
130
- if (node === null || node === void 0 || typeof node !== "object") continue;
131
- switch (segment.type) {
132
- case "property": {
133
- const child = node[segment.key];
134
- if (child !== void 0) next.push(child);
135
- break;
136
- }
137
- case "index":
138
- if (Array.isArray(node) && segment.index < node.length) next.push(node[segment.index]);
139
- break;
140
- case "wildcard":
141
- if (Array.isArray(node)) next.push(...node);
142
- break;
143
- }
144
- }
145
- parents = next;
146
- }
147
- let count = 0;
148
- for (const parent of parents) {
149
- if (parent === null || parent === void 0 || typeof parent !== "object") continue;
150
- switch (lastSegment.type) {
151
- case "property":
152
- if (lastSegment.key in parent) {
153
- parent[lastSegment.key] = value;
154
- count++;
155
- }
156
- break;
157
- case "index":
158
- if (Array.isArray(parent) && lastSegment.index < parent.length) {
159
- parent[lastSegment.index] = value;
160
- count++;
161
- }
162
- break;
163
- case "wildcard":
164
- if (Array.isArray(parent)) for (let i = 0; i < parent.length; i++) {
165
- parent[i] = value;
166
- count++;
167
- }
168
- break;
169
- }
170
- }
171
- return count;
149
+ function jsonPathResolve(obj, path) {
150
+ return walkJsonPath(obj, path).map((entry) => entry.path);
172
151
  }
173
152
 
174
153
  //#endregion
175
- export { jsonPathGet, jsonPathSet, parseJsonPath };
154
+ export { jsonPathGet, jsonPathResolve, parseJsonPath };
@@ -1,9 +1,10 @@
1
1
  import { LegacyVersionFilesSchema } from "../schemas/version-files.js";
2
- import { jsonPathGet, jsonPathSet } from "./jsonpath.js";
3
- import { Schema } from "effect";
2
+ import { jsonPathGet, jsonPathResolve, parseJsonPath } from "./jsonpath.js";
3
+ import { Effect, Schema } from "effect";
4
4
  import { readFileSync, writeFileSync } from "node:fs";
5
5
  import { join, relative, resolve } from "node:path";
6
6
  import { globSync } from "tinyglobby";
7
+ import { applyEdits, modify, parse } from "jsonc-effect";
7
8
 
8
9
  //#region src/changesets/utils/version-files.ts
9
10
  /**
@@ -193,31 +194,39 @@ var VersionFiles = class VersionFiles {
193
194
  return content.match(/^(\s+)"/m)?.[1] ?? " ";
194
195
  }
195
196
  /**
196
- * Update JSON file at specified JSONPath locations.
197
+ * Update a JSON (or JSONC) file at specified JSONPath locations,
198
+ * preserving the original formatting byte-for-byte.
197
199
  *
198
200
  * @remarks
199
- * Reads the file, detects its indentation style and trailing newline
200
- * preference, applies all JSONPath updates via {@link jsonPathSet},
201
- * and writes the result back preserving the original formatting.
202
- * Returns `undefined` if no JSONPath locations matched (no write occurs).
201
+ * The write is performed with `jsonc-effect`'s format-preserving
202
+ * `modify`/`applyEdits` rather than a `JSON.parse`/`JSON.stringify`
203
+ * round-trip (which always explodes inline arrays one-element-per-line and
204
+ * drops comments). Each JSONPath expression is resolved to concrete
205
+ * `(string | number)[]` paths against the parsed document, and each
206
+ * concrete path becomes a minimal text edit that touches only the target
207
+ * value's span — so inline arrays, comments,
208
+ * indentation, and the trailing-newline preference all survive; a one-line
209
+ * version bump produces a one-line diff.
210
+ *
211
+ * Insertion semantics: a concrete, wildcard-free JSONPath whose leaf
212
+ * property does not exist yet is inserted after the last sibling using the
213
+ * document's detected indent (the one case where indent detection still
214
+ * matters). Wildcard expressions only ever update existing matches. Returns
215
+ * `undefined` (no write) when nothing was updated or inserted.
203
216
  *
204
217
  * @param filePath - Absolute path to the JSON file
205
218
  * @param jsonPaths - JSONPath expressions to update
206
219
  * @param version - New version string
207
220
  * @returns Update result, or `undefined` if no changes were made
221
+ *
222
+ * @see {@link jsonPathResolve} for concrete-path enumeration
223
+ * @see {@link VersionFiles.applyVersionEdit} for the per-path edit
208
224
  */
209
225
  static updateFile(filePath, jsonPaths, version) {
210
- const content = readFileSync(filePath, "utf-8");
211
- const indent = VersionFiles.detectIndent(content);
212
- const trailingNewline = content.endsWith("\n");
213
- const obj = JSON.parse(content);
214
- const previousValues = jsonPaths.flatMap((jp) => jsonPathGet(obj, jp));
215
- let totalUpdated = 0;
216
- for (const jp of jsonPaths) totalUpdated += jsonPathSet(obj, jp, version);
217
- if (totalUpdated === 0) return;
218
- let output = JSON.stringify(obj, null, indent);
219
- if (trailingNewline) output += "\n";
220
- writeFileSync(filePath, output, "utf-8");
226
+ const original = readFileSync(filePath, "utf-8");
227
+ const { content, previousValues, totalChanged } = VersionFiles.computeUpdate(original, jsonPaths, version);
228
+ if (totalChanged === 0) return;
229
+ writeFileSync(filePath, content, "utf-8");
221
230
  return {
222
231
  filePath,
223
232
  jsonPaths,
@@ -226,6 +235,83 @@ var VersionFiles = class VersionFiles {
226
235
  };
227
236
  }
228
237
  /**
238
+ * Compute the full update for a document without touching the filesystem:
239
+ * the edited content, the previous values at every matched path, and how
240
+ * many locations actually changed.
241
+ *
242
+ * @remarks
243
+ * This is the single decision path shared by {@link VersionFiles.updateFile}
244
+ * and the dry-run branches of the two process methods, so a preview reports
245
+ * exactly the files a real run would write — including pending inserts of a
246
+ * not-yet-existing wildcard-free leaf, and excluding same-value no-ops.
247
+ *
248
+ * @param original - Document text as read from disk
249
+ * @param jsonPaths - JSONPath expressions to update
250
+ * @param version - New version string
251
+ * @returns The updated content, previous values, and changed-location count
252
+ */
253
+ static computeUpdate(original, jsonPaths, version) {
254
+ let content = original;
255
+ const obj = Effect.runSync(parse(content));
256
+ const previousValues = jsonPaths.flatMap((jp) => jsonPathGet(obj, jp));
257
+ const indent = VersionFiles.detectIndent(content);
258
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
259
+ let totalChanged = 0;
260
+ for (const jp of jsonPaths) {
261
+ const segments = parseJsonPath(jp);
262
+ if (segments.length === 0) continue;
263
+ const hasWildcard = segments.some((segment) => segment.type === "wildcard");
264
+ let concretePaths;
265
+ if (hasWildcard) concretePaths = jsonPathResolve(obj, jp);
266
+ else {
267
+ const direct = segments.map((segment) => segment.type === "property" ? segment.key : segment.index);
268
+ concretePaths = jsonPathResolve(obj, jp).length > 0 || typeof direct[direct.length - 1] === "string" ? [direct] : [];
269
+ }
270
+ for (const concretePath of concretePaths) {
271
+ const updated = VersionFiles.applyVersionEdit(content, concretePath, version, indent, eol);
272
+ if (updated !== void 0) {
273
+ content = updated;
274
+ totalChanged += 1;
275
+ }
276
+ }
277
+ }
278
+ return {
279
+ content,
280
+ previousValues,
281
+ totalChanged
282
+ };
283
+ }
284
+ /**
285
+ * Compute the format-preserving edit for a single concrete path, returning
286
+ * the updated document, or `undefined` when nothing changed.
287
+ *
288
+ * @remarks
289
+ * Delegates to `jsonc-effect`'s {@link modify} + {@link applyEdits}
290
+ * (requires `jsonc-effect >= 0.3.1`, whose edit spans touch only the target
291
+ * value), so every other byte of the document is preserved. When the leaf
292
+ * of a wildcard-free path does not exist, `modify` inserts the property
293
+ * after the last sibling using the supplied formatting options — the only
294
+ * case where the detected indent matters. A path whose parent is missing or
295
+ * not an object cannot be navigated; the resulting modification error is
296
+ * caught and reported as "no change" so the file is left alone.
297
+ *
298
+ * @param content - Current document text
299
+ * @param concretePath - A wildcard-free `(string | number)[]` path
300
+ * @param version - New version string
301
+ * @param indentUnit - One indentation level, for inserted text
302
+ * @param eol - End-of-line sequence, for inserted text
303
+ * @returns The updated document, or `undefined` if the path was unchanged
304
+ */
305
+ static applyVersionEdit(content, concretePath, version, indentUnit, eol) {
306
+ const insertSpaces = !indentUnit.includes(" ");
307
+ const program = modify(content, [...concretePath], version, { formattingOptions: {
308
+ insertSpaces,
309
+ tabSize: insertSpaces ? indentUnit.length : 1,
310
+ eol
311
+ } }).pipe(Effect.flatMap((edits) => applyEdits(content, edits)), Effect.map((updated) => updated === content ? void 0 : updated), Effect.catchTag("JsoncModificationError", () => Effect.succeed(void 0)));
312
+ return Effect.runSync(program);
313
+ }
314
+ /**
229
315
  * Orchestrate the full version file update flow.
230
316
  *
231
317
  * @remarks
@@ -251,9 +337,8 @@ var VersionFiles = class VersionFiles {
251
337
  try {
252
338
  if (dryRun) {
253
339
  const content = readFileSync(filePath, "utf-8");
254
- const obj = JSON.parse(content);
255
- const previousValues = jsonPaths.flatMap((jp) => jsonPathGet(obj, jp));
256
- if (previousValues.length > 0) updates.push({
340
+ const { previousValues, totalChanged } = VersionFiles.computeUpdate(content, jsonPaths, version);
341
+ if (totalChanged > 0) updates.push({
257
342
  filePath,
258
343
  jsonPaths,
259
344
  version,
@@ -297,9 +382,8 @@ var VersionFiles = class VersionFiles {
297
382
  for (const filePath of vf.matchedFiles) try {
298
383
  if (dryRun) {
299
384
  const content = readFileSync(filePath, "utf-8");
300
- const obj = JSON.parse(content);
301
- const previousValues = jsonPaths.flatMap((jp) => jsonPathGet(obj, jp));
302
- if (previousValues.length > 0) updates.push({
385
+ const { previousValues, totalChanged } = VersionFiles.computeUpdate(content, jsonPaths, scope.version);
386
+ if (totalChanged > 0) updates.push({
303
387
  filePath,
304
388
  jsonPaths,
305
389
  version: scope.version,
package/index.d.ts CHANGED
@@ -3766,8 +3766,20 @@ interface DepsRegenOptions {
3766
3766
  readonly cwd: string;
3767
3767
  /** Override the base branch used to compute the merge-base when `from` is omitted. */
3768
3768
  readonly base?: string;
3769
- /** Restrict regeneration to a single workspace package. */
3769
+ /** Restrict regeneration to a single workspace package. Unioned with {@link DepsRegenOptions.packages}. */
3770
3770
  readonly package?: string;
3771
+ /**
3772
+ * Restrict regeneration to these workspace packages. Like `package`, an
3773
+ * explicit target bypasses the versionable gate but NOT the changeset
3774
+ * ignore list. Unioned with `package` when both are set.
3775
+ */
3776
+ readonly packages?: ReadonlyArray<string>;
3777
+ /**
3778
+ * Drop these packages from scope entirely — no changesets are written for
3779
+ * them and none of their stale pure-dependency changesets are deleted.
3780
+ * Applies to both repo-wide and explicitly-targeted runs (exclude wins).
3781
+ */
3782
+ readonly exclude?: ReadonlyArray<string>;
3771
3783
  /**
3772
3784
  * When `true`, retain `devDependency` rows (the `deps detect` path);
3773
3785
  * when falsy (the `deps regen` default), drop them unconditionally.
@@ -4848,20 +4860,74 @@ declare class VersionFiles {
4848
4860
  */
4849
4861
  static detectIndent(content: string): string;
4850
4862
  /**
4851
- * Update JSON file at specified JSONPath locations.
4863
+ * Update a JSON (or JSONC) file at specified JSONPath locations,
4864
+ * preserving the original formatting byte-for-byte.
4852
4865
  *
4853
4866
  * @remarks
4854
- * Reads the file, detects its indentation style and trailing newline
4855
- * preference, applies all JSONPath updates via {@link jsonPathSet},
4856
- * and writes the result back preserving the original formatting.
4857
- * Returns `undefined` if no JSONPath locations matched (no write occurs).
4867
+ * The write is performed with `jsonc-effect`'s format-preserving
4868
+ * `modify`/`applyEdits` rather than a `JSON.parse`/`JSON.stringify`
4869
+ * round-trip (which always explodes inline arrays one-element-per-line and
4870
+ * drops comments). Each JSONPath expression is resolved to concrete
4871
+ * `(string | number)[]` paths against the parsed document, and each
4872
+ * concrete path becomes a minimal text edit that touches only the target
4873
+ * value's span — so inline arrays, comments,
4874
+ * indentation, and the trailing-newline preference all survive; a one-line
4875
+ * version bump produces a one-line diff.
4876
+ *
4877
+ * Insertion semantics: a concrete, wildcard-free JSONPath whose leaf
4878
+ * property does not exist yet is inserted after the last sibling using the
4879
+ * document's detected indent (the one case where indent detection still
4880
+ * matters). Wildcard expressions only ever update existing matches. Returns
4881
+ * `undefined` (no write) when nothing was updated or inserted.
4858
4882
  *
4859
4883
  * @param filePath - Absolute path to the JSON file
4860
4884
  * @param jsonPaths - JSONPath expressions to update
4861
4885
  * @param version - New version string
4862
4886
  * @returns Update result, or `undefined` if no changes were made
4887
+ *
4888
+ * @see {@link jsonPathResolve} for concrete-path enumeration
4889
+ * @see {@link VersionFiles.applyVersionEdit} for the per-path edit
4863
4890
  */
4864
4891
  static updateFile(filePath: string, jsonPaths: readonly string[], version: string): VersionFileUpdate | undefined;
4892
+ /**
4893
+ * Compute the full update for a document without touching the filesystem:
4894
+ * the edited content, the previous values at every matched path, and how
4895
+ * many locations actually changed.
4896
+ *
4897
+ * @remarks
4898
+ * This is the single decision path shared by {@link VersionFiles.updateFile}
4899
+ * and the dry-run branches of the two process methods, so a preview reports
4900
+ * exactly the files a real run would write — including pending inserts of a
4901
+ * not-yet-existing wildcard-free leaf, and excluding same-value no-ops.
4902
+ *
4903
+ * @param original - Document text as read from disk
4904
+ * @param jsonPaths - JSONPath expressions to update
4905
+ * @param version - New version string
4906
+ * @returns The updated content, previous values, and changed-location count
4907
+ */
4908
+ private static computeUpdate;
4909
+ /**
4910
+ * Compute the format-preserving edit for a single concrete path, returning
4911
+ * the updated document, or `undefined` when nothing changed.
4912
+ *
4913
+ * @remarks
4914
+ * Delegates to `jsonc-effect`'s {@link modify} + {@link applyEdits}
4915
+ * (requires `jsonc-effect >= 0.3.1`, whose edit spans touch only the target
4916
+ * value), so every other byte of the document is preserved. When the leaf
4917
+ * of a wildcard-free path does not exist, `modify` inserts the property
4918
+ * after the last sibling using the supplied formatting options — the only
4919
+ * case where the detected indent matters. A path whose parent is missing or
4920
+ * not an object cannot be navigated; the resulting modification error is
4921
+ * caught and reported as "no change" so the file is left alone.
4922
+ *
4923
+ * @param content - Current document text
4924
+ * @param concretePath - A wildcard-free `(string | number)[]` path
4925
+ * @param version - New version string
4926
+ * @param indentUnit - One indentation level, for inserted text
4927
+ * @param eol - End-of-line sequence, for inserted text
4928
+ * @returns The updated document, or `undefined` if the path was unchanged
4929
+ */
4930
+ private static applyVersionEdit;
4865
4931
  /**
4866
4932
  * Orchestrate the full version file update flow.
4867
4933
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/silk-effects",
3
- "version": "3.0.2",
3
+ "version": "3.1.0",
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",
@@ -33,7 +33,7 @@
33
33
  "@changesets/get-github-info": "^1.0.0-next.3",
34
34
  "@changesets/get-release-plan": "^5.0.0-next.7",
35
35
  "@manypkg/get-packages": "^3.1.0",
36
- "jsonc-effect": "^0.3.0",
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
39
  "prettier": "^3.9.4",