@codluv/versionguard 0.8.0 → 0.9.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.
@@ -0,0 +1,4741 @@
1
+ import * as childProcess from "node:child_process";
2
+ import { execFileSync, execSync } from "node:child_process";
3
+ import * as path from "node:path";
4
+ import * as fs from "node:fs";
5
+ import * as yaml from "js-yaml";
6
+ import { parse } from "smol-toml";
7
+ import { globSync } from "glob";
8
+ import { fileURLToPath } from "node:url";
9
+ //#region \0rolldown/runtime.js
10
+ var __defProp = Object.defineProperty;
11
+ var __exportAll = (all, no_symbols) => {
12
+ let target = {};
13
+ for (var name in all) __defProp(target, name, {
14
+ get: all[name],
15
+ enumerable: true
16
+ });
17
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
18
+ return target;
19
+ };
20
+ //#endregion
21
+ //#region src/scheme-rules.ts
22
+ /**
23
+ * Validates a pre-release / modifier tag against the allowed modifiers list.
24
+ *
25
+ * @remarks
26
+ * Both SemVer prerelease identifiers (e.g. `alpha` from `1.2.3-alpha.1`) and
27
+ * CalVer modifiers (e.g. `rc` from `2026.3.0-rc2`) share the same validation
28
+ * logic: strip trailing digits/dots to get the base tag, then check the
29
+ * whitelist.
30
+ *
31
+ * @param modifier - Raw modifier string (e.g. `"alpha.1"`, `"rc2"`, `"dev"`).
32
+ * @param schemeRules - Scheme rules containing the allowed modifiers list.
33
+ * @returns A validation error when the modifier is disallowed, otherwise `null`.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * import { validateModifier } from './scheme-rules';
38
+ *
39
+ * const error = validateModifier('alpha.1', { maxNumericSegments: 3, allowedModifiers: ['dev', 'alpha', 'beta', 'rc'] });
40
+ * // => null (allowed)
41
+ * ```
42
+ *
43
+ * @internal
44
+ * @since 0.6.0
45
+ */
46
+ function validateModifier(modifier, schemeRules) {
47
+ if (!modifier || !schemeRules?.allowedModifiers) return null;
48
+ const baseModifier = modifier.replace(/[\d.]+$/, "") || modifier;
49
+ if (!schemeRules.allowedModifiers.includes(baseModifier)) return {
50
+ message: `Modifier "${modifier}" is not allowed. Allowed: ${schemeRules.allowedModifiers.join(", ")}`,
51
+ severity: "error"
52
+ };
53
+ return null;
54
+ }
55
+ //#endregion
56
+ //#region src/calver.ts
57
+ /**
58
+ * Calendar version parsing, formatting, and comparison helpers.
59
+ *
60
+ * @remarks
61
+ * Supports the full calver.org specification with all standard tokens:
62
+ * Year (`YYYY`, `YY`, `0Y`), Month (`MM`, `M`, `0M`), Week (`WW`, `0W`),
63
+ * Day (`DD`, `D`, `0D`), and Counter (`MICRO`/`PATCH`).
64
+ *
65
+ * `MICRO` is the CalVer-standard name for the counter segment.
66
+ * `PATCH` is accepted as a SemVer-familiar alias and behaves identically.
67
+ *
68
+ * @packageDocumentation
69
+ */
70
+ var calver_exports = /* @__PURE__ */ __exportAll({
71
+ compare: () => compare$1,
72
+ format: () => format$1,
73
+ getCurrentVersion: () => getCurrentVersion,
74
+ getNextVersions: () => getNextVersions,
75
+ getRegexForFormat: () => getRegexForFormat,
76
+ increment: () => increment$1,
77
+ isValidCalVerFormat: () => isValidCalVerFormat,
78
+ parse: () => parse$2,
79
+ parseFormat: () => parseFormat,
80
+ validate: () => validate$2
81
+ });
82
+ /** All recognized CalVer tokens. */
83
+ var VALID_TOKENS = new Set([
84
+ "YYYY",
85
+ "YY",
86
+ "0Y",
87
+ "MM",
88
+ "M",
89
+ "0M",
90
+ "WW",
91
+ "0W",
92
+ "DD",
93
+ "D",
94
+ "0D",
95
+ "MICRO",
96
+ "PATCH"
97
+ ]);
98
+ /** Year tokens. */
99
+ var YEAR_TOKENS = new Set([
100
+ "YYYY",
101
+ "YY",
102
+ "0Y"
103
+ ]);
104
+ /** Month tokens. */
105
+ var MONTH_TOKENS = new Set([
106
+ "MM",
107
+ "M",
108
+ "0M"
109
+ ]);
110
+ /** Week tokens. */
111
+ var WEEK_TOKENS = new Set(["WW", "0W"]);
112
+ /** Day tokens. */
113
+ var DAY_TOKENS = new Set([
114
+ "DD",
115
+ "D",
116
+ "0D"
117
+ ]);
118
+ /** Counter tokens (MICRO is canonical, PATCH is alias). */
119
+ var COUNTER_TOKENS = new Set(["MICRO", "PATCH"]);
120
+ /**
121
+ * Validates that a CalVer format string is composed of valid tokens
122
+ * and follows structural rules.
123
+ *
124
+ * @remarks
125
+ * Structural rules enforced:
126
+ * - Must have at least 2 segments
127
+ * - First segment must be a year token
128
+ * - Week tokens and Month/Day tokens are mutually exclusive
129
+ * - Counter (MICRO/PATCH) can only appear as the last segment
130
+ *
131
+ * @param formatStr - Format string to validate.
132
+ * @returns `true` when the format is valid.
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * import { isValidCalVerFormat } from 'versionguard';
137
+ *
138
+ * isValidCalVerFormat('YYYY.MM.MICRO'); // true
139
+ * isValidCalVerFormat('INVALID'); // false
140
+ * ```
141
+ *
142
+ * @public
143
+ * @since 0.3.0
144
+ */
145
+ function isValidCalVerFormat(formatStr) {
146
+ const tokens = formatStr.split(".");
147
+ if (tokens.length < 2) return false;
148
+ if (!tokens.every((t) => VALID_TOKENS.has(t))) return false;
149
+ if (!YEAR_TOKENS.has(tokens[0])) return false;
150
+ const hasWeek = tokens.some((t) => WEEK_TOKENS.has(t));
151
+ const hasMonthOrDay = tokens.some((t) => MONTH_TOKENS.has(t) || DAY_TOKENS.has(t));
152
+ if (hasWeek && hasMonthOrDay) return false;
153
+ const counterIndex = tokens.findIndex((t) => COUNTER_TOKENS.has(t));
154
+ if (counterIndex !== -1 && counterIndex !== tokens.length - 1) return false;
155
+ return true;
156
+ }
157
+ /**
158
+ * Breaks a CalVer format string into its component tokens.
159
+ *
160
+ * @remarks
161
+ * This helper is used internally by parsing, formatting, and version generation helpers
162
+ * to decide which date parts or counters are present in a given CalVer layout.
163
+ *
164
+ * @param calverFormat - Format string to inspect.
165
+ * @returns The parsed token definition for the requested format.
166
+ *
167
+ * @example
168
+ * ```ts
169
+ * import { parseFormat } from 'versionguard';
170
+ *
171
+ * parseFormat('YYYY.MM.MICRO');
172
+ * // => { year: 'YYYY', month: 'MM', counter: 'MICRO' }
173
+ * ```
174
+ *
175
+ * @public
176
+ * @since 0.1.0
177
+ */
178
+ function parseFormat(calverFormat) {
179
+ const tokens = calverFormat.split(".");
180
+ const result = { year: tokens[0] };
181
+ for (let i = 1; i < tokens.length; i++) {
182
+ const token = tokens[i];
183
+ if (MONTH_TOKENS.has(token)) result.month = token;
184
+ else if (WEEK_TOKENS.has(token)) result.week = token;
185
+ else if (DAY_TOKENS.has(token)) result.day = token;
186
+ else if (COUNTER_TOKENS.has(token)) result.counter = token;
187
+ }
188
+ return result;
189
+ }
190
+ /** The optional modifier suffix pattern (e.g., `-alpha.1`, `-rc2`). */
191
+ var MODIFIER_PATTERN = "(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?";
192
+ /**
193
+ * Maps a CalVer token to a strict regex capture group.
194
+ *
195
+ * Patterns enforce value-level constraints (e.g., month 1-12, day 1-31)
196
+ * at the regex level, not just structurally.
197
+ */
198
+ function tokenPattern(token) {
199
+ switch (token) {
200
+ case "YYYY": return "([1-9]\\d{3})";
201
+ case "YY": return "(\\d{1,3})";
202
+ case "0Y": return "(\\d{2,3})";
203
+ case "MM":
204
+ case "M": return "([1-9]|1[0-2])";
205
+ case "0M": return "(0[1-9]|1[0-2])";
206
+ case "WW": return "([1-9]|[1-4]\\d|5[0-3])";
207
+ case "0W": return "(0[1-9]|[1-4]\\d|5[0-3])";
208
+ case "DD":
209
+ case "D": return "([1-9]|[12]\\d|3[01])";
210
+ case "0D": return "(0[1-9]|[12]\\d|3[01])";
211
+ case "MICRO":
212
+ case "PATCH": return "(0|[1-9]\\d*)";
213
+ default: throw new Error(`Unsupported CalVer token: ${token}`);
214
+ }
215
+ }
216
+ /**
217
+ * Builds a regular expression that matches a supported CalVer format.
218
+ *
219
+ * @remarks
220
+ * The returned regular expression is anchored to the start and end of the string so it can
221
+ * be used directly for strict validation of a complete version value.
222
+ *
223
+ * @param calverFormat - Format string to convert into a regular expression.
224
+ * @returns A strict regular expression for the supplied CalVer format.
225
+ *
226
+ * @example
227
+ * ```ts
228
+ * import { getRegexForFormat } from 'versionguard';
229
+ *
230
+ * getRegexForFormat('YYYY.0M.0D').test('2026.03.21');
231
+ * // => true
232
+ * ```
233
+ *
234
+ * @public
235
+ * @since 0.1.0
236
+ */
237
+ function getRegexForFormat(calverFormat) {
238
+ const pattern = calverFormat.split(".").map(tokenPattern).join("\\.");
239
+ return new RegExp(`^${pattern}${MODIFIER_PATTERN}$`);
240
+ }
241
+ /**
242
+ * Parses a CalVer string using the supplied format.
243
+ *
244
+ * @remarks
245
+ * The parser returns `null` when the string does not structurally match the requested format.
246
+ * It does not enforce range rules such as future-date rejection; use {@link validate} for that.
247
+ *
248
+ * @param version - Version string to parse.
249
+ * @param calverFormat - Format expected for the version string.
250
+ * @returns Parsed CalVer components, or `null` when the string does not match the format.
251
+ *
252
+ * @example
253
+ * ```ts
254
+ * import { parse } from 'versionguard';
255
+ *
256
+ * parse('2026.3.0', 'YYYY.M.MICRO')?.month;
257
+ * // => 3
258
+ * ```
259
+ *
260
+ * @see {@link validate} to apply date-range and future-date validation.
261
+ * @public
262
+ * @since 0.1.0
263
+ */
264
+ function parse$2(version, calverFormat) {
265
+ const match = version.match(getRegexForFormat(calverFormat));
266
+ if (!match) return null;
267
+ const definition = parseFormat(calverFormat);
268
+ const yearToken = definition.year;
269
+ let year = Number.parseInt(match[1], 10);
270
+ if (yearToken === "YY" || yearToken === "0Y") year = 2e3 + year;
271
+ let cursor = 2;
272
+ let month;
273
+ let day;
274
+ let patch;
275
+ if (definition.month) {
276
+ month = Number.parseInt(match[cursor], 10);
277
+ cursor += 1;
278
+ }
279
+ if (definition.week) {
280
+ month = Number.parseInt(match[cursor], 10);
281
+ cursor += 1;
282
+ }
283
+ if (definition.day) {
284
+ day = Number.parseInt(match[cursor], 10);
285
+ cursor += 1;
286
+ }
287
+ if (definition.counter) {
288
+ patch = Number.parseInt(match[cursor], 10);
289
+ cursor += 1;
290
+ }
291
+ const modifier = match[cursor] || void 0;
292
+ return {
293
+ year,
294
+ month: month ?? 1,
295
+ day,
296
+ patch,
297
+ modifier,
298
+ format: calverFormat,
299
+ raw: version
300
+ };
301
+ }
302
+ /**
303
+ * Validates a CalVer string against formatting and date rules.
304
+ *
305
+ * @remarks
306
+ * Validation checks the requested CalVer format, month and day ranges, and optionally rejects
307
+ * future dates relative to the current system date.
308
+ *
309
+ * @param version - Version string to validate.
310
+ * @param calverFormat - Format expected for the version string.
311
+ * @param preventFutureDates - Whether future dates should be reported as errors.
312
+ * @param schemeRules - Optional scheme rules for modifier validation and segment count warnings.
313
+ * @returns A validation result containing any discovered errors and the parsed version on success.
314
+ *
315
+ * @example
316
+ * ```ts
317
+ * import { validate } from 'versionguard';
318
+ *
319
+ * validate('2026.3.0', 'YYYY.M.MICRO', false).valid;
320
+ * // => true
321
+ * ```
322
+ *
323
+ * @public
324
+ * @since 0.1.0
325
+ */
326
+ function validate$2(version, calverFormat, preventFutureDates = true, schemeRules) {
327
+ const errors = [];
328
+ const parsed = parse$2(version, calverFormat);
329
+ if (!parsed) return {
330
+ valid: false,
331
+ errors: [{
332
+ message: `Invalid CalVer format: "${version}". Expected format: ${calverFormat}`,
333
+ severity: "error"
334
+ }]
335
+ };
336
+ const definition = parseFormat(calverFormat);
337
+ if (definition.month && (parsed.month < 1 || parsed.month > 12)) errors.push({
338
+ message: `Invalid month: ${parsed.month}. Must be between 1 and 12.`,
339
+ severity: "error"
340
+ });
341
+ if (definition.week && (parsed.month < 1 || parsed.month > 53)) errors.push({
342
+ message: `Invalid week: ${parsed.month}. Must be between 1 and 53.`,
343
+ severity: "error"
344
+ });
345
+ if (parsed.day !== void 0) {
346
+ if (parsed.day < 1 || parsed.day > 31) errors.push({
347
+ message: `Invalid day: ${parsed.day}. Must be between 1 and 31.`,
348
+ severity: "error"
349
+ });
350
+ else if (definition.month) {
351
+ const daysInMonth = new Date(parsed.year, parsed.month, 0).getDate();
352
+ if (parsed.day > daysInMonth) errors.push({
353
+ message: `Invalid day: ${parsed.day}. ${parsed.year}-${String(parsed.month).padStart(2, "0")} has only ${daysInMonth} days.`,
354
+ severity: "error"
355
+ });
356
+ }
357
+ }
358
+ if (preventFutureDates) {
359
+ const now = /* @__PURE__ */ new Date();
360
+ const currentYear = now.getFullYear();
361
+ const currentMonth = now.getMonth() + 1;
362
+ const currentDay = now.getDate();
363
+ if (parsed.year > currentYear) errors.push({
364
+ message: `Future year not allowed: ${parsed.year}. Current year is ${currentYear}.`,
365
+ severity: "error"
366
+ });
367
+ else if (definition.month && parsed.year === currentYear && parsed.month > currentMonth) errors.push({
368
+ message: `Future month not allowed: ${parsed.year}.${parsed.month}. Current month is ${currentMonth}.`,
369
+ severity: "error"
370
+ });
371
+ else if (definition.month && parsed.year === currentYear && parsed.month === currentMonth && parsed.day !== void 0 && parsed.day > currentDay) errors.push({
372
+ message: `Future day not allowed: ${parsed.year}.${parsed.month}.${parsed.day}. Current day is ${currentDay}.`,
373
+ severity: "error"
374
+ });
375
+ }
376
+ if (parsed.modifier) {
377
+ const modifierError = validateModifier(parsed.modifier, schemeRules);
378
+ if (modifierError) errors.push(modifierError);
379
+ }
380
+ if (schemeRules?.maxNumericSegments) {
381
+ const segmentCount = calverFormat.split(".").length;
382
+ if (segmentCount > schemeRules.maxNumericSegments) errors.push({
383
+ message: `Format has ${segmentCount} segments, convention recommends ${schemeRules.maxNumericSegments} or fewer`,
384
+ severity: "warning"
385
+ });
386
+ }
387
+ return {
388
+ valid: errors.filter((e) => e.severity === "error").length === 0,
389
+ errors,
390
+ version: {
391
+ type: "calver",
392
+ version: parsed
393
+ }
394
+ };
395
+ }
396
+ function formatToken(token, value) {
397
+ switch (token) {
398
+ case "0M":
399
+ case "0D":
400
+ case "0W":
401
+ case "0Y": return String(token === "0Y" ? value % 100 : value).padStart(2, "0");
402
+ case "YY": return String(value % 100).padStart(2, "0");
403
+ default: return String(value);
404
+ }
405
+ }
406
+ /**
407
+ * Formats a parsed CalVer object back into a version string.
408
+ *
409
+ * @remarks
410
+ * Missing `day` and `patch` values fall back to `1` and `0` respectively when the selected
411
+ * format requires those tokens.
412
+ *
413
+ * @param version - Parsed CalVer value to serialize.
414
+ * @returns The formatted CalVer string.
415
+ *
416
+ * @example
417
+ * ```ts
418
+ * import { format } from 'versionguard';
419
+ *
420
+ * const version = { year: 2026, month: 3, day: 21, format: 'YYYY.0M.0D', raw: '2026.03.21' };
421
+ *
422
+ * format(version);
423
+ * // => '2026.03.21'
424
+ * ```
425
+ *
426
+ * @public
427
+ * @since 0.1.0
428
+ */
429
+ function format$1(version) {
430
+ const definition = parseFormat(version.format);
431
+ const parts = [formatToken(definition.year, version.year)];
432
+ if (definition.month) parts.push(formatToken(definition.month, version.month));
433
+ if (definition.week) parts.push(formatToken(definition.week, version.month));
434
+ if (definition.day) parts.push(formatToken(definition.day, version.day ?? 1));
435
+ if (definition.counter) parts.push(formatToken(definition.counter, version.patch ?? 0));
436
+ const base = parts.join(".");
437
+ return version.modifier ? `${base}-${version.modifier}` : base;
438
+ }
439
+ /**
440
+ * Creates the current CalVer string for a format.
441
+ *
442
+ * @remarks
443
+ * This helper derives its values from the provided date and initializes any counter to `0`.
444
+ * It is useful for generating a same-day baseline before incrementing counter-based formats.
445
+ *
446
+ * @param calverFormat - Format to generate.
447
+ * @param now - Date used as the source for year, month, and day values.
448
+ * @returns The current version string for the requested format.
449
+ *
450
+ * @example
451
+ * ```ts
452
+ * import { getCurrentVersion } from 'versionguard';
453
+ *
454
+ * getCurrentVersion('YYYY.M.MICRO', new Date('2026-03-21T00:00:00Z'));
455
+ * // => '2026.3.0'
456
+ * ```
457
+ *
458
+ * @public
459
+ * @since 0.1.0
460
+ */
461
+ function getCurrentVersion(calverFormat, now = /* @__PURE__ */ new Date()) {
462
+ const definition = parseFormat(calverFormat);
463
+ return format$1({
464
+ year: now.getFullYear(),
465
+ month: now.getMonth() + 1,
466
+ day: definition.day ? now.getDate() : void 0,
467
+ patch: definition.counter ? 0 : void 0,
468
+ format: calverFormat,
469
+ raw: ""
470
+ });
471
+ }
472
+ /**
473
+ * Compares two CalVer strings using a shared format.
474
+ *
475
+ * @remarks
476
+ * Comparison is performed component-by-component in year, month, day, then counter order.
477
+ * Missing day and counter values are treated as `0` during comparison.
478
+ *
479
+ * @param a - Left-hand version string.
480
+ * @param b - Right-hand version string.
481
+ * @param calverFormat - Format used to parse both versions.
482
+ * @returns `1` when `a` is greater, `-1` when `b` is greater, or `0` when they are equal.
483
+ *
484
+ * @example
485
+ * ```ts
486
+ * import { compare } from 'versionguard';
487
+ *
488
+ * compare('2026.3.2', '2026.3.1', 'YYYY.M.MICRO');
489
+ * // => 1
490
+ * ```
491
+ *
492
+ * @public
493
+ * @since 0.1.0
494
+ */
495
+ function compare$1(a, b, calverFormat) {
496
+ const left = parse$2(a, calverFormat);
497
+ const right = parse$2(b, calverFormat);
498
+ if (!left || !right) throw new Error(`Invalid CalVer comparison between "${a}" and "${b}"`);
499
+ for (const key of [
500
+ "year",
501
+ "month",
502
+ "day",
503
+ "patch"
504
+ ]) {
505
+ const leftValue = left[key] ?? 0;
506
+ const rightValue = right[key] ?? 0;
507
+ if (leftValue !== rightValue) return leftValue > rightValue ? 1 : -1;
508
+ }
509
+ return 0;
510
+ }
511
+ /**
512
+ * Increments a CalVer string.
513
+ *
514
+ * @remarks
515
+ * Counter-based formats increment the existing counter. Formats without a counter are
516
+ * promoted to a counter-based output by appending `.MICRO` with an initial value of `0`.
517
+ *
518
+ * @param version - Current version string.
519
+ * @param calverFormat - Format used to parse the current version.
520
+ * @returns The next version string.
521
+ *
522
+ * @example
523
+ * ```ts
524
+ * import { increment } from 'versionguard';
525
+ *
526
+ * increment('2026.3.1', 'YYYY.M.MICRO');
527
+ * // => '2026.3.2'
528
+ * ```
529
+ *
530
+ * @public
531
+ * @since 0.1.0
532
+ */
533
+ function increment$1(version, calverFormat) {
534
+ const parsed = parse$2(version, calverFormat);
535
+ if (!parsed) throw new Error(`Invalid CalVer version: ${version}`);
536
+ const definition = parseFormat(calverFormat);
537
+ const next = {
538
+ ...parsed,
539
+ raw: version
540
+ };
541
+ if (definition.counter) next.patch = (parsed.patch ?? 0) + 1;
542
+ else {
543
+ next.patch = 0;
544
+ next.format = `${calverFormat}.MICRO`;
545
+ }
546
+ return format$1(next);
547
+ }
548
+ /**
549
+ * Returns the most likely next CalVer candidates.
550
+ *
551
+ * @remarks
552
+ * The first candidate is the version derived from the current date. The second candidate is the
553
+ * incremented form of the supplied current version.
554
+ *
555
+ * @param currentVersion - Existing project version.
556
+ * @param calverFormat - Format used to generate both candidates.
557
+ * @returns Two candidate version strings ordered as current-date then incremented version.
558
+ *
559
+ * @example
560
+ * ```ts
561
+ * import { getNextVersions } from 'versionguard';
562
+ *
563
+ * getNextVersions('2026.3.1', 'YYYY.M.MICRO').length;
564
+ * // => 2
565
+ * ```
566
+ *
567
+ * @public
568
+ * @since 0.1.0
569
+ */
570
+ function getNextVersions(currentVersion, calverFormat) {
571
+ return [getCurrentVersion(calverFormat), increment$1(currentVersion, calverFormat)];
572
+ }
573
+ //#endregion
574
+ //#region src/changelog.ts
575
+ var CHANGELOG_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
576
+ /** Default Keep a Changelog section names. */
577
+ var KEEP_A_CHANGELOG_SECTIONS = [
578
+ "Added",
579
+ "Changed",
580
+ "Deprecated",
581
+ "Removed",
582
+ "Fixed",
583
+ "Security"
584
+ ];
585
+ /**
586
+ * Validates a changelog file for release readiness.
587
+ *
588
+ * @public
589
+ * @since 0.1.0
590
+ * @remarks
591
+ * The validator checks for a top-level changelog heading, an `[Unreleased]`
592
+ * section, and optionally a dated entry for the requested version.
593
+ *
594
+ * When `structure.enforceStructure` is `true`, section headers (`### Name`)
595
+ * are validated against the allowed list and empty sections produce warnings.
596
+ *
597
+ * @param changelogPath - Path to the changelog file.
598
+ * @param version - Version that must be present in the changelog.
599
+ * @param strict - Whether to require compare links and dated release headings.
600
+ * @param requireEntry - Whether the requested version must already have an entry.
601
+ * @param structure - Optional structure enforcement options.
602
+ * @returns The result of validating the changelog file.
603
+ * @example
604
+ * ```ts
605
+ * import { validateChangelog } from 'versionguard';
606
+ *
607
+ * const result = validateChangelog('CHANGELOG.md', '1.2.0', true, true, {
608
+ * enforceStructure: true,
609
+ * sections: ['Added', 'Changed', 'Fixed'],
610
+ * });
611
+ * ```
612
+ */
613
+ function validateChangelog(changelogPath, version, strict = true, requireEntry = true, structure) {
614
+ if (!fs.existsSync(changelogPath)) return {
615
+ valid: !requireEntry,
616
+ errors: requireEntry ? [`Changelog not found: ${changelogPath}`] : [],
617
+ hasEntryForVersion: false
618
+ };
619
+ const errors = [];
620
+ const content = fs.readFileSync(changelogPath, "utf-8");
621
+ if (!content.startsWith("# Changelog")) errors.push("Changelog must start with \"# Changelog\"");
622
+ if (!content.includes("## [Unreleased]")) errors.push("Changelog must have an [Unreleased] section");
623
+ const versionHeader = `## [${version}]`;
624
+ const hasEntryForVersion = content.includes(versionHeader);
625
+ if (requireEntry && !hasEntryForVersion) errors.push(`Changelog must have an entry for version ${version}`);
626
+ if (strict) {
627
+ if (!content.includes("[Unreleased]:")) errors.push("Changelog should include compare links at the bottom");
628
+ const versionHeaderMatch = content.match(new RegExp(`## \\[${escapeRegExp$1(version)}\\] - ([^\r\n]+)`));
629
+ if (requireEntry && hasEntryForVersion) {
630
+ if (!versionHeaderMatch) errors.push(`Version ${version} entry must use "## [${version}] - YYYY-MM-DD" format`);
631
+ else if (!CHANGELOG_DATE_REGEX.test(versionHeaderMatch[1])) errors.push(`Version ${version} entry date must use YYYY-MM-DD format`);
632
+ }
633
+ }
634
+ if (structure?.enforceStructure) {
635
+ const sectionErrors = validateSections(content, structure.sections ?? KEEP_A_CHANGELOG_SECTIONS);
636
+ errors.push(...sectionErrors);
637
+ }
638
+ return {
639
+ valid: errors.length === 0,
640
+ errors,
641
+ hasEntryForVersion
642
+ };
643
+ }
644
+ /**
645
+ * Validates that all `### SectionName` headers use allowed names
646
+ * and flags empty sections.
647
+ */
648
+ function validateSections(content, allowed) {
649
+ const errors = [];
650
+ const lines = content.split("\n");
651
+ for (let i = 0; i < lines.length; i++) {
652
+ const sectionMatch = lines[i].match(/^### (.+)/);
653
+ if (!sectionMatch) continue;
654
+ const sectionName = sectionMatch[1].trim();
655
+ if (!allowed.includes(sectionName)) errors.push(`Invalid changelog section "### ${sectionName}" (line ${i + 1}). Allowed: ${allowed.join(", ")}`);
656
+ const nextContentLine = lines.slice(i + 1).find((l) => l.trim().length > 0);
657
+ if (!nextContentLine || nextContentLine.startsWith("#")) errors.push(`Empty changelog section "### ${sectionName}" (line ${i + 1})`);
658
+ }
659
+ return errors;
660
+ }
661
+ /**
662
+ * Inserts a new version entry beneath the `[Unreleased]` section.
663
+ *
664
+ * @public
665
+ * @since 0.1.0
666
+ * @remarks
667
+ * If the changelog already contains the requested version, no changes are made.
668
+ * The inserted entry includes a starter `Added` subsection for follow-up edits.
669
+ *
670
+ * @param changelogPath - Path to the changelog file.
671
+ * @param version - Version to add.
672
+ * @param date - Release date to write in `YYYY-MM-DD` format.
673
+ * @example
674
+ * ```ts
675
+ * import { addVersionEntry } from 'versionguard';
676
+ *
677
+ * addVersionEntry('CHANGELOG.md', '1.2.0', '2026-03-21');
678
+ * ```
679
+ */
680
+ function addVersionEntry(changelogPath, version, date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)) {
681
+ if (!fs.existsSync(changelogPath)) throw new Error(`Changelog not found: ${changelogPath}`);
682
+ const content = fs.readFileSync(changelogPath, "utf-8");
683
+ if (content.includes(`## [${version}]`)) return;
684
+ const block = `## [${version}] - ${date}\n\n### Added\n\n- Describe changes here.\n\n`;
685
+ const unreleasedMatch = content.match(/## \[Unreleased\]\r?\n(?:\r?\n)?/);
686
+ if (!unreleasedMatch || unreleasedMatch.index === void 0) throw new Error("Changelog must have an [Unreleased] section");
687
+ const insertIndex = unreleasedMatch.index + unreleasedMatch[0].length;
688
+ const updated = `${content.slice(0, insertIndex)}${block}${content.slice(insertIndex)}`;
689
+ fs.writeFileSync(changelogPath, updated, "utf-8");
690
+ }
691
+ /**
692
+ * Detects whether a changelog has been mangled by Changesets.
693
+ *
694
+ * @remarks
695
+ * Changesets prepends version content above the Keep a Changelog preamble,
696
+ * producing `## 0.4.0` (no brackets, no date) before the "All notable changes"
697
+ * paragraph. This function detects that pattern.
698
+ *
699
+ * @param changelogPath - Path to the changelog file.
700
+ * @returns `true` when the changelog appears to be mangled by Changesets.
701
+ *
702
+ * @example
703
+ * ```ts
704
+ * import { isChangesetMangled } from 'versionguard';
705
+ *
706
+ * if (isChangesetMangled('CHANGELOG.md')) {
707
+ * fixChangesetMangling('CHANGELOG.md');
708
+ * }
709
+ * ```
710
+ *
711
+ * @public
712
+ * @since 0.4.0
713
+ */
714
+ function isChangesetMangled(changelogPath) {
715
+ if (!fs.existsSync(changelogPath)) return false;
716
+ const content = fs.readFileSync(changelogPath, "utf-8");
717
+ return /^## \d+\.\d+/m.test(content) && content.includes("## [Unreleased]");
718
+ }
719
+ /** Maps Changesets section names to Keep a Changelog section names. */
720
+ var SECTION_MAP = {
721
+ "Major Changes": "Changed",
722
+ "Minor Changes": "Added",
723
+ "Patch Changes": "Fixed"
724
+ };
725
+ /**
726
+ * Fixes a Changesets-mangled changelog into proper Keep a Changelog format.
727
+ *
728
+ * @remarks
729
+ * This function:
730
+ * 1. Extracts the version number and content prepended by Changesets
731
+ * 2. Converts Changesets section names (Minor Changes, Patch Changes) to
732
+ * Keep a Changelog names (Added, Fixed)
733
+ * 3. Strips commit hashes from entry lines
734
+ * 4. Adds the date and brackets to the version header
735
+ * 5. Inserts the entry after `## [Unreleased]` in the correct position
736
+ * 6. Restores the preamble to its proper location
737
+ *
738
+ * @param changelogPath - Path to the changelog file to fix.
739
+ * @param date - Release date in `YYYY-MM-DD` format.
740
+ * @returns `true` when the file was modified, `false` when no fix was needed.
741
+ *
742
+ * @example
743
+ * ```ts
744
+ * import { fixChangesetMangling } from 'versionguard';
745
+ *
746
+ * const fixed = fixChangesetMangling('CHANGELOG.md');
747
+ * ```
748
+ *
749
+ * @public
750
+ * @since 0.4.0
751
+ */
752
+ function fixChangesetMangling(changelogPath, date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)) {
753
+ if (!fs.existsSync(changelogPath)) return false;
754
+ const content = fs.readFileSync(changelogPath, "utf-8");
755
+ const versionMatch = content.match(/^## (\d+\.\d+\.\d+[^\n]*)\n/m);
756
+ if (!versionMatch || versionMatch.index === void 0) return false;
757
+ const fullHeader = versionMatch[0];
758
+ if (fullHeader.includes("[")) return false;
759
+ const version = versionMatch[1].trim();
760
+ if (content.includes(`## [${version}]`)) return false;
761
+ const startIndex = versionMatch.index;
762
+ const preambleMatch = content.indexOf("All notable changes", startIndex);
763
+ const unreleasedMatch = content.indexOf("## [Unreleased]", startIndex);
764
+ let endIndex;
765
+ if (preambleMatch !== -1 && preambleMatch < unreleasedMatch) endIndex = preambleMatch;
766
+ else if (unreleasedMatch !== -1) endIndex = unreleasedMatch;
767
+ else return false;
768
+ const newEntry = `## [${version}] - ${date}\n\n${transformChangesetsContent(content.slice(startIndex + fullHeader.length, endIndex).trim())}\n\n`;
769
+ const beforeChangesets = content.slice(0, startIndex);
770
+ const afterChangesets = content.slice(endIndex);
771
+ const unreleasedInAfter = afterChangesets.indexOf("## [Unreleased]");
772
+ if (unreleasedInAfter === -1) {
773
+ const rebuilt = `${beforeChangesets}${newEntry}${afterChangesets}`;
774
+ fs.writeFileSync(changelogPath, rebuilt, "utf-8");
775
+ return true;
776
+ }
777
+ const unreleasedLineEnd = afterChangesets.indexOf("\n", unreleasedInAfter);
778
+ const withLinks = updateCompareLinks(`${beforeChangesets}${unreleasedLineEnd !== -1 ? afterChangesets.slice(0, unreleasedLineEnd + 1) : afterChangesets}\n${newEntry}${unreleasedLineEnd !== -1 ? afterChangesets.slice(unreleasedLineEnd + 1) : ""}`, version);
779
+ fs.writeFileSync(changelogPath, withLinks, "utf-8");
780
+ return true;
781
+ }
782
+ /**
783
+ * Transforms Changesets-style content into Keep a Changelog sections.
784
+ *
785
+ * Converts "### Minor Changes" → "### Added", strips commit hashes, etc.
786
+ */
787
+ function transformChangesetsContent(block) {
788
+ const lines = block.split("\n");
789
+ const result = [];
790
+ for (const line of lines) {
791
+ const sectionMatch = line.match(/^### (.+)/);
792
+ if (sectionMatch) {
793
+ const mapped = SECTION_MAP[sectionMatch[1]] ?? sectionMatch[1];
794
+ result.push(`### ${mapped}`);
795
+ continue;
796
+ }
797
+ const entryMatch = line.match(/^(\s*-\s+)[a-f0-9]{7,}: (?:feat|fix|chore|docs|refactor|perf|test|ci|build|style)(?:\([^)]*\))?: (.+)/);
798
+ if (entryMatch) {
799
+ result.push(`${entryMatch[1]}${entryMatch[2]}`);
800
+ continue;
801
+ }
802
+ const simpleHashMatch = line.match(/^(\s*-\s+)[a-f0-9]{7,}: (.+)/);
803
+ if (simpleHashMatch) {
804
+ result.push(`${simpleHashMatch[1]}${simpleHashMatch[2]}`);
805
+ continue;
806
+ }
807
+ result.push(line);
808
+ }
809
+ return result.join("\n");
810
+ }
811
+ /**
812
+ * Updates the compare links section at the bottom of the changelog.
813
+ */
814
+ function updateCompareLinks(content, version) {
815
+ const unreleasedLinkRegex = /\[Unreleased\]: (https:\/\/[^\s]+\/compare\/v)([\d.]+)(\.\.\.HEAD)/;
816
+ const match = content.match(unreleasedLinkRegex);
817
+ if (match) {
818
+ const baseUrl = match[1].replace(/v$/, "");
819
+ const previousVersion = match[2];
820
+ const newUnreleasedLink = `[Unreleased]: ${baseUrl}v${version}...HEAD`;
821
+ const newVersionLink = `[${version}]: ${baseUrl}v${previousVersion}...v${version}`;
822
+ let updated = content.replace(unreleasedLinkRegex, newUnreleasedLink);
823
+ if (!updated.includes(`[${version}]:`)) updated = updated.replace(newUnreleasedLink, `${newUnreleasedLink}\n${newVersionLink}`);
824
+ return updated;
825
+ }
826
+ return content;
827
+ }
828
+ function escapeRegExp$1(value) {
829
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
830
+ }
831
+ //#endregion
832
+ //#region src/github/dependabot.ts
833
+ /**
834
+ * Dependabot configuration generation from detected project manifests.
835
+ *
836
+ * @packageDocumentation
837
+ */
838
+ /**
839
+ * Maps VersionGuard manifest source types to Dependabot package-ecosystem values.
840
+ *
841
+ * @remarks
842
+ * Returns `null` for sources that have no Dependabot equivalent (VERSION files,
843
+ * git tags, custom regex). The `auto` source is resolved at detection time,
844
+ * not mapped directly.
845
+ *
846
+ * @public
847
+ * @since 0.9.0
848
+ */
849
+ var MANIFEST_TO_ECOSYSTEM = {
850
+ auto: null,
851
+ "package.json": "npm",
852
+ "composer.json": "composer",
853
+ "Cargo.toml": "cargo",
854
+ "pyproject.toml": "pip",
855
+ "pubspec.yaml": "pub",
856
+ "pom.xml": "maven",
857
+ VERSION: null,
858
+ "git-tag": null,
859
+ custom: null
860
+ };
861
+ /**
862
+ * Generates Dependabot YAML configuration from detected manifests.
863
+ *
864
+ * @remarks
865
+ * Each detected manifest is mapped to its Dependabot ecosystem. A
866
+ * `github-actions` entry is always appended since any GitHub-hosted
867
+ * project benefits from action version updates.
868
+ *
869
+ * @param manifests - Detected manifest source types from the project.
870
+ * @returns The Dependabot configuration as a YAML string.
871
+ *
872
+ * @example
873
+ * ```ts
874
+ * import { generateDependabotConfig } from 'versionguard';
875
+ *
876
+ * const config = generateDependabotConfig(['package.json', 'Cargo.toml']);
877
+ * ```
878
+ *
879
+ * @public
880
+ * @since 0.9.0
881
+ */
882
+ function generateDependabotConfig(manifests) {
883
+ const ecosystems = /* @__PURE__ */ new Set();
884
+ for (const manifest of manifests) {
885
+ const ecosystem = MANIFEST_TO_ECOSYSTEM[manifest];
886
+ if (ecosystem) ecosystems.add(ecosystem);
887
+ }
888
+ const updates = [];
889
+ for (const ecosystem of ecosystems) updates.push({
890
+ "package-ecosystem": ecosystem,
891
+ directory: "/",
892
+ schedule: { interval: "weekly" },
893
+ groups: { "minor-and-patch": { "update-types": ["minor", "patch"] } }
894
+ });
895
+ updates.push({
896
+ "package-ecosystem": "github-actions",
897
+ directory: "/",
898
+ schedule: { interval: "weekly" }
899
+ });
900
+ return yaml.dump({
901
+ version: 2,
902
+ updates
903
+ }, {
904
+ indent: 2,
905
+ lineWidth: 120,
906
+ noRefs: true,
907
+ quotingType: "\"",
908
+ forceQuotes: false
909
+ });
910
+ }
911
+ /**
912
+ * Writes a Dependabot configuration file to `.github/dependabot.yml`.
913
+ *
914
+ * @param cwd - Project directory.
915
+ * @param content - YAML content to write.
916
+ * @returns The absolute path to the created file.
917
+ *
918
+ * @public
919
+ * @since 0.9.0
920
+ */
921
+ function writeDependabotConfig(cwd, content) {
922
+ const dir = path.join(cwd, ".github");
923
+ fs.mkdirSync(dir, { recursive: true });
924
+ const filePath = path.join(dir, "dependabot.yml");
925
+ fs.writeFileSync(filePath, content, "utf-8");
926
+ return filePath;
927
+ }
928
+ /**
929
+ * Checks whether `.github/dependabot.yml` exists in the project.
930
+ *
931
+ * @param cwd - Project directory.
932
+ * @returns `true` when the file exists.
933
+ *
934
+ * @public
935
+ * @since 0.9.0
936
+ */
937
+ function dependabotConfigExists(cwd) {
938
+ return fs.existsSync(path.join(cwd, ".github", "dependabot.yml"));
939
+ }
940
+ //#endregion
941
+ //#region src/hooks.ts
942
+ var HOOK_NAMES$1 = [
943
+ "pre-commit",
944
+ "pre-push",
945
+ "post-tag"
946
+ ];
947
+ /** Markers that delimit the VG block within a composite hook script. */
948
+ var VG_BLOCK_START = "# >>> versionguard >>>";
949
+ var VG_BLOCK_END = "# <<< versionguard <<<";
950
+ /**
951
+ * Installs VersionGuard-managed Git hooks in a repository.
952
+ *
953
+ * @public
954
+ * @since 0.1.0
955
+ * @remarks
956
+ * When a hook file already exists from another tool (Husky, lefthook, etc.),
957
+ * VersionGuard **appends** its validation block instead of overwriting.
958
+ * The block is delimited by markers so it can be cleanly removed later.
959
+ *
960
+ * If the hook already contains a VersionGuard block, it is replaced in-place
961
+ * (idempotent).
962
+ *
963
+ * @param config - Git configuration that selects which hooks to install.
964
+ * @param cwd - Repository directory where hooks should be installed.
965
+ * @example
966
+ * ```ts
967
+ * import { getDefaultConfig, installHooks } from 'versionguard';
968
+ *
969
+ * installHooks(getDefaultConfig().git, process.cwd());
970
+ * ```
971
+ */
972
+ function installHooks(config, cwd = process.cwd()) {
973
+ const gitDir = findGitDir(cwd);
974
+ if (!gitDir) throw new Error("Not a git repository. Run `git init` first.");
975
+ const hooksDir = path.join(gitDir, "hooks");
976
+ fs.mkdirSync(hooksDir, { recursive: true });
977
+ for (const hookName of HOOK_NAMES$1) if (config.hooks[hookName]) {
978
+ const hookPath = path.join(hooksDir, hookName);
979
+ const vgBlock = generateHookBlock(hookName);
980
+ if (fs.existsSync(hookPath)) {
981
+ const existing = fs.readFileSync(hookPath, "utf-8");
982
+ if (existing.includes(VG_BLOCK_START)) {
983
+ const updated = replaceVgBlock(existing, vgBlock);
984
+ fs.writeFileSync(hookPath, updated, {
985
+ encoding: "utf-8",
986
+ mode: 493
987
+ });
988
+ } else if (isLegacyVgHook(existing)) fs.writeFileSync(hookPath, `#!/bin/sh\n\n${vgBlock}\n`, {
989
+ encoding: "utf-8",
990
+ mode: 493
991
+ });
992
+ else {
993
+ const appended = `${existing.trimEnd()}\n\n${vgBlock}\n`;
994
+ fs.writeFileSync(hookPath, appended, {
995
+ encoding: "utf-8",
996
+ mode: 493
997
+ });
998
+ }
999
+ } else fs.writeFileSync(hookPath, `#!/bin/sh\n\n${vgBlock}\n`, {
1000
+ encoding: "utf-8",
1001
+ mode: 493
1002
+ });
1003
+ }
1004
+ }
1005
+ /**
1006
+ * Removes VersionGuard-managed Git hooks from a repository.
1007
+ *
1008
+ * @public
1009
+ * @since 0.1.0
1010
+ * @remarks
1011
+ * Only the VersionGuard block (delimited by markers) is removed.
1012
+ * Other hook content from Husky, lefthook, etc. is preserved.
1013
+ * If the hook becomes empty after removal, the file is deleted.
1014
+ *
1015
+ * @param cwd - Repository directory whose hooks should be cleaned up.
1016
+ * @example
1017
+ * ```ts
1018
+ * import { uninstallHooks } from 'versionguard';
1019
+ *
1020
+ * uninstallHooks(process.cwd());
1021
+ * ```
1022
+ */
1023
+ function uninstallHooks(cwd = process.cwd()) {
1024
+ const gitDir = findGitDir(cwd);
1025
+ if (!gitDir) return;
1026
+ const hooksDir = path.join(gitDir, "hooks");
1027
+ for (const hookName of HOOK_NAMES$1) {
1028
+ const hookPath = path.join(hooksDir, hookName);
1029
+ if (!fs.existsSync(hookPath)) continue;
1030
+ const content = fs.readFileSync(hookPath, "utf-8");
1031
+ if (!content.includes("versionguard")) continue;
1032
+ if (content.includes(VG_BLOCK_START)) {
1033
+ const trimmed = removeVgBlock(content).trim();
1034
+ if (!trimmed || trimmed === "#!/bin/sh") fs.unlinkSync(hookPath);
1035
+ else if (isLegacyVgHook(trimmed)) fs.unlinkSync(hookPath);
1036
+ else fs.writeFileSync(hookPath, `${trimmed}\n`, {
1037
+ encoding: "utf-8",
1038
+ mode: 493
1039
+ });
1040
+ } else if (isLegacyVgHook(content)) fs.unlinkSync(hookPath);
1041
+ }
1042
+ }
1043
+ /**
1044
+ * Finds the nearest `.git` directory by walking up from a starting directory.
1045
+ *
1046
+ * @public
1047
+ * @since 0.1.0
1048
+ * @remarks
1049
+ * This only resolves `.git` directories and returns `null` when the search
1050
+ * reaches the filesystem root without finding a repository.
1051
+ *
1052
+ * @param cwd - Directory to start searching from.
1053
+ * @returns The resolved `.git` directory path, or `null` when none is found.
1054
+ * @example
1055
+ * ```ts
1056
+ * import { findGitDir } from 'versionguard';
1057
+ *
1058
+ * const gitDir = findGitDir(process.cwd());
1059
+ * ```
1060
+ */
1061
+ function findGitDir(cwd) {
1062
+ let current = cwd;
1063
+ while (true) {
1064
+ const gitPath = path.join(current, ".git");
1065
+ if (fs.existsSync(gitPath) && fs.statSync(gitPath).isDirectory()) return gitPath;
1066
+ const parent = path.dirname(current);
1067
+ if (parent === current) return null;
1068
+ current = parent;
1069
+ }
1070
+ }
1071
+ /**
1072
+ * Checks whether all VersionGuard-managed hooks are installed.
1073
+ *
1074
+ * @public
1075
+ * @since 0.1.0
1076
+ * @remarks
1077
+ * A hook counts as installed when the file exists and contains the
1078
+ * `versionguard` invocation — either as a standalone hook or appended
1079
+ * to an existing hook from another tool.
1080
+ *
1081
+ * @param cwd - Repository directory to inspect.
1082
+ * @returns `true` when every managed hook is installed.
1083
+ * @example
1084
+ * ```ts
1085
+ * import { areHooksInstalled } from 'versionguard';
1086
+ *
1087
+ * const installed = areHooksInstalled(process.cwd());
1088
+ * ```
1089
+ */
1090
+ function areHooksInstalled(cwd = process.cwd()) {
1091
+ const gitDir = findGitDir(cwd);
1092
+ if (!gitDir) return false;
1093
+ return HOOK_NAMES$1.every((hookName) => {
1094
+ const hookPath = path.join(gitDir, "hooks", hookName);
1095
+ return fs.existsSync(hookPath) && fs.readFileSync(hookPath, "utf-8").includes("versionguard");
1096
+ });
1097
+ }
1098
+ /**
1099
+ * Generates the delimited VG block for a Git hook.
1100
+ *
1101
+ * @param hookName - Name of the Git hook to generate.
1102
+ * @returns The VG block with start/end markers.
1103
+ */
1104
+ function generateHookBlock(hookName) {
1105
+ return `${VG_BLOCK_START}
1106
+ # VersionGuard ${hookName} hook
1107
+ # --no-install prevents accidentally downloading an unscoped package
1108
+ # if @codluv/versionguard is not installed locally
1109
+ npx --no-install versionguard validate --hook=${hookName}
1110
+ status=$?
1111
+ if [ $status -ne 0 ]; then
1112
+ echo "VersionGuard validation failed."
1113
+ exit $status
1114
+ fi
1115
+ ${VG_BLOCK_END}`;
1116
+ }
1117
+ /**
1118
+ * Generates the shell script content for a Git hook.
1119
+ *
1120
+ * @public
1121
+ * @since 0.1.0
1122
+ * @remarks
1123
+ * The generated script delegates to `npx versionguard validate` and exits with
1124
+ * the validation status code. Uses delimited block markers for cooperative
1125
+ * installation with other hook tools.
1126
+ *
1127
+ * @param hookName - Name of the Git hook to generate.
1128
+ * @returns Executable shell script contents for the hook.
1129
+ * @example
1130
+ * ```ts
1131
+ * import { generateHookScript } from 'versionguard';
1132
+ *
1133
+ * const script = generateHookScript('pre-commit');
1134
+ * ```
1135
+ */
1136
+ function generateHookScript(hookName) {
1137
+ return `#!/bin/sh\n\n${generateHookBlock(hookName)}\n`;
1138
+ }
1139
+ /** Detects a legacy VG hook (pre-marker format) that has no other tool content. */
1140
+ function isLegacyVgHook(content) {
1141
+ if (!content.includes("versionguard validate")) return false;
1142
+ if (content.includes(VG_BLOCK_START)) return false;
1143
+ if (content.includes("husky")) return false;
1144
+ if (content.includes("lefthook")) return false;
1145
+ if (content.includes("pre-commit run")) return false;
1146
+ return true;
1147
+ }
1148
+ /** Replaces an existing VG block within a hook script. */
1149
+ function replaceVgBlock(content, newBlock) {
1150
+ const startIdx = content.indexOf(VG_BLOCK_START);
1151
+ const endIdx = content.indexOf(VG_BLOCK_END);
1152
+ if (startIdx === -1 || endIdx === -1) return content;
1153
+ return content.slice(0, startIdx) + newBlock + content.slice(endIdx + 22);
1154
+ }
1155
+ /** Removes the VG block from a hook script. */
1156
+ function removeVgBlock(content) {
1157
+ const startIdx = content.indexOf(VG_BLOCK_START);
1158
+ const endIdx = content.indexOf(VG_BLOCK_END);
1159
+ if (startIdx === -1 || endIdx === -1) return content;
1160
+ return content.slice(0, startIdx).replace(/\n\n$/, "\n") + content.slice(endIdx + 22).replace(/^\n\n/, "\n");
1161
+ }
1162
+ //#endregion
1163
+ //#region src/sources/git-tag.ts
1164
+ /**
1165
+ * Git tag-based version source for Go, Swift, and similar ecosystems.
1166
+ *
1167
+ * @packageDocumentation
1168
+ */
1169
+ /**
1170
+ * Reads version from the latest Git tag. Writing creates a new annotated tag.
1171
+ *
1172
+ * @remarks
1173
+ * This provider is used for languages where the version is determined
1174
+ * entirely by Git tags (Go, Swift, PHP/Packagist).
1175
+ *
1176
+ * The tag prefix (`v` by default) is auto-detected from existing tags
1177
+ * when writing, so projects using unprefixed tags (e.g. `1.0.0`) stay
1178
+ * consistent.
1179
+ *
1180
+ * @public
1181
+ * @since 0.3.0
1182
+ */
1183
+ var GitTagSource = class {
1184
+ /** Human-readable provider name. */
1185
+ name = "git-tag";
1186
+ /** Empty string since git-tag has no manifest file. */
1187
+ manifestFile = "";
1188
+ /**
1189
+ * Returns `true` when `cwd` is inside a Git repository.
1190
+ *
1191
+ * @param cwd - Project directory to check.
1192
+ * @returns Whether a Git repository is found.
1193
+ */
1194
+ exists(cwd) {
1195
+ try {
1196
+ execFileSync("git", ["rev-parse", "--git-dir"], {
1197
+ cwd,
1198
+ stdio: [
1199
+ "pipe",
1200
+ "pipe",
1201
+ "ignore"
1202
+ ]
1203
+ });
1204
+ return true;
1205
+ } catch {
1206
+ return false;
1207
+ }
1208
+ }
1209
+ /**
1210
+ * Reads the version string from the latest Git tag.
1211
+ *
1212
+ * @param cwd - Project directory containing the Git repository.
1213
+ * @returns The version string extracted from the latest version tag.
1214
+ */
1215
+ getVersion(cwd) {
1216
+ try {
1217
+ return this.describeVersionTag(cwd).replace(/^v/, "");
1218
+ } catch {
1219
+ throw new Error("No version tags found. Create a tag first (e.g., git tag v0.1.0)");
1220
+ }
1221
+ }
1222
+ /**
1223
+ * Creates a new annotated Git tag for the given version.
1224
+ *
1225
+ * @param version - Version string to tag.
1226
+ * @param cwd - Project directory containing the Git repository.
1227
+ */
1228
+ setVersion(version, cwd) {
1229
+ execFileSync("git", [
1230
+ "tag",
1231
+ "-a",
1232
+ `${this.detectPrefix(cwd)}${version}`,
1233
+ "-m",
1234
+ `Release ${version}`
1235
+ ], {
1236
+ cwd,
1237
+ stdio: [
1238
+ "pipe",
1239
+ "pipe",
1240
+ "ignore"
1241
+ ]
1242
+ });
1243
+ }
1244
+ /** Try version-like tag patterns, fall back to any tag. */
1245
+ describeVersionTag(cwd) {
1246
+ try {
1247
+ return execFileSync("git", [
1248
+ "describe",
1249
+ "--tags",
1250
+ "--abbrev=0",
1251
+ "--match",
1252
+ "v[0-9]*"
1253
+ ], {
1254
+ cwd,
1255
+ encoding: "utf-8",
1256
+ stdio: [
1257
+ "pipe",
1258
+ "pipe",
1259
+ "ignore"
1260
+ ]
1261
+ }).trim();
1262
+ } catch {}
1263
+ try {
1264
+ return execFileSync("git", [
1265
+ "describe",
1266
+ "--tags",
1267
+ "--abbrev=0",
1268
+ "--match",
1269
+ "[0-9]*"
1270
+ ], {
1271
+ cwd,
1272
+ encoding: "utf-8",
1273
+ stdio: [
1274
+ "pipe",
1275
+ "pipe",
1276
+ "ignore"
1277
+ ]
1278
+ }).trim();
1279
+ } catch {
1280
+ throw new Error("No version tags found");
1281
+ }
1282
+ }
1283
+ /** Detect whether existing tags use a `v` prefix or not. */
1284
+ detectPrefix(cwd) {
1285
+ try {
1286
+ return this.describeVersionTag(cwd).startsWith("v") ? "v" : "";
1287
+ } catch {
1288
+ return "v";
1289
+ }
1290
+ }
1291
+ };
1292
+ //#endregion
1293
+ //#region src/sources/utils.ts
1294
+ /**
1295
+ * Shared utilities for version source providers.
1296
+ *
1297
+ * @packageDocumentation
1298
+ */
1299
+ /**
1300
+ * Traverses a nested object using a dotted key path.
1301
+ *
1302
+ * @remarks
1303
+ * Walks each segment of the dotted path in order, returning `undefined` as
1304
+ * soon as a missing or non-object segment is encountered.
1305
+ *
1306
+ * @param obj - Object to traverse.
1307
+ * @param dotPath - Dot-separated key path (e.g. `'package.version'`).
1308
+ * @returns The value at the path, or `undefined` if any segment is missing.
1309
+ *
1310
+ * @example
1311
+ * ```ts
1312
+ * import { getNestedValue } from './utils';
1313
+ *
1314
+ * const obj = { package: { version: '1.0.0' } };
1315
+ * const version = getNestedValue(obj, 'package.version'); // '1.0.0'
1316
+ * ```
1317
+ *
1318
+ * @public
1319
+ * @since 0.3.0
1320
+ */
1321
+ function getNestedValue(obj, dotPath) {
1322
+ let current = obj;
1323
+ for (const key of dotPath.split(".")) {
1324
+ if (current === null || typeof current !== "object") return;
1325
+ current = current[key];
1326
+ }
1327
+ return current;
1328
+ }
1329
+ /**
1330
+ * Sets a value at a dotted key path, throwing if intermediate segments are missing.
1331
+ *
1332
+ * @remarks
1333
+ * Traverses each intermediate segment and throws when a segment is missing or
1334
+ * not an object. The final key is created or overwritten.
1335
+ *
1336
+ * @param obj - Object to mutate.
1337
+ * @param dotPath - Dot-separated key path.
1338
+ * @param value - Value to set at the final key.
1339
+ *
1340
+ * @example
1341
+ * ```ts
1342
+ * import { setNestedValue } from './utils';
1343
+ *
1344
+ * const obj = { package: { version: '1.0.0' } };
1345
+ * setNestedValue(obj, 'package.version', '2.0.0');
1346
+ * ```
1347
+ *
1348
+ * @public
1349
+ * @since 0.3.0
1350
+ */
1351
+ function setNestedValue(obj, dotPath, value) {
1352
+ const keys = dotPath.split(".");
1353
+ let current = obj;
1354
+ for (let i = 0; i < keys.length - 1; i++) {
1355
+ const next = current[keys[i]];
1356
+ if (typeof next !== "object" || next === null) throw new Error(`Missing intermediate key '${keys.slice(0, i + 1).join(".")}' in manifest`);
1357
+ current = next;
1358
+ }
1359
+ current[keys[keys.length - 1]] = value;
1360
+ }
1361
+ /**
1362
+ * Escapes special regex characters in a string for safe use in `new RegExp()`.
1363
+ *
1364
+ * @remarks
1365
+ * Prefixes every character that has special meaning in a regular expression
1366
+ * with a backslash so the resulting string matches literally.
1367
+ *
1368
+ * @param value - Raw string to escape.
1369
+ * @returns The escaped string safe for embedding in a `RegExp` constructor.
1370
+ *
1371
+ * @example
1372
+ * ```ts
1373
+ * import { escapeRegExp } from './utils';
1374
+ *
1375
+ * const escaped = escapeRegExp('file.txt'); // 'file\\.txt'
1376
+ * ```
1377
+ *
1378
+ * @public
1379
+ * @since 0.3.0
1380
+ */
1381
+ function escapeRegExp(value) {
1382
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1383
+ }
1384
+ //#endregion
1385
+ //#region src/sources/json.ts
1386
+ /**
1387
+ * JSON-based version source provider for package.json and composer.json.
1388
+ *
1389
+ * @packageDocumentation
1390
+ */
1391
+ /**
1392
+ * Reads and writes version strings from JSON manifest files.
1393
+ *
1394
+ * @remarks
1395
+ * Supports dotted key paths for nested version fields and preserves the
1396
+ * original indentation style when writing back to disk.
1397
+ *
1398
+ * @public
1399
+ * @since 0.3.0
1400
+ */
1401
+ var JsonVersionSource = class {
1402
+ /** Human-readable provider name. */
1403
+ name;
1404
+ /** Filename of the JSON manifest (e.g. `'package.json'`). */
1405
+ manifestFile;
1406
+ /** Dotted key path to the version field within the JSON document. */
1407
+ versionPath;
1408
+ /**
1409
+ * Creates a new JSON version source.
1410
+ *
1411
+ * @param manifestFile - JSON manifest filename.
1412
+ * @param versionPath - Dotted key path to the version field.
1413
+ */
1414
+ constructor(manifestFile = "package.json", versionPath = "version") {
1415
+ this.name = manifestFile;
1416
+ this.manifestFile = manifestFile;
1417
+ this.versionPath = versionPath;
1418
+ }
1419
+ /**
1420
+ * Returns `true` when the manifest file exists in `cwd`.
1421
+ *
1422
+ * @param cwd - Project directory to check.
1423
+ * @returns Whether the manifest file exists.
1424
+ */
1425
+ exists(cwd) {
1426
+ return fs.existsSync(path.join(cwd, this.manifestFile));
1427
+ }
1428
+ /**
1429
+ * Reads the version string from the JSON manifest.
1430
+ *
1431
+ * @param cwd - Project directory containing the manifest.
1432
+ * @returns The version string extracted from the manifest.
1433
+ */
1434
+ getVersion(cwd) {
1435
+ const filePath = path.join(cwd, this.manifestFile);
1436
+ if (!fs.existsSync(filePath)) throw new Error(`${this.manifestFile} not found in ${cwd}`);
1437
+ const version = getNestedValue(JSON.parse(fs.readFileSync(filePath, "utf-8")), this.versionPath);
1438
+ if (typeof version !== "string" || version.length === 0) throw new Error(`No version field in ${this.manifestFile}`);
1439
+ return version;
1440
+ }
1441
+ /**
1442
+ * Writes a version string to the JSON manifest, preserving indentation.
1443
+ *
1444
+ * @param version - Version string to write.
1445
+ * @param cwd - Project directory containing the manifest.
1446
+ */
1447
+ setVersion(version, cwd) {
1448
+ const filePath = path.join(cwd, this.manifestFile);
1449
+ if (!fs.existsSync(filePath)) throw new Error(`${this.manifestFile} not found in ${cwd}`);
1450
+ const raw = fs.readFileSync(filePath, "utf-8");
1451
+ const indent = raw.match(/^(\s+)"/m)?.[1]?.length ?? 2;
1452
+ const content = JSON.parse(raw);
1453
+ setNestedValue(content, this.versionPath, version);
1454
+ fs.writeFileSync(filePath, `${JSON.stringify(content, null, indent)}\n`, "utf-8");
1455
+ }
1456
+ };
1457
+ //#endregion
1458
+ //#region src/sources/regex.ts
1459
+ /**
1460
+ * Regex-based version source for source-code manifests.
1461
+ *
1462
+ * Handles gemspec, mix.exs, setup.py, build.gradle, etc.
1463
+ *
1464
+ * @packageDocumentation
1465
+ */
1466
+ /**
1467
+ * Reads and writes version strings using regex extraction from source files.
1468
+ *
1469
+ * @remarks
1470
+ * Capture group 1 of the provided regex must match the version string.
1471
+ * Uses position-based replacement to avoid wrong-match corruption when
1472
+ * writing back to disk.
1473
+ *
1474
+ * @public
1475
+ * @since 0.3.0
1476
+ */
1477
+ var RegexVersionSource = class {
1478
+ /** Human-readable provider name. */
1479
+ name;
1480
+ /** Filename of the source manifest (e.g. `'setup.py'`). */
1481
+ manifestFile;
1482
+ /** Compiled regex used to locate the version string. */
1483
+ versionRegex;
1484
+ /**
1485
+ * Creates a new regex version source.
1486
+ *
1487
+ * @param manifestFile - Source manifest filename.
1488
+ * @param versionRegex - Regex string with at least one capture group for the version.
1489
+ */
1490
+ constructor(manifestFile, versionRegex) {
1491
+ this.name = manifestFile;
1492
+ this.manifestFile = manifestFile;
1493
+ try {
1494
+ this.versionRegex = new RegExp(versionRegex, "m");
1495
+ } catch (err) {
1496
+ throw new Error(`Invalid version regex for ${manifestFile}: ${err.message}`);
1497
+ }
1498
+ if (!/\((?!\?)/.test(versionRegex)) throw new Error(`Version regex for ${manifestFile} must contain at least one capture group`);
1499
+ }
1500
+ /**
1501
+ * Returns `true` when the manifest file exists in `cwd`.
1502
+ *
1503
+ * @param cwd - Project directory to check.
1504
+ * @returns Whether the manifest file exists.
1505
+ */
1506
+ exists(cwd) {
1507
+ return fs.existsSync(path.join(cwd, this.manifestFile));
1508
+ }
1509
+ /**
1510
+ * Reads the version string from the source manifest using regex extraction.
1511
+ *
1512
+ * @param cwd - Project directory containing the manifest.
1513
+ * @returns The version string captured by group 1 of the regex.
1514
+ */
1515
+ getVersion(cwd) {
1516
+ const filePath = path.join(cwd, this.manifestFile);
1517
+ if (!fs.existsSync(filePath)) throw new Error(`${this.manifestFile} not found in ${cwd}`);
1518
+ const match = fs.readFileSync(filePath, "utf-8").match(this.versionRegex);
1519
+ if (!match?.[1]) throw new Error(`No version match found in ${this.manifestFile}`);
1520
+ return match[1];
1521
+ }
1522
+ /**
1523
+ * Writes a version string to the source manifest using position-based replacement.
1524
+ *
1525
+ * @param version - Version string to write.
1526
+ * @param cwd - Project directory containing the manifest.
1527
+ */
1528
+ setVersion(version, cwd) {
1529
+ const filePath = path.join(cwd, this.manifestFile);
1530
+ if (!fs.existsSync(filePath)) throw new Error(`${this.manifestFile} not found in ${cwd}`);
1531
+ const content = fs.readFileSync(filePath, "utf-8");
1532
+ const match = this.versionRegex.exec(content);
1533
+ if (!match || match.index === void 0) throw new Error(`No version match found in ${this.manifestFile}`);
1534
+ const captureStart = match.index + match[0].indexOf(match[1]);
1535
+ const captureEnd = captureStart + match[1].length;
1536
+ const updated = content.slice(0, captureStart) + version + content.slice(captureEnd);
1537
+ fs.writeFileSync(filePath, updated, "utf-8");
1538
+ }
1539
+ };
1540
+ //#endregion
1541
+ //#region src/sources/toml.ts
1542
+ /**
1543
+ * TOML-based version source provider for Cargo.toml and pyproject.toml.
1544
+ *
1545
+ * @packageDocumentation
1546
+ */
1547
+ /**
1548
+ * Reads and writes version strings from TOML manifest files.
1549
+ *
1550
+ * @remarks
1551
+ * Uses targeted regex replacement for writes to preserve file formatting,
1552
+ * comments, and whitespace. Supports standard section headers, dotted keys,
1553
+ * and inline table syntax.
1554
+ *
1555
+ * @public
1556
+ * @since 0.3.0
1557
+ */
1558
+ var TomlVersionSource = class {
1559
+ /** Human-readable provider name. */
1560
+ name;
1561
+ /** Filename of the TOML manifest (e.g. `'Cargo.toml'`). */
1562
+ manifestFile;
1563
+ /** Dotted key path to the version field within the TOML document. */
1564
+ versionPath;
1565
+ /**
1566
+ * Creates a new TOML version source.
1567
+ *
1568
+ * @param manifestFile - TOML manifest filename.
1569
+ * @param versionPath - Dotted key path to the version field.
1570
+ */
1571
+ constructor(manifestFile = "Cargo.toml", versionPath = "package.version") {
1572
+ this.name = manifestFile;
1573
+ this.manifestFile = manifestFile;
1574
+ this.versionPath = versionPath;
1575
+ }
1576
+ /**
1577
+ * Returns `true` when the manifest file exists in `cwd`.
1578
+ *
1579
+ * @param cwd - Project directory to check.
1580
+ * @returns Whether the manifest file exists.
1581
+ */
1582
+ exists(cwd) {
1583
+ return fs.existsSync(path.join(cwd, this.manifestFile));
1584
+ }
1585
+ /**
1586
+ * Reads the version string from the TOML manifest.
1587
+ *
1588
+ * @param cwd - Project directory containing the manifest.
1589
+ * @returns The version string extracted from the manifest.
1590
+ */
1591
+ getVersion(cwd) {
1592
+ const filePath = path.join(cwd, this.manifestFile);
1593
+ if (!fs.existsSync(filePath)) throw new Error(`${this.manifestFile} not found in ${cwd}`);
1594
+ const version = getNestedValue(parse(fs.readFileSync(filePath, "utf-8")), this.versionPath);
1595
+ if (typeof version !== "string" || version.length === 0) throw new Error(`No version field at '${this.versionPath}' in ${this.manifestFile}`);
1596
+ return version;
1597
+ }
1598
+ /**
1599
+ * Writes a version string to the TOML manifest, preserving formatting.
1600
+ *
1601
+ * @param version - Version string to write.
1602
+ * @param cwd - Project directory containing the manifest.
1603
+ */
1604
+ setVersion(version, cwd) {
1605
+ const filePath = path.join(cwd, this.manifestFile);
1606
+ if (!fs.existsSync(filePath)) throw new Error(`${this.manifestFile} not found in ${cwd}`);
1607
+ const content = fs.readFileSync(filePath, "utf-8");
1608
+ const updated = replaceTomlVersion(content, this.getSectionKey(), version);
1609
+ if (updated === content) throw new Error(`Could not find version field to update in ${this.manifestFile}`);
1610
+ fs.writeFileSync(filePath, updated, "utf-8");
1611
+ }
1612
+ /**
1613
+ * Splits the dotted version path into a TOML section name and key name.
1614
+ *
1615
+ * @returns An object with `section` and `key` components.
1616
+ */
1617
+ getSectionKey() {
1618
+ const parts = this.versionPath.split(".");
1619
+ if (parts.length === 1) return {
1620
+ section: "",
1621
+ key: parts[0]
1622
+ };
1623
+ return {
1624
+ section: parts.slice(0, -1).join("."),
1625
+ key: parts[parts.length - 1]
1626
+ };
1627
+ }
1628
+ };
1629
+ /**
1630
+ * Replace a version value within a TOML file, preserving formatting.
1631
+ *
1632
+ * Tries three patterns in order:
1633
+ * 1. [section] header + key = "value" line (standard)
1634
+ * 2. Dotted key syntax: section.key = "value" (M-004)
1635
+ * 3. Inline table: section = { ..., key = "value", ... } (M-010)
1636
+ */
1637
+ function replaceTomlVersion(content, target, newVersion) {
1638
+ const result = replaceInSection(content, target, newVersion);
1639
+ if (result !== content) return result;
1640
+ if (target.section) {
1641
+ const dottedRegex = new RegExp(`^(\\s*${escapeRegExp(target.section)}\\.${escapeRegExp(target.key)}\\s*=\\s*)(["'])([^"']*)(\\2)`, "m");
1642
+ const dottedResult = content.replace(dottedRegex, `$1$2${newVersion}$4`);
1643
+ if (dottedResult !== content) return dottedResult;
1644
+ }
1645
+ if (target.section) {
1646
+ const inlineRegex = new RegExp(`^(\\s*${escapeRegExp(target.section)}\\s*=\\s*\\{[^}]*${escapeRegExp(target.key)}\\s*=\\s*)(["'])([^"']*)(\\2)`, "m");
1647
+ const inlineResult = content.replace(inlineRegex, `$1$2${newVersion}$4`);
1648
+ if (inlineResult !== content) return inlineResult;
1649
+ }
1650
+ return content;
1651
+ }
1652
+ /** Standard section-header-based replacement. */
1653
+ function replaceInSection(content, target, newVersion) {
1654
+ const lines = content.split("\n");
1655
+ const sectionHeader = target.section ? `[${target.section}]` : null;
1656
+ let inSection = sectionHeader === null;
1657
+ const versionRegex = new RegExp(`^(\\s*${escapeRegExp(target.key)}\\s*=\\s*)(["'])([^"']*)(\\2)`);
1658
+ for (let i = 0; i < lines.length; i++) {
1659
+ const trimmed = lines[i].trim();
1660
+ if (sectionHeader !== null) {
1661
+ if (trimmed === sectionHeader) {
1662
+ inSection = true;
1663
+ continue;
1664
+ }
1665
+ if (inSection && trimmed.startsWith("[") && trimmed !== sectionHeader) {
1666
+ inSection = false;
1667
+ continue;
1668
+ }
1669
+ }
1670
+ if (inSection) {
1671
+ if (lines[i].match(versionRegex)) {
1672
+ lines[i] = lines[i].replace(versionRegex, `$1$2${newVersion}$4`);
1673
+ return lines.join("\n");
1674
+ }
1675
+ }
1676
+ }
1677
+ return content;
1678
+ }
1679
+ //#endregion
1680
+ //#region src/sources/version-file.ts
1681
+ /**
1682
+ * Plain text VERSION file provider.
1683
+ *
1684
+ * @packageDocumentation
1685
+ */
1686
+ /**
1687
+ * Reads and writes version strings from a plain text VERSION file.
1688
+ *
1689
+ * @remarks
1690
+ * The file is expected to contain only the version string, optionally
1691
+ * followed by a trailing newline. Binary files and empty files are
1692
+ * rejected with a descriptive error.
1693
+ *
1694
+ * @public
1695
+ * @since 0.3.0
1696
+ */
1697
+ var VersionFileSource = class {
1698
+ /** Human-readable provider name. */
1699
+ name;
1700
+ /** Filename of the version file (e.g. `'VERSION'`). */
1701
+ manifestFile;
1702
+ /**
1703
+ * Creates a new plain text version file source.
1704
+ *
1705
+ * @param manifestFile - Version filename.
1706
+ */
1707
+ constructor(manifestFile = "VERSION") {
1708
+ this.name = manifestFile;
1709
+ this.manifestFile = manifestFile;
1710
+ }
1711
+ /**
1712
+ * Returns `true` when the version file exists in `cwd`.
1713
+ *
1714
+ * @param cwd - Project directory to check.
1715
+ * @returns Whether the version file exists.
1716
+ */
1717
+ exists(cwd) {
1718
+ return fs.existsSync(path.join(cwd, this.manifestFile));
1719
+ }
1720
+ /**
1721
+ * Reads the version string from the plain text version file.
1722
+ *
1723
+ * @param cwd - Project directory containing the version file.
1724
+ * @returns The version string from the first line of the file.
1725
+ */
1726
+ getVersion(cwd) {
1727
+ const filePath = path.join(cwd, this.manifestFile);
1728
+ if (!fs.existsSync(filePath)) throw new Error(`${this.manifestFile} not found in ${cwd}`);
1729
+ const raw = fs.readFileSync(filePath, "utf-8");
1730
+ if (raw.includes("\0")) throw new Error(`${this.manifestFile} appears to be a binary file`);
1731
+ const version = raw.split("\n")[0].trim();
1732
+ if (version.length === 0) throw new Error(`${this.manifestFile} is empty`);
1733
+ return version;
1734
+ }
1735
+ /**
1736
+ * Writes a version string to the plain text version file.
1737
+ *
1738
+ * @param version - Version string to write.
1739
+ * @param cwd - Project directory containing the version file.
1740
+ */
1741
+ setVersion(version, cwd) {
1742
+ const filePath = path.join(cwd, this.manifestFile);
1743
+ if (!fs.existsSync(filePath)) throw new Error(`${this.manifestFile} not found in ${cwd}`);
1744
+ fs.writeFileSync(filePath, `${version}\n`, "utf-8");
1745
+ }
1746
+ };
1747
+ //#endregion
1748
+ //#region src/sources/yaml.ts
1749
+ /**
1750
+ * YAML-based version source provider for pubspec.yaml and similar manifests.
1751
+ *
1752
+ * @packageDocumentation
1753
+ */
1754
+ /**
1755
+ * Reads and writes version strings from YAML manifest files.
1756
+ *
1757
+ * @remarks
1758
+ * Supports dotted key paths (e.g. `'flutter.version'`) for nested values.
1759
+ * Uses targeted regex replacement for writes to preserve comments and formatting.
1760
+ *
1761
+ * @public
1762
+ * @since 0.3.0
1763
+ */
1764
+ var YamlVersionSource = class {
1765
+ /** Human-readable provider name. */
1766
+ name;
1767
+ /** Filename of the YAML manifest (e.g. `'pubspec.yaml'`). */
1768
+ manifestFile;
1769
+ /** Dotted key path to the version field within the YAML document. */
1770
+ versionKey;
1771
+ /**
1772
+ * Creates a new YAML version source.
1773
+ *
1774
+ * @param manifestFile - YAML manifest filename.
1775
+ * @param versionKey - Dotted key path to the version field.
1776
+ */
1777
+ constructor(manifestFile = "pubspec.yaml", versionKey = "version") {
1778
+ this.name = manifestFile;
1779
+ this.manifestFile = manifestFile;
1780
+ this.versionKey = versionKey;
1781
+ }
1782
+ /**
1783
+ * Returns `true` when the manifest file exists in `cwd`.
1784
+ *
1785
+ * @param cwd - Project directory to check.
1786
+ * @returns Whether the manifest file exists.
1787
+ */
1788
+ exists(cwd) {
1789
+ return fs.existsSync(path.join(cwd, this.manifestFile));
1790
+ }
1791
+ /**
1792
+ * Reads the version string from the YAML manifest.
1793
+ *
1794
+ * @param cwd - Project directory containing the manifest.
1795
+ * @returns The version string extracted from the manifest.
1796
+ */
1797
+ getVersion(cwd) {
1798
+ const filePath = path.join(cwd, this.manifestFile);
1799
+ if (!fs.existsSync(filePath)) throw new Error(`${this.manifestFile} not found in ${cwd}`);
1800
+ const content = fs.readFileSync(filePath, "utf-8");
1801
+ const parsed = yaml.load(content);
1802
+ if (!parsed || typeof parsed !== "object") throw new Error(`Failed to parse ${this.manifestFile}`);
1803
+ const version = getNestedValue(parsed, this.versionKey);
1804
+ if (typeof version !== "string" || version.length === 0) {
1805
+ if (typeof version === "number") return String(version);
1806
+ throw new Error(`No version field in ${this.manifestFile}`);
1807
+ }
1808
+ return version;
1809
+ }
1810
+ /**
1811
+ * Writes a version string to the YAML manifest, preserving formatting.
1812
+ *
1813
+ * @param version - Version string to write.
1814
+ * @param cwd - Project directory containing the manifest.
1815
+ */
1816
+ setVersion(version, cwd) {
1817
+ const filePath = path.join(cwd, this.manifestFile);
1818
+ if (!fs.existsSync(filePath)) throw new Error(`${this.manifestFile} not found in ${cwd}`);
1819
+ const keyParts = this.versionKey.split(".");
1820
+ const leafKey = keyParts[keyParts.length - 1];
1821
+ const content = fs.readFileSync(filePath, "utf-8");
1822
+ const regex = new RegExp(`^(\\s*${escapeRegExp(leafKey)}:\\s*)(["']?)(.+?)\\2\\s*$`, "m");
1823
+ const updated = content.replace(regex, `$1$2${version}$2`);
1824
+ if (updated === content) throw new Error(`Could not find version field to update in ${this.manifestFile}`);
1825
+ fs.writeFileSync(filePath, updated, "utf-8");
1826
+ }
1827
+ };
1828
+ //#endregion
1829
+ //#region src/sources/resolve.ts
1830
+ /**
1831
+ * Version source auto-detection and resolution.
1832
+ *
1833
+ * @packageDocumentation
1834
+ */
1835
+ /** Valid manifest source types for config validation (H-006). */
1836
+ var VALID_SOURCES = new Set([
1837
+ "auto",
1838
+ "package.json",
1839
+ "composer.json",
1840
+ "Cargo.toml",
1841
+ "pyproject.toml",
1842
+ "pubspec.yaml",
1843
+ "pom.xml",
1844
+ "VERSION",
1845
+ "git-tag",
1846
+ "custom"
1847
+ ]);
1848
+ /**
1849
+ * Known manifest detection entries, ordered by priority.
1850
+ *
1851
+ * When `source` is `'auto'`, the first entry whose file exists wins.
1852
+ */
1853
+ var DETECTION_TABLE = [
1854
+ {
1855
+ file: "package.json",
1856
+ source: "package.json",
1857
+ factory: () => new JsonVersionSource("package.json", "version")
1858
+ },
1859
+ {
1860
+ file: "Cargo.toml",
1861
+ source: "Cargo.toml",
1862
+ factory: () => new TomlVersionSource("Cargo.toml", "package.version")
1863
+ },
1864
+ {
1865
+ file: "pyproject.toml",
1866
+ source: "pyproject.toml",
1867
+ factory: () => new TomlVersionSource("pyproject.toml", "project.version")
1868
+ },
1869
+ {
1870
+ file: "pubspec.yaml",
1871
+ source: "pubspec.yaml",
1872
+ factory: () => new YamlVersionSource("pubspec.yaml", "version")
1873
+ },
1874
+ {
1875
+ file: "composer.json",
1876
+ source: "composer.json",
1877
+ factory: () => new JsonVersionSource("composer.json", "version")
1878
+ },
1879
+ {
1880
+ file: "pom.xml",
1881
+ source: "pom.xml",
1882
+ factory: () => new RegexVersionSource("pom.xml", "<project[^>]*>[\\s\\S]*?<version>([^<]+)</version>")
1883
+ },
1884
+ {
1885
+ file: "VERSION",
1886
+ source: "VERSION",
1887
+ factory: () => new VersionFileSource("VERSION")
1888
+ }
1889
+ ];
1890
+ /**
1891
+ * Validates that a file path does not escape the project directory (C-002).
1892
+ */
1893
+ function assertPathContained(manifestFile, cwd) {
1894
+ const resolved = path.resolve(cwd, manifestFile);
1895
+ const root = path.resolve(cwd);
1896
+ if (!resolved.startsWith(`${root}${path.sep}`) && resolved !== root) throw new Error(`Manifest path "${manifestFile}" resolves outside the project directory`);
1897
+ }
1898
+ /**
1899
+ * Creates a provider for a specific manifest source type.
1900
+ */
1901
+ function createProvider(source, config, cwd) {
1902
+ if (!VALID_SOURCES.has(source)) throw new Error(`Invalid manifest source "${source}". Valid sources: ${[...VALID_SOURCES].join(", ")}`);
1903
+ switch (source) {
1904
+ case "package.json": return new JsonVersionSource("package.json", config.path ?? "version");
1905
+ case "composer.json": return new JsonVersionSource("composer.json", config.path ?? "version");
1906
+ case "Cargo.toml": return new TomlVersionSource("Cargo.toml", config.path ?? "package.version");
1907
+ case "pyproject.toml": return new TomlVersionSource("pyproject.toml", config.path ?? "project.version");
1908
+ case "pubspec.yaml": return new YamlVersionSource("pubspec.yaml", config.path ?? "version");
1909
+ case "pom.xml": return new RegexVersionSource("pom.xml", config.regex ?? "<project[^>]*>[\\s\\S]*?<version>([^<]+)</version>");
1910
+ case "VERSION": return new VersionFileSource(config.path ?? "VERSION");
1911
+ case "git-tag": return new GitTagSource();
1912
+ case "custom":
1913
+ if (!config.regex) throw new Error("Custom manifest source requires a 'regex' field in manifest config");
1914
+ if (!config.path) throw new Error("Custom manifest source requires a 'path' field (manifest filename) in manifest config");
1915
+ assertPathContained(config.path, cwd);
1916
+ return new RegexVersionSource(config.path, config.regex);
1917
+ default: throw new Error(`Unknown manifest source: ${source}`);
1918
+ }
1919
+ }
1920
+ /**
1921
+ * Resolves the version source provider for a project.
1922
+ *
1923
+ * @remarks
1924
+ * When `source` is `'auto'`, scans the project directory for known manifest
1925
+ * files and returns the first match. Throws with helpful guidance if no
1926
+ * supported manifest is found.
1927
+ *
1928
+ * @param config - Manifest configuration from `.versionguard.yml`.
1929
+ * @param cwd - Project directory to scan.
1930
+ * @returns The resolved version source provider.
1931
+ *
1932
+ * @example
1933
+ * ```ts
1934
+ * import { resolveVersionSource } from './resolve';
1935
+ *
1936
+ * const provider = resolveVersionSource({ source: 'auto' }, process.cwd());
1937
+ * const version = provider.getVersion(process.cwd());
1938
+ * ```
1939
+ *
1940
+ * @public
1941
+ * @since 0.3.0
1942
+ */
1943
+ function resolveVersionSource(config, cwd = process.cwd()) {
1944
+ if (config.source !== "auto") return createProvider(config.source, config, cwd);
1945
+ for (const entry of DETECTION_TABLE) {
1946
+ const provider = entry.factory();
1947
+ if (provider.exists(cwd)) return provider;
1948
+ }
1949
+ const supported = DETECTION_TABLE.map((e) => e.file).join(", ");
1950
+ throw new Error(`No supported manifest file found in ${cwd}. Looked for: ${supported}. Set manifest.source explicitly in .versionguard.yml or create a supported manifest file.`);
1951
+ }
1952
+ /**
1953
+ * Detects all manifest files present in a project directory.
1954
+ *
1955
+ * @remarks
1956
+ * Useful for polyglot projects that may have multiple version sources.
1957
+ * Scans the detection table in priority order and returns all matches.
1958
+ *
1959
+ * @param cwd - Project directory to scan.
1960
+ * @returns Array of detected manifest source types.
1961
+ *
1962
+ * @example
1963
+ * ```ts
1964
+ * import { detectManifests } from './resolve';
1965
+ *
1966
+ * const manifests = detectManifests(process.cwd());
1967
+ * // ['package.json', 'Cargo.toml']
1968
+ * ```
1969
+ *
1970
+ * @public
1971
+ * @since 0.3.0
1972
+ */
1973
+ function detectManifests(cwd = process.cwd()) {
1974
+ const detected = [];
1975
+ for (const entry of DETECTION_TABLE) if (entry.factory().exists(cwd)) detected.push(entry.source);
1976
+ return detected;
1977
+ }
1978
+ //#endregion
1979
+ //#region src/project.ts
1980
+ /**
1981
+ * Gets the `package.json` path for a project directory.
1982
+ *
1983
+ * @public
1984
+ * @since 0.1.0
1985
+ * @remarks
1986
+ * This is a convenience helper used by the package read and write helpers.
1987
+ *
1988
+ * @param cwd - Project directory containing `package.json`.
1989
+ * @returns The resolved `package.json` path.
1990
+ * @example
1991
+ * ```ts
1992
+ * import { getPackageJsonPath } from 'versionguard';
1993
+ *
1994
+ * const packagePath = getPackageJsonPath(process.cwd());
1995
+ * ```
1996
+ */
1997
+ function getPackageJsonPath(cwd = process.cwd()) {
1998
+ return path.join(cwd, "package.json");
1999
+ }
2000
+ /**
2001
+ * Reads and parses a project's `package.json` file.
2002
+ *
2003
+ * @public
2004
+ * @since 0.1.0
2005
+ * @remarks
2006
+ * An error is thrown when `package.json` does not exist in the requested
2007
+ * directory.
2008
+ *
2009
+ * @param cwd - Project directory containing `package.json`.
2010
+ * @returns The parsed `package.json` document.
2011
+ * @example
2012
+ * ```ts
2013
+ * import { readPackageJson } from 'versionguard';
2014
+ *
2015
+ * const pkg = readPackageJson(process.cwd());
2016
+ * ```
2017
+ */
2018
+ function readPackageJson(cwd = process.cwd()) {
2019
+ const packagePath = getPackageJsonPath(cwd);
2020
+ if (!fs.existsSync(packagePath)) throw new Error(`package.json not found in ${cwd}`);
2021
+ return JSON.parse(fs.readFileSync(packagePath, "utf-8"));
2022
+ }
2023
+ /**
2024
+ * Writes a `package.json` document back to disk.
2025
+ *
2026
+ * @public
2027
+ * @since 0.1.0
2028
+ * @remarks
2029
+ * Output is formatted with two-space indentation and always ends with a
2030
+ * trailing newline.
2031
+ *
2032
+ * @param pkg - Parsed `package.json` data to write.
2033
+ * @param cwd - Project directory containing `package.json`.
2034
+ * @example
2035
+ * ```ts
2036
+ * import { readPackageJson, writePackageJson } from 'versionguard';
2037
+ *
2038
+ * const pkg = readPackageJson(process.cwd());
2039
+ * writePackageJson(pkg, process.cwd());
2040
+ * ```
2041
+ */
2042
+ function writePackageJson(pkg, cwd = process.cwd()) {
2043
+ fs.writeFileSync(getPackageJsonPath(cwd), `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
2044
+ }
2045
+ /**
2046
+ * Gets the version string from the project manifest.
2047
+ *
2048
+ * When a `manifest` config is provided, uses the configured version source
2049
+ * provider (auto-detection or explicit). Falls back to `package.json` for
2050
+ * backwards compatibility when no config is provided.
2051
+ *
2052
+ * @public
2053
+ * @since 0.1.0
2054
+ * @remarks
2055
+ * This throws when the manifest file does not exist or does not contain
2056
+ * a version field.
2057
+ *
2058
+ * @param cwd - Project directory containing the manifest.
2059
+ * @param manifest - Optional manifest configuration for language-agnostic support.
2060
+ * @returns The project version string.
2061
+ * @example
2062
+ * ```ts
2063
+ * import { getPackageVersion } from 'versionguard';
2064
+ *
2065
+ * // Read from package.json (legacy fallback)
2066
+ * const version = getPackageVersion(process.cwd());
2067
+ *
2068
+ * // Read from a configured manifest source
2069
+ * const versionAlt = getPackageVersion(process.cwd(), { source: 'Cargo.toml' });
2070
+ * ```
2071
+ */
2072
+ function getPackageVersion(cwd = process.cwd(), manifest) {
2073
+ if (manifest) return resolveVersionSource(manifest, cwd).getVersion(cwd);
2074
+ const pkg = readPackageJson(cwd);
2075
+ if (typeof pkg.version !== "string" || pkg.version.length === 0) throw new Error("No version field in package.json");
2076
+ return pkg.version;
2077
+ }
2078
+ /**
2079
+ * Sets the version field in the project manifest.
2080
+ *
2081
+ * When a `manifest` config is provided, uses the configured version source
2082
+ * provider. Falls back to `package.json` for backwards compatibility when
2083
+ * no config is provided.
2084
+ *
2085
+ * @public
2086
+ * @since 0.1.0
2087
+ * @remarks
2088
+ * The existing document is read, the version field is replaced, and the
2089
+ * file is written back to disk.
2090
+ *
2091
+ * @param version - Version string to persist.
2092
+ * @param cwd - Project directory containing the manifest.
2093
+ * @param manifest - Optional manifest configuration for language-agnostic support.
2094
+ * @example
2095
+ * ```ts
2096
+ * import { setPackageVersion } from 'versionguard';
2097
+ *
2098
+ * // Write to package.json (legacy fallback)
2099
+ * setPackageVersion('1.2.3', process.cwd());
2100
+ *
2101
+ * // Write to a configured manifest source
2102
+ * setPackageVersion('1.2.3', process.cwd(), { source: 'Cargo.toml' });
2103
+ * ```
2104
+ */
2105
+ function setPackageVersion(version, cwd = process.cwd(), manifest) {
2106
+ if (manifest) {
2107
+ resolveVersionSource(manifest, cwd).setVersion(version, cwd);
2108
+ return;
2109
+ }
2110
+ const pkg = readPackageJson(cwd);
2111
+ pkg.version = version;
2112
+ writePackageJson(pkg, cwd);
2113
+ }
2114
+ /**
2115
+ * Resolves the version source provider for a project.
2116
+ *
2117
+ * @remarks
2118
+ * Delegates to `resolveVersionSource` to create the appropriate provider
2119
+ * for the configured manifest type. Use this when you need direct access
2120
+ * to the provider's `exists`, `getVersion`, and `setVersion` methods.
2121
+ *
2122
+ * @param manifest - Manifest configuration.
2123
+ * @param cwd - Project directory.
2124
+ * @returns The resolved provider instance.
2125
+ *
2126
+ * @example
2127
+ * ```ts
2128
+ * import { getVersionSource } from 'versionguard';
2129
+ *
2130
+ * const source = getVersionSource({ source: 'package.json' }, process.cwd());
2131
+ * const version = source.getVersion(process.cwd());
2132
+ * ```
2133
+ *
2134
+ * @public
2135
+ * @since 0.3.0
2136
+ */
2137
+ function getVersionSource(manifest, cwd = process.cwd()) {
2138
+ return resolveVersionSource(manifest, cwd);
2139
+ }
2140
+ //#endregion
2141
+ //#region src/semver.ts
2142
+ /**
2143
+ * Semantic version parsing, validation, comparison, and increment helpers.
2144
+ *
2145
+ * @packageDocumentation
2146
+ */
2147
+ var semver_exports = /* @__PURE__ */ __exportAll({
2148
+ compare: () => compare,
2149
+ eq: () => eq,
2150
+ format: () => format,
2151
+ gt: () => gt,
2152
+ increment: () => increment,
2153
+ lt: () => lt,
2154
+ parse: () => parse$1,
2155
+ validate: () => validate$1
2156
+ });
2157
+ var SEMVER_REGEX = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
2158
+ /**
2159
+ * Parses a semantic version string.
2160
+ *
2161
+ * @remarks
2162
+ * This helper enforces the standard SemVer structure and returns `null` when the input does not
2163
+ * match. It preserves prerelease and build identifiers as ordered string segments.
2164
+ *
2165
+ * @param version - Version string to parse.
2166
+ * @returns Parsed semantic version components, or `null` when the input is invalid.
2167
+ *
2168
+ * @example
2169
+ * ```ts
2170
+ * import { parse } from 'versionguard';
2171
+ *
2172
+ * parse('1.2.3-alpha.1+build.5')?.prerelease;
2173
+ * // => ['alpha', '1']
2174
+ * ```
2175
+ *
2176
+ * @public
2177
+ * @since 0.1.0
2178
+ */
2179
+ function parse$1(version) {
2180
+ const match = version.match(SEMVER_REGEX);
2181
+ if (!match) return null;
2182
+ return {
2183
+ major: Number.parseInt(match[1], 10),
2184
+ minor: Number.parseInt(match[2], 10),
2185
+ patch: Number.parseInt(match[3], 10),
2186
+ prerelease: match[4] ? match[4].split(".") : [],
2187
+ build: match[5] ? match[5].split(".") : [],
2188
+ raw: version
2189
+ };
2190
+ }
2191
+ function getStructuralErrors(version) {
2192
+ const errors = [];
2193
+ if (version.startsWith("v")) {
2194
+ errors.push({
2195
+ message: `Version should not start with 'v': ${version}`,
2196
+ severity: "error"
2197
+ });
2198
+ return errors;
2199
+ }
2200
+ const segments = version.split(/[+-]/, 1)[0].split(".");
2201
+ if (segments.length === 3) {
2202
+ const leadingZeroSegment = segments.find((segment) => /^0\d+$/.test(segment));
2203
+ if (leadingZeroSegment) {
2204
+ errors.push({
2205
+ message: `Invalid SemVer: numeric segment "${leadingZeroSegment}" has a leading zero`,
2206
+ severity: "error"
2207
+ });
2208
+ return errors;
2209
+ }
2210
+ }
2211
+ const prerelease = version.match(/-([^+]+)/)?.[1];
2212
+ if (prerelease) {
2213
+ const invalidPrerelease = prerelease.split(".").find((segment) => /^0\d+$/.test(segment));
2214
+ if (invalidPrerelease) {
2215
+ errors.push({
2216
+ message: `Invalid SemVer: prerelease identifier "${invalidPrerelease}" has a leading zero`,
2217
+ severity: "error"
2218
+ });
2219
+ return errors;
2220
+ }
2221
+ }
2222
+ errors.push({
2223
+ message: `Invalid SemVer format: "${version}". Expected MAJOR.MINOR.PATCH[-prerelease][+build].`,
2224
+ severity: "error"
2225
+ });
2226
+ return errors;
2227
+ }
2228
+ /**
2229
+ * Validates that a string is a supported semantic version.
2230
+ *
2231
+ * @remarks
2232
+ * When validation fails, the result includes targeted structural errors for common cases such as
2233
+ * leading `v` prefixes and numeric segments with leading zeroes.
2234
+ *
2235
+ * When a {@link SemVerConfig} is provided, additional policy checks are applied
2236
+ * (v-prefix tolerance, build-metadata restrictions, prerelease requirements).
2237
+ * When {@link SchemeRules} are provided, the prerelease tag is validated against
2238
+ * the allowed modifiers list — the same logic CalVer uses for its modifiers.
2239
+ *
2240
+ * @param version - Version string to validate.
2241
+ * @param semverConfig - Optional SemVer-specific configuration.
2242
+ * @param schemeRules - Optional scheme rules for modifier validation.
2243
+ * @returns A validation result containing any detected errors and the parsed version on success.
2244
+ *
2245
+ * @example
2246
+ * ```ts
2247
+ * import { validate } from 'versionguard';
2248
+ *
2249
+ * validate('1.2.3').valid;
2250
+ * // => true
2251
+ * ```
2252
+ *
2253
+ * @public
2254
+ * @since 0.1.0
2255
+ */
2256
+ function validate$1(version, semverConfig, schemeRules) {
2257
+ let input = version;
2258
+ if (input.startsWith("v") || input.startsWith("V")) {
2259
+ if (semverConfig?.allowVPrefix) input = input.slice(1);
2260
+ }
2261
+ const parsed = parse$1(input);
2262
+ if (!parsed) return {
2263
+ valid: false,
2264
+ errors: getStructuralErrors(version)
2265
+ };
2266
+ const errors = [];
2267
+ if (semverConfig && !semverConfig.allowBuildMetadata && parsed.build.length > 0) errors.push({
2268
+ message: `Build metadata is not allowed: "${parsed.build.join(".")}"`,
2269
+ severity: "error"
2270
+ });
2271
+ if (semverConfig?.requirePrerelease && parsed.prerelease.length === 0) errors.push({
2272
+ message: "A prerelease label is required (e.g., 1.2.3-alpha.1)",
2273
+ severity: "error"
2274
+ });
2275
+ if (parsed.prerelease.length > 0) {
2276
+ const modifierError = validateModifier(parsed.prerelease[0], schemeRules);
2277
+ if (modifierError) errors.push(modifierError);
2278
+ }
2279
+ return {
2280
+ valid: errors.filter((e) => e.severity === "error").length === 0,
2281
+ errors,
2282
+ version: {
2283
+ type: "semver",
2284
+ version: parsed
2285
+ }
2286
+ };
2287
+ }
2288
+ /**
2289
+ * Compares two semantic version strings.
2290
+ *
2291
+ * @remarks
2292
+ * Comparison follows SemVer precedence rules, including special handling for prerelease
2293
+ * identifiers and ignoring build metadata.
2294
+ *
2295
+ * @param a - Left-hand version string.
2296
+ * @param b - Right-hand version string.
2297
+ * @returns `1` when `a` is greater, `-1` when `b` is greater, or `0` when they are equal.
2298
+ *
2299
+ * @example
2300
+ * ```ts
2301
+ * import { compare } from 'versionguard';
2302
+ *
2303
+ * compare('1.2.3', '1.2.3-alpha.1');
2304
+ * // => 1
2305
+ * ```
2306
+ *
2307
+ * @public
2308
+ * @since 0.1.0
2309
+ */
2310
+ function compare(a, b) {
2311
+ const left = parse$1(a);
2312
+ const right = parse$1(b);
2313
+ if (!left || !right) throw new Error(`Invalid SemVer comparison between "${a}" and "${b}"`);
2314
+ for (const key of [
2315
+ "major",
2316
+ "minor",
2317
+ "patch"
2318
+ ]) if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1;
2319
+ const leftHasPrerelease = left.prerelease.length > 0;
2320
+ const rightHasPrerelease = right.prerelease.length > 0;
2321
+ if (leftHasPrerelease && !rightHasPrerelease) return -1;
2322
+ if (!leftHasPrerelease && rightHasPrerelease) return 1;
2323
+ const length = Math.max(left.prerelease.length, right.prerelease.length);
2324
+ for (let index = 0; index < length; index += 1) {
2325
+ const leftValue = left.prerelease[index];
2326
+ const rightValue = right.prerelease[index];
2327
+ if (leftValue === void 0) return -1;
2328
+ if (rightValue === void 0) return 1;
2329
+ const leftNumeric = /^\d+$/.test(leftValue) ? Number.parseInt(leftValue, 10) : null;
2330
+ const rightNumeric = /^\d+$/.test(rightValue) ? Number.parseInt(rightValue, 10) : null;
2331
+ if (leftNumeric !== null && rightNumeric !== null) {
2332
+ if (leftNumeric !== rightNumeric) return leftNumeric > rightNumeric ? 1 : -1;
2333
+ continue;
2334
+ }
2335
+ if (leftNumeric !== null) return -1;
2336
+ if (rightNumeric !== null) return 1;
2337
+ if (leftValue !== rightValue) return leftValue > rightValue ? 1 : -1;
2338
+ }
2339
+ return 0;
2340
+ }
2341
+ /**
2342
+ * Checks whether one semantic version is greater than another.
2343
+ *
2344
+ * @remarks
2345
+ * This is a convenience wrapper around {@link compare} for callers that only need a boolean.
2346
+ *
2347
+ * @param a - Left-hand version string.
2348
+ * @param b - Right-hand version string.
2349
+ * @returns `true` when `a` has higher precedence than `b`.
2350
+ *
2351
+ * @example
2352
+ * ```ts
2353
+ * import { gt } from 'versionguard';
2354
+ *
2355
+ * gt('1.2.4', '1.2.3');
2356
+ * // => true
2357
+ * ```
2358
+ *
2359
+ * @see {@link compare} for full precedence ordering.
2360
+ * @public
2361
+ * @since 0.1.0
2362
+ */
2363
+ function gt(a, b) {
2364
+ return compare(a, b) > 0;
2365
+ }
2366
+ /**
2367
+ * Checks whether one semantic version is less than another.
2368
+ *
2369
+ * @remarks
2370
+ * This is a convenience wrapper around {@link compare} for callers that only need a boolean.
2371
+ *
2372
+ * @param a - Left-hand version string.
2373
+ * @param b - Right-hand version string.
2374
+ * @returns `true` when `a` has lower precedence than `b`.
2375
+ *
2376
+ * @example
2377
+ * ```ts
2378
+ * import { lt } from 'versionguard';
2379
+ *
2380
+ * lt('1.2.3-alpha.1', '1.2.3');
2381
+ * // => true
2382
+ * ```
2383
+ *
2384
+ * @see {@link compare} for full precedence ordering.
2385
+ * @public
2386
+ * @since 0.1.0
2387
+ */
2388
+ function lt(a, b) {
2389
+ return compare(a, b) < 0;
2390
+ }
2391
+ /**
2392
+ * Checks whether two semantic versions are equal in precedence.
2393
+ *
2394
+ * @remarks
2395
+ * This is a convenience wrapper around {@link compare}. Build metadata is ignored because
2396
+ * precedence comparisons in SemVer do not consider it.
2397
+ *
2398
+ * @param a - Left-hand version string.
2399
+ * @param b - Right-hand version string.
2400
+ * @returns `true` when both versions compare as equal.
2401
+ *
2402
+ * @example
2403
+ * ```ts
2404
+ * import { eq } from 'versionguard';
2405
+ *
2406
+ * eq('1.2.3', '1.2.3');
2407
+ * // => true
2408
+ * ```
2409
+ *
2410
+ * @see {@link compare} for full precedence ordering.
2411
+ * @public
2412
+ * @since 0.1.0
2413
+ */
2414
+ function eq(a, b) {
2415
+ return compare(a, b) === 0;
2416
+ }
2417
+ /**
2418
+ * Increments a semantic version string by release type.
2419
+ *
2420
+ * @remarks
2421
+ * Incrementing `major` or `minor` resets lower-order numeric segments. When a prerelease label is
2422
+ * provided, it is appended to the newly generated version.
2423
+ *
2424
+ * @param version - Current semantic version string.
2425
+ * @param release - Segment to increment.
2426
+ * @param prerelease - Optional prerelease suffix to append to the next version.
2427
+ * @returns The incremented semantic version string.
2428
+ *
2429
+ * @example
2430
+ * ```ts
2431
+ * import { increment } from 'versionguard';
2432
+ *
2433
+ * increment('1.2.3', 'minor', 'beta.1');
2434
+ * // => '1.3.0-beta.1'
2435
+ * ```
2436
+ *
2437
+ * @public
2438
+ * @since 0.1.0
2439
+ */
2440
+ function increment(version, release, prerelease) {
2441
+ const parsed = parse$1(version);
2442
+ if (!parsed) throw new Error(`Invalid SemVer version: ${version}`);
2443
+ if (release === "major") return `${parsed.major + 1}.0.0${prerelease ? `-${prerelease}` : ""}`;
2444
+ if (release === "minor") return `${parsed.major}.${parsed.minor + 1}.0${prerelease ? `-${prerelease}` : ""}`;
2445
+ return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}${prerelease ? `-${prerelease}` : ""}`;
2446
+ }
2447
+ /**
2448
+ * Formats a parsed semantic version object.
2449
+ *
2450
+ * @remarks
2451
+ * Prerelease and build metadata segments are only included when their arrays contain values.
2452
+ *
2453
+ * @param version - Parsed semantic version to serialize.
2454
+ * @returns The normalized semantic version string.
2455
+ *
2456
+ * @example
2457
+ * ```ts
2458
+ * import { format } from 'versionguard';
2459
+ *
2460
+ * const version = { major: 1, minor: 2, patch: 3, prerelease: ['rc', '1'], build: ['build', '5'], raw: '1.2.3-rc.1+build.5' };
2461
+ *
2462
+ * format(version);
2463
+ * // => '1.2.3-rc.1+build.5'
2464
+ * ```
2465
+ *
2466
+ * @public
2467
+ * @since 0.1.0
2468
+ */
2469
+ function format(version) {
2470
+ let output = `${version.major}.${version.minor}.${version.patch}`;
2471
+ if (version.prerelease.length > 0) output += `-${version.prerelease.join(".")}`;
2472
+ if (version.build.length > 0) output += `+${version.build.join(".")}`;
2473
+ return output;
2474
+ }
2475
+ //#endregion
2476
+ //#region src/sync.ts
2477
+ function resolveFiles(patterns, cwd, ignore = []) {
2478
+ return [...new Set(patterns.flatMap((pattern) => globSync(pattern, {
2479
+ cwd,
2480
+ absolute: true,
2481
+ ignore
2482
+ })))].sort();
2483
+ }
2484
+ function getLineNumber(content, offset) {
2485
+ return content.slice(0, offset).split("\n").length;
2486
+ }
2487
+ function extractVersion(groups) {
2488
+ return groups[1] ?? groups[0] ?? "";
2489
+ }
2490
+ function applyTemplate(template, groups, version) {
2491
+ return template.replace(/\$(\d+)|\{\{version\}\}/g, (match, groupIndex) => {
2492
+ if (match === "{{version}}") return version;
2493
+ return groups[Number.parseInt(groupIndex ?? "0", 10) - 1] ?? "";
2494
+ });
2495
+ }
2496
+ function stringifyCapture(value) {
2497
+ return typeof value === "string" ? value : "";
2498
+ }
2499
+ /**
2500
+ * Synchronizes configured files to a single version string.
2501
+ *
2502
+ * @public
2503
+ * @since 0.1.0
2504
+ * @remarks
2505
+ * File globs are resolved relative to `cwd`, then each matched file is updated
2506
+ * with the configured replacement patterns.
2507
+ *
2508
+ * @param version - Version string to write into matching files.
2509
+ * @param config - Sync configuration describing files and replacement patterns.
2510
+ * @param cwd - Project directory used to resolve file globs.
2511
+ * @returns A sync result for each resolved file.
2512
+ * @example
2513
+ * ```ts
2514
+ * import { getDefaultConfig, syncVersion } from 'versionguard';
2515
+ *
2516
+ * const results = syncVersion('1.2.3', getDefaultConfig().sync, process.cwd());
2517
+ * ```
2518
+ */
2519
+ function syncVersion(version, config, cwd = process.cwd()) {
2520
+ return resolveFiles(config.files, cwd).map((filePath) => syncFile(filePath, version, config.patterns));
2521
+ }
2522
+ /**
2523
+ * Synchronizes a single file to a target version.
2524
+ *
2525
+ * @public
2526
+ * @since 0.1.0
2527
+ * @remarks
2528
+ * Each configured regex is applied globally, and `{{version}}` placeholders in
2529
+ * templates are replaced with the supplied version.
2530
+ *
2531
+ * @param filePath - Absolute or relative path to the file to update.
2532
+ * @param version - Version string to write.
2533
+ * @param patterns - Replacement patterns to apply.
2534
+ * @returns A result describing whether the file changed and what changed.
2535
+ * @example
2536
+ * ```ts
2537
+ * import { getDefaultConfig, syncFile } from 'versionguard';
2538
+ *
2539
+ * const result = syncFile('README.md', '1.2.3', getDefaultConfig().sync.patterns);
2540
+ * ```
2541
+ */
2542
+ function syncFile(filePath, version, patterns) {
2543
+ const original = fs.readFileSync(filePath, "utf-8");
2544
+ let updatedContent = original;
2545
+ const changes = [];
2546
+ for (const pattern of patterns) {
2547
+ const regex = new RegExp(pattern.regex, "gm");
2548
+ updatedContent = updatedContent.replace(regex, (match, ...args) => {
2549
+ const offsetIndex = typeof args.at(-1) === "object" && args.at(-1) !== null ? -3 : -2;
2550
+ const offset = args.at(offsetIndex);
2551
+ const groups = args.slice(0, offsetIndex).map((value) => stringifyCapture(value));
2552
+ const found = extractVersion(groups);
2553
+ if (found === "Unreleased") return match;
2554
+ if (found !== version) changes.push({
2555
+ line: getLineNumber(updatedContent, offset),
2556
+ oldValue: found,
2557
+ newValue: version
2558
+ });
2559
+ return applyTemplate(pattern.template, groups, version) || match;
2560
+ });
2561
+ }
2562
+ const result = {
2563
+ file: filePath,
2564
+ updated: updatedContent !== original,
2565
+ changes
2566
+ };
2567
+ if (result.updated) fs.writeFileSync(filePath, updatedContent, "utf-8");
2568
+ return result;
2569
+ }
2570
+ /**
2571
+ * Checks configured files for hardcoded version mismatches.
2572
+ *
2573
+ * @public
2574
+ * @since 0.1.0
2575
+ * @remarks
2576
+ * Files matching the sync config are scanned without modification, and every
2577
+ * captured version that differs from `expectedVersion` is returned.
2578
+ *
2579
+ * @param expectedVersion - Version all matching entries should use.
2580
+ * @param config - Sync configuration describing files and replacement patterns.
2581
+ * @param ignorePatterns - Glob patterns to exclude while scanning.
2582
+ * @param cwd - Project directory used to resolve file globs.
2583
+ * @returns A list of detected version mismatches.
2584
+ * @example
2585
+ * ```ts
2586
+ * import { checkHardcodedVersions, getDefaultConfig } from 'versionguard';
2587
+ *
2588
+ * const mismatches = checkHardcodedVersions(
2589
+ * '1.2.3',
2590
+ * getDefaultConfig().sync,
2591
+ * getDefaultConfig().ignore,
2592
+ * process.cwd(),
2593
+ * );
2594
+ * ```
2595
+ */
2596
+ function checkHardcodedVersions(expectedVersion, config, ignorePatterns, cwd = process.cwd()) {
2597
+ const mismatches = [];
2598
+ const files = resolveFiles(config.files, cwd, ignorePatterns);
2599
+ for (const filePath of files) {
2600
+ const content = fs.readFileSync(filePath, "utf-8");
2601
+ for (const pattern of config.patterns) {
2602
+ const regex = new RegExp(pattern.regex, "gm");
2603
+ let match = regex.exec(content);
2604
+ while (match) {
2605
+ const found = extractVersion(match.slice(1));
2606
+ if (found !== "Unreleased" && found !== expectedVersion) mismatches.push({
2607
+ file: path.relative(cwd, filePath),
2608
+ line: getLineNumber(content, match.index),
2609
+ found
2610
+ });
2611
+ match = regex.exec(content);
2612
+ }
2613
+ }
2614
+ }
2615
+ return mismatches;
2616
+ }
2617
+ /** Extensions that are almost certainly binary and should be skipped. */
2618
+ var BINARY_EXTENSIONS = new Set([
2619
+ ".png",
2620
+ ".jpg",
2621
+ ".jpeg",
2622
+ ".gif",
2623
+ ".ico",
2624
+ ".svg",
2625
+ ".woff",
2626
+ ".woff2",
2627
+ ".ttf",
2628
+ ".eot",
2629
+ ".otf",
2630
+ ".zip",
2631
+ ".tar",
2632
+ ".gz",
2633
+ ".bz2",
2634
+ ".7z",
2635
+ ".pdf",
2636
+ ".exe",
2637
+ ".dll",
2638
+ ".so",
2639
+ ".dylib",
2640
+ ".wasm",
2641
+ ".mp3",
2642
+ ".mp4",
2643
+ ".webm",
2644
+ ".webp",
2645
+ ".avif"
2646
+ ]);
2647
+ /**
2648
+ * Scans the entire repository for hardcoded version literals.
2649
+ *
2650
+ * @public
2651
+ * @since 0.8.0
2652
+ * @remarks
2653
+ * Unlike {@link checkHardcodedVersions}, which only checks files listed in
2654
+ * `sync.files`, this function globs the entire repository (respecting
2655
+ * `.gitignore` and `ignore` patterns) and applies configurable version-like
2656
+ * regex patterns. An allowlist filters out intentional references.
2657
+ *
2658
+ * @param expectedVersion - Version all matching entries should use.
2659
+ * @param scanConfig - Scan configuration with patterns and allowlist.
2660
+ * @param ignorePatterns - Glob patterns to exclude while scanning.
2661
+ * @param cwd - Project directory used to resolve file globs.
2662
+ * @returns A list of detected version mismatches across the repository.
2663
+ * @example
2664
+ * ```ts
2665
+ * import { getDefaultConfig, scanRepoForVersions } from 'versionguard';
2666
+ *
2667
+ * const config = getDefaultConfig();
2668
+ * const findings = scanRepoForVersions('1.2.3', config.scan, config.ignore, process.cwd());
2669
+ * ```
2670
+ */
2671
+ function scanRepoForVersions(expectedVersion, scanConfig, ignorePatterns, cwd = process.cwd()) {
2672
+ const files = [...new Set(globSync("**/*", {
2673
+ cwd,
2674
+ absolute: true,
2675
+ dot: true,
2676
+ ignore: [
2677
+ ...ignorePatterns,
2678
+ "CHANGELOG.md",
2679
+ "*.lock",
2680
+ "package-lock.json",
2681
+ "yarn.lock",
2682
+ "pnpm-lock.yaml",
2683
+ ".versionguard.yml",
2684
+ ".versionguard.yaml"
2685
+ ]
2686
+ }))].sort();
2687
+ const allowedFiles = new Set(scanConfig.allowlist.flatMap((entry) => resolveFiles([entry.file], cwd, [])));
2688
+ const mismatches = [];
2689
+ for (const filePath of files) {
2690
+ if (BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase())) continue;
2691
+ if (allowedFiles.has(filePath)) continue;
2692
+ let content;
2693
+ try {
2694
+ content = fs.readFileSync(filePath, "utf-8");
2695
+ } catch {
2696
+ continue;
2697
+ }
2698
+ if (content.slice(0, 8192).includes("\0")) continue;
2699
+ for (const patternStr of scanConfig.patterns) {
2700
+ const regex = new RegExp(patternStr, "gm");
2701
+ let match = regex.exec(content);
2702
+ while (match) {
2703
+ const found = match[1] ?? match[0] ?? "";
2704
+ if (found && found !== expectedVersion && found !== "Unreleased") mismatches.push({
2705
+ file: path.relative(cwd, filePath),
2706
+ line: getLineNumber(content, match.index),
2707
+ found
2708
+ });
2709
+ match = regex.exec(content);
2710
+ }
2711
+ }
2712
+ }
2713
+ return mismatches;
2714
+ }
2715
+ //#endregion
2716
+ //#region src/types.ts
2717
+ /**
2718
+ * Default SemVer configuration used when no explicit config is provided.
2719
+ *
2720
+ * @internal
2721
+ */
2722
+ var DEFAULT_SEMVER_CONFIG = {
2723
+ allowVPrefix: false,
2724
+ allowBuildMetadata: true,
2725
+ requirePrerelease: false
2726
+ };
2727
+ /**
2728
+ * Resolves the SemVer config from a VersionGuard config.
2729
+ *
2730
+ * @remarks
2731
+ * Returns the explicit `semver` block when present, otherwise falls back
2732
+ * to sensible defaults. Unlike CalVer, SemVer works out of the box without
2733
+ * explicit configuration.
2734
+ *
2735
+ * @param config - The full VersionGuard configuration object.
2736
+ * @returns The resolved SemVer configuration.
2737
+ *
2738
+ * @example
2739
+ * ```ts
2740
+ * import { getSemVerConfig } from './types';
2741
+ *
2742
+ * const sv = getSemVerConfig(config);
2743
+ * console.log(sv.allowVPrefix); // false
2744
+ * ```
2745
+ *
2746
+ * @public
2747
+ * @since 0.6.0
2748
+ */
2749
+ function getSemVerConfig(config) {
2750
+ return {
2751
+ ...DEFAULT_SEMVER_CONFIG,
2752
+ ...config.versioning.semver
2753
+ };
2754
+ }
2755
+ /**
2756
+ * Extracts the CalVer config from a VersionGuard config, throwing if missing.
2757
+ *
2758
+ * @remarks
2759
+ * This is a convenience helper that validates the `calver` block exists
2760
+ * before returning it. Use this instead of accessing `config.versioning.calver`
2761
+ * directly to get a clear error when the config is misconfigured.
2762
+ *
2763
+ * @param config - The full VersionGuard configuration object.
2764
+ * @returns The validated CalVer configuration.
2765
+ *
2766
+ * @example
2767
+ * ```ts
2768
+ * import { getCalVerConfig } from './types';
2769
+ *
2770
+ * const calver = getCalVerConfig(config);
2771
+ * console.log(calver.format); // 'YYYY.MM.DD'
2772
+ * ```
2773
+ *
2774
+ * @public
2775
+ * @since 0.3.0
2776
+ */
2777
+ function getCalVerConfig(config) {
2778
+ if (!config.versioning.calver) throw new Error("CalVer configuration is required when versioning.type is \"calver\"");
2779
+ return config.versioning.calver;
2780
+ }
2781
+ //#endregion
2782
+ //#region src/ckm/engine.ts
2783
+ /**
2784
+ * Topic derivation rules applied to CKM concept names.
2785
+ *
2786
+ * @remarks
2787
+ * Strips common suffixes and normalizes to lowercase slugs.
2788
+ */
2789
+ function deriveTopicSlug(conceptName) {
2790
+ return conceptName.replace(/Config$/, "").replace(/Result$/, "").replace(/Options$/, "").toLowerCase();
2791
+ }
2792
+ /**
2793
+ * Checks if a concept name represents a config-like topic.
2794
+ *
2795
+ * @remarks
2796
+ * Only `*Config` interfaces become topics. Result types, internal
2797
+ * types, and options are excluded from the topic index.
2798
+ */
2799
+ function isTopicConcept(name) {
2800
+ return name.endsWith("Config") && name !== "VersionGuardConfig";
2801
+ }
2802
+ /**
2803
+ * Matches an operation to a topic by keyword analysis.
2804
+ *
2805
+ * @remarks
2806
+ * Checks the operation name and description against the topic slug
2807
+ * and related concept names.
2808
+ */
2809
+ function operationMatchesTopic(op, topicSlug, conceptNames) {
2810
+ const haystack = `${op.name} ${op.what}`.toLowerCase();
2811
+ if (haystack.includes(topicSlug)) return true;
2812
+ return conceptNames.some((n) => haystack.includes(n.toLowerCase()));
2813
+ }
2814
+ /**
2815
+ * Creates a CKM engine from a parsed manifest.
2816
+ *
2817
+ * @remarks
2818
+ * This is the main entry point for the reusable CKM module.
2819
+ * Pass the parsed contents of `ckm.json` and get back an engine
2820
+ * that auto-derives topics and provides formatted output.
2821
+ *
2822
+ * @param manifest - Parsed CKM manifest (from forge-ts `ckm.json`).
2823
+ * @returns A configured CKM engine.
2824
+ *
2825
+ * @example
2826
+ * ```ts
2827
+ * import { createCkmEngine } from './ckm/engine';
2828
+ *
2829
+ * const engine = createCkmEngine(manifest);
2830
+ * console.log(engine.getTopicIndex('mytool'));
2831
+ * ```
2832
+ *
2833
+ * @public
2834
+ * @since 0.4.0
2835
+ */
2836
+ function createCkmEngine(manifest) {
2837
+ const topics = deriveTopics(manifest);
2838
+ return {
2839
+ topics,
2840
+ getTopicIndex: (toolName = "tool") => formatTopicIndex(topics, toolName),
2841
+ getTopicContent: (name) => formatTopicContent(topics, name),
2842
+ getTopicJson: (name) => buildTopicJson(topics, manifest, name),
2843
+ getManifest: () => manifest
2844
+ };
2845
+ }
2846
+ /**
2847
+ * Derives topics from the CKM manifest automatically.
2848
+ */
2849
+ function deriveTopics(manifest) {
2850
+ const topics = [];
2851
+ for (const concept of manifest.concepts) {
2852
+ if (!isTopicConcept(concept.name)) continue;
2853
+ const slug = deriveTopicSlug(concept.name);
2854
+ const conceptNames = [concept.name];
2855
+ const relatedConcepts = manifest.concepts.filter((c) => c.name !== concept.name && (c.name.toLowerCase().includes(slug) || slug.includes(deriveTopicSlug(c.name))));
2856
+ conceptNames.push(...relatedConcepts.map((c) => c.name));
2857
+ const operations = manifest.operations.filter((op) => operationMatchesTopic(op, slug, conceptNames));
2858
+ const configSchema = manifest.configSchema.filter((c) => conceptNames.some((n) => c.key?.startsWith(n)));
2859
+ const constraints = manifest.constraints.filter((c) => conceptNames.some((n) => c.enforcedBy?.includes(n)) || operations.some((o) => c.enforcedBy?.includes(o.name)));
2860
+ topics.push({
2861
+ name: slug,
2862
+ summary: concept.what,
2863
+ concepts: [concept, ...relatedConcepts],
2864
+ operations,
2865
+ configSchema,
2866
+ constraints
2867
+ });
2868
+ }
2869
+ return topics;
2870
+ }
2871
+ /**
2872
+ * Formats the topic index for terminal display.
2873
+ */
2874
+ function formatTopicIndex(topics, toolName) {
2875
+ const lines = [
2876
+ `${toolName} CKM — Codebase Knowledge Manifest`,
2877
+ "",
2878
+ `Usage: ${toolName} ckm [topic] [--json] [--llm]`,
2879
+ "",
2880
+ "Topics:"
2881
+ ];
2882
+ const maxName = Math.max(...topics.map((t) => t.name.length));
2883
+ for (const topic of topics) lines.push(` ${topic.name.padEnd(maxName + 2)}${topic.summary}`);
2884
+ lines.push("");
2885
+ lines.push("Flags:");
2886
+ lines.push(" --json Machine-readable CKM output (concepts, operations, config schema)");
2887
+ lines.push(" --llm Full API context for LLM agents (forge-ts llms.txt)");
2888
+ return lines.join("\n");
2889
+ }
2890
+ /**
2891
+ * Formats a topic's content for human-readable terminal display.
2892
+ */
2893
+ function formatTopicContent(topics, topicName) {
2894
+ const topic = topics.find((t) => t.name === topicName);
2895
+ if (!topic) return null;
2896
+ const lines = [`# ${topic.summary}`, ""];
2897
+ if (topic.concepts.length > 0) {
2898
+ lines.push("## Concepts", "");
2899
+ for (const c of topic.concepts) {
2900
+ lines.push(` ${c.name} — ${c.what}`);
2901
+ if (c.properties) for (const p of c.properties) {
2902
+ const def = findDefault(topic.configSchema, c.name, p.name);
2903
+ lines.push(` ${p.name}: ${p.type}${def ? ` = ${def}` : ""}`);
2904
+ if (p.description) lines.push(` ${p.description}`);
2905
+ }
2906
+ lines.push("");
2907
+ }
2908
+ }
2909
+ if (topic.operations.length > 0) {
2910
+ lines.push("## Operations", "");
2911
+ for (const o of topic.operations) {
2912
+ lines.push(` ${o.name}() — ${o.what}`);
2913
+ if (o.inputs) for (const i of o.inputs) lines.push(` @param ${i.name}: ${i.description}`);
2914
+ lines.push("");
2915
+ }
2916
+ }
2917
+ if (topic.configSchema.length > 0) {
2918
+ lines.push("## Config Fields", "");
2919
+ for (const c of topic.configSchema) {
2920
+ lines.push(` ${c.key}: ${c.type}${c.default ? ` = ${c.default}` : ""}`);
2921
+ if (c.description) lines.push(` ${c.description}`);
2922
+ }
2923
+ lines.push("");
2924
+ }
2925
+ if (topic.constraints.length > 0) {
2926
+ lines.push("## Constraints", "");
2927
+ for (const c of topic.constraints) {
2928
+ lines.push(` [${c.id}] ${c.rule}`);
2929
+ lines.push(` Enforced by: ${c.enforcedBy}`);
2930
+ }
2931
+ lines.push("");
2932
+ }
2933
+ return lines.join("\n");
2934
+ }
2935
+ function findDefault(schema, conceptName, propName) {
2936
+ return schema.find((c) => c.key === `${conceptName}.${propName}`)?.default;
2937
+ }
2938
+ /**
2939
+ * Builds the JSON output for a topic or the full index.
2940
+ */
2941
+ function buildTopicJson(topics, manifest, topicName) {
2942
+ if (!topicName) return {
2943
+ topics: topics.map((t) => ({
2944
+ name: t.name,
2945
+ summary: t.summary,
2946
+ concepts: t.concepts.length,
2947
+ operations: t.operations.length,
2948
+ configFields: t.configSchema.length,
2949
+ constraints: t.constraints.length
2950
+ })),
2951
+ ckm: {
2952
+ concepts: manifest.concepts.length,
2953
+ operations: manifest.operations.length,
2954
+ constraints: manifest.constraints.length,
2955
+ workflows: manifest.workflows.length,
2956
+ configSchema: manifest.configSchema.length
2957
+ }
2958
+ };
2959
+ const topic = topics.find((t) => t.name === topicName);
2960
+ if (!topic) return {
2961
+ error: `Unknown topic: ${topicName}`,
2962
+ topics: topics.map((t) => t.name)
2963
+ };
2964
+ return {
2965
+ topic: topic.name,
2966
+ summary: topic.summary,
2967
+ concepts: topic.concepts,
2968
+ operations: topic.operations,
2969
+ configSchema: topic.configSchema,
2970
+ constraints: topic.constraints
2971
+ };
2972
+ }
2973
+ //#endregion
2974
+ //#region src/config.ts
2975
+ var CONFIG_FILE_NAMES = [
2976
+ ".versionguard.yml",
2977
+ ".versionguard.yaml",
2978
+ "versionguard.yml",
2979
+ "versionguard.yaml"
2980
+ ];
2981
+ var DEFAULT_CONFIG = {
2982
+ versioning: {
2983
+ type: "semver",
2984
+ schemeRules: {
2985
+ maxNumericSegments: 3,
2986
+ allowedModifiers: [
2987
+ "dev",
2988
+ "alpha",
2989
+ "beta",
2990
+ "rc"
2991
+ ]
2992
+ },
2993
+ semver: {
2994
+ allowVPrefix: false,
2995
+ allowBuildMetadata: true,
2996
+ requirePrerelease: false
2997
+ },
2998
+ calver: {
2999
+ format: "YYYY.MM.PATCH",
3000
+ preventFutureDates: true,
3001
+ strictMutualExclusion: true
3002
+ }
3003
+ },
3004
+ manifest: { source: "auto" },
3005
+ sync: {
3006
+ files: ["README.md", "CHANGELOG.md"],
3007
+ patterns: [{
3008
+ regex: "(version\\s*[=:]\\s*[\"'])(.+?)([\"'])",
3009
+ template: "$1{{version}}$3"
3010
+ }, {
3011
+ regex: "(##\\s*\\[)(.+?)(\\])",
3012
+ template: "$1{{version}}$3"
3013
+ }]
3014
+ },
3015
+ changelog: {
3016
+ enabled: true,
3017
+ file: "CHANGELOG.md",
3018
+ strict: true,
3019
+ requireEntry: true,
3020
+ enforceStructure: false,
3021
+ sections: [
3022
+ "Added",
3023
+ "Changed",
3024
+ "Deprecated",
3025
+ "Removed",
3026
+ "Fixed",
3027
+ "Security"
3028
+ ]
3029
+ },
3030
+ github: { dependabot: true },
3031
+ scan: {
3032
+ enabled: false,
3033
+ patterns: [
3034
+ "(?:version\\s*[:=]\\s*[\"'])([\\d]+\\.[\\d]+\\.[\\d]+(?:-[\\w.]+)?)[\"']",
3035
+ "(?:FROM\\s+\\S+:)(\\d+\\.\\d+\\.\\d+(?:-[\\w.]+)?)",
3036
+ "(?:uses:\\s+\\S+@v?)(\\d+\\.\\d+\\.\\d+(?:-[\\w.]+)?)"
3037
+ ],
3038
+ allowlist: []
3039
+ },
3040
+ git: {
3041
+ hooks: {
3042
+ "pre-commit": true,
3043
+ "pre-push": true,
3044
+ "post-tag": true
3045
+ },
3046
+ enforceHooks: true
3047
+ },
3048
+ ignore: [
3049
+ "node_modules/**",
3050
+ "dist/**",
3051
+ ".git/**",
3052
+ "*.lock",
3053
+ "package-lock.json"
3054
+ ]
3055
+ };
3056
+ var MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
3057
+ /**
3058
+ * Returns a deep-cloned copy of the built-in VersionGuard configuration.
3059
+ *
3060
+ * @public
3061
+ * @since 0.1.0
3062
+ * @remarks
3063
+ * A fresh clone is returned so callers can safely modify the result without
3064
+ * mutating shared defaults.
3065
+ *
3066
+ * @returns The default VersionGuard configuration.
3067
+ * @example
3068
+ * ```ts
3069
+ * import { getDefaultConfig } from 'versionguard';
3070
+ *
3071
+ * const config = getDefaultConfig();
3072
+ * ```
3073
+ */
3074
+ function getDefaultConfig() {
3075
+ return structuredClone(DEFAULT_CONFIG);
3076
+ }
3077
+ /**
3078
+ * Finds the first supported VersionGuard config file in a directory.
3079
+ *
3080
+ * @public
3081
+ * @since 0.1.0
3082
+ * @remarks
3083
+ * Search order follows `CONFIG_FILE_NAMES`, so `.versionguard.yml` takes
3084
+ * precedence over the other supported filenames.
3085
+ *
3086
+ * @param cwd - Directory to search.
3087
+ * @returns The resolved config path, or `null` when no config file exists.
3088
+ * @example
3089
+ * ```ts
3090
+ * import { findConfig } from 'versionguard';
3091
+ *
3092
+ * const configPath = findConfig(process.cwd());
3093
+ * ```
3094
+ */
3095
+ function findConfig(cwd = process.cwd()) {
3096
+ for (const fileName of CONFIG_FILE_NAMES) {
3097
+ const fullPath = path.join(cwd, fileName);
3098
+ if (fs.existsSync(fullPath)) return fullPath;
3099
+ }
3100
+ return null;
3101
+ }
3102
+ /**
3103
+ * Loads a VersionGuard config file from disk.
3104
+ *
3105
+ * @public
3106
+ * @since 0.1.0
3107
+ * @remarks
3108
+ * The parsed YAML object is merged with the built-in defaults so omitted keys
3109
+ * inherit their default values.
3110
+ *
3111
+ * @param configPath - Path to the YAML config file.
3112
+ * @returns The merged VersionGuard configuration.
3113
+ * @example
3114
+ * ```ts
3115
+ * import { loadConfig } from 'versionguard';
3116
+ *
3117
+ * const config = loadConfig('.versionguard.yml');
3118
+ * ```
3119
+ */
3120
+ function loadConfig(configPath) {
3121
+ const content = fs.readFileSync(configPath, "utf-8");
3122
+ const parsed = yaml.load(content);
3123
+ if (parsed === void 0) return getDefaultConfig();
3124
+ if (!isPlainObject(parsed)) throw new Error(`Config file must contain a YAML object: ${configPath}`);
3125
+ return mergeDeep(getDefaultConfig(), parsed);
3126
+ }
3127
+ /**
3128
+ * Resolves the active VersionGuard configuration for a project.
3129
+ *
3130
+ * @public
3131
+ * @since 0.1.0
3132
+ * @remarks
3133
+ * If no config file is present, this falls back to the built-in defaults.
3134
+ *
3135
+ * @param cwd - Project directory to inspect.
3136
+ * @returns The resolved VersionGuard configuration.
3137
+ * @example
3138
+ * ```ts
3139
+ * import { getConfig } from 'versionguard';
3140
+ *
3141
+ * const config = getConfig(process.cwd());
3142
+ * ```
3143
+ */
3144
+ function getConfig(cwd = process.cwd()) {
3145
+ const configPath = findConfig(cwd);
3146
+ return configPath ? loadConfig(configPath) : getDefaultConfig();
3147
+ }
3148
+ /**
3149
+ * Initializes a new VersionGuard config file in a project.
3150
+ *
3151
+ * @public
3152
+ * @since 0.1.0
3153
+ * @remarks
3154
+ * This writes `.versionguard.yml` using the bundled example when available,
3155
+ * otherwise it writes a generated default configuration.
3156
+ *
3157
+ * @param cwd - Project directory where the config should be created.
3158
+ * @returns The path to the created config file.
3159
+ * @example
3160
+ * ```ts
3161
+ * import { initConfig } from 'versionguard';
3162
+ *
3163
+ * const configPath = initConfig(process.cwd());
3164
+ * ```
3165
+ */
3166
+ function initConfig(cwd = process.cwd()) {
3167
+ const configPath = path.join(cwd, ".versionguard.yml");
3168
+ const existingConfigPath = findConfig(cwd);
3169
+ if (existingConfigPath) throw new Error(`Config file already exists: ${existingConfigPath}`);
3170
+ const examplePath = path.join(MODULE_DIR, "..", ".versionguard.yml.example");
3171
+ const content = fs.existsSync(examplePath) ? fs.readFileSync(examplePath, "utf-8") : generateDefaultConfig();
3172
+ fs.writeFileSync(configPath, content, "utf-8");
3173
+ return configPath;
3174
+ }
3175
+ function generateDefaultConfig() {
3176
+ return `# VersionGuard Configuration
3177
+ # Change "type" to switch between semver and calver — both blocks are always present.
3178
+ versioning:
3179
+ type: semver
3180
+ semver:
3181
+ allowVPrefix: false
3182
+ allowBuildMetadata: true
3183
+ requirePrerelease: false
3184
+ calver:
3185
+ format: "YYYY.MM.PATCH"
3186
+ preventFutureDates: true
3187
+
3188
+ sync:
3189
+ files:
3190
+ - "README.md"
3191
+ - "CHANGELOG.md"
3192
+ patterns:
3193
+ - regex: '(version\\s*[=:]\\s*["''])(.+?)(["''])'
3194
+ template: '$1{{version}}$3'
3195
+ - regex: '(##\\s*\\[)(.+?)(\\])'
3196
+ template: '$1{{version}}$3'
3197
+
3198
+ changelog:
3199
+ enabled: true
3200
+ file: "CHANGELOG.md"
3201
+ strict: true
3202
+ requireEntry: true
3203
+
3204
+ github:
3205
+ dependabot: true
3206
+
3207
+ git:
3208
+ hooks:
3209
+ pre-commit: true
3210
+ pre-push: true
3211
+ post-tag: true
3212
+ enforceHooks: true
3213
+
3214
+ ignore:
3215
+ - "node_modules/**"
3216
+ - "dist/**"
3217
+ - ".git/**"
3218
+ `;
3219
+ }
3220
+ function isPlainObject(value) {
3221
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3222
+ }
3223
+ function mergeDeep(target, source) {
3224
+ /* v8 ignore next 3 -- defensive fallback for non-object nested overrides */
3225
+ if (!isPlainObject(target) || !isPlainObject(source)) return source ?? target;
3226
+ const output = { ...target };
3227
+ for (const [key, value] of Object.entries(source)) {
3228
+ const current = output[key];
3229
+ output[key] = isPlainObject(current) && isPlainObject(value) ? mergeDeep(current, value) : value;
3230
+ }
3231
+ return output;
3232
+ }
3233
+ //#endregion
3234
+ //#region src/feedback/index.ts
3235
+ /**
3236
+ * Generates actionable feedback for a version string.
3237
+ *
3238
+ * @public
3239
+ * @since 0.1.0
3240
+ * @remarks
3241
+ * This helper dispatches to the SemVer or CalVer feedback flow based on the
3242
+ * configured versioning strategy and returns both hard validation errors and
3243
+ * softer suggestions for likely fixes.
3244
+ *
3245
+ * @param version - Version string to evaluate.
3246
+ * @param config - Loaded VersionGuard configuration.
3247
+ * @param previousVersion - Optional previous version used for progression checks.
3248
+ * @returns A feedback object with validation errors and suggested fixes.
3249
+ *
3250
+ * @example
3251
+ * ```typescript
3252
+ * const feedbackResult = getVersionFeedback('1.2.3', config, '1.2.2');
3253
+ * console.log(feedbackResult.valid);
3254
+ * ```
3255
+ */
3256
+ function getVersionFeedback(version, config, previousVersion) {
3257
+ if (config.versioning.type === "semver") return getSemVerFeedback(version, previousVersion);
3258
+ return getCalVerFeedback(version, getCalVerConfig(config), previousVersion);
3259
+ }
3260
+ function getSemVerFeedback(version, previousVersion) {
3261
+ const errors = [];
3262
+ const suggestions = [];
3263
+ const parsed = parse$1(version);
3264
+ if (!parsed) {
3265
+ const validation = validate$1(version);
3266
+ if (version.startsWith("v")) {
3267
+ const cleanVersion = version.slice(1);
3268
+ errors.push({
3269
+ message: `Version should not start with 'v': ${version}`,
3270
+ severity: "error"
3271
+ });
3272
+ suggestions.push({
3273
+ message: `Remove the 'v' prefix`,
3274
+ fix: `npx versionguard fix --version ${cleanVersion}`,
3275
+ autoFixable: true
3276
+ });
3277
+ } else if (version.split(".").length === 2) {
3278
+ errors.push({
3279
+ message: `Version missing patch number: ${version}`,
3280
+ severity: "error"
3281
+ });
3282
+ suggestions.push({
3283
+ message: `Add patch number (e.g., ${version}.0)`,
3284
+ fix: `npx versionguard fix --version ${version}.0`,
3285
+ autoFixable: true
3286
+ });
3287
+ } else if (/^\d+\.\d+\.\d+\.\d+$/.test(version)) {
3288
+ errors.push({
3289
+ message: `Version has too many segments: ${version}`,
3290
+ severity: "error"
3291
+ });
3292
+ suggestions.push({
3293
+ message: `Use only 3 segments (MAJOR.MINOR.PATCH)`,
3294
+ autoFixable: false
3295
+ });
3296
+ } else if (validation.errors.some((error) => error.message.includes("leading zero"))) {
3297
+ errors.push(...validation.errors);
3298
+ suggestions.push({
3299
+ message: `Remove leading zeros (e.g., 1.2.3 instead of 01.02.03)`,
3300
+ autoFixable: false
3301
+ });
3302
+ } else {
3303
+ errors.push(...validation.errors.length > 0 ? validation.errors : [{
3304
+ message: `Invalid SemVer format: ${version}`,
3305
+ severity: "error"
3306
+ }]);
3307
+ suggestions.push({
3308
+ message: `Use format: MAJOR.MINOR.PATCH (e.g., 1.0.0)`,
3309
+ autoFixable: false
3310
+ });
3311
+ }
3312
+ return {
3313
+ valid: false,
3314
+ errors,
3315
+ suggestions,
3316
+ canAutoFix: suggestions.some((s) => s.autoFixable)
3317
+ };
3318
+ }
3319
+ if (previousVersion) {
3320
+ const prevParsed = parse$1(previousVersion);
3321
+ if (prevParsed) {
3322
+ const comparison = compare(version, previousVersion);
3323
+ if (comparison < 0) {
3324
+ errors.push({
3325
+ message: `Version ${version} is older than previous ${previousVersion}`,
3326
+ severity: "error"
3327
+ });
3328
+ suggestions.push({
3329
+ message: `Version must be greater than ${previousVersion}`,
3330
+ fix: `npx versionguard fix --version ${increment(previousVersion, "patch")}`,
3331
+ autoFixable: true
3332
+ });
3333
+ } else if (comparison === 0) {
3334
+ errors.push({
3335
+ message: `Version ${version} is the same as previous`,
3336
+ severity: "error"
3337
+ });
3338
+ suggestions.push({
3339
+ message: `Bump the version`,
3340
+ fix: `npx versionguard fix --version ${increment(previousVersion, "patch")}`,
3341
+ autoFixable: true
3342
+ });
3343
+ } else {
3344
+ const majorJump = parsed.major - prevParsed.major;
3345
+ const minorJump = parsed.minor - prevParsed.minor;
3346
+ const patchJump = parsed.patch - prevParsed.patch;
3347
+ if (majorJump > 1) suggestions.push({
3348
+ message: `⚠️ Major version jumped by ${majorJump} (from ${previousVersion} to ${version})`,
3349
+ autoFixable: false
3350
+ });
3351
+ if (minorJump > 10) suggestions.push({
3352
+ message: `⚠️ Minor version jumped by ${minorJump} - did you mean to do a major bump?`,
3353
+ autoFixable: false
3354
+ });
3355
+ if (patchJump > 20) suggestions.push({
3356
+ message: `⚠️ Patch version jumped by ${patchJump} - consider a minor bump instead`,
3357
+ autoFixable: false
3358
+ });
3359
+ }
3360
+ }
3361
+ }
3362
+ return {
3363
+ valid: errors.length === 0,
3364
+ errors,
3365
+ suggestions,
3366
+ canAutoFix: suggestions.some((s) => s.autoFixable)
3367
+ };
3368
+ }
3369
+ function getCalVerFeedback(version, calverConfig, previousVersion) {
3370
+ const errors = [];
3371
+ const suggestions = [];
3372
+ const { format, preventFutureDates } = calverConfig;
3373
+ const parsed = parse$2(version, format);
3374
+ if (!parsed) {
3375
+ errors.push({
3376
+ message: `Invalid CalVer format: ${version}`,
3377
+ severity: "error"
3378
+ });
3379
+ suggestions.push({
3380
+ message: `Expected format: ${format}`,
3381
+ fix: `Update version to current date: "${getCurrentVersion(format)}"`,
3382
+ autoFixable: true
3383
+ });
3384
+ return {
3385
+ valid: false,
3386
+ errors,
3387
+ suggestions,
3388
+ canAutoFix: true
3389
+ };
3390
+ }
3391
+ const validation = validate$2(version, format, preventFutureDates);
3392
+ errors.push(...validation.errors);
3393
+ const now = /* @__PURE__ */ new Date();
3394
+ if (validation.errors.some((error) => error.message.startsWith("Invalid month:"))) suggestions.push({
3395
+ message: `Month must be between 1-12`,
3396
+ autoFixable: false
3397
+ });
3398
+ if (validation.errors.some((error) => error.message.startsWith("Invalid day:"))) suggestions.push({
3399
+ message: `Day must be valid for the selected month`,
3400
+ autoFixable: false
3401
+ });
3402
+ if (preventFutureDates && parsed.year > now.getFullYear()) suggestions.push({
3403
+ message: `Use current year (${now.getFullYear()}) or a past year`,
3404
+ fix: `npx versionguard fix --version ${formatCalVerVersion({
3405
+ ...parsed,
3406
+ year: now.getFullYear()
3407
+ })}`,
3408
+ autoFixable: true
3409
+ });
3410
+ if (preventFutureDates && parsed.year === now.getFullYear() && parsed.month > now.getMonth() + 1) suggestions.push({
3411
+ message: `Current month is ${now.getMonth() + 1}`,
3412
+ fix: `npx versionguard fix --version ${formatCalVerVersion({
3413
+ ...parsed,
3414
+ month: now.getMonth() + 1
3415
+ })}`,
3416
+ autoFixable: true
3417
+ });
3418
+ if (preventFutureDates && parsed.year === now.getFullYear() && parsed.month === now.getMonth() + 1 && parsed.day !== void 0 && parsed.day > now.getDate()) suggestions.push({
3419
+ message: `Current day is ${now.getDate()}`,
3420
+ fix: `npx versionguard fix --version ${formatCalVerVersion({
3421
+ ...parsed,
3422
+ day: now.getDate()
3423
+ })}`,
3424
+ autoFixable: true
3425
+ });
3426
+ if (previousVersion) {
3427
+ if (parse$2(previousVersion, format)) {
3428
+ if (compare$1(version, previousVersion, format) <= 0) {
3429
+ errors.push({
3430
+ message: `Version ${version} is not newer than previous ${previousVersion}`,
3431
+ severity: "error"
3432
+ });
3433
+ suggestions.push({
3434
+ message: `CalVer must increase over time`,
3435
+ fix: `npx versionguard fix --version ${increment$1(previousVersion, format)}`,
3436
+ autoFixable: true
3437
+ });
3438
+ }
3439
+ }
3440
+ }
3441
+ return {
3442
+ valid: errors.length === 0,
3443
+ errors,
3444
+ suggestions,
3445
+ canAutoFix: suggestions.some((s) => s.autoFixable)
3446
+ };
3447
+ }
3448
+ function formatCalVerVersion(version) {
3449
+ return format$1({
3450
+ ...version,
3451
+ raw: version.raw || ""
3452
+ });
3453
+ }
3454
+ /**
3455
+ * Generates suggestions for version sync mismatches in a file.
3456
+ *
3457
+ * @public
3458
+ * @since 0.1.0
3459
+ * @remarks
3460
+ * The returned suggestions include a general sync command and may include
3461
+ * file-type-specific hints for markdown or source files.
3462
+ *
3463
+ * @param file - File containing the mismatched version string.
3464
+ * @param foundVersion - Version currently found in the file.
3465
+ * @param expectedVersion - Version that should appear in the file.
3466
+ * @returns Suggestions for resolving the mismatch.
3467
+ *
3468
+ * @example
3469
+ * ```typescript
3470
+ * const suggestions = getSyncFeedback('README.md', '1.0.0', '1.0.1');
3471
+ * console.log(suggestions[0]?.message);
3472
+ * ```
3473
+ */
3474
+ function getSyncFeedback(file, foundVersion, expectedVersion) {
3475
+ const suggestions = [{
3476
+ message: `${file} has version "${foundVersion}" but should be "${expectedVersion}"`,
3477
+ fix: `npx versionguard sync`,
3478
+ autoFixable: true
3479
+ }];
3480
+ if (file.endsWith(".md")) suggestions.push({
3481
+ message: `For markdown files, check headers like "## [${expectedVersion}]"`,
3482
+ autoFixable: false
3483
+ });
3484
+ if (file.endsWith(".ts") || file.endsWith(".js")) suggestions.push({
3485
+ message: `For code files, check constants like "export const VERSION = '${expectedVersion}'"`,
3486
+ autoFixable: false
3487
+ });
3488
+ return suggestions;
3489
+ }
3490
+ /**
3491
+ * Generates suggestions for changelog-related validation issues.
3492
+ *
3493
+ * @public
3494
+ * @since 0.1.0
3495
+ * @remarks
3496
+ * This helper suggests either creating a missing entry or reconciling the latest
3497
+ * changelog version with the package version.
3498
+ *
3499
+ * @param hasEntry - Whether the changelog already contains an entry for the version.
3500
+ * @param version - Package version that should appear in the changelog.
3501
+ * @param latestChangelogVersion - Most recent version currently found in the changelog.
3502
+ * @returns Suggestions for bringing changelog state back into sync.
3503
+ *
3504
+ * @example
3505
+ * ```typescript
3506
+ * const suggestions = getChangelogFeedback(false, '1.2.3', '1.2.2');
3507
+ * console.log(suggestions.length > 0);
3508
+ * ```
3509
+ */
3510
+ function getChangelogFeedback(hasEntry, version, latestChangelogVersion) {
3511
+ const suggestions = [];
3512
+ if (!hasEntry) {
3513
+ suggestions.push({
3514
+ message: `CHANGELOG.md is missing entry for version ${version}`,
3515
+ fix: `npx versionguard fix`,
3516
+ autoFixable: true
3517
+ });
3518
+ suggestions.push({
3519
+ message: `Or manually add: "## [${version}] - YYYY-MM-DD" under [Unreleased]`,
3520
+ autoFixable: false
3521
+ });
3522
+ }
3523
+ if (latestChangelogVersion && latestChangelogVersion !== version) suggestions.push({
3524
+ message: `CHANGELOG.md latest entry is ${latestChangelogVersion}, but manifest version is ${version}`,
3525
+ fix: `Make sure versions are in sync`,
3526
+ autoFixable: false
3527
+ });
3528
+ return suggestions;
3529
+ }
3530
+ /**
3531
+ * Generates suggestions for git tag mismatches.
3532
+ *
3533
+ * @public
3534
+ * @since 0.1.0
3535
+ * @remarks
3536
+ * This helper focuses on discrepancies between git tag versions, package.json,
3537
+ * and repository files that still need to be synchronized.
3538
+ *
3539
+ * @param tagVersion - Version represented by the git tag.
3540
+ * @param packageVersion - Version currently stored in `package.json`.
3541
+ * @param hasUnsyncedFiles - Whether repository files are still out of sync.
3542
+ * @returns Suggestions for correcting tag-related issues.
3543
+ *
3544
+ * @example
3545
+ * ```typescript
3546
+ * const suggestions = getTagFeedback('v1.2.2', '1.2.3', true);
3547
+ * console.log(suggestions.map((item) => item.message));
3548
+ * ```
3549
+ */
3550
+ function getTagFeedback(tagVersion, packageVersion, hasUnsyncedFiles) {
3551
+ const suggestions = [];
3552
+ if (tagVersion !== packageVersion) suggestions.push({
3553
+ message: `Git tag "${tagVersion}" doesn't match manifest version "${packageVersion}"`,
3554
+ fix: `Delete tag and recreate: git tag -d ${tagVersion} && git tag ${packageVersion}`,
3555
+ autoFixable: false
3556
+ });
3557
+ if (hasUnsyncedFiles) suggestions.push({
3558
+ message: `Files are out of sync with version ${packageVersion}`,
3559
+ fix: `npx versionguard sync`,
3560
+ autoFixable: true
3561
+ });
3562
+ return suggestions;
3563
+ }
3564
+ //#endregion
3565
+ //#region src/fix/index.ts
3566
+ /**
3567
+ * Updates the `package.json` version field when needed.
3568
+ *
3569
+ * @public
3570
+ * @since 0.1.0
3571
+ * @remarks
3572
+ * This helper writes the target version only when `package.json` exists and the
3573
+ * current version differs from the requested value.
3574
+ *
3575
+ * @param targetVersion - Version that should be written to `package.json`.
3576
+ * @param cwd - Repository directory containing `package.json`.
3577
+ * @param manifest - Optional manifest configuration for language-agnostic support.
3578
+ * @returns The result of the package version fix attempt.
3579
+ *
3580
+ * @example
3581
+ * ```typescript
3582
+ * // Fix using legacy package.json fallback
3583
+ * const result = fixPackageVersion('1.2.3', process.cwd());
3584
+ * console.log(result.fixed);
3585
+ *
3586
+ * // Fix using a configured manifest source
3587
+ * const result2 = fixPackageVersion('1.2.3', process.cwd(), { source: 'Cargo.toml' });
3588
+ * ```
3589
+ */
3590
+ function fixPackageVersion(targetVersion, cwd = process.cwd(), manifest) {
3591
+ if (!manifest) {
3592
+ const packagePath = path.join(cwd, "package.json");
3593
+ if (!fs.existsSync(packagePath)) return {
3594
+ fixed: false,
3595
+ message: "package.json not found"
3596
+ };
3597
+ const pkg = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
3598
+ const oldVersion = typeof pkg.version === "string" ? pkg.version : void 0;
3599
+ if (oldVersion === targetVersion) return {
3600
+ fixed: false,
3601
+ message: `Already at version ${targetVersion}`
3602
+ };
3603
+ setPackageVersion(targetVersion, cwd);
3604
+ return {
3605
+ fixed: true,
3606
+ message: `Updated package.json from ${oldVersion} to ${targetVersion}`,
3607
+ file: packagePath
3608
+ };
3609
+ }
3610
+ const provider = getVersionSource(manifest, cwd);
3611
+ let oldVersion;
3612
+ try {
3613
+ oldVersion = provider.getVersion(cwd);
3614
+ } catch {
3615
+ return {
3616
+ fixed: false,
3617
+ message: "Version source not found"
3618
+ };
3619
+ }
3620
+ if (oldVersion === targetVersion) return {
3621
+ fixed: false,
3622
+ message: `Already at version ${targetVersion}`
3623
+ };
3624
+ provider.setVersion(targetVersion, cwd);
3625
+ return {
3626
+ fixed: true,
3627
+ message: `Updated version from ${oldVersion} to ${targetVersion}`,
3628
+ file: provider.manifestFile ? path.join(cwd, provider.manifestFile) : void 0
3629
+ };
3630
+ }
3631
+ /**
3632
+ * Synchronizes configured files to the package version.
3633
+ *
3634
+ * @public
3635
+ * @since 0.1.0
3636
+ * @remarks
3637
+ * This helper uses the configured sync targets to update version strings across
3638
+ * the repository and reports only the files that changed.
3639
+ *
3640
+ * @param config - Loaded VersionGuard configuration.
3641
+ * @param cwd - Repository directory to synchronize.
3642
+ * @returns A list of per-file sync results.
3643
+ *
3644
+ * @example
3645
+ * ```typescript
3646
+ * const results = fixSyncIssues(config, process.cwd());
3647
+ * console.log(results.length);
3648
+ * ```
3649
+ */
3650
+ function fixSyncIssues(config, cwd = process.cwd()) {
3651
+ const results = syncVersion(getPackageVersion(cwd, config.manifest), config.sync, cwd).filter((result) => result.updated).map((result) => ({
3652
+ fixed: true,
3653
+ message: `Updated ${path.relative(cwd, result.file)} (${result.changes.length} changes)`,
3654
+ file: result.file
3655
+ }));
3656
+ if (results.length === 0) results.push({
3657
+ fixed: false,
3658
+ message: "All files already in sync"
3659
+ });
3660
+ return results;
3661
+ }
3662
+ /**
3663
+ * Ensures the changelog contains an entry for a version.
3664
+ *
3665
+ * @public
3666
+ * @since 0.1.0
3667
+ * @remarks
3668
+ * When the changelog file does not exist, this helper creates a starter changelog.
3669
+ * Otherwise it appends a new version entry only when one is missing.
3670
+ *
3671
+ * @param version - Version that should appear in the changelog.
3672
+ * @param config - Loaded VersionGuard configuration.
3673
+ * @param cwd - Repository directory containing the changelog file.
3674
+ * @returns The result of the changelog fix attempt.
3675
+ *
3676
+ * @example
3677
+ * ```typescript
3678
+ * const result = fixChangelog('1.2.3', config, process.cwd());
3679
+ * console.log(result.message);
3680
+ * ```
3681
+ */
3682
+ function fixChangelog(version, config, cwd = process.cwd()) {
3683
+ const changelogPath = path.join(cwd, config.changelog.file);
3684
+ if (!fs.existsSync(changelogPath)) {
3685
+ createInitialChangelog(changelogPath, version);
3686
+ return {
3687
+ fixed: true,
3688
+ message: `Created ${config.changelog.file} with entry for ${version}`,
3689
+ file: changelogPath
3690
+ };
3691
+ }
3692
+ if (fs.readFileSync(changelogPath, "utf-8").includes(`## [${version}]`)) return {
3693
+ fixed: false,
3694
+ message: `Changelog already has entry for ${version}`
3695
+ };
3696
+ addVersionEntry(changelogPath, version);
3697
+ return {
3698
+ fixed: true,
3699
+ message: `Added entry for ${version} to ${config.changelog.file}`,
3700
+ file: changelogPath
3701
+ };
3702
+ }
3703
+ /**
3704
+ * Create initial changelog
3705
+ */
3706
+ function createInitialChangelog(changelogPath, version) {
3707
+ const content = `# Changelog
3708
+
3709
+ All notable changes to this project will be documented in this file.
3710
+
3711
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
3712
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
3713
+
3714
+ ## [Unreleased]
3715
+
3716
+ ## [${version}] - ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}
3717
+
3718
+ ### Added
3719
+
3720
+ - Initial release
3721
+
3722
+ [Unreleased]: https://github.com/yourorg/project/compare/v${version}...HEAD
3723
+ [${version}]: https://github.com/yourorg/project/releases/tag/v${version}
3724
+ `;
3725
+ fs.writeFileSync(changelogPath, content, "utf-8");
3726
+ }
3727
+ /**
3728
+ * Runs all configured auto-fix operations.
3729
+ *
3730
+ * @public
3731
+ * @since 0.1.0
3732
+ * @remarks
3733
+ * This helper optionally updates the package version first, then synchronizes
3734
+ * configured files, and finally updates the changelog when changelog support is
3735
+ * enabled.
3736
+ *
3737
+ * @param config - Loaded VersionGuard configuration.
3738
+ * @param targetVersion - Optional version to apply before running other fixes.
3739
+ * @param cwd - Repository directory where fixes should run.
3740
+ * @returns Ordered results describing every fix step that ran.
3741
+ *
3742
+ * @example
3743
+ * ```typescript
3744
+ * const results = fixAll(config, '1.2.3', process.cwd());
3745
+ * console.log(results.some((result) => result.fixed));
3746
+ * ```
3747
+ */
3748
+ function fixAll(config, targetVersion, cwd = process.cwd()) {
3749
+ const results = [];
3750
+ const version = targetVersion || getPackageVersion(cwd, config.manifest);
3751
+ if (targetVersion && targetVersion !== getPackageVersion(cwd, config.manifest)) results.push(fixPackageVersion(targetVersion, cwd, config.manifest));
3752
+ const syncResults = fixSyncIssues(config, cwd);
3753
+ results.push(...syncResults);
3754
+ if (config.changelog.enabled) {
3755
+ const changelogPath = path.join(cwd, config.changelog.file);
3756
+ if (isChangesetMangled(changelogPath)) {
3757
+ if (fixChangesetMangling(changelogPath)) results.push({
3758
+ fixed: true,
3759
+ message: `Restructured ${config.changelog.file} from Changesets format to Keep a Changelog`,
3760
+ file: changelogPath
3761
+ });
3762
+ }
3763
+ const changelogResult = fixChangelog(version, config, cwd);
3764
+ if (changelogResult.fixed) results.push(changelogResult);
3765
+ }
3766
+ return results;
3767
+ }
3768
+ /**
3769
+ * Suggests candidate next versions for a release.
3770
+ *
3771
+ * @public
3772
+ * @since 0.1.0
3773
+ * @remarks
3774
+ * The suggestions depend on the configured versioning mode. SemVer returns one or
3775
+ * more bump options, while CalVer suggests the current date-based version and an
3776
+ * incremented same-day release.
3777
+ *
3778
+ * @param currentVersion - Current package version.
3779
+ * @param config - Loaded VersionGuard configuration.
3780
+ * @param changeType - Preferred bump type, or `auto` to include common options.
3781
+ * @returns Candidate versions paired with the reason for each suggestion.
3782
+ *
3783
+ * @example
3784
+ * ```typescript
3785
+ * const suggestions = suggestNextVersion('1.2.3', config, 'minor');
3786
+ * console.log(suggestions[0]?.version);
3787
+ * ```
3788
+ */
3789
+ function suggestNextVersion(currentVersion, config, changeType) {
3790
+ const suggestions = [];
3791
+ if (config.versioning.type === "semver") {
3792
+ if (!changeType || changeType === "auto" || changeType === "patch") suggestions.push({
3793
+ version: increment(currentVersion, "patch"),
3794
+ reason: "Patch - bug fixes, small changes"
3795
+ });
3796
+ if (!changeType || changeType === "auto" || changeType === "minor") suggestions.push({
3797
+ version: increment(currentVersion, "minor"),
3798
+ reason: "Minor - new features, backwards compatible"
3799
+ });
3800
+ if (!changeType || changeType === "auto" || changeType === "major") suggestions.push({
3801
+ version: increment(currentVersion, "major"),
3802
+ reason: "Major - breaking changes"
3803
+ });
3804
+ } else {
3805
+ const format = getCalVerConfig(config).format;
3806
+ const currentCal = getCurrentVersion(format);
3807
+ suggestions.push({
3808
+ version: currentCal,
3809
+ reason: "Current date - new release today"
3810
+ });
3811
+ suggestions.push({
3812
+ version: increment$1(currentVersion, format),
3813
+ reason: "Increment patch - additional release today"
3814
+ });
3815
+ }
3816
+ return suggestions;
3817
+ }
3818
+ //#endregion
3819
+ //#region src/guard.ts
3820
+ var HOOK_NAMES = [
3821
+ "pre-commit",
3822
+ "pre-push",
3823
+ "post-tag"
3824
+ ];
3825
+ /**
3826
+ * Checks whether git hooks have been redirected away from the repository.
3827
+ *
3828
+ * @remarks
3829
+ * When `core.hooksPath` is set to a non-default location, git hooks installed
3830
+ * in `.git/hooks/` are silently ignored. This is a common bypass vector.
3831
+ *
3832
+ * @param cwd - Repository directory to inspect.
3833
+ * @returns A guard warning when a hooksPath override is detected.
3834
+ *
3835
+ * @example
3836
+ * ```ts
3837
+ * import { checkHooksPathOverride } from './guard';
3838
+ *
3839
+ * const warning = checkHooksPathOverride(process.cwd());
3840
+ * if (warning) console.warn(warning.message);
3841
+ * ```
3842
+ *
3843
+ * @public
3844
+ * @since 0.2.0
3845
+ */
3846
+ function checkHooksPathOverride(cwd) {
3847
+ try {
3848
+ const hooksPath = execSync("git config core.hooksPath", {
3849
+ cwd,
3850
+ encoding: "utf-8"
3851
+ }).trim();
3852
+ if (hooksPath) {
3853
+ const resolved = path.resolve(cwd, hooksPath);
3854
+ const huskyDir = path.resolve(cwd, ".husky");
3855
+ if (resolved === huskyDir || resolved.startsWith(`${huskyDir}${path.sep}`)) return {
3856
+ code: "HOOKS_PATH_HUSKY",
3857
+ severity: "warning",
3858
+ message: `Husky detected — core.hooksPath is set to "${hooksPath}". Hooks in .git/hooks/ are bypassed. Add versionguard validate to your .husky/pre-commit manually or use a tool like forge-ts that manages .husky/ hooks cooperatively.`
3859
+ };
3860
+ return {
3861
+ code: "HOOKS_PATH_OVERRIDE",
3862
+ severity: "error",
3863
+ message: `git core.hooksPath is set to "${hooksPath}" — hooks in .git/hooks/ are bypassed`,
3864
+ fix: "git config --unset core.hooksPath"
3865
+ };
3866
+ }
3867
+ } catch {}
3868
+ return null;
3869
+ }
3870
+ /**
3871
+ * Checks whether the HUSKY environment variable is disabling hooks.
3872
+ *
3873
+ * @remarks
3874
+ * Setting `HUSKY=0` is a documented way to disable Husky hooks. Since
3875
+ * VersionGuard hooks may run alongside or through Husky, this bypass
3876
+ * can silently disable enforcement.
3877
+ *
3878
+ * @returns A guard warning when the HUSKY bypass is detected.
3879
+ *
3880
+ * @example
3881
+ * ```ts
3882
+ * import { checkHuskyBypass } from './guard';
3883
+ *
3884
+ * const warning = checkHuskyBypass();
3885
+ * if (warning) console.warn(warning.message);
3886
+ * ```
3887
+ *
3888
+ * @public
3889
+ * @since 0.2.0
3890
+ */
3891
+ function checkHuskyBypass() {
3892
+ if (process.env.HUSKY === "0") return {
3893
+ code: "HUSKY_BYPASS",
3894
+ severity: "error",
3895
+ message: "HUSKY=0 is set — git hooks are disabled via environment variable",
3896
+ fix: "unset HUSKY"
3897
+ };
3898
+ return null;
3899
+ }
3900
+ /**
3901
+ * Verifies that installed hook scripts match the expected content.
3902
+ *
3903
+ * @remarks
3904
+ * This compares each hook file against what `generateHookScript` would produce.
3905
+ * Tampered hooks that still contain "versionguard" pass `areHooksInstalled` but
3906
+ * may have had critical lines removed or modified.
3907
+ *
3908
+ * @param config - VersionGuard configuration that defines which hooks should exist.
3909
+ * @param cwd - Repository directory to inspect.
3910
+ * @returns Guard warnings for each hook that has been tampered with.
3911
+ *
3912
+ * @example
3913
+ * ```ts
3914
+ * import { checkHookIntegrity } from './guard';
3915
+ *
3916
+ * const warnings = checkHookIntegrity(config, process.cwd());
3917
+ * for (const w of warnings) console.warn(w.code, w.message);
3918
+ * ```
3919
+ *
3920
+ * @public
3921
+ * @since 0.2.0
3922
+ */
3923
+ function checkHookIntegrity(config, cwd) {
3924
+ const warnings = [];
3925
+ const gitDir = findGitDir(cwd);
3926
+ if (!gitDir) return warnings;
3927
+ const hooksDir = path.join(gitDir, "hooks");
3928
+ for (const hookName of HOOK_NAMES) {
3929
+ if (!config.git.hooks[hookName]) continue;
3930
+ const hookPath = path.join(hooksDir, hookName);
3931
+ if (!fs.existsSync(hookPath)) {
3932
+ warnings.push({
3933
+ code: "HOOK_MISSING",
3934
+ severity: "error",
3935
+ message: `Required hook "${hookName}" is not installed`,
3936
+ fix: "npx versionguard hooks install"
3937
+ });
3938
+ continue;
3939
+ }
3940
+ const actual = fs.readFileSync(hookPath, "utf-8");
3941
+ if (actual !== generateHookScript(hookName)) if (!actual.includes("versionguard")) warnings.push({
3942
+ code: "HOOK_REPLACED",
3943
+ severity: "error",
3944
+ message: `Hook "${hookName}" has been replaced — versionguard invocation is missing`,
3945
+ fix: "npx versionguard hooks install"
3946
+ });
3947
+ else warnings.push({
3948
+ code: "HOOK_TAMPERED",
3949
+ severity: "warning",
3950
+ message: `Hook "${hookName}" has been modified from the expected template`,
3951
+ fix: "npx versionguard hooks install"
3952
+ });
3953
+ }
3954
+ return warnings;
3955
+ }
3956
+ /**
3957
+ * Checks whether hooks are configured as required but not enforced.
3958
+ *
3959
+ * @remarks
3960
+ * When hooks are enabled in the config but `enforceHooks` is false, validation
3961
+ * will not fail for missing hooks. In strict mode this is a policy gap.
3962
+ *
3963
+ * @param config - VersionGuard configuration to inspect.
3964
+ * @returns A guard warning when hooks are enabled but not enforced.
3965
+ *
3966
+ * @example
3967
+ * ```ts
3968
+ * import { checkEnforceHooksPolicy } from './guard';
3969
+ *
3970
+ * const warning = checkEnforceHooksPolicy(config);
3971
+ * if (warning) console.warn(warning.message);
3972
+ * ```
3973
+ *
3974
+ * @public
3975
+ * @since 0.2.0
3976
+ */
3977
+ function checkEnforceHooksPolicy(config) {
3978
+ if (HOOK_NAMES.some((name) => config.git.hooks[name]) && !config.git.enforceHooks) return {
3979
+ code: "HOOKS_NOT_ENFORCED",
3980
+ severity: "warning",
3981
+ message: "Hooks are enabled but enforceHooks is false — missing hooks will not fail validation",
3982
+ fix: "Set git.enforceHooks: true in .versionguard.yml"
3983
+ };
3984
+ return null;
3985
+ }
3986
+ /**
3987
+ * Runs all guard checks and returns a consolidated report.
3988
+ *
3989
+ * @remarks
3990
+ * This is the primary entry point for strict mode. It runs every detection
3991
+ * check and returns a report indicating whether the repository is safe from
3992
+ * known bypass patterns.
3993
+ *
3994
+ * @param config - VersionGuard configuration.
3995
+ * @param cwd - Repository directory to inspect.
3996
+ * @returns A guard report with all findings.
3997
+ *
3998
+ * @example
3999
+ * ```ts
4000
+ * import { runGuardChecks } from './guard';
4001
+ *
4002
+ * const report = runGuardChecks(config, process.cwd());
4003
+ * if (!report.safe) console.error('Guard check failed:', report.warnings);
4004
+ * ```
4005
+ *
4006
+ * @public
4007
+ * @since 0.2.0
4008
+ */
4009
+ function runGuardChecks(config, cwd) {
4010
+ const warnings = [];
4011
+ const hooksPathWarning = checkHooksPathOverride(cwd);
4012
+ if (hooksPathWarning) warnings.push(hooksPathWarning);
4013
+ const huskyWarning = checkHuskyBypass();
4014
+ if (huskyWarning) warnings.push(huskyWarning);
4015
+ const integrityWarnings = checkHookIntegrity(config, cwd);
4016
+ warnings.push(...integrityWarnings);
4017
+ const enforceWarning = checkEnforceHooksPolicy(config);
4018
+ if (enforceWarning) warnings.push(enforceWarning);
4019
+ return {
4020
+ safe: !warnings.some((w) => w.severity === "error"),
4021
+ warnings
4022
+ };
4023
+ }
4024
+ //#endregion
4025
+ //#region src/project-root.ts
4026
+ /**
4027
+ * Project root detection and boundary validation.
4028
+ *
4029
+ * @packageDocumentation
4030
+ */
4031
+ /** Files that indicate a project root directory. */
4032
+ var PROJECT_MARKERS = [
4033
+ ".versionguard.yml",
4034
+ ".versionguard.yaml",
4035
+ "versionguard.yml",
4036
+ "versionguard.yaml",
4037
+ ".git",
4038
+ "package.json",
4039
+ "Cargo.toml",
4040
+ "pyproject.toml",
4041
+ "pubspec.yaml",
4042
+ "composer.json",
4043
+ "pom.xml",
4044
+ "go.mod",
4045
+ "mix.exs",
4046
+ "Gemfile",
4047
+ ".csproj"
4048
+ ];
4049
+ /**
4050
+ * Walks up from `startDir` to find the nearest project root.
4051
+ *
4052
+ * @remarks
4053
+ * Checks for VersionGuard config files first, then `.git`, then manifest files.
4054
+ * Stops at the filesystem root if nothing is found.
4055
+ *
4056
+ * @param startDir - Directory to start searching from.
4057
+ * @returns Detection result with the project root path and what was found.
4058
+ *
4059
+ * @example
4060
+ * ```ts
4061
+ * import { findProjectRoot } from 'versionguard';
4062
+ *
4063
+ * const result = findProjectRoot(process.cwd());
4064
+ * if (!result.found) {
4065
+ * console.log('Not in a project directory');
4066
+ * }
4067
+ * ```
4068
+ *
4069
+ * @public
4070
+ * @since 0.4.0
4071
+ */
4072
+ function findProjectRoot(startDir) {
4073
+ let current = path.resolve(startDir);
4074
+ while (true) {
4075
+ for (const marker of PROJECT_MARKERS) if (marker.startsWith(".") && marker !== ".git" && !marker.startsWith(".version")) try {
4076
+ if (fs.readdirSync(current).some((f) => f.endsWith(marker))) return buildResult(current, marker);
4077
+ } catch {}
4078
+ else if (fs.existsSync(path.join(current, marker))) return buildResult(current, marker);
4079
+ const parent = path.dirname(current);
4080
+ if (parent === current) return {
4081
+ found: false,
4082
+ root: path.resolve(startDir),
4083
+ hasConfig: false,
4084
+ hasGit: false,
4085
+ hasManifest: false
4086
+ };
4087
+ current = parent;
4088
+ }
4089
+ }
4090
+ function buildResult(root, marker) {
4091
+ return {
4092
+ found: true,
4093
+ root,
4094
+ marker,
4095
+ hasConfig: [
4096
+ ".versionguard.yml",
4097
+ ".versionguard.yaml",
4098
+ "versionguard.yml",
4099
+ "versionguard.yaml"
4100
+ ].some((c) => fs.existsSync(path.join(root, c))),
4101
+ hasGit: fs.existsSync(path.join(root, ".git")),
4102
+ hasManifest: [
4103
+ "package.json",
4104
+ "Cargo.toml",
4105
+ "pyproject.toml",
4106
+ "pubspec.yaml",
4107
+ "composer.json",
4108
+ "pom.xml",
4109
+ "VERSION"
4110
+ ].some((m) => fs.existsSync(path.join(root, m)))
4111
+ };
4112
+ }
4113
+ /**
4114
+ * Formats a helpful error message when a command can't find a project.
4115
+ *
4116
+ * @remarks
4117
+ * The message includes actionable hints such as running `versionguard init` or
4118
+ * changing to a project root directory. Useful for CLI commands that require a
4119
+ * VersionGuard-enabled project.
4120
+ *
4121
+ * @param cwd - The directory that was checked.
4122
+ * @param command - The command that was attempted.
4123
+ * @returns A formatted, helpful error message.
4124
+ *
4125
+ * @example
4126
+ * ```ts
4127
+ * import { formatNotProjectError } from 'versionguard';
4128
+ *
4129
+ * const msg = formatNotProjectError('/tmp/empty', 'validate');
4130
+ * console.error(msg);
4131
+ * ```
4132
+ *
4133
+ * @public
4134
+ * @since 0.4.0
4135
+ */
4136
+ function formatNotProjectError(cwd, command) {
4137
+ return [
4138
+ `Not a VersionGuard project: ${path.basename(cwd) || cwd}`,
4139
+ "",
4140
+ "No .versionguard.yml, .git directory, or manifest file found.",
4141
+ "",
4142
+ "To get started:",
4143
+ " versionguard init Set up a new project interactively",
4144
+ " versionguard init --yes Set up with defaults",
4145
+ "",
4146
+ "Or run from a project root directory:",
4147
+ ` cd /path/to/project && versionguard ${command}`
4148
+ ].join("\n");
4149
+ }
4150
+ //#endregion
4151
+ //#region src/tag/index.ts
4152
+ function runGit(cwd, args, encoding) {
4153
+ return childProcess.execFileSync("git", args, {
4154
+ cwd,
4155
+ encoding,
4156
+ stdio: [
4157
+ "pipe",
4158
+ "pipe",
4159
+ "ignore"
4160
+ ]
4161
+ });
4162
+ }
4163
+ function runGitText(cwd, args) {
4164
+ return runGit(cwd, args, "utf-8");
4165
+ }
4166
+ /**
4167
+ * Returns the most recent reachable git tag for a repository.
4168
+ *
4169
+ * @public
4170
+ * @since 0.1.0
4171
+ * @remarks
4172
+ * This helper reads the most recent annotated or lightweight tag that `git describe`
4173
+ * can resolve from the current HEAD. It returns `null` when no tags are available
4174
+ * or when git metadata cannot be read.
4175
+ *
4176
+ * @param cwd - Repository directory to inspect.
4177
+ * @returns The latest tag details, or `null` when no tag can be resolved.
4178
+ *
4179
+ * @example
4180
+ * ```typescript
4181
+ * const latestTag = getLatestTag(process.cwd());
4182
+ *
4183
+ * if (latestTag) {
4184
+ * console.log(latestTag.version);
4185
+ * }
4186
+ * ```
4187
+ */
4188
+ function getLatestTag(cwd = process.cwd()) {
4189
+ try {
4190
+ const tagName = runGitText(cwd, [
4191
+ "describe",
4192
+ "--tags",
4193
+ "--abbrev=0"
4194
+ ]).trim();
4195
+ const version = tagName.replace(/^v/, "");
4196
+ const dateResult = runGitText(cwd, [
4197
+ "log",
4198
+ "-1",
4199
+ "--format=%ai",
4200
+ tagName
4201
+ ]);
4202
+ const message = runGitText(cwd, [
4203
+ "tag",
4204
+ "-l",
4205
+ tagName,
4206
+ "--format=%(contents)"
4207
+ ]).trim();
4208
+ return {
4209
+ name: tagName,
4210
+ version,
4211
+ message: message.length > 0 ? message : void 0,
4212
+ date: new Date(dateResult.trim())
4213
+ };
4214
+ } catch {
4215
+ return null;
4216
+ }
4217
+ }
4218
+ /**
4219
+ * Lists all tags in a repository.
4220
+ *
4221
+ * @public
4222
+ * @since 0.1.0
4223
+ * @remarks
4224
+ * This helper returns tag names in the order provided by `git tag --list`. The
4225
+ * `date` field is populated with the current time because the implementation does
4226
+ * not perform per-tag date lookups.
4227
+ *
4228
+ * @param cwd - Repository directory to inspect.
4229
+ * @returns A list of discovered tags, or an empty array when tags cannot be read.
4230
+ *
4231
+ * @example
4232
+ * ```typescript
4233
+ * const tags = getAllTags(process.cwd());
4234
+ * console.log(tags.map((tag) => tag.name));
4235
+ * ```
4236
+ */
4237
+ function getAllTags(cwd = process.cwd()) {
4238
+ try {
4239
+ return runGitText(cwd, ["tag", "--list"]).trim().split("\n").filter(Boolean).map((name) => ({
4240
+ name,
4241
+ version: name.replace(/^v/, ""),
4242
+ date: /* @__PURE__ */ new Date()
4243
+ }));
4244
+ } catch {
4245
+ return [];
4246
+ }
4247
+ }
4248
+ /**
4249
+ * Creates a release tag and optionally fixes version state first.
4250
+ *
4251
+ * @public
4252
+ * @since 0.1.0
4253
+ * @remarks
4254
+ * When `autoFix` is enabled, this helper updates versioned files, stages the
4255
+ * changes, and creates a release commit before creating the annotated tag. It
4256
+ * returns a structured result instead of throwing for most expected failures.
4257
+ *
4258
+ * @param version - Version to embed in the new tag name.
4259
+ * @param message - Custom annotated tag message.
4260
+ * @param autoFix - Whether to auto-fix version mismatches before tagging.
4261
+ * @param config - Loaded VersionGuard configuration used for validation and fixes.
4262
+ * @param cwd - Repository directory where git commands should run.
4263
+ * @returns The tagging outcome and any actions performed along the way.
4264
+ *
4265
+ * @example
4266
+ * ```typescript
4267
+ * const result = createTag('1.2.3', 'Release 1.2.3', true, config, process.cwd());
4268
+ *
4269
+ * if (!result.success) {
4270
+ * console.error(result.message);
4271
+ * }
4272
+ * ```
4273
+ */
4274
+ function createTag(version, message, autoFix = true, config, cwd = process.cwd()) {
4275
+ const actions = [];
4276
+ try {
4277
+ if (!config) return {
4278
+ success: false,
4279
+ message: "VersionGuard config is required to create tags safely",
4280
+ actions
4281
+ };
4282
+ const packageVersion = getPackageVersion(cwd, config.manifest);
4283
+ const preflightError = getTagPreflightError(config, cwd, version, autoFix);
4284
+ if (preflightError) return {
4285
+ success: false,
4286
+ message: preflightError,
4287
+ actions
4288
+ };
4289
+ if (version !== packageVersion && !autoFix) return {
4290
+ success: false,
4291
+ message: `Version mismatch: manifest version is ${packageVersion}, tag is ${version}`,
4292
+ actions: []
4293
+ };
4294
+ if (autoFix) {
4295
+ const fixResults = version !== packageVersion ? fixAll(config, version, cwd) : fixAll(config, void 0, cwd);
4296
+ for (const result of fixResults) if (result.fixed) actions.push(result.message);
4297
+ if (fixResults.some((result) => result.fixed)) {
4298
+ runGit(cwd, ["add", "-A"]);
4299
+ runGit(cwd, [
4300
+ "commit",
4301
+ "--no-verify",
4302
+ "-m",
4303
+ `chore(release): ${version}`
4304
+ ]);
4305
+ actions.push("Committed version changes");
4306
+ }
4307
+ }
4308
+ const tagName = `v${version}`;
4309
+ const tagMessage = message || `Release ${version}`;
4310
+ if (getAllTags(cwd).some((tag) => tag.name === tagName)) return {
4311
+ success: false,
4312
+ message: `Tag ${tagName} already exists`,
4313
+ actions
4314
+ };
4315
+ runGit(cwd, [
4316
+ "tag",
4317
+ "-a",
4318
+ tagName,
4319
+ "-m",
4320
+ tagMessage
4321
+ ]);
4322
+ actions.push(`Created tag ${tagName}`);
4323
+ return {
4324
+ success: true,
4325
+ message: `Successfully created tag ${tagName}`,
4326
+ actions
4327
+ };
4328
+ } catch (err) {
4329
+ return {
4330
+ success: false,
4331
+ message: `Failed to create tag: ${err.message}`,
4332
+ actions
4333
+ };
4334
+ }
4335
+ }
4336
+ /**
4337
+ * Runs post-tag validation and sync checks.
4338
+ *
4339
+ * @public
4340
+ * @since 0.1.0
4341
+ * @remarks
4342
+ * This helper is intended for the `post-tag` git hook flow. It validates the
4343
+ * latest tag against the configured versioning rules and reports any required
4344
+ * follow-up actions without mutating git history.
4345
+ *
4346
+ * @param config - Loaded VersionGuard configuration used during validation.
4347
+ * @param cwd - Repository directory where validation should run.
4348
+ * @returns The post-tag workflow result and any follow-up actions.
4349
+ *
4350
+ * @example
4351
+ * ```typescript
4352
+ * const result = handlePostTag(config, process.cwd());
4353
+ * console.log(result.success);
4354
+ * ```
4355
+ */
4356
+ function handlePostTag(config, cwd = process.cwd()) {
4357
+ const actions = [];
4358
+ try {
4359
+ const preflightError = getTagPreflightError(config, cwd);
4360
+ if (preflightError) return {
4361
+ success: false,
4362
+ message: preflightError,
4363
+ actions
4364
+ };
4365
+ const tag = getLatestTag(cwd);
4366
+ if (!tag) return {
4367
+ success: false,
4368
+ message: "No tag found",
4369
+ actions
4370
+ };
4371
+ const packageVersion = getPackageVersion(cwd, config.manifest);
4372
+ if (tag.version !== packageVersion) return {
4373
+ success: false,
4374
+ message: `Tag version ${tag.version} doesn't match manifest version ${packageVersion}`,
4375
+ actions: [
4376
+ "To fix: delete tag and recreate with correct version",
4377
+ ` git tag -d ${tag.name}`,
4378
+ ` Update manifest to ${tag.version}`,
4379
+ ` git tag ${tag.name}`
4380
+ ]
4381
+ };
4382
+ const syncResults = fixAll(config, packageVersion, cwd);
4383
+ for (const result of syncResults)
4384
+ /* v8 ignore next 3 -- preflight blocks remaining post-tag fixes */
4385
+ if (result.fixed) actions.push(result.message);
4386
+ return {
4387
+ success: true,
4388
+ message: `Post-tag workflow completed for ${tag.name}`,
4389
+ actions
4390
+ };
4391
+ } catch (err) {
4392
+ return {
4393
+ success: false,
4394
+ message: `Post-tag workflow failed: ${err.message}`,
4395
+ actions
4396
+ };
4397
+ }
4398
+ }
4399
+ function getTagPreflightError(config, cwd, expectedVersion, allowAutoFix = false) {
4400
+ if (config.git.enforceHooks && !areHooksInstalled(cwd)) return "Git hooks must be installed before creating or validating release tags";
4401
+ if (hasDirtyWorktree(cwd)) return "Working tree must be clean before creating or validating release tags";
4402
+ const version = expectedVersion ?? getPackageVersion(cwd, config.manifest);
4403
+ const versionResult = config.versioning.type === "semver" ? validate$1(version, getSemVerConfig(config), config.versioning.schemeRules) : validate$2(version, config.versioning.calver?.format ?? "YYYY.MM.PATCH", config.versioning.calver?.preventFutureDates ?? true, config.versioning.schemeRules);
4404
+ if (!versionResult.valid) return versionResult.errors[0]?.message ?? `Invalid version: ${version}`;
4405
+ if (allowAutoFix) return null;
4406
+ const mismatches = checkHardcodedVersions(version, config.sync, config.ignore, cwd);
4407
+ if (mismatches.length > 0) {
4408
+ const mismatch = mismatches[0];
4409
+ return `Version mismatch in ${mismatch.file}:${mismatch.line} - found "${mismatch.found}" but expected "${version}"`;
4410
+ }
4411
+ const changelogResult = validateChangelog(path.join(cwd, config.changelog.file), version, config.changelog.strict, config.changelog.requireEntry, {
4412
+ enforceStructure: config.changelog.enforceStructure,
4413
+ sections: config.changelog.sections
4414
+ });
4415
+ if (!changelogResult.valid) return changelogResult.errors[0] ?? "Changelog validation failed";
4416
+ return null;
4417
+ }
4418
+ function hasDirtyWorktree(cwd) {
4419
+ try {
4420
+ return runGitText(cwd, ["status", "--porcelain"]).trim().length > 0;
4421
+ } catch {
4422
+ return true;
4423
+ }
4424
+ }
4425
+ /**
4426
+ * Validates that a local tag is safe to push to the default remote.
4427
+ *
4428
+ * @public
4429
+ * @since 0.1.0
4430
+ * @remarks
4431
+ * This helper checks that the tag exists locally and, when the tag also exists on
4432
+ * `origin`, verifies that both references point to the same commit.
4433
+ *
4434
+ * @param tagName - Name of the tag to validate.
4435
+ * @param cwd - Repository directory where git commands should run.
4436
+ * @returns A validation result with an optional suggested fix command.
4437
+ *
4438
+ * @example
4439
+ * ```typescript
4440
+ * const result = validateTagForPush('v1.2.3', process.cwd());
4441
+ * console.log(result.valid);
4442
+ * ```
4443
+ */
4444
+ function validateTagForPush(tagName, cwd = process.cwd()) {
4445
+ try {
4446
+ runGit(cwd, ["rev-parse", tagName]);
4447
+ try {
4448
+ runGit(cwd, [
4449
+ "ls-remote",
4450
+ "--tags",
4451
+ "origin",
4452
+ tagName
4453
+ ]);
4454
+ const localHash = runGitText(cwd, ["rev-parse", tagName]).trim();
4455
+ const remoteOutput = runGitText(cwd, [
4456
+ "ls-remote",
4457
+ "--tags",
4458
+ "origin",
4459
+ tagName
4460
+ ]).trim();
4461
+ if (remoteOutput && !remoteOutput.includes(localHash)) return {
4462
+ valid: false,
4463
+ message: `Tag ${tagName} exists on remote with different commit`,
4464
+ fix: `Delete remote tag first: git push origin :refs/tags/${tagName}`
4465
+ };
4466
+ } catch {
4467
+ return {
4468
+ valid: true,
4469
+ message: `Tag ${tagName} is valid for push`
4470
+ };
4471
+ }
4472
+ return {
4473
+ valid: true,
4474
+ message: `Tag ${tagName} is valid for push`
4475
+ };
4476
+ } catch {
4477
+ return {
4478
+ valid: false,
4479
+ message: `Tag ${tagName} not found locally`,
4480
+ fix: `Create tag: git tag ${tagName}`
4481
+ };
4482
+ }
4483
+ }
4484
+ /**
4485
+ * Suggests an annotated tag message from changelog content.
4486
+ *
4487
+ * @public
4488
+ * @since 0.1.0
4489
+ * @remarks
4490
+ * When a matching changelog entry exists, this helper uses the first bullet point
4491
+ * as a concise release summary. It falls back to a generic `Release {version}`
4492
+ * message when no changelog context is available.
4493
+ *
4494
+ * @param version - Version that the tag will represent.
4495
+ * @param cwd - Repository directory containing the changelog file.
4496
+ * @returns A suggested annotated tag message.
4497
+ *
4498
+ * @example
4499
+ * ```typescript
4500
+ * const message = suggestTagMessage('1.2.3', process.cwd());
4501
+ * console.log(message);
4502
+ * ```
4503
+ */
4504
+ function suggestTagMessage(version, cwd = process.cwd()) {
4505
+ try {
4506
+ const changelogPath = path.join(cwd, "CHANGELOG.md");
4507
+ if (fs.existsSync(changelogPath)) {
4508
+ const content = fs.readFileSync(changelogPath, "utf-8");
4509
+ const versionRegex = new RegExp(`## \\[${version}\\].*?\\n(.*?)(?=\\n## \\[|\\n\\n## |$)`, "s");
4510
+ const match = content.match(versionRegex);
4511
+ if (match) {
4512
+ const bulletMatch = match[1].match(/- (.+)/);
4513
+ if (bulletMatch) return `Release ${version}: ${bulletMatch[1].trim()}`;
4514
+ }
4515
+ }
4516
+ } catch {
4517
+ return `Release ${version}`;
4518
+ }
4519
+ return `Release ${version}`;
4520
+ }
4521
+ //#endregion
4522
+ //#region src/index.ts
4523
+ /**
4524
+ * Public API for VersionGuard.
4525
+ *
4526
+ * @packageDocumentation
4527
+ * @public
4528
+ */
4529
+ /**
4530
+ * Validates a version string against the active versioning strategy.
4531
+ *
4532
+ * @public
4533
+ * @since 0.1.0
4534
+ * @remarks
4535
+ * This helper dispatches to SemVer or CalVer validation based on
4536
+ * `config.versioning.type`.
4537
+ *
4538
+ * @param version - Version string to validate.
4539
+ * @param config - VersionGuard configuration that selects the validation rules.
4540
+ * @returns The validation result for the provided version.
4541
+ * @example
4542
+ * ```ts
4543
+ * import { getDefaultConfig, validateVersion } from 'versionguard';
4544
+ *
4545
+ * const result = validateVersion('1.2.3', getDefaultConfig());
4546
+ * ```
4547
+ */
4548
+ function validateVersion(version, config) {
4549
+ if (config.versioning.type === "semver") return validate$1(version, getSemVerConfig(config), config.versioning.schemeRules);
4550
+ const calverConfig = getCalVerConfig(config);
4551
+ return validate$2(version, calverConfig.format, calverConfig.preventFutureDates, config.versioning.schemeRules);
4552
+ }
4553
+ /**
4554
+ * Validates the current project state against the supplied configuration.
4555
+ *
4556
+ * @public
4557
+ * @since 0.1.0
4558
+ * @remarks
4559
+ * This reads the package version from `package.json`, validates the version
4560
+ * format, checks synchronized files, and optionally validates the changelog.
4561
+ *
4562
+ * @param config - VersionGuard configuration to apply.
4563
+ * @param cwd - Project directory to inspect.
4564
+ * @returns A full validation report for the project rooted at `cwd`.
4565
+ * @example
4566
+ * ```ts
4567
+ * import { getDefaultConfig, validate } from 'versionguard';
4568
+ *
4569
+ * const result = validate(getDefaultConfig(), process.cwd());
4570
+ * ```
4571
+ */
4572
+ function validate(config, cwd = process.cwd()) {
4573
+ const errors = [];
4574
+ let version;
4575
+ try {
4576
+ version = getPackageVersion(cwd, config.manifest);
4577
+ } catch (err) {
4578
+ return {
4579
+ valid: false,
4580
+ version: "",
4581
+ versionValid: false,
4582
+ syncValid: false,
4583
+ changelogValid: false,
4584
+ errors: [err.message]
4585
+ };
4586
+ }
4587
+ const versionResult = validateVersion(version, config);
4588
+ if (!versionResult.valid) errors.push(...versionResult.errors.map((error) => error.message));
4589
+ const hardcoded = checkHardcodedVersions(version, config.sync, config.ignore, cwd);
4590
+ if (hardcoded.length > 0) for (const mismatch of hardcoded) errors.push(`Version mismatch in ${mismatch.file}:${mismatch.line} - found "${mismatch.found}" but expected "${version}"`);
4591
+ if (config.scan?.enabled) {
4592
+ const scanFindings = scanRepoForVersions(version, config.scan, config.ignore, cwd);
4593
+ for (const finding of scanFindings) errors.push(`Stale version in ${finding.file}:${finding.line} - found "${finding.found}" but expected "${version}"`);
4594
+ }
4595
+ let changelogValid = true;
4596
+ if (config.changelog.enabled) {
4597
+ const changelogResult = validateChangelog(path.join(cwd, config.changelog.file), version, config.changelog.strict, config.changelog.requireEntry, {
4598
+ enforceStructure: config.changelog.enforceStructure,
4599
+ sections: config.changelog.sections
4600
+ });
4601
+ if (!changelogResult.valid) {
4602
+ changelogValid = false;
4603
+ errors.push(...changelogResult.errors);
4604
+ }
4605
+ }
4606
+ return {
4607
+ valid: errors.length === 0,
4608
+ version,
4609
+ versionValid: versionResult.valid,
4610
+ syncValid: hardcoded.length === 0,
4611
+ changelogValid,
4612
+ errors
4613
+ };
4614
+ }
4615
+ /**
4616
+ * Runs an extended readiness check for a project.
4617
+ *
4618
+ * @public
4619
+ * @since 0.1.0
4620
+ * @remarks
4621
+ * In addition to `validate`, this inspects Git state so callers can determine
4622
+ * whether hooks are installed and the worktree is clean.
4623
+ *
4624
+ * @param config - VersionGuard configuration to apply.
4625
+ * @param cwd - Project directory to inspect.
4626
+ * @returns A readiness report that includes validation and Git diagnostics.
4627
+ * @example
4628
+ * ```ts
4629
+ * import { doctor, getDefaultConfig } from 'versionguard';
4630
+ *
4631
+ * const report = doctor(getDefaultConfig(), process.cwd());
4632
+ * ```
4633
+ */
4634
+ function doctor(config, cwd = process.cwd()) {
4635
+ const validation = validate(config, cwd);
4636
+ const gitRepository = findGitDir(cwd) !== null;
4637
+ const hooksInstalled = gitRepository ? areHooksInstalled(cwd) : false;
4638
+ const worktreeClean = gitRepository ? isWorktreeClean(cwd) : true;
4639
+ const errors = [...validation.errors];
4640
+ if (gitRepository && config.git.enforceHooks && !hooksInstalled) errors.push("Git hooks are not installed");
4641
+ if (gitRepository && !worktreeClean) errors.push("Working tree is not clean");
4642
+ if (gitRepository && config.github?.dependabot && !dependabotConfigExists(cwd)) errors.push(".github/dependabot.yml is missing — run `vg init` to generate it");
4643
+ return {
4644
+ ready: errors.length === 0,
4645
+ version: validation.version,
4646
+ versionValid: validation.versionValid,
4647
+ syncValid: validation.syncValid,
4648
+ changelogValid: validation.changelogValid,
4649
+ gitRepository,
4650
+ hooksInstalled,
4651
+ worktreeClean,
4652
+ errors
4653
+ };
4654
+ }
4655
+ /**
4656
+ * Synchronizes configured files to the current package version.
4657
+ *
4658
+ * @public
4659
+ * @since 0.1.0
4660
+ * @remarks
4661
+ * This reads the version from `package.json` and rewrites configured files
4662
+ * using the sync patterns defined in the VersionGuard config.
4663
+ *
4664
+ * @param config - VersionGuard configuration containing sync rules.
4665
+ * @param cwd - Project directory whose files should be synchronized.
4666
+ * @example
4667
+ * ```ts
4668
+ * import { getDefaultConfig, sync } from 'versionguard';
4669
+ *
4670
+ * sync(getDefaultConfig(), process.cwd());
4671
+ * ```
4672
+ */
4673
+ function sync(config, cwd = process.cwd()) {
4674
+ syncVersion(getPackageVersion(cwd, config.manifest), config.sync, cwd);
4675
+ }
4676
+ /**
4677
+ * Determines whether a project can move from one version to another.
4678
+ *
4679
+ * @public
4680
+ * @since 0.1.0
4681
+ * @remarks
4682
+ * Both the current and proposed versions must validate against the configured
4683
+ * versioning scheme, and the new version must compare greater than the current
4684
+ * version.
4685
+ *
4686
+ * @param currentVersion - Version currently in use.
4687
+ * @param newVersion - Proposed next version.
4688
+ * @param config - VersionGuard configuration that defines version rules.
4689
+ * @returns An object indicating whether the bump is allowed and why it failed.
4690
+ * @example
4691
+ * ```ts
4692
+ * import { canBump, getDefaultConfig } from 'versionguard';
4693
+ *
4694
+ * const result = canBump('1.2.3', '1.3.0', getDefaultConfig());
4695
+ * ```
4696
+ */
4697
+ function canBump(currentVersion, newVersion, config) {
4698
+ const currentValid = validateVersion(currentVersion, config);
4699
+ const newValid = validateVersion(newVersion, config);
4700
+ if (!currentValid.valid) return {
4701
+ canBump: false,
4702
+ error: `Current version is invalid: ${currentVersion}`
4703
+ };
4704
+ if (!newValid.valid) return {
4705
+ canBump: false,
4706
+ error: `New version is invalid: ${newVersion}`
4707
+ };
4708
+ if (config.versioning.type === "semver") {
4709
+ if (!gt(newVersion, currentVersion)) return {
4710
+ canBump: false,
4711
+ error: `New version ${newVersion} must be greater than current ${currentVersion}`
4712
+ };
4713
+ } else {
4714
+ const calverConfig = getCalVerConfig(config);
4715
+ const currentParsed = parse$2(currentVersion, calverConfig.format);
4716
+ const newParsed = parse$2(newVersion, calverConfig.format);
4717
+ if (!currentParsed || !newParsed) return {
4718
+ canBump: false,
4719
+ error: "Failed to parse CalVer versions"
4720
+ };
4721
+ if (compare$1(newVersion, currentVersion, calverConfig.format) <= 0) return {
4722
+ canBump: false,
4723
+ error: `New CalVer ${newVersion} must be newer than current ${currentVersion}`
4724
+ };
4725
+ }
4726
+ return { canBump: true };
4727
+ }
4728
+ function isWorktreeClean(cwd) {
4729
+ try {
4730
+ return execSync("git status --porcelain", {
4731
+ cwd,
4732
+ encoding: "utf-8"
4733
+ }).trim().length === 0;
4734
+ } catch {
4735
+ return false;
4736
+ }
4737
+ }
4738
+ //#endregion
4739
+ export { generateDependabotConfig as $, createCkmEngine as A, detectManifests as B, suggestNextVersion as C, getVersionFeedback as D, getTagFeedback as E, syncVersion as F, RegexVersionSource as G, YamlVersionSource as H, semver_exports as I, areHooksInstalled as J, JsonVersionSource as K, getPackageVersion as L, getSemVerConfig as M, checkHardcodedVersions as N, getConfig as O, scanRepoForVersions as P, dependabotConfigExists as Q, getVersionSource as R, fixSyncIssues as S, getSyncFeedback as T, VersionFileSource as U, resolveVersionSource as V, TomlVersionSource as W, uninstallHooks as X, installHooks as Y, MANIFEST_TO_ECOSYSTEM as Z, checkHuskyBypass as _, validateVersion as a, isValidCalVerFormat as at, fixChangelog as b, getLatestTag as c, validateTagForPush as d, writeDependabotConfig as et, findProjectRoot as f, checkHooksPathOverride as g, checkHookIntegrity as h, validate as i, calver_exports as it, getCalVerConfig as j, initConfig as k, handlePostTag as l, checkEnforceHooksPolicy as m, doctor as n, isChangesetMangled as nt, createTag as o, formatNotProjectError as p, GitTagSource as q, sync as r, validateChangelog as rt, getAllTags as s, canBump as t, fixChangesetMangling as tt, suggestTagMessage as u, runGuardChecks as v, getChangelogFeedback as w, fixPackageVersion as x, fixAll as y, setPackageVersion as z };
4740
+
4741
+ //# sourceMappingURL=src-BPMDUQfR.js.map