diff --git a/README.md b/README.md index e9522153..3058bba3 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,11 @@ semver.lt('1.2.3', '9.8.7') // true semver.minVersion('>=1.0.0') // '1.0.0' semver.valid(semver.coerce('v2')) // '2.0.0' semver.valid(semver.coerce('42.6.7.9.3-alpha')) // '42.6.7' + +// Strict mode - rejects version strings with 'v' prefix +semver.valid('v1.2.3') // '1.2.3' (default behavior) +semver.valid('v1.2.3', { strict: true }) // throws TypeError +semver.valid('1.2.3', { strict: true }) // '1.2.3' ``` You can also just load the module for the function that you care about if @@ -116,6 +121,10 @@ Options: -p --include-prerelease Always include prerelease versions in range matching +-s --strict + Reject version strings with leading 'v' prefix + (throws error if version starts with 'v' or 'V') + -c --coerce Coerce a string into SemVer if possible (does not imply --loose) @@ -419,6 +428,10 @@ are: behavior](https://github.com/npm/node-semver#prerelease-tags) of excluding prerelease tagged versions from ranges unless they are explicitly opted into. +- `strict`: When set to `true`, versions with leading "v" or "V" prefixes + will be rejected and throw a TypeError. By default (when `false`), such + prefixes are allowed and stripped. This option enforces strict SemVer + compliance where version strings must not contain prefixes. Strict-mode Comparators and Ranges will be strict about the SemVer strings that they parse. diff --git a/bin/semver.js b/bin/semver.js index dbb1bf53..55da1275 100755 --- a/bin/semver.js +++ b/bin/semver.js @@ -23,6 +23,8 @@ let coerce = false let rtl = false +let strict = false + let identifier let identifierBase @@ -56,6 +58,9 @@ const main = () => { case '-p': case '--include-prerelease': includePrerelease = true break + case '-s': case '--strict': + strict = true + break case '-v': case '--version': versions.push(argv.shift()) break @@ -100,12 +105,23 @@ const main = () => { } } - options = parseOptions({ loose, includePrerelease, rtl }) + options = parseOptions({ loose, includePrerelease, rtl, strict }) versions = versions.map((v) => { return coerce ? (semver.coerce(v, options) || { version: v }).version : v }).filter((v) => { - return semver.valid(v) + if (strict) { + // In strict mode, throw errors instead of filtering + try { + new (require('../classes/semver'))(v, options) + return true + } catch (err) { + console.error(`Error: ${err.message}`) + process.exit(1) + } + } else { + return semver.valid(v) + } }) if (!versions.length) { return fail() @@ -165,6 +181,10 @@ Options: -p --include-prerelease Always include prerelease versions in range matching +-s --strict + Reject version strings with leading 'v' prefix + (throws error if version starts with 'v' or 'V') + -c --coerce Coerce a string into SemVer if possible (does not imply --loose) diff --git a/classes/semver.js b/classes/semver.js index 2efba0f4..9a5e8226 100644 --- a/classes/semver.js +++ b/classes/semver.js @@ -34,6 +34,11 @@ class SemVer { // don't run into trouble passing this.options around. this.includePrerelease = !!options.includePrerelease + // Check for strict mode - reject versions with leading 'v' + if (options.strict && /^v/i.test(version.trim())) { + throw new TypeError(`Invalid version in strict mode: version cannot start with 'v'. Got: ${version}`) + } + const m = version.trim().match(options.loose ? re[t.LOOSE] : re[t.FULL]) if (!m) { diff --git a/tap-snapshots/test/bin/semver.js.test.cjs b/tap-snapshots/test/bin/semver.js.test.cjs index 4938f10d..5c71f792 100644 --- a/tap-snapshots/test/bin/semver.js.test.cjs +++ b/tap-snapshots/test/bin/semver.js.test.cjs @@ -83,6 +83,10 @@ Object { -p --include-prerelease Always include prerelease versions in range matching + -s --strict + Reject version strings with leading 'v' prefix + (throws error if version starts with 'v' or 'V') + -c --coerce Coerce a string into SemVer if possible (does not imply --loose) @@ -144,6 +148,10 @@ Object { -p --include-prerelease Always include prerelease versions in range matching + -s --strict + Reject version strings with leading 'v' prefix + (throws error if version starts with 'v' or 'V') + -c --coerce Coerce a string into SemVer if possible (does not imply --loose) @@ -205,6 +213,10 @@ Object { -p --include-prerelease Always include prerelease versions in range matching + -s --strict + Reject version strings with leading 'v' prefix + (throws error if version starts with 'v' or 'V') + -c --coerce Coerce a string into SemVer if possible (does not imply --loose) @@ -266,6 +278,10 @@ Object { -p --include-prerelease Always include prerelease versions in range matching + -s --strict + Reject version strings with leading 'v' prefix + (throws error if version starts with 'v' or 'V') + -c --coerce Coerce a string into SemVer if possible (does not imply --loose) @@ -348,15 +364,6 @@ Object { } ` -exports[`test/bin/semver.js TAP inc tests > -i release 1.0.0-pre`] = ` -Object { - "code": 0, - "err": "", - "out": "1.0.0\\n", - "signal": null, -} -` - exports[`test/bin/semver.js TAP sorting and filtering > 1.2.3 -v 3.2.1 --version 2.3.4 -rv 1`] = ` Object { "code": 0, @@ -484,3 +491,57 @@ Object { "signal": null, } ` + +exports[`test/bin/semver.js TAP strict mode tests > 1.2.3 --strict 1`] = ` +Object { + "code": 0, + "err": "", + "out": "1.2.3\\n", + "signal": null, +} +` + +exports[`test/bin/semver.js TAP strict mode tests > 1.2.3-alpha.1 -s 1`] = ` +Object { + "code": 0, + "err": "", + "out": "1.2.3-alpha.1\\n", + "signal": null, +} +` + +exports[`test/bin/semver.js TAP strict mode tests > V1.2.3 -s 1`] = ` +Object { + "code": 1, + "err": "Error: Invalid version in strict mode: version cannot start with 'v'. Got: V1.2.3\\n", + "out": "", + "signal": null, +} +` + +exports[`test/bin/semver.js TAP strict mode tests > V1.2.3 1`] = ` +Object { + "code": 1, + "err": "", + "out": "", + "signal": null, +} +` + +exports[`test/bin/semver.js TAP strict mode tests > v1.2.3 --strict 1`] = ` +Object { + "code": 1, + "err": "Error: Invalid version in strict mode: version cannot start with 'v'. Got: v1.2.3\\n", + "out": "", + "signal": null, +} +` + +exports[`test/bin/semver.js TAP strict mode tests > v1.2.3 1`] = ` +Object { + "code": 0, + "err": "", + "out": "1.2.3\\n", + "signal": null, +} +` diff --git a/test/bin/semver.js b/test/bin/semver.js index 0514103b..cf3305f4 100644 --- a/test/bin/semver.js +++ b/test/bin/semver.js @@ -81,3 +81,15 @@ t.test('args with equals', t => Promise.all([ t.equal(res1.out.trim(), expected) t.strictSame(res1, res2, args.join(' ')) }))) + +t.test('strict mode tests', t => Promise.all([ + // Default behavior - v-prefix allowed + ['v1.2.3'], + ['V1.2.3'], + // Strict mode rejects v-prefix + ['v1.2.3', '--strict'], + ['V1.2.3', '-s'], + // Strict mode allows normal versions + ['1.2.3', '--strict'], + ['1.2.3-alpha.1', '-s'], +].map(args => t.resolveMatchSnapshot(run(args), args.join(' '))))) diff --git a/test/classes/semver.js b/test/classes/semver.js index 61119745..b43825a3 100644 --- a/test/classes/semver.js +++ b/test/classes/semver.js @@ -184,3 +184,96 @@ test('compareBuild', (t) => { t.end() }) + +test('strict option behavior', (t) => { + // Default behavior - v-prefix allowed + t.test('default allows v-prefix', (t) => { + const v = new SemVer('v1.2.3') + t.equal(v.version, '1.2.3') + t.equal(v.major, 1) + t.equal(v.minor, 2) + t.equal(v.patch, 3) + t.end() + }) + + // Explicit strict: false - v-prefix allowed + t.test('strict: false allows v-prefix', (t) => { + const v = new SemVer('v1.2.3', { strict: false }) + t.equal(v.version, '1.2.3') + t.equal(v.major, 1) + t.equal(v.minor, 2) + t.equal(v.patch, 3) + t.end() + }) + + // Strict: true without v-prefix - should work + t.test('strict: true allows valid version', (t) => { + const v = new SemVer('1.2.3', { strict: true }) + t.equal(v.version, '1.2.3') + t.equal(v.major, 1) + t.equal(v.minor, 2) + t.equal(v.patch, 3) + t.end() + }) + + // Strict: true with prerelease - should work + t.test('strict: true allows valid prerelease version', (t) => { + const v = new SemVer('1.2.3-alpha.1', { strict: true }) + t.equal(v.version, '1.2.3-alpha.1') + t.equal(v.major, 1) + t.equal(v.minor, 2) + t.equal(v.patch, 3) + t.strictSame(v.prerelease, ['alpha', 1]) + t.end() + }) + + // Strict: true with lowercase v-prefix - should throw + t.test('strict: true rejects lowercase v-prefix', (t) => { + t.throws( + () => new SemVer('v1.2.3', { strict: true }), + { + name: 'TypeError', + message: "Invalid version in strict mode: version cannot start with 'v'. Got: v1.2.3", + } + ) + t.end() + }) + + // Strict: true with uppercase V-prefix - should throw + t.test('strict: true rejects uppercase V-prefix', (t) => { + t.throws( + () => new SemVer('V1.2.3', { strict: true }), + { + name: 'TypeError', + message: "Invalid version in strict mode: version cannot start with 'v'. Got: V1.2.3", + } + ) + t.end() + }) + + // Strict: true with v-prefix and prerelease - should throw + t.test('strict: true rejects v-prefix with prerelease', (t) => { + t.throws( + () => new SemVer('v1.2.3-alpha.1', { strict: true }), + { + name: 'TypeError', + message: "Invalid version in strict mode: version cannot start with 'v'. Got: v1.2.3-alpha.1", + } + ) + t.end() + }) + + // Strict: true with whitespace and v-prefix - should throw + t.test('strict: true rejects v-prefix with whitespace', (t) => { + t.throws( + () => new SemVer(' v1.2.3 ', { strict: true }), + { + name: 'TypeError', + message: "Invalid version in strict mode: version cannot start with 'v'. Got: v1.2.3 ", + } + ) + t.end() + }) + + t.end() +}) diff --git a/test/internal/parse-options.js b/test/internal/parse-options.js index d1650153..254953c9 100644 --- a/test/internal/parse-options.js +++ b/test/internal/parse-options.js @@ -32,6 +32,8 @@ t.test('any object passed is returned', t => { t.strictSame(parseOptions({ loose: true }), { loose: true }) t.strictSame(parseOptions({ rtl: true }), { rtl: true }) t.strictSame(parseOptions({ includePrerelease: true }), { includePrerelease: true }) + t.strictSame(parseOptions({ strict: true }), { strict: true }) + t.strictSame(parseOptions({ strict: false }), { strict: false }) t.strictSame(parseOptions({ loose: true, rtl: true }), { loose: true, rtl: true }) t.strictSame(parseOptions({ loose: true, includePrerelease: true }), { loose: true, @@ -41,5 +43,10 @@ t.test('any object passed is returned', t => { rtl: true, includePrerelease: true, }) + t.strictSame(parseOptions({ loose: true, includePrerelease: true, strict: true }), { + loose: true, + includePrerelease: true, + strict: true, + }) t.end() })