Repository: npm/node-semver (JavaScript)

Issue:
[BUG] Coercing version with prerelease identifier that starts with digits returns truncated identifier
### Is there an existing issue for this?

- [x] I have searched the existing issues

### Current Behavior

Using semver `7.7.1` (and possibly earlier versions), attempting to coerce a version string that contains a prerelease identifier that starts with digits, will result in that identifier to be truncated after the digits.

Here are some example:

- `1.0.0-alpha.1ab` produces `1.0.0-alpha.1`
- `1.0.0-alpha.12ab` produces `1.0.0-alpha.12`
- `1.0.0-alpha.1234.23cd` produces `1.0.0-alpha.1234.23`

This is problematic when the identifier is a hash that may start with a digit.

The issue doesn't seem to happen if the identifier is composed of only number, or if the identifier starts with an alphabetic character:

- `1.9.5-nightly.abc123` is coerced as expected
- `1.9.5-nightly.abcdef` is coerced as expected
- `1.9.5-nightly.123456` is coerced as expected

### Expected Behavior

The prerelease identifier isn't truncated.

### Steps To Reproduce

1. Clone the reproduction repository: https://github.com/nhedger/semver-prerelease-issue
2. Install the dependencies
3. Run `node index.mjs`
4. See that the prerelease identifier is truncated

### Environment

- npm: 10.8.2
- Node: 20.17.0
- OS: macOS
- platform: MacBook Pro M1



Discussion:

Patch:
diff --git a/classes/semver.js b/classes/semver.js
index 6fbc062b..97049a40 100644
--- a/classes/semver.js
+++ b/classes/semver.js
@@ -1,6 +1,6 @@
 const debug = require('../internal/debug')
 const { MAX_LENGTH, MAX_SAFE_INTEGER } = require('../internal/constants')
-const { safeRe: re, safeSrc: src, t } = require('../internal/re')
+const { safeRe: re, t } = require('../internal/re')
 
 const parseOptions = require('../internal/parse-options')
 const { compareIdentifiers } = require('../internal/identifiers')
@@ -182,8 +182,7 @@ class SemVer {
       }
       // Avoid an invalid semver results
       if (identifier) {
-        const r = new RegExp(`^${this.options.loose ? src[t.PRERELEASELOOSE] : src[t.PRERELEASE]}$`)
-        const match = `-${identifier}`.match(r)
+        const match = `-${identifier}`.match(this.options.loose ? re[t.PRERELEASELOOSE] : re[t.PRERELEASE])
         if (!match || match[1] !== identifier) {
           throw new Error(`invalid identifier: ${identifier}`)
         }
diff --git a/internal/re.js b/internal/re.js
index 2a956ba0..0a1c0e65 100644
--- a/internal/re.js
+++ b/internal/re.js
@@ -76,12 +76,14 @@ createToken('MAINVERSIONLOOSE', `(${src[t.NUMERICIDENTIFIERLOOSE]})\\.` +
 
 // ## Pre-release Version Identifier
 // A numeric identifier, or a non-numeric identifier.
+// Non-numberic identifiers include numberic identifiers but can be longer.
+// Therefore non-numberic identifiers must go first.
 
-createToken('PRERELEASEIDENTIFIER', `(?:${src[t.NUMERICIDENTIFIER]
-}|${src[t.NONNUMERICIDENTIFIER]})`)
+createToken('PRERELEASEIDENTIFIER', `(?:${src[t.NONNUMERICIDENTIFIER]
+}|${src[t.NUMERICIDENTIFIER]})`)
 
-createToken('PRERELEASEIDENTIFIERLOOSE', `(?:${src[t.NUMERICIDENTIFIERLOOSE]
-}|${src[t.NONNUMERICIDENTIFIER]})`)
+createToken('PRERELEASEIDENTIFIERLOOSE', `(?:${src[t.NONNUMERICIDENTIFIER]
+}|${src[t.NUMERICIDENTIFIERLOOSE]})`)
 
 // ## Pre-release Version
 // Hyphen, followed by one or more dot-separated pre-release version


Test patch:
diff --git a/test/classes/semver.js b/test/classes/semver.js
index 946fa417..f4abad04 100644
--- a/test/classes/semver.js
+++ b/test/classes/semver.js
@@ -3,7 +3,22 @@ const SemVer = require('../../classes/semver')
 const increments = require('../fixtures/increments.js')
 const comparisons = require('../fixtures/comparisons.js')
 const equality = require('../fixtures/equality.js')
-const invalidVersions = require('../fixtures/invalid-versions')
+const invalidVersions = require('../fixtures/invalid-versions.js')
+const validVersions = require('../fixtures/valid-versions.js')
+
+test('valid versions', t => {
+  t.plan(validVersions.length)
+  validVersions.forEach(([v, major, minor, patch, prerelease, build]) => t.test(v, t => {
+    const s = new SemVer(v)
+    t.strictSame(s.major, major)
+    t.strictSame(s.minor, minor)
+    t.strictSame(s.patch, patch)
+    t.strictSame(s.prerelease, prerelease)
+    t.strictSame(s.build, build)
+    t.strictSame(s.raw, v)
+    t.end()
+  }))
+})
 
 test('comparisons', t => {
   t.plan(comparisons.length)
diff --git a/test/fixtures/valid-versions.js b/test/fixtures/valid-versions.js
new file mode 100644
index 00000000..e2bf3d11
--- /dev/null
+++ b/test/fixtures/valid-versions.js
@@ -0,0 +1,31 @@
+// [version, major, minor, patch, prerelease[], build[]]
+module.exports = [
+  ['1.0.0', 1, 0, 0, [], []],
+  ['2.1.0', 2, 1, 0, [], []],
+  ['3.2.1', 3, 2, 1, [], []],
+  ['v1.2.3', 1, 2, 3, [], []],
+
+  // prerelease
+  ['1.2.3-0', 1, 2, 3, [0], []],
+  ['1.2.3-123', 1, 2, 3, [123], []],
+  ['1.2.3-1.2.3', 1, 2, 3, [1, 2, 3], []],
+  ['1.2.3-1a', 1, 2, 3, ['1a'], []],
+  ['1.2.3-a1', 1, 2, 3, ['a1'], []],
+  ['1.2.3-alpha', 1, 2, 3, ['alpha'], []],
+  ['1.2.3-alpha.1', 1, 2, 3, ['alpha', 1], []],
+  ['1.2.3-alpha-1', 1, 2, 3, ['alpha-1'], []],
+  ['1.2.3-alpha-.-beta', 1, 2, 3, ['alpha-', '-beta'], []],
+
+  // build
+  ['1.2.3+456', 1, 2, 3, [], ['456']],
+  ['1.2.3+build', 1, 2, 3, [], ['build']],
+  ['1.2.3+new-build', 1, 2, 3, [], ['new-build']],
+  ['1.2.3+build.1', 1, 2, 3, [], ['build', '1']],
+  ['1.2.3+build.1a', 1, 2, 3, [], ['build', '1a']],
+  ['1.2.3+build.a1', 1, 2, 3, [], ['build', 'a1']],
+  ['1.2.3+build.alpha', 1, 2, 3, [], ['build', 'alpha']],
+  ['1.2.3+build.alpha.beta', 1, 2, 3, [], ['build', 'alpha', 'beta']],
+
+  // mixed
+  ['1.2.3-alpha+build', 1, 2, 3, ['alpha'], ['build']],
+]
diff --git a/test/functions/coerce.js b/test/functions/coerce.js
index 24e2ff76..4ecc3c0d 100644
--- a/test/functions/coerce.js
+++ b/test/functions/coerce.js
@@ -118,6 +118,13 @@ test('coerce tests', (t) => {
     ['1.2.3.4-rc.5', '1.2.3', { includePrerelease: true }],
     ['1.2.3.4+rev.6', '1.2.3', { includePrerelease: true }],
 
+    ['1.0.0-1a', '1.0.0-1a', { includePrerelease: true }],
+    ['1.0.0-alpha.12ab', '1.0.0-alpha.12ab', { includePrerelease: true }],
+    ['1.0.0-alpha.1234.23cd', '1.0.0-alpha.1234.23cd', { includePrerelease: true }],
+    ['1.0.0-nightly.abc123', '1.0.0-nightly.abc123', { includePrerelease: true }],
+    ['1.0.0-nightly.abcdef', '1.0.0-nightly.abcdef', { includePrerelease: true }],
+    ['1.0.0-nightly.123456', '1.0.0-nightly.123456', { includePrerelease: true }],
+
     ['1+rev.6', '1.0.0+rev.6', { includePrerelease: true }],
     ['1.2+rev.6', '1.2.0+rev.6', { includePrerelease: true }],
     ['1.2.3+rev.6', '1.2.3+rev.6', { includePrerelease: true }],