semver-ratchet 1.0.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 +284 -0
- package/bin/semver-ratchet +4 -0
- package/index.js +29 -0
- package/package.json +50 -0
- package/src/cli.js +431 -0
- package/src/config.js +74 -0
- package/src/git_adapter.js +112 -0
- package/src/git_mock.js +348 -0
- package/src/git_real.js +210 -0
- package/src/version.js +340 -0
package/src/version.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version calculation logic for semver-ratchet.
|
|
3
|
+
*
|
|
4
|
+
* This module provides the core version calculation functions
|
|
5
|
+
* that work with both real and mock git adapters.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { RealGitAdapter } from './git_real.js';
|
|
9
|
+
import { getManualVersion, getDefaultBump, getVerifyMinDepth } from './config.js';
|
|
10
|
+
|
|
11
|
+
// Global git adapter instance (can be overridden for testing)
|
|
12
|
+
let _gitAdapter = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get the current git adapter instance.
|
|
16
|
+
* @returns {GitAdapter} The git adapter
|
|
17
|
+
*/
|
|
18
|
+
function getGitAdapter() {
|
|
19
|
+
if (!_gitAdapter) {
|
|
20
|
+
_gitAdapter = new RealGitAdapter();
|
|
21
|
+
}
|
|
22
|
+
return _gitAdapter;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Set the git adapter instance (for testing).
|
|
27
|
+
* @param {GitAdapter} adapter - The git adapter to use
|
|
28
|
+
*/
|
|
29
|
+
export function setGitAdapter(adapter) {
|
|
30
|
+
_gitAdapter = adapter;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the current git adapter (for testing).
|
|
35
|
+
* @returns {GitAdapter} The git adapter
|
|
36
|
+
*/
|
|
37
|
+
export function getGitAdapterInstance() {
|
|
38
|
+
return getGitAdapter();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Calculate CRC32 hash of a string as unsigned 32-bit integer.
|
|
43
|
+
* @param {string} str - Input string
|
|
44
|
+
* @returns {number} CRC32 hash as unsigned 32-bit integer
|
|
45
|
+
*/
|
|
46
|
+
export function calculateCrc32Unsigned(str) {
|
|
47
|
+
let crc = 0xFFFFFFFF;
|
|
48
|
+
|
|
49
|
+
// Encode string as UTF-8 bytes
|
|
50
|
+
const encoder = new TextEncoder();
|
|
51
|
+
const bytes = encoder.encode(str);
|
|
52
|
+
|
|
53
|
+
// CRC32 polynomial
|
|
54
|
+
const polynomial = 0xEDB88320;
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
57
|
+
crc = crc ^ bytes[i];
|
|
58
|
+
for (let j = 0; j < 8; j++) {
|
|
59
|
+
if (crc & 1) {
|
|
60
|
+
crc = (crc >>> 1) ^ polynomial;
|
|
61
|
+
} else {
|
|
62
|
+
crc = crc >>> 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Return as unsigned 32-bit integer
|
|
68
|
+
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse a SemVer string into {major, minor, patch} object.
|
|
73
|
+
* @param {string} version - Version string in format "X.Y.Z"
|
|
74
|
+
* @returns {{major: number, minor: number, patch: number}}
|
|
75
|
+
* @throws {Error} If version format is invalid
|
|
76
|
+
*/
|
|
77
|
+
export function parseSemver(version) {
|
|
78
|
+
// First, strip pre-release and build metadata if present
|
|
79
|
+
// e.g., "1.0.0-alpha" -> "1.0.0", "1.0.0+build123" -> "1.0.0"
|
|
80
|
+
const cleanVersion = version.split(/[-+]/)[0];
|
|
81
|
+
|
|
82
|
+
// Check for exactly 3 numeric components
|
|
83
|
+
const parts = cleanVersion.split('.');
|
|
84
|
+
if (parts.length !== 3) {
|
|
85
|
+
throw new Error(`Invalid SemVer format: ${version}. Expected X.Y.Z format`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check for leading zeros (SemVer 2.0.0 Section 9 violation)
|
|
89
|
+
for (let i = 0; i < parts.length; i++) {
|
|
90
|
+
const part = parts[i];
|
|
91
|
+
|
|
92
|
+
// Check if part is empty
|
|
93
|
+
if (part.length === 0) {
|
|
94
|
+
throw new Error(`Invalid SemVer format: ${version}. Empty component found`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check if part contains only digits
|
|
98
|
+
if (!/^\d+$/.test(part)) {
|
|
99
|
+
throw new Error(`Invalid SemVer format: ${version}. Components must be non-negative integers`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check for leading zeros (but allow single "0")
|
|
103
|
+
if (part.length > 1 && part[0] === '0') {
|
|
104
|
+
throw new Error(`Invalid SemVer format: ${version}. Leading zeros not allowed (SemVer 2.0.0 Section 9)`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
major: parseInt(parts[0], 10),
|
|
110
|
+
minor: parseInt(parts[1], 10),
|
|
111
|
+
patch: parseInt(parts[2], 10),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compare two SemVer versions.
|
|
117
|
+
* @param {string} v1 - First version
|
|
118
|
+
* @param {string} v2 - Second version
|
|
119
|
+
* @returns {number} -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
|
120
|
+
*/
|
|
121
|
+
export function compareSemver(v1, v2) {
|
|
122
|
+
const { major: m1, minor: n1, patch: p1 } = parseSemver(v1);
|
|
123
|
+
const { major: m2, minor: n2, patch: p2 } = parseSemver(v2);
|
|
124
|
+
|
|
125
|
+
if (m1 !== m2) return m1 < m2 ? -1 : 1;
|
|
126
|
+
if (n1 !== n2) return n1 < n2 ? -1 : 1;
|
|
127
|
+
if (p1 !== p2) return p1 < p2 ? -1 : 1;
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Generate a list of compatible version strings for dependency pinning.
|
|
133
|
+
* For a version like 4.3.1, returns: ["4.3.1", "4.3", "4"]
|
|
134
|
+
* @param {string} version - Full version string in SemVer format
|
|
135
|
+
* @returns {string[]} List of compatible version strings
|
|
136
|
+
* @throws {Error} If version format is invalid
|
|
137
|
+
*/
|
|
138
|
+
export function getCompatibleVersions(version) {
|
|
139
|
+
const { major, minor, patch } = parseSemver(version);
|
|
140
|
+
|
|
141
|
+
return [
|
|
142
|
+
`${major}.${minor}.${patch}`, // Full version
|
|
143
|
+
`${major}.${minor}`, // Major.minor (for ^ pinning)
|
|
144
|
+
`${major}`, // Major only (for ~ pinning)
|
|
145
|
+
];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if current branch is main or master.
|
|
150
|
+
* @returns {boolean} True if on main/master branch
|
|
151
|
+
*/
|
|
152
|
+
export function isMainBranch() {
|
|
153
|
+
return getGitAdapter().isMainBranch();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get the current branch name.
|
|
158
|
+
* @returns {string} The branch name
|
|
159
|
+
*/
|
|
160
|
+
export function getCurrentBranch() {
|
|
161
|
+
return getGitAdapter().getCurrentBranch();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Determine the version bump type from commit messages.
|
|
166
|
+
* @param {string[]} messages - List of commit messages
|
|
167
|
+
* @param {string} [defaultBump='patch'] - Default bump type if no tags found
|
|
168
|
+
* @returns {string} 'major', 'minor', or 'patch'
|
|
169
|
+
* @throws {Error} If defaultBump is invalid
|
|
170
|
+
*/
|
|
171
|
+
export function determineVersionBump(messages, defaultBump = 'patch') {
|
|
172
|
+
const validDefaults = ['major', 'minor', 'patch'];
|
|
173
|
+
if (!validDefaults.includes(defaultBump.toLowerCase())) {
|
|
174
|
+
throw new Error(`Invalid defaultBump: ${defaultBump}. Must be one of: ${validDefaults.join(', ')}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check for explicit tags (case-insensitive)
|
|
178
|
+
for (const message of messages) {
|
|
179
|
+
const upperMessage = message.toUpperCase();
|
|
180
|
+
if (upperMessage.includes('[MAJOR]')) {
|
|
181
|
+
return 'major';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const message of messages) {
|
|
186
|
+
const upperMessage = message.toUpperCase();
|
|
187
|
+
if (upperMessage.includes('[MINOR]')) {
|
|
188
|
+
return 'minor';
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const message of messages) {
|
|
193
|
+
const upperMessage = message.toUpperCase();
|
|
194
|
+
if (upperMessage.includes('[PATCH]')) {
|
|
195
|
+
return 'patch';
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Return default if no explicit tags found
|
|
200
|
+
return defaultBump.toLowerCase();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Calculate the main branch version based on tags and commits.
|
|
205
|
+
* @param {string} defaultBump - Default bump type (major|minor|patch)
|
|
206
|
+
* @param {boolean} verifyHistory - Whether to verify git history
|
|
207
|
+
* @returns {string} SemVer version string
|
|
208
|
+
* @throws {Error} If on feature branch or git operations fail
|
|
209
|
+
*/
|
|
210
|
+
export function calculateMainVersion(defaultBump = 'patch', verifyHistory = true) {
|
|
211
|
+
const adapter = getGitAdapter();
|
|
212
|
+
|
|
213
|
+
// Verify we're on main branch
|
|
214
|
+
if (!adapter.isMainBranch()) {
|
|
215
|
+
throw new Error('Cannot calculate main version on feature branch');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Verify git history if requested
|
|
219
|
+
if (verifyHistory) {
|
|
220
|
+
try {
|
|
221
|
+
adapter.verifyGitHistory(getVerifyMinDepth());
|
|
222
|
+
} catch {
|
|
223
|
+
// History verification failed, but we can still calculate version
|
|
224
|
+
// Just use the latest tag or default
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Get the latest tag
|
|
229
|
+
const latestTag = adapter.getLatestTag();
|
|
230
|
+
|
|
231
|
+
if (!latestTag) {
|
|
232
|
+
// No tags yet, start at 1.0.0
|
|
233
|
+
return '1.0.0';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Parse the latest version
|
|
237
|
+
const { major, minor, patch } = parseSemver(latestTag);
|
|
238
|
+
|
|
239
|
+
// Get commit messages since the latest tag
|
|
240
|
+
const commitMessages = adapter.getCommitMessages(latestTag);
|
|
241
|
+
|
|
242
|
+
// Determine the bump type
|
|
243
|
+
const bumpType = determineVersionBump(commitMessages, defaultBump);
|
|
244
|
+
|
|
245
|
+
// Calculate new version
|
|
246
|
+
switch (bumpType) {
|
|
247
|
+
case 'major':
|
|
248
|
+
return `${major + 1}.0.0`;
|
|
249
|
+
case 'minor':
|
|
250
|
+
return `${major}.${minor + 1}.0`;
|
|
251
|
+
case 'patch':
|
|
252
|
+
default:
|
|
253
|
+
return `${major}.${minor}.${patch + 1}`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Calculate the feature branch version.
|
|
259
|
+
* @param {string} _defaultBump - Default bump type (not used for feature branches)
|
|
260
|
+
* @param {boolean} _verifyHistory - Whether to verify git history
|
|
261
|
+
* @returns {string} Feature version string (0.{CRC}.{Distance})
|
|
262
|
+
* @throws {Error} If on main branch
|
|
263
|
+
*/
|
|
264
|
+
export function calculateFeatureVersion(_defaultBump = 'patch', _verifyHistory = true) {
|
|
265
|
+
const adapter = getGitAdapter();
|
|
266
|
+
|
|
267
|
+
// Verify we're NOT on main branch
|
|
268
|
+
if (adapter.isMainBranch()) {
|
|
269
|
+
throw new Error('Cannot calculate feature version on main branch');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const branchName = adapter.getCurrentBranch();
|
|
273
|
+
const crc = calculateCrc32Unsigned(branchName);
|
|
274
|
+
const distance = adapter.getGitDistance();
|
|
275
|
+
|
|
276
|
+
return `0.${crc}.${distance}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Get the current version based on git state.
|
|
281
|
+
* @param {string} [defaultBump] - Default bump type for main branch (uses env/config if not provided)
|
|
282
|
+
* @param {boolean} [verifyHistory=true] - Whether to verify git history
|
|
283
|
+
* @returns {string} Version string
|
|
284
|
+
*/
|
|
285
|
+
export function getVersion(defaultBump, _verifyHistory = true) {
|
|
286
|
+
// Check for manual version override first
|
|
287
|
+
const manualVersion = getManualVersion();
|
|
288
|
+
if (manualVersion !== undefined) {
|
|
289
|
+
return manualVersion;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Use config default if not provided
|
|
293
|
+
if (defaultBump === undefined) {
|
|
294
|
+
defaultBump = getDefaultBump();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const adapter = getGitAdapter();
|
|
298
|
+
|
|
299
|
+
if (adapter.isMainBranch()) {
|
|
300
|
+
return calculateMainVersion(defaultBump, _verifyHistory);
|
|
301
|
+
} else {
|
|
302
|
+
return calculateFeatureVersion(defaultBump, _verifyHistory);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Verify git history is sufficient.
|
|
308
|
+
* @param {number} [fetchDepth=50] - Minimum number of commits required
|
|
309
|
+
* @throws {Error} If history is insufficient
|
|
310
|
+
*/
|
|
311
|
+
export function verifyGitHistory(fetchDepth = 50) {
|
|
312
|
+
getGitAdapter().verifyGitHistory(fetchDepth);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Create a git tag for the current version.
|
|
317
|
+
* @param {string} [defaultBump='patch'] - Default bump type
|
|
318
|
+
* @param {boolean} _force - Whether to overwrite existing tag
|
|
319
|
+
* @returns {boolean} True if tag was created
|
|
320
|
+
*/
|
|
321
|
+
export function createGitTag(defaultBump = 'patch', _force = false) {
|
|
322
|
+
const adapter = getGitAdapter();
|
|
323
|
+
|
|
324
|
+
if (!adapter.isMainBranch()) {
|
|
325
|
+
throw new Error('Git tags can only be created on main branch');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const version = getVersion(defaultBump, false);
|
|
329
|
+
return adapter.createTag(version, _force);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Push a git tag to remote.
|
|
334
|
+
* @param {string} _version - Version string to push (without 'v' prefix)
|
|
335
|
+
* @param {string} [remote='origin'] - Remote name
|
|
336
|
+
*/
|
|
337
|
+
export function pushGitTag(_version, remote = 'origin') {
|
|
338
|
+
const adapter = getGitAdapter();
|
|
339
|
+
adapter.pushTag(_version, remote);
|
|
340
|
+
}
|