@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.
- package/README.md +116 -57
- package/dist/chunks/src-BPMDUQfR.js +4741 -0
- package/dist/chunks/src-BPMDUQfR.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +820 -712
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/github/dependabot.d.ts +62 -0
- package/dist/github/dependabot.d.ts.map +1 -0
- package/dist/github/index.d.ts +7 -0
- package/dist/github/index.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -59
- package/dist/init-wizard.d.ts +2 -0
- package/dist/init-wizard.d.ts.map +1 -1
- package/dist/types.d.ts +24 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +12 -12
- package/dist/chunks/index-Cipg9sxE.js +0 -3086
- package/dist/chunks/index-Cipg9sxE.js.map +0 -1
- package/dist/index.js.map +0 -1
|
@@ -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
|