@credentum/semver-explain 0.0.1

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.
Files changed (4) hide show
  1. package/README.md +83 -0
  2. package/index.d.ts +31 -0
  3. package/index.js +363 -0
  4. package/package.json +28 -0
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # semver-explain
2
+
3
+ Explain semver ranges in plain English. The `cronstrue` for version constraints.
4
+
5
+ ```typescript
6
+ import { explain } from '@credentum/semver-explain';
7
+
8
+ explain('^1.2.3');
9
+ // => ">=1.2.3 <2.0.0 — Compatible with 1.2.3, allowing minor and patch updates"
10
+
11
+ explain('~1.2.3');
12
+ // => ">=1.2.3 <1.3.0 — Approximately 1.2.3, allowing only patch updates"
13
+
14
+ explain('1.2.x');
15
+ // => ">=1.2.0 <1.3.0 — Any patch version within 1.2"
16
+
17
+ explain('>=1.0.0 <2.0.0');
18
+ // => ">=1.0.0 and <2.0.0"
19
+
20
+ explain('^1.2.3 || ^2.0.0');
21
+ // => ">=1.2.3 <2.0.0 — Compatible with 1.2.3 | OR | >=2.0.0 <3.0.0 — Compatible with 2.0.0"
22
+ ```
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ npm install @credentum/semver-explain
28
+ ```
29
+
30
+ ## Why?
31
+
32
+ Every team using npm encounters semver ranges. The `^` and `~` operators are unintuitive — even experienced developers confuse them. The answer is always a StackOverflow search:
33
+
34
+ - ["What's the difference between tilde(~) and caret(^)?"](https://stackoverflow.com/questions/22343224) — **2.1M views**
35
+ - ["What does ^0.0.1 mean?"](https://stackoverflow.com/questions/22137778) — 200K+ views
36
+ - `semver` on npm — 498M weekly downloads, but no explain function
37
+
38
+ `cronstrue` converts cron to English (3.5M/week). No equivalent exists for semver. Until now.
39
+
40
+ ## API
41
+
42
+ ### `explain(range: string): string`
43
+
44
+ Converts a semver range string into a human-readable English explanation.
45
+
46
+ **Supports all npm semver range syntax:**
47
+
48
+ | Input | Output |
49
+ |-------|--------|
50
+ | `1.2.3` | `1.2.3 — Exactly version 1.2.3` |
51
+ | `^1.2.3` | `>=1.2.3 <2.0.0 — Compatible with 1.2.3, allowing minor and patch updates` |
52
+ | `^0.2.3` | `>=0.2.3 <0.3.0 — Compatible with 0.2.3, allowing only patch updates` |
53
+ | `^0.0.3` | `>=0.0.3 <0.0.4 — Only version 0.0.3 (caret on 0.0.x is exact)` |
54
+ | `~1.2.3` | `>=1.2.3 <1.3.0 — Approximately 1.2.3, allowing only patch updates` |
55
+ | `~1.2` | `>=1.2.0 <1.3.0 — Any patch version within 1.2` |
56
+ | `~0.2` | `>=0.2.0 <0.3.0 — Any patch version within 0.2` |
57
+ | `1.x` | `>=1.0.0 <2.0.0 — Any version starting with 1` |
58
+ | `1.2.x` | `>=1.2.0 <1.3.0 — Any patch version within 1.2` |
59
+ | `*` | `Any version` |
60
+ | `>=1.2.3` | `>=1.2.3` |
61
+ | `1.2.3 - 2.3.4` | `>=1.2.3 <=2.3.4 — Between 1.2.3 and 2.3.4, inclusive` |
62
+ | `>=1.0.0 <2.0.0` | `>=1.0.0 and <2.0.0` |
63
+ | `^1 \|\| ^2` | Explains each branch separated by ` \| OR \| ` |
64
+
65
+ ### `expandRange(range: string): string`
66
+
67
+ Returns only the expanded comparator form without the English description. Useful for tooling.
68
+
69
+ ```typescript
70
+ import { expandRange } from '@credentum/semver-explain';
71
+
72
+ expandRange('^1.2.3'); // ">=1.2.3 <2.0.0"
73
+ expandRange('~1.2.3'); // ">=1.2.3 <1.3.0"
74
+ expandRange('1.2.x'); // ">=1.2.0 <1.3.0"
75
+ ```
76
+
77
+ ## Zero Dependencies
78
+
79
+ Pure TypeScript. No dependency on the `semver` package. Implements range expansion from the [node-semver specification](https://github.com/npm/node-semver#ranges) directly.
80
+
81
+ ## License
82
+
83
+ MIT
package/index.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * semver-explain — Explain semver ranges in plain English.
3
+ * Zero dependencies. Pure functions.
4
+ *
5
+ * Implements range expansion per the node-semver specification:
6
+ * https://github.com/npm/node-semver#ranges
7
+ */
8
+ /**
9
+ * Explain a semver range in plain English.
10
+ *
11
+ * Supports all npm semver range syntax:
12
+ * - Exact: 1.2.3
13
+ * - Caret: ^1.2.3
14
+ * - Tilde: ~1.2.3
15
+ * - X-ranges: 1.x, 1.2.*, *
16
+ * - Hyphen: 1.2.3 - 2.3.4
17
+ * - Comparators: >=1.0.0, <2.0.0
18
+ * - Compound: >=1.0.0 <2.0.0
19
+ * - Union: ^1 || ^2
20
+ *
21
+ * @param range - A semver range string
22
+ * @returns Human-readable explanation
23
+ */
24
+ export declare function explain(range: string): string;
25
+ /**
26
+ * Return only the expanded comparator form, without the English description.
27
+ *
28
+ * @param range - A semver range string
29
+ * @returns Expanded comparator string (e.g. ">=1.2.3 <2.0.0")
30
+ */
31
+ export declare function expandRange(range: string): string;
package/index.js ADDED
@@ -0,0 +1,363 @@
1
+ /**
2
+ * semver-explain — Explain semver ranges in plain English.
3
+ * Zero dependencies. Pure functions.
4
+ *
5
+ * Implements range expansion per the node-semver specification:
6
+ * https://github.com/npm/node-semver#ranges
7
+ */
8
+ /**
9
+ * Parse a version string like "1.2.3" into a Version object.
10
+ * Missing components default to 0.
11
+ */
12
+ function parseVersion(v) {
13
+ const parts = v.split(".");
14
+ return {
15
+ major: parseInt(parts[0], 10) || 0,
16
+ minor: parts.length > 1 ? parseInt(parts[1], 10) || 0 : 0,
17
+ patch: parts.length > 2 ? parseInt(parts[2], 10) || 0 : 0,
18
+ };
19
+ }
20
+ /** Format a Version back to string */
21
+ function fmt(v) {
22
+ return `${v.major}.${v.minor}.${v.patch}`;
23
+ }
24
+ /** Count how many numeric parts were provided (1, 2, or 3) */
25
+ function partCount(v) {
26
+ const stripped = v.replace(/^[v=\s]+/, "");
27
+ return stripped.split(".").length;
28
+ }
29
+ /**
30
+ * Expand a caret range (^) to its comparator bounds.
31
+ *
32
+ * Per node-semver spec:
33
+ * - ^1.2.3 => >=1.2.3 <2.0.0 (leftmost non-zero is major)
34
+ * - ^0.2.3 => >=0.2.3 <0.3.0 (leftmost non-zero is minor)
35
+ * - ^0.0.3 => >=0.0.3 <0.0.4 (leftmost non-zero is patch)
36
+ * - ^1.2.x => >=1.2.0 <2.0.0
37
+ * - ^0.0.x => >=0.0.0 <0.1.0
38
+ * - ^0.0 => >=0.0.0 <0.1.0
39
+ * - ^1.x => >=1.0.0 <2.0.0
40
+ * - ^0.x => >=0.0.0 <1.0.0
41
+ */
42
+ function expandCaret(raw) {
43
+ const body = raw.replace(/^[v=\s]+/, "");
44
+ const parts = body.split(".");
45
+ const hasX = parts.some((p) => p === "x" || p === "X" || p === "*");
46
+ const numParts = parts.length;
47
+ // Replace x/X/* with 0 for parsing
48
+ const cleaned = parts.map((p) => p === "x" || p === "X" || p === "*" ? "0" : p);
49
+ const major = parseInt(cleaned[0], 10) || 0;
50
+ const minor = numParts > 1 ? parseInt(cleaned[1], 10) || 0 : 0;
51
+ const patch = numParts > 2 ? parseInt(cleaned[2], 10) || 0 : 0;
52
+ const lower = `${major}.${minor}.${patch}`;
53
+ let upper;
54
+ let description;
55
+ if (major !== 0) {
56
+ upper = `${major + 1}.0.0`;
57
+ if (minor === 0 && patch === 0) {
58
+ description = `Compatible with ${major}.x, allowing minor and patch updates`;
59
+ }
60
+ else {
61
+ description = `Compatible with ${lower}, allowing minor and patch updates`;
62
+ }
63
+ }
64
+ else if (minor !== 0) {
65
+ upper = `0.${minor + 1}.0`;
66
+ description = `Compatible with ${lower}, allowing only patch updates`;
67
+ }
68
+ else if (patch !== 0 && !hasX) {
69
+ upper = `0.0.${patch + 1}`;
70
+ description = `Only version ${lower} (caret on 0.0.x is exact)`;
71
+ }
72
+ else if (hasX && numParts >= 2 && cleaned[1] === "0") {
73
+ // ^0.0.x or ^0.0
74
+ upper = "0.1.0";
75
+ description = `Any patch version within 0.0`;
76
+ }
77
+ else if (hasX || numParts === 1) {
78
+ upper = `${major + 1}.0.0`;
79
+ description = `Any version starting with ${major}`;
80
+ }
81
+ else {
82
+ // ^0.0.0
83
+ upper = "0.1.0";
84
+ description = `Any patch version within 0.0`;
85
+ }
86
+ return { lower, upper, description };
87
+ }
88
+ /**
89
+ * Expand a tilde range (~) to its comparator bounds.
90
+ *
91
+ * Per node-semver spec:
92
+ * - ~1.2.3 => >=1.2.3 <1.3.0 (patch-level changes allowed)
93
+ * - ~1.2 => >=1.2.0 <1.3.0 (same as 1.2.x)
94
+ * - ~1 => >=1.0.0 <2.0.0 (same as 1.x)
95
+ * - ~0.2.3 => >=0.2.3 <0.3.0
96
+ */
97
+ function expandTilde(raw) {
98
+ const body = raw.replace(/^[v=\s]+/, "");
99
+ const parts = body.split(".");
100
+ const numParts = parts.length;
101
+ const major = parseInt(parts[0], 10) || 0;
102
+ const minor = numParts > 1 ? parseInt(parts[1], 10) || 0 : 0;
103
+ const patch = numParts > 2 ? parseInt(parts[2], 10) || 0 : 0;
104
+ const lower = `${major}.${minor}.${patch}`;
105
+ if (numParts === 1) {
106
+ // ~1 => >=1.0.0 <2.0.0
107
+ return {
108
+ lower,
109
+ upper: `${major + 1}.0.0`,
110
+ description: `Any version starting with ${major}`,
111
+ };
112
+ }
113
+ // ~1.2.3 or ~1.2 => >=1.2.x <1.3.0
114
+ const upper = `${major}.${minor + 1}.0`;
115
+ if (patch === 0) {
116
+ return {
117
+ lower,
118
+ upper,
119
+ description: `Any patch version within ${major}.${minor}`,
120
+ };
121
+ }
122
+ return {
123
+ lower,
124
+ upper,
125
+ description: `Approximately ${lower}, allowing only patch updates`,
126
+ };
127
+ }
128
+ /**
129
+ * Expand an x-range (1.x, 1.2.x, *, x) to its comparator bounds.
130
+ */
131
+ function expandXRange(raw) {
132
+ const body = raw.replace(/^[v=\s]+/, "").trim();
133
+ // Pure wildcard
134
+ if (body === "*" || body === "x" || body === "X" || body === "") {
135
+ return { lower: "", upper: "", description: "Any version" };
136
+ }
137
+ const parts = body.split(".");
138
+ const major = parseInt(parts[0], 10);
139
+ if (isNaN(major)) {
140
+ return { lower: "", upper: "", description: "Any version" };
141
+ }
142
+ // 1.x or 1.*
143
+ if (parts.length === 1 ||
144
+ (parts.length >= 2 &&
145
+ (parts[1] === "x" || parts[1] === "X" || parts[1] === "*"))) {
146
+ return {
147
+ lower: `${major}.0.0`,
148
+ upper: `${major + 1}.0.0`,
149
+ description: `Any version starting with ${major}`,
150
+ };
151
+ }
152
+ // 1.2.x or 1.2.*
153
+ const minor = parseInt(parts[1], 10) || 0;
154
+ if (parts.length >= 3 &&
155
+ (parts[2] === "x" || parts[2] === "X" || parts[2] === "*")) {
156
+ return {
157
+ lower: `${major}.${minor}.0`,
158
+ upper: `${major}.${minor + 1}.0`,
159
+ description: `Any patch version within ${major}.${minor}`,
160
+ };
161
+ }
162
+ // Shouldn't reach here, but handle gracefully
163
+ return {
164
+ lower: `${major}.${minor}.0`,
165
+ upper: `${major}.${minor + 1}.0`,
166
+ description: `Any patch version within ${major}.${minor}`,
167
+ };
168
+ }
169
+ /**
170
+ * Expand a hyphen range (A - B) to its comparator bounds.
171
+ *
172
+ * Per node-semver spec:
173
+ * - 1.2.3 - 2.3.4 => >=1.2.3 <=2.3.4
174
+ * - 1.2 - 2.3.4 => >=1.2.0 <=2.3.4
175
+ * - 1.2.3 - 2.3 => >=1.2.3 <2.4.0
176
+ * - 1.2.3 - 2 => >=1.2.3 <3.0.0
177
+ */
178
+ function expandHyphen(left, right) {
179
+ const lBody = left.replace(/^[v=\s]+/, "").trim();
180
+ const rBody = right.replace(/^[v=\s]+/, "").trim();
181
+ const lv = parseVersion(lBody);
182
+ const rv = parseVersion(rBody);
183
+ const rParts = partCount(rBody);
184
+ const lower = fmt(lv);
185
+ if (rParts === 3) {
186
+ // Full version on right: inclusive upper bound
187
+ return {
188
+ expanded: `>=${lower} <=${fmt(rv)}`,
189
+ description: `Between ${lower} and ${fmt(rv)}, inclusive`,
190
+ };
191
+ }
192
+ // Partial version on right: exclusive upper bound on next increment
193
+ let upper;
194
+ if (rParts === 2) {
195
+ upper = `${rv.major}.${rv.minor + 1}.0`;
196
+ }
197
+ else {
198
+ upper = `${rv.major + 1}.0.0`;
199
+ }
200
+ return {
201
+ expanded: `>=${lower} <${upper}`,
202
+ description: `Between ${lower} and ${rBody}`,
203
+ };
204
+ }
205
+ /**
206
+ * Check if a string is a simple comparator (>=, <=, >, <, =).
207
+ */
208
+ function isComparator(token) {
209
+ return /^(>=|<=|>|<|=)\s*\d/.test(token.trim());
210
+ }
211
+ /**
212
+ * Explain a single simple range (no OR unions).
213
+ * This handles: exact, caret, tilde, x-range, hyphen, and comparators.
214
+ */
215
+ function explainSimple(range) {
216
+ const trimmed = range.trim();
217
+ if (trimmed === "" || trimmed === "*") {
218
+ return "Any version";
219
+ }
220
+ // Hyphen range: "A - B"
221
+ const hyphenMatch = trimmed.match(/^([v=\s]*[\dx.*X]+(?:\.[\dx.*X]+)*)\s+-\s+([v=\s]*[\dx.*X]+(?:\.[\dx.*X]+)*)$/);
222
+ if (hyphenMatch) {
223
+ const { expanded, description } = expandHyphen(hyphenMatch[1], hyphenMatch[2]);
224
+ return `${expanded} \u2014 ${description}`;
225
+ }
226
+ // Caret range
227
+ if (trimmed.startsWith("^")) {
228
+ const body = trimmed.slice(1);
229
+ const { lower, upper, description } = expandCaret(body);
230
+ return `>=${lower} <${upper} \u2014 ${description}`;
231
+ }
232
+ // Tilde range
233
+ if (trimmed.startsWith("~")) {
234
+ const body = trimmed.slice(1);
235
+ const { lower, upper, description } = expandTilde(body);
236
+ return `>=${lower} <${upper} \u2014 ${description}`;
237
+ }
238
+ // X-range (contains x, X, or * in version parts)
239
+ if (/[xX*]/.test(trimmed) || /^\d+$/.test(trimmed)) {
240
+ // Single number like "1" is treated as 1.x.x
241
+ if (/^\d+$/.test(trimmed)) {
242
+ const major = parseInt(trimmed, 10);
243
+ return `>=${major}.0.0 <${major + 1}.0.0 \u2014 Any version starting with ${major}`;
244
+ }
245
+ const { lower, upper, description } = expandXRange(trimmed);
246
+ if (lower === "" && upper === "") {
247
+ return description;
248
+ }
249
+ return `>=${lower} <${upper} \u2014 ${description}`;
250
+ }
251
+ // Compound comparators (space-separated, e.g. ">=1.0.0 <2.0.0")
252
+ const tokens = trimmed.split(/\s+/);
253
+ if (tokens.length > 1 && tokens.every((t) => isComparator(t))) {
254
+ return tokens.join(" and ");
255
+ }
256
+ // Single comparator (>=, <=, >, <, =)
257
+ if (isComparator(trimmed)) {
258
+ const normalized = trimmed.replace(/^=\s*/, "");
259
+ if (trimmed.startsWith("=")) {
260
+ return `${normalized} \u2014 Exactly version ${normalized}`;
261
+ }
262
+ return trimmed;
263
+ }
264
+ // Exact version (e.g. "1.2.3")
265
+ if (/^\d+\.\d+\.\d+/.test(trimmed)) {
266
+ return `${trimmed} \u2014 Exactly version ${trimmed}`;
267
+ }
268
+ // Partial version without x (e.g. "1.2")
269
+ if (/^\d+\.\d+$/.test(trimmed)) {
270
+ const major = parseInt(trimmed.split(".")[0], 10);
271
+ const minor = parseInt(trimmed.split(".")[1], 10);
272
+ return `>=${major}.${minor}.0 <${major}.${minor + 1}.0 \u2014 Any patch version within ${trimmed}`;
273
+ }
274
+ // Fallback: return as-is
275
+ return trimmed;
276
+ }
277
+ /**
278
+ * Explain a semver range in plain English.
279
+ *
280
+ * Supports all npm semver range syntax:
281
+ * - Exact: 1.2.3
282
+ * - Caret: ^1.2.3
283
+ * - Tilde: ~1.2.3
284
+ * - X-ranges: 1.x, 1.2.*, *
285
+ * - Hyphen: 1.2.3 - 2.3.4
286
+ * - Comparators: >=1.0.0, <2.0.0
287
+ * - Compound: >=1.0.0 <2.0.0
288
+ * - Union: ^1 || ^2
289
+ *
290
+ * @param range - A semver range string
291
+ * @returns Human-readable explanation
292
+ */
293
+ export function explain(range) {
294
+ if (typeof range !== "string") {
295
+ return "Invalid range";
296
+ }
297
+ const trimmed = range.trim();
298
+ if (trimmed === "") {
299
+ return "Any version";
300
+ }
301
+ // Split on || for OR unions
302
+ const branches = trimmed.split(/\s*\|\|\s*/);
303
+ if (branches.length === 1) {
304
+ return explainSimple(branches[0]);
305
+ }
306
+ // Multiple branches: explain each, join with OR
307
+ return branches.map((b) => explainSimple(b)).join(" | OR | ");
308
+ }
309
+ /**
310
+ * Return only the expanded comparator form, without the English description.
311
+ *
312
+ * @param range - A semver range string
313
+ * @returns Expanded comparator string (e.g. ">=1.2.3 <2.0.0")
314
+ */
315
+ export function expandRange(range) {
316
+ if (typeof range !== "string") {
317
+ return "Invalid range";
318
+ }
319
+ const trimmed = range.trim();
320
+ if (trimmed === "" || trimmed === "*") {
321
+ return "*";
322
+ }
323
+ // Split on || for OR unions
324
+ const branches = trimmed.split(/\s*\|\|\s*/);
325
+ const expanded = branches.map((branch) => {
326
+ const b = branch.trim();
327
+ // Hyphen range
328
+ const hyphenMatch = b.match(/^([v=\s]*[\dx.*X]+(?:\.[\dx.*X]+)*)\s+-\s+([v=\s]*[\dx.*X]+(?:\.[\dx.*X]+)*)$/);
329
+ if (hyphenMatch) {
330
+ return expandHyphen(hyphenMatch[1], hyphenMatch[2]).expanded;
331
+ }
332
+ // Caret
333
+ if (b.startsWith("^")) {
334
+ const { lower, upper } = expandCaret(b.slice(1));
335
+ return `>=${lower} <${upper}`;
336
+ }
337
+ // Tilde
338
+ if (b.startsWith("~")) {
339
+ const { lower, upper } = expandTilde(b.slice(1));
340
+ return `>=${lower} <${upper}`;
341
+ }
342
+ // X-range
343
+ if (/[xX*]/.test(b)) {
344
+ const { lower, upper } = expandXRange(b);
345
+ if (lower === "" && upper === "")
346
+ return "*";
347
+ return `>=${lower} <${upper}`;
348
+ }
349
+ // Single number (1 => 1.x.x)
350
+ if (/^\d+$/.test(b)) {
351
+ const major = parseInt(b, 10);
352
+ return `>=${major}.0.0 <${major + 1}.0.0`;
353
+ }
354
+ // Partial version (1.2 => 1.2.x)
355
+ if (/^\d+\.\d+$/.test(b)) {
356
+ const [maj, min] = b.split(".").map(Number);
357
+ return `>=${maj}.${min}.0 <${maj}.${min + 1}.0`;
358
+ }
359
+ // Already a comparator or exact version
360
+ return b;
361
+ });
362
+ return expanded.join(" || ");
363
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@credentum/semver-explain",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Explain semver ranges in plain English. The cronstrue for version constraints.",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "files": [
9
+ "index.js",
10
+ "index.d.ts"
11
+ ],
12
+ "keywords": [
13
+ "semver",
14
+ "explain",
15
+ "human-readable",
16
+ "version",
17
+ "range",
18
+ "caret",
19
+ "tilde",
20
+ "npm",
21
+ "package.json"
22
+ ],
23
+ "license": "MIT",
24
+ "dependencies": {},
25
+ "devDependencies": {
26
+ "typescript": "^5.0.0"
27
+ }
28
+ }