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/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
+ }