versionary 0.19.0 → 0.20.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.
@@ -44,6 +44,7 @@ export declare const configSchema: z.ZodObject<{
44
44
  json: "json";
45
45
  toml: "toml";
46
46
  yaml: "yaml";
47
+ nix: "nix";
47
48
  regex: "regex";
48
49
  }>;
49
50
  path: z.ZodString;
@@ -4,14 +4,17 @@ exports.configSchema = void 0;
4
4
  const zod_1 = require("zod");
5
5
  const artifactRuleSchema = zod_1.z
6
6
  .object({
7
- type: zod_1.z.enum(["json", "toml", "yaml", "regex"]),
7
+ type: zod_1.z.enum(["json", "toml", "yaml", "nix", "regex"]),
8
8
  path: zod_1.z.string().min(1),
9
9
  "field-path": zod_1.z.string().optional(),
10
10
  jsonpath: zod_1.z.string().optional(),
11
11
  pattern: zod_1.z.string().optional(),
12
12
  })
13
13
  .superRefine((value, ctx) => {
14
- const needsJsonPath = value.type === "json" || value.type === "toml" || value.type === "yaml";
14
+ const needsJsonPath = value.type === "json" ||
15
+ value.type === "toml" ||
16
+ value.type === "yaml" ||
17
+ value.type === "nix";
15
18
  const hasFieldPath = Boolean(value["field-path"] ?? value.jsonpath);
16
19
  if (needsJsonPath && !hasFieldPath) {
17
20
  ctx.addIssue({
@@ -160,6 +160,164 @@ function applyTomlRulePreservingFormatting(content, fieldPath, version) {
160
160
  const [, prefix = "", quote = '"', , , suffix = ""] = match;
161
161
  return content.replace(linePattern, `${prefix}${quote}${version}${quote}${suffix}`);
162
162
  }
163
+ function findMatchingBrace(content, openBraceIndex, endExclusive) {
164
+ let depth = 0;
165
+ let inDoubleQuoted = false;
166
+ let inMultiSingleQuoted = false;
167
+ let inLineComment = false;
168
+ let inBlockComment = false;
169
+ for (let index = openBraceIndex; index < endExclusive; index += 1) {
170
+ const current = content[index] ?? "";
171
+ const next = content[index + 1] ?? "";
172
+ if (inLineComment) {
173
+ if (current === "\n") {
174
+ inLineComment = false;
175
+ }
176
+ continue;
177
+ }
178
+ if (inBlockComment) {
179
+ if (current === "*" && next === "/") {
180
+ inBlockComment = false;
181
+ index += 1;
182
+ }
183
+ continue;
184
+ }
185
+ if (inDoubleQuoted) {
186
+ if (current === "\\") {
187
+ index += 1;
188
+ continue;
189
+ }
190
+ if (current === '"') {
191
+ inDoubleQuoted = false;
192
+ }
193
+ continue;
194
+ }
195
+ if (inMultiSingleQuoted) {
196
+ if (current === "'" && next === "'") {
197
+ inMultiSingleQuoted = false;
198
+ index += 1;
199
+ }
200
+ continue;
201
+ }
202
+ if (current === "#") {
203
+ inLineComment = true;
204
+ continue;
205
+ }
206
+ if (current === "/" && next === "*") {
207
+ inBlockComment = true;
208
+ index += 1;
209
+ continue;
210
+ }
211
+ if (current === '"') {
212
+ inDoubleQuoted = true;
213
+ continue;
214
+ }
215
+ if (current === "'" && next === "'") {
216
+ inMultiSingleQuoted = true;
217
+ index += 1;
218
+ continue;
219
+ }
220
+ if (current === "{") {
221
+ depth += 1;
222
+ continue;
223
+ }
224
+ if (current === "}") {
225
+ depth -= 1;
226
+ if (depth === 0) {
227
+ return index;
228
+ }
229
+ }
230
+ }
231
+ return -1;
232
+ }
233
+ function resolveNixPathTokens(fieldPath) {
234
+ const tokens = parseFieldPath(fieldPath);
235
+ if (tokens.some((token) => typeof token === "number")) {
236
+ throw new Error(`Nix artifact rules do not support array index segments in field-path "${fieldPath}".`);
237
+ }
238
+ return tokens;
239
+ }
240
+ function escapeForRegex(input) {
241
+ return input.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
242
+ }
243
+ function buildNixKeyPattern(key) {
244
+ const escaped = escapeForRegex(key);
245
+ return `(?:${escaped}|"${escaped}")`;
246
+ }
247
+ function findNixScopeRanges(content, key, rangeStart, rangeEnd) {
248
+ const keyPattern = buildNixKeyPattern(key);
249
+ const assignmentPattern = new RegExp(`^\\s*${keyPattern}\\s*=.*\\{(?:\\s*(?:#.*)?)$`, "gmu");
250
+ const scopedContent = content.slice(rangeStart, rangeEnd);
251
+ const ranges = [];
252
+ let match = assignmentPattern.exec(scopedContent);
253
+ while (match) {
254
+ const matchText = match[0] ?? "";
255
+ const relLineStart = match.index;
256
+ const relBracePos = matchText.lastIndexOf("{");
257
+ if (relLineStart >= 0 && relBracePos >= 0) {
258
+ const absoluteBrace = rangeStart + relLineStart + relBracePos;
259
+ const closingBrace = findMatchingBrace(content, absoluteBrace, rangeEnd);
260
+ if (closingBrace < 0) {
261
+ throw new Error(`Nix field-path segment "${key}" has an unterminated attrset.`);
262
+ }
263
+ ranges.push({ start: absoluteBrace + 1, end: closingBrace });
264
+ }
265
+ match = assignmentPattern.exec(scopedContent);
266
+ }
267
+ return ranges;
268
+ }
269
+ function applyNixRulePreservingFormatting(content, fieldPath, version) {
270
+ const tokens = resolveNixPathTokens(fieldPath);
271
+ const parentTokens = tokens.slice(0, -1);
272
+ const leaf = tokens.at(-1);
273
+ if (!leaf) {
274
+ throw new Error(`field-path "${fieldPath}" does not resolve to an existing field.`);
275
+ }
276
+ let candidateRanges = [
277
+ { start: 0, end: content.length },
278
+ ];
279
+ for (const parent of parentTokens) {
280
+ const nextRanges = [];
281
+ for (const range of candidateRanges) {
282
+ const nested = findNixScopeRanges(content, parent, range.start, range.end);
283
+ nextRanges.push(...nested);
284
+ }
285
+ candidateRanges = nextRanges;
286
+ if (candidateRanges.length === 0) {
287
+ throw new Error(`field-path "${fieldPath}" does not resolve to an existing field.`);
288
+ }
289
+ }
290
+ const leafPattern = new RegExp(`(^\\s*${buildNixKeyPattern(leaf)}\\s*=\\s*)(["'])([^"']*)(\\2)(\\s*;)`, "gmu");
291
+ const replacements = [];
292
+ for (const range of candidateRanges) {
293
+ const segment = content.slice(range.start, range.end);
294
+ let match = leafPattern.exec(segment);
295
+ while (match) {
296
+ const full = match[0] ?? "";
297
+ const prefix = match[1] ?? "";
298
+ const quote = match[2] ?? '"';
299
+ const suffix = match[5] ?? ";";
300
+ const relStart = match.index;
301
+ replacements.push({
302
+ start: range.start + relStart,
303
+ end: range.start + relStart + full.length,
304
+ replacement: `${prefix}${quote}${version}${quote}${suffix}`,
305
+ });
306
+ match = leafPattern.exec(segment);
307
+ }
308
+ }
309
+ if (replacements.length === 0) {
310
+ throw new Error(`field-path "${fieldPath}" does not resolve to an existing field.`);
311
+ }
312
+ if (replacements.length > 1) {
313
+ throw new Error(`Nix artifact rule field-path "${fieldPath}" matched multiple assignments; refine the path to match exactly one field.`);
314
+ }
315
+ const [target] = replacements;
316
+ if (!target) {
317
+ throw new Error("Nix replacement target missing.");
318
+ }
319
+ return `${content.slice(0, target.start)}${target.replacement}${content.slice(target.end)}`;
320
+ }
163
321
  function applyArtifactRuleToContent(content, rule, version) {
164
322
  if (rule.type === "regex") {
165
323
  if (!rule.pattern) {
@@ -175,6 +333,9 @@ function applyArtifactRuleToContent(content, rule, version) {
175
333
  if (rule.type === "toml") {
176
334
  return applyTomlRulePreservingFormatting(content, resolveFieldPath(rule), version);
177
335
  }
336
+ if (rule.type === "nix") {
337
+ return applyNixRulePreservingFormatting(content, resolveFieldPath(rule), version);
338
+ }
178
339
  const parsed = yaml_1.default.parse(content);
179
340
  setVersionAtJsonPath(parsed, resolveFieldPath(rule), version);
180
341
  return `${yaml_1.default.stringify(parsed)}`;
@@ -173,14 +173,25 @@ function renderReleasePlanChangelog(plan, options = {}) {
173
173
  if (!plan.nextVersion) {
174
174
  return "";
175
175
  }
176
- const propagatedRootPackage = plan.packages?.find((pkg) => pkg.path === "." && pkg.bumpReason === "dependency-propagation");
176
+ const propagatedRootPackage = plan.packages?.find((pkg) => pkg.path === ".");
177
177
  const isDirectBump = (pkg) => pkg.bumpReason === "direct" ||
178
178
  (pkg.bumpReason === undefined &&
179
179
  Boolean(pkg.nextVersion) &&
180
180
  pkg.commits.length > 0);
181
- const dependencies = propagatedRootPackage && plan.packages
181
+ const legacyDependencySources = propagatedRootPackage?.bumpReason === "dependency-propagation" &&
182
+ plan.packages
182
183
  ? plan.packages
183
184
  .filter((pkg) => pkg.path !== "." && isDirectBump(pkg) && pkg.nextVersion)
185
+ .map((pkg) => pkg.path)
186
+ : [];
187
+ const dependencySourcePaths = [
188
+ ...(propagatedRootPackage?.dependencySourcePaths ??
189
+ legacyDependencySources),
190
+ ].sort((a, b) => a.localeCompare(b));
191
+ const dependencies = plan.packages
192
+ ? dependencySourcePaths
193
+ .map((sourcePath) => plan.packages?.find((pkg) => pkg.path === sourcePath && pkg.nextVersion))
194
+ .filter((sourcePackage) => Boolean(sourcePackage))
184
195
  .map((pkg) => ({
185
196
  name: pkg.path,
186
197
  version: pkg.nextVersion,
@@ -19,6 +19,7 @@ export interface ReleasePlan {
19
19
  currentVersion: string;
20
20
  nextVersion: string | null;
21
21
  bumpReason?: "direct" | "dependency-propagation";
22
+ dependencySourcePaths?: string[];
22
23
  commits: ParsedCommit[];
23
24
  }>;
24
25
  }
@@ -108,6 +108,7 @@ function createReleasePlan(cwd = process.cwd()) {
108
108
  ...(implicitRootPlan ? [implicitRootPlan] : []),
109
109
  ].sort((a, b) => a.path.localeCompare(b.path));
110
110
  const packageCurrentVersionByPath = {};
111
+ const packageNextVersionByPath = {};
111
112
  const strategyPackagesByName = new Map();
112
113
  for (const packagePlan of packagePlans) {
113
114
  const packageConfig = loaded.config.packages?.[packagePlan.path] ?? {};
@@ -135,17 +136,56 @@ function createReleasePlan(cwd = process.cwd()) {
135
136
  });
136
137
  }
137
138
  packageCurrentVersionByPath[packagePlan.path] = packagePlan.currentVersion;
139
+ if (packagePlan.nextVersion) {
140
+ packageNextVersionByPath[packagePlan.path] = packagePlan.nextVersion;
141
+ }
138
142
  }
139
143
  const impactedPaths = new Set();
144
+ const dependencySourcePathsByPackage = new Map();
145
+ const addDependencySourcePath = (targetPath, sourcePath) => {
146
+ if (targetPath === sourcePath) {
147
+ return;
148
+ }
149
+ const existing = dependencySourcePathsByPackage.get(targetPath);
150
+ if (existing) {
151
+ existing.add(sourcePath);
152
+ return;
153
+ }
154
+ dependencySourcePathsByPackage.set(targetPath, new Set([sourcePath]));
155
+ };
140
156
  for (const strategyGroup of strategyPackagesByName.values()) {
141
- const impacted = strategyGroup.strategy.propagateDependentPatchImpacts?.(cwd, strategyGroup.packages);
142
- for (const pkgPath of impacted ?? []) {
157
+ const impactedByAll = strategyGroup.strategy.propagateDependentPatchImpacts?.(cwd, strategyGroup.packages) ?? [];
158
+ for (const pkgPath of impactedByAll) {
143
159
  impactedPaths.add(pkgPath);
144
160
  }
161
+ const sourcePackages = strategyGroup.packages.filter((pkg) => Boolean(pkg.nextVersion));
162
+ for (const sourcePackage of sourcePackages) {
163
+ const scopedImpacts = strategyGroup.strategy.propagateDependentPatchImpacts?.(cwd, strategyGroup.packages.map((pkg) => ({
164
+ ...pkg,
165
+ nextVersion: pkg.packagePath === sourcePackage.packagePath
166
+ ? sourcePackage.nextVersion
167
+ : null,
168
+ }))) ?? [];
169
+ for (const impactedPath of scopedImpacts) {
170
+ addDependencySourcePath(impactedPath, sourcePackage.packagePath);
171
+ }
172
+ }
145
173
  }
146
174
  const adjustedPackages = packagePlans.map((pkgPlan) => {
175
+ const dependencySourcePaths = [
176
+ ...(dependencySourcePathsByPackage.get(pkgPlan.path) ??
177
+ new Set()),
178
+ ]
179
+ .filter((sourcePath) => Boolean(packageNextVersionByPath[sourcePath]))
180
+ .sort((a, b) => a.localeCompare(b));
147
181
  if (pkgPlan.nextVersion || !impactedPaths.has(pkgPlan.path)) {
148
- return pkgPlan;
182
+ if (dependencySourcePaths.length === 0) {
183
+ return pkgPlan;
184
+ }
185
+ return {
186
+ ...pkgPlan,
187
+ dependencySourcePaths,
188
+ };
149
189
  }
150
190
  const current = packageCurrentVersionByPath[pkgPlan.path] ?? pkgPlan.currentVersion;
151
191
  return {
@@ -153,6 +193,7 @@ function createReleasePlan(cwd = process.cwd()) {
153
193
  releaseType: "patch",
154
194
  nextVersion: (0, semver_js_1.bumpVersion)(current, "patch", { allowStableMajor }),
155
195
  bumpReason: "dependency-propagation",
196
+ dependencySourcePaths,
156
197
  };
157
198
  });
158
199
  const visiblePackages = adjustedPackages.filter((pkgPlan) => !pkgPlan.implicitRoot || hasExplicitRootPackage);
@@ -336,18 +336,23 @@ function renderSimpleReviewRequestBody(version, previousVersion, commits, plan =
336
336
  pkg.commits.length > 0);
337
337
  const findPropagatedDependencies = (packagePath, packages) => {
338
338
  const target = packages.find((pkg) => pkg.path === packagePath);
339
- if (!target || target.bumpReason !== "dependency-propagation") {
339
+ if (!target) {
340
340
  return [];
341
341
  }
342
- const directSources = packages
343
- .filter((pkg) => isDirectBump(pkg))
342
+ const sourcePaths = target.dependencySourcePaths && target.dependencySourcePaths.length > 0
343
+ ? target.dependencySourcePaths
344
+ : target.bumpReason === "dependency-propagation"
345
+ ? packages.filter((pkg) => isDirectBump(pkg)).map((pkg) => pkg.path)
346
+ : [];
347
+ return sourcePaths
348
+ .map((sourcePath) => packages.find((pkg) => pkg.path === sourcePath))
349
+ .filter((sourcePackage) => Boolean(sourcePackage))
344
350
  .map((pkg) => ({
345
351
  name: formatPackageLabel(pkg.path),
346
352
  version: pkg.nextVersion ?? "",
347
353
  }))
348
354
  .filter((dependency) => dependency.version.length > 0)
349
355
  .sort((a, b) => a.name.localeCompare(b.name));
350
- return directSources;
351
356
  };
352
357
  if (plan?.packages && plan.packages.length > 1) {
353
358
  const sections = [];
@@ -243,17 +243,24 @@ function collectRustTargetManifests(cwd, versionFile, includeWorkspaceMembers) {
243
243
  const parsedRoot = parseCargoManifest(versionFile, rootRaw);
244
244
  const rootIsCrate = parsedRoot.packageTable !== null;
245
245
  const rootDir = node_path_1.default.dirname(rootManifestPath);
246
- const workspaceMembers = includeWorkspaceMembers
246
+ const augmentingMembers = includeWorkspaceMembers
247
247
  ? resolveWorkspaceMemberManifests(rootDir, parsedRoot.workspaceTable)
248
248
  : [];
249
249
  if (rootIsCrate) {
250
250
  const relRoot = normalizeSlashPath(node_path_1.default.relative(cwd, rootManifestPath));
251
- return [...new Set([relRoot, ...workspaceMembers])].sort((a, b) => a.localeCompare(b));
252
- }
253
- if (workspaceMembers.length > 0) {
254
- return workspaceMembers;
255
- }
256
- throw new Error(`Configured rust target "${versionFile}" is not a Rust crate manifest. Expected [package].version or [workspace].members with crate Cargo.toml files.`);
251
+ return [...new Set([relRoot, ...augmentingMembers])].sort((a, b) => a.localeCompare(b));
252
+ }
253
+ const fallbackMembers = includeWorkspaceMembers
254
+ ? augmentingMembers
255
+ : resolveWorkspaceMemberManifests(rootDir, parsedRoot.workspaceTable);
256
+ if (fallbackMembers.length > 0) {
257
+ return fallbackMembers;
258
+ }
259
+ const isWorkspaceOnly = parsedRoot.workspaceTable !== null;
260
+ const detail = isWorkspaceOnly
261
+ ? `Workspace root "${versionFile}" has no [workspace].members resolving to crate Cargo.toml files.`
262
+ : `"${versionFile}" has neither [package] nor [workspace].`;
263
+ throw new Error(`Configured rust target "${versionFile}" is not a Rust crate manifest. ${detail} Either remove the "packages" config so the workspace is auto-discovered, or point a package at a member crate path (e.g. "packages": { "crates/foo": {} }).`);
257
264
  }
258
265
  function isWorkspaceInheritedVersion(rawVersion) {
259
266
  if (!rawVersion || typeof rawVersion !== "object") {
@@ -2,7 +2,7 @@ export type ConfigFileFormat = "jsonc" | "json" | "toml" | "js";
2
2
  export type VersionaryChangelogFormat = "markdown-changelog" | "r-news";
3
3
  export type ReleaseReferenceCommentsMode = "off" | "best-effort" | "strict";
4
4
  export interface VersionaryArtifactRule {
5
- type: "json" | "toml" | "yaml" | "regex";
5
+ type: "json" | "toml" | "yaml" | "nix" | "regex";
6
6
  path: string;
7
7
  "field-path"?: string;
8
8
  jsonpath?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "versionary",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Automatic release framework based on conventional commits and semantic versioning",
5
5
  "keywords": [
6
6
  "releasing",