From fdc66ebe3a81735c661e6e26e8cb701f0bf85f93 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 16 May 2026 18:34:25 +0530 Subject: [PATCH 01/11] Add version package --- pkg/version/version.go | 77 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 pkg/version/version.go diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 00000000..580f0467 --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,77 @@ +package version + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/Masterminds/semver/v3" +) + +const maxVersionLength = 64 + +var trailingAlphaSuffix = regexp.MustCompile(`^(\d+(?:\.\d+){0,2})([A-Za-z][A-Za-z0-9.]*)$`) + +// Normalize converts a version string into strict semver format (X.Y.Z[-prerelease][+build]). +// +// It handles common PHP/WordPress patterns: +// - leading 'v' or 'V' prefix (v1.2.3 -> 1.2.3) +// - leading zeros (01.0.0 -> 1.0.0) +// - short forms (1, 1.2 -> 1.0.0, 1.2.0) +// - alphabetic suffixes without separator (1.0.0beta -> 1.0.0-beta) +// - 4+ dotted segments collapsed into prerelease (1.0.0.0 -> 1.0.0-0) +// +// The output is guaranteed to satisfy semver.StrictNewVersion and to be no +// longer than 64 characters. Inputs that cannot be normalized return an error. +func Normalize(version string) (string, error) { + version = strings.TrimSpace(version) + if version == "" { + return "", errors.New("version cannot be empty") + } + if len(version) > maxVersionLength { + return "", fmt.Errorf("input version length %d exceeds maximum of %d", len(version), maxVersionLength) + } + + // Fast path for already-normalized versions. + if v, err := semver.StrictNewVersion(version); err == nil { + return v.String(), nil + } + + // Strip a single leading 'v' or 'V'. + if version[0] == 'v' || version[0] == 'V' { + version = version[1:] + } + + // Insert hyphen before an alphabetic qualifier with no separator. + // 1.0.0beta1 -> 1.0.0-beta1, 2.1rc1 -> 2.1-rc1, 3.0a -> 3.0-a + version = trailingAlphaSuffix.ReplaceAllString(version, "$1-$2") + + // Collapse 4+ dotted segments into a prerelease. + // 1.0.0.0 -> 1.0.0-0 + // 1.2.3.4.5 -> 1.2.3-4.5 + // 1.0.0.alpha.1 -> 1.0.0-alpha.1 + if parts := strings.Split(version, "."); len(parts) > 3 { + version = fmt.Sprintf("%s.%s.%s-%s", + parts[0], parts[1], parts[2], + strings.Join(parts[3:], ".")) + } + + // Coerce to semver, which will handle leading zeros and short forms. + v, err := semver.NewVersion(version) + if err != nil { + return "", fmt.Errorf("cannot normalize %q to semver: %w", version, err) + } + + result := v.String() + + if len(result) > maxVersionLength { + return "", fmt.Errorf("normalized result %q has length %d, exceeds maximum of %d", + result, len(result), maxVersionLength) + } + if _, err := semver.StrictNewVersion(result); err != nil { + return "", fmt.Errorf("normalized result %q is not strict semver: %w", result, err) + } + + return result, nil +} From dae1fba8954721c81a6cc0079e016fde18e525fd Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 16 May 2026 18:39:20 +0530 Subject: [PATCH 02/11] Update version normalization --- cli/command/init/init.go | 77 ++-------------------------------------- 1 file changed, 2 insertions(+), 75 deletions(-) diff --git a/cli/command/init/init.go b/cli/command/init/init.go index 5cf0897c..63358787 100644 --- a/cli/command/init/init.go +++ b/cli/command/init/init.go @@ -15,6 +15,7 @@ import ( "wpm/pkg/pm/wpmjson" "wpm/pkg/pm/wpmjson/types" "wpm/pkg/pm/wpmjson/validator" + "wpm/pkg/version" "wpm/pkg/wp/parser" "github.com/Masterminds/semver/v3" @@ -237,7 +238,7 @@ func runExistingInit(wpmCli command.Cli, opts *initOptions) error { } // Normalize and validate version - v, err := normalizeVersion(wpmCfg.Version) + v, err := version.Normalize(wpmCfg.Version) if err != nil { return errors.New("invalid version format: " + err.Error()) } @@ -682,80 +683,6 @@ func buildWpmConfig(opts initOptions, pkgType string, mainFileHeaders any, readm return cfg } -func normalizeVersion(version string) (string, error) { - if version == "" { - return "", errors.New("version cannot be empty") - } - - v, err := semver.NewVersion(version) - if err == nil { - return v.String(), nil - } - - // Attempt to normalize the version format to be compatible with semver. - // If version has more than 2 dots, we replace the last dot with a hyphen - // Example: - // 1.0.0.0 -> 1.0.0-0 - // 1.0.0.alpha.1+build -> 1.0.0-alpha.1+build - parts := strings.Split(version, ".") - if len(parts) > 3 { - major := parts[0] - minor := parts[1] - patch := parts[2] - prerelease := strings.Join(parts[3:], ".") - - version = fmt.Sprintf("%s.%s.%s-%s", major, minor, patch, prerelease) - } - - // If version part start with 0, we remove it - // Example: - // 01.0.0 -> 1.0.0 - // 1.01.0 -> 1.1.0 - // 1.0.01 -> 1.0.1 - // 1.0.01-beta -> 1.0.1-beta - // Split version into parts - parts = strings.Split(version, ".") - for i, part := range parts { - // Check if part starts with '0' and has more characters - if len(part) > 1 && part[0] == '0' { - // Split part into numeric and non-numeric (e.g., "01-beta" -> "01" and "-beta") - numericPart := part - nonNumericPart := "" - if hyphenIndex := strings.Index(part, "-"); hyphenIndex != -1 { - numericPart = part[:hyphenIndex] - nonNumericPart = part[hyphenIndex:] - } - - // Check if numeric part is all digits and starts with '0' - isNumeric := true - for _, r := range numericPart { - if !unicode.IsDigit(r) { - isNumeric = false - break - } - } - - if isNumeric && len(numericPart) > 1 && numericPart[0] == '0' { - // Remove leading zeros from numeric part - trimmed := strings.TrimLeft(numericPart, "0") - if trimmed == "" { - trimmed = "0" - } - // Reconstruct the part - parts[i] = trimmed + nonNumericPart - } - } - } - version = strings.Join(parts, ".") - - v, err = semver.NewVersion(version) - if err != nil { - return "", err - } - - return v.String(), nil -} - func detectPackageType(cwd string) string { if _, err := os.Stat(filepath.Join(cwd, "style.css")); err == nil { return "theme" From 32fc11e33dadf7c70fde3cc31cadd9f6640bb5e7 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 16 May 2026 19:48:39 +0530 Subject: [PATCH 03/11] Update checks for enabling color output --- pkg/streams/out.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/streams/out.go b/pkg/streams/out.go index e04fc966..dc68ec34 100644 --- a/pkg/streams/out.go +++ b/pkg/streams/out.go @@ -27,12 +27,15 @@ func (o *Out) WriteString(s string) { // IsColorEnabled returns true if color output is enabled for this stream. func (o *Out) IsColorEnabled() bool { + if v, ok := os.LookupEnv("FORCE_COLOR"); ok && v != "" { + return true + } + if os.Getenv("NO_COLOR") != "" { return false } - force := os.Getenv("CLICOLOR_FORCE") - if force != "" && force != "0" { + if force := os.Getenv("CLICOLOR_FORCE"); force != "" && force != "0" { return true } From b162ae7fc6a233903af8c20a383ef536f80a2fa7 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 16 May 2026 19:48:46 +0530 Subject: [PATCH 04/11] Update package details colorization --- cli/command/publish/publish.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/cli/command/publish/publish.go b/cli/command/publish/publish.go index 68c329d9..ab851a3a 100644 --- a/cli/command/publish/publish.go +++ b/cli/command/publish/publish.go @@ -199,8 +199,6 @@ func runPublish(ctx context.Context, wpmCli command.Cli, opts publishOptions) er } } - digest := base64.StdEncoding.EncodeToString(hasher.Sum(nil)) - if opts.verbose { fmt.Fprint(wpmCli.Err(), "\n") } @@ -210,18 +208,23 @@ func runPublish(ctx context.Context, wpmCli command.Cli, opts publishOptions) er return errors.New("tarball size is zero, cannot publish empty package") } - dim := aec.Faint.Apply - blue := aec.LightBlueF.Apply + c := func(a aec.ANSI, s string) string { + if !wpmCli.Err().IsColorEnabled() { + return s + } + return a.Apply(s) + } w := tabwriter.NewWriter(wpmCli.Err(), 0, 0, 2, ' ', 0) + digest := base64.StdEncoding.EncodeToString(hasher.Sum(nil)) packedSize := units.HumanSize(float64(counter.total)) unpackedSize := units.HumanSize(float64(tarballer.UnpackedSize())) - fmt.Fprintf(w, "├─ %s:\t%s\n", blue("Tag"), opts.tag) - fmt.Fprintf(w, "├─ %s:\t%s\n", blue("Access"), opts.access) - fmt.Fprintf(w, "├─ %s:\t%d\n", blue("Files"), tarballer.FileCount()) - fmt.Fprintf(w, "├─ %s:\t%s %s\n", blue("Size"), packedSize, dim(fmt.Sprintf("(%s unpacked)", unpackedSize))) - fmt.Fprintf(w, "└─ %s:\t%s\n", blue("Digest"), digest) + fmt.Fprintf(w, "├─ %s:\t%s\n", c(aec.LightBlueF, "Tag"), opts.tag) + fmt.Fprintf(w, "├─ %s:\t%s\n", c(aec.LightBlueF, "Access"), opts.access) + fmt.Fprintf(w, "├─ %s:\t%d\n", c(aec.LightBlueF, "Files"), tarballer.FileCount()) + fmt.Fprintf(w, "├─ %s:\t%s %s\n", c(aec.LightBlueF, "Size"), packedSize, c(aec.Faint, fmt.Sprintf("(%s unpacked)", unpackedSize))) + fmt.Fprintf(w, "└─ %s:\t%s\n", c(aec.LightBlueF, "Digest"), digest) w.Flush() fmt.Fprint(wpmCli.Err(), "\n") From f9f90bc3e54e66bf1c215d91388d29454a6544ca Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 16 May 2026 20:04:03 +0530 Subject: [PATCH 05/11] Update preferences order in color enabled check --- pkg/streams/out.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/streams/out.go b/pkg/streams/out.go index dc68ec34..ade590ec 100644 --- a/pkg/streams/out.go +++ b/pkg/streams/out.go @@ -27,14 +27,14 @@ func (o *Out) WriteString(s string) { // IsColorEnabled returns true if color output is enabled for this stream. func (o *Out) IsColorEnabled() bool { - if v, ok := os.LookupEnv("FORCE_COLOR"); ok && v != "" { - return true - } - if os.Getenv("NO_COLOR") != "" { return false } + if force := os.Getenv("FORCE_COLOR"); force != "" && force != "0" { + return true + } + if force := os.Getenv("CLICOLOR_FORCE"); force != "" && force != "0" { return true } From 816972aad51152c7fc402396a21e240e8c141266 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 16 May 2026 20:28:47 +0530 Subject: [PATCH 06/11] Fix leading zeros in 4th numeric segment --- pkg/version/version.go | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/pkg/version/version.go b/pkg/version/version.go index 580f0467..5ae2a5ea 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -11,13 +11,17 @@ import ( const maxVersionLength = 64 -var trailingAlphaSuffix = regexp.MustCompile(`^(\d+(?:\.\d+){0,2})([A-Za-z][A-Za-z0-9.]*)$`) +var ( + numericIdentifier = regexp.MustCompile(`^\d+$`) + trailingAlphaSuffix = regexp.MustCompile(`^(\d+(?:\.\d+){0,2})([A-Za-z][A-Za-z0-9.]*)$`) +) // Normalize converts a version string into strict semver format (X.Y.Z[-prerelease][+build]). // // It handles common PHP/WordPress patterns: // - leading 'v' or 'V' prefix (v1.2.3 -> 1.2.3) -// - leading zeros (01.0.0 -> 1.0.0) +// - leading zeros in core segments (01.0.0 -> 1.0.0) +// - leading zeros in numeric prerelease segments (1.0.0.01 -> 1.0.0-1) // - short forms (1, 1.2 -> 1.0.0, 1.2.0) // - alphabetic suffixes without separator (1.0.0beta -> 1.0.0-beta) // - 4+ dotted segments collapsed into prerelease (1.0.0.0 -> 1.0.0-0) @@ -41,20 +45,31 @@ func Normalize(version string) (string, error) { // Strip a single leading 'v' or 'V'. if version[0] == 'v' || version[0] == 'V' { version = version[1:] + if version == "" { + return "", errors.New("version contains only a 'v' prefix") + } } // Insert hyphen before an alphabetic qualifier with no separator. // 1.0.0beta1 -> 1.0.0-beta1, 2.1rc1 -> 2.1-rc1, 3.0a -> 3.0-a version = trailingAlphaSuffix.ReplaceAllString(version, "$1-$2") - // Collapse 4+ dotted segments into a prerelease. + // Collapse 4+ dotted segments into a prerelease, stripping leading zeros + // from any purely numeric segment (semver forbids them in numeric IDs). // 1.0.0.0 -> 1.0.0-0 + // 1.0.0.01 -> 1.0.0-1 + // 1.0.0.01.02 -> 1.0.0-1.2 // 1.2.3.4.5 -> 1.2.3-4.5 // 1.0.0.alpha.1 -> 1.0.0-alpha.1 + // 1.0.0.0beta -> 1.0.0-0beta (mixed; left alone) if parts := strings.Split(version, "."); len(parts) > 3 { + pre := make([]string, len(parts)-3) + for i, p := range parts[3:] { + pre[i] = stripLeadingZeros(p) + } version = fmt.Sprintf("%s.%s.%s-%s", parts[0], parts[1], parts[2], - strings.Join(parts[3:], ".")) + strings.Join(pre, ".")) } // Coerce to semver, which will handle leading zeros and short forms. @@ -75,3 +90,14 @@ func Normalize(version string) (string, error) { return result, nil } + +func stripLeadingZeros(s string) string { + if !numericIdentifier.MatchString(s) { + return s + } + trimmed := strings.TrimLeft(s, "0") + if trimmed == "" { + return "0" + } + return trimmed +} From 4de1a5a853532e557768c369e7561b7b2f043088 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 16 May 2026 20:29:03 +0530 Subject: [PATCH 07/11] Add test cases for version.Normalize --- pkg/version/version_test.go | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 pkg/version/version_test.go diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go new file mode 100644 index 00000000..788596f7 --- /dev/null +++ b/pkg/version/version_test.go @@ -0,0 +1,71 @@ +package version + +import "testing" + +func TestNormalize(t *testing.T) { + cases := []struct { + name string + in string + want string + wantErr bool + }{ + // Already strict. + {"strict passthrough", "1.2.3", "1.2.3", false}, + {"strict with build", "1.2.3+build.5", "1.2.3+build.5", false}, + {"strict with prerelease", "1.2.3-beta.1", "1.2.3-beta.1", false}, + + // v/V prefix. + {"lowercase v prefix", "v1.2.3", "1.2.3", false}, + {"uppercase V prefix", "V1.2.3", "1.2.3", false}, + {"v prefix with 4 segments", "v1.0.0.01", "1.0.0-1", false}, + + // Leading zeros in core. + {"leading zero major", "01.0.0", "1.0.0", false}, + {"leading zero minor", "1.01.0", "1.1.0", false}, + {"leading zero patch", "1.0.01", "1.0.1", false}, + {"leading zero with suffix", "1.0.01-beta", "1.0.1-beta", false}, + + // Short forms. + {"single segment", "1", "1.0.0", false}, + {"two segments", "1.2", "1.2.0", false}, + + // Alphabetic suffix without separator. + {"patch+alpha", "1.0.0beta", "1.0.0-beta", false}, + {"patch+alpha+num", "1.0.0beta1", "1.0.0-beta1", false}, + {"minor+rc", "2.1rc1", "2.1.0-rc1", false}, + {"minor+a", "3.0a", "3.0.0-a", false}, + + // 4+ segments collapsed. + {"four zeros", "1.0.0.0", "1.0.0-0", false}, + {"five segments", "1.2.3.4.5", "1.2.3-4.5", false}, + {"four segments alpha", "1.0.0.alpha.1", "1.0.0-alpha.1", false}, + + // The fix: leading zeros in prerelease. + {"prerelease leading zero", "1.0.0.01", "1.0.0-1", false}, + {"prerelease all zeros", "1.0.0.00", "1.0.0-0", false}, + {"prerelease multi leading zero", "1.0.0.01.02", "1.0.0-1.2", false}, + {"prerelease mixed alpha+zero", "1.0.0.alpha.01", "1.0.0-alpha.1", false}, + {"prerelease 0beta untouched", "1.0.0.0beta", "1.0.0-0beta", false}, + + // Whitespace. + {"trim spaces", " 1.2.3 ", "1.2.3", false}, + + // Errors. + {"empty", "", "", true}, + {"only v", "v", "", true}, + {"only whitespace", " ", "", true}, + {"garbage", "not-a-version", "", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Normalize(tc.in) + if (err != nil) != tc.wantErr { + t.Fatalf("Normalize(%q) error = %v, wantErr = %v", tc.in, err, tc.wantErr) + } + if got != tc.want { + t.Errorf("Normalize(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} From cc4dfe6c502cb8e117732cbb8076c0bc3e5f1eb1 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 16 May 2026 21:30:47 +0530 Subject: [PATCH 08/11] Fix converting dot to hyphen in version meta --- pkg/version/version.go | 52 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/pkg/version/version.go b/pkg/version/version.go index 5ae2a5ea..69b02dc9 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -33,13 +33,12 @@ func Normalize(version string) (string, error) { if version == "" { return "", errors.New("version cannot be empty") } - if len(version) > maxVersionLength { - return "", fmt.Errorf("input version length %d exceeds maximum of %d", len(version), maxVersionLength) - } // Fast path for already-normalized versions. if v, err := semver.StrictNewVersion(version); err == nil { - return v.String(), nil + if len(version) <= maxVersionLength { + return v.String(), nil + } } // Strip a single leading 'v' or 'V'. @@ -50,9 +49,36 @@ func Normalize(version string) (string, error) { } } + // Isolate the core version from prerelease/build metadata to prevent + // mangling valid semver extensions (e.g., dotted prereleases) below. + core := version + var meta string + + idxPre := strings.IndexByte(version, '-') + idxBld := strings.IndexByte(version, '+') + cutoff := -1 + + if idxPre != -1 { + cutoff = idxPre + } + if idxBld != -1 && (cutoff == -1 || idxBld < cutoff) { + cutoff = idxBld + } + + if cutoff != -1 { + core = version[:cutoff] + meta = version[cutoff:] + } + // Insert hyphen before an alphabetic qualifier with no separator. // 1.0.0beta1 -> 1.0.0-beta1, 2.1rc1 -> 2.1-rc1, 3.0a -> 3.0-a - version = trailingAlphaSuffix.ReplaceAllString(version, "$1-$2") + core = trailingAlphaSuffix.ReplaceAllString(core, "$1-$2") + + // If the regex introduced a hyphen, move that new suffix into meta. + if idx := strings.IndexByte(core, '-'); idx != -1 { + meta = core[idx:] + meta + core = core[:idx] + } // Collapse 4+ dotted segments into a prerelease, stripping leading zeros // from any purely numeric segment (semver forbids them in numeric IDs). @@ -62,18 +88,24 @@ func Normalize(version string) (string, error) { // 1.2.3.4.5 -> 1.2.3-4.5 // 1.0.0.alpha.1 -> 1.0.0-alpha.1 // 1.0.0.0beta -> 1.0.0-0beta (mixed; left alone) - if parts := strings.Split(version, "."); len(parts) > 3 { + if parts := strings.Split(core, "."); len(parts) > 3 { pre := make([]string, len(parts)-3) for i, p := range parts[3:] { pre[i] = stripLeadingZeros(p) } - version = fmt.Sprintf("%s.%s.%s-%s", - parts[0], parts[1], parts[2], - strings.Join(pre, ".")) + core = fmt.Sprintf("%s.%s.%s", parts[0], parts[1], parts[2]) + + // Prepend the collapsed segments to any existing metadata + if meta == "" || meta[0] == '+' { + meta = "-" + strings.Join(pre, ".") + meta + } else { + // Merge with existing prerelease by replacing the initial '-' with a '.' + meta = "-" + strings.Join(pre, ".") + "." + meta[1:] + } } // Coerce to semver, which will handle leading zeros and short forms. - v, err := semver.NewVersion(version) + v, err := semver.NewVersion(core + meta) if err != nil { return "", fmt.Errorf("cannot normalize %q to semver: %w", version, err) } From c7f978b47dde444df738db294727b6f29ab9f8c9 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 16 May 2026 21:31:08 +0530 Subject: [PATCH 09/11] Add more test cases for version normalization --- pkg/version/version_test.go | 105 +++++++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 26 deletions(-) diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go index 788596f7..f1e64f47 100644 --- a/pkg/version/version_test.go +++ b/pkg/version/version_test.go @@ -9,52 +9,105 @@ func TestNormalize(t *testing.T) { want string wantErr bool }{ - // Already strict. - {"strict passthrough", "1.2.3", "1.2.3", false}, + // Strict Passthrough + {"strict standard", "1.2.3", "1.2.3", false}, {"strict with build", "1.2.3+build.5", "1.2.3+build.5", false}, {"strict with prerelease", "1.2.3-beta.1", "1.2.3-beta.1", false}, + {"strict with both", "1.2.3-beta.1+build.123", "1.2.3-beta.1+build.123", false}, - // v/V prefix. + // Prefixes & Whitespace + {"trim spaces", " 1.2.3 ", "1.2.3", false}, {"lowercase v prefix", "v1.2.3", "1.2.3", false}, {"uppercase V prefix", "V1.2.3", "1.2.3", false}, - {"v prefix with 4 segments", "v1.0.0.01", "1.0.0-1", false}, - - // Leading zeros in core. - {"leading zero major", "01.0.0", "1.0.0", false}, - {"leading zero minor", "1.01.0", "1.1.0", false}, - {"leading zero patch", "1.0.01", "1.0.1", false}, - {"leading zero with suffix", "1.0.01-beta", "1.0.1-beta", false}, + {"trim spaces with v", " v1.2.3 ", "1.2.3", false}, - // Short forms. + // Short Forms {"single segment", "1", "1.0.0", false}, {"two segments", "1.2", "1.2.0", false}, + {"v single segment", "v1", "1.0.0", false}, - // Alphabetic suffix without separator. + // Leading Zeros in Core Segments + {"leading zero major", "01.0.0", "1.0.0", false}, + {"leading zero minor", "1.02.0", "1.2.0", false}, + {"leading zero patch", "1.2.03", "1.2.3", false}, + {"leading zeros in all", "01.02.03", "1.2.3", false}, + {"leading zero with suffix", "01.0.01-beta", "1.0.1-beta", false}, + + // Alphabetic Suffixes + {"short form alpha", "1beta", "1.0.0-beta", false}, + {"two segments alpha", "1.2beta", "1.2.0-beta", false}, {"patch+alpha", "1.0.0beta", "1.0.0-beta", false}, {"patch+alpha+num", "1.0.0beta1", "1.0.0-beta1", false}, {"minor+rc", "2.1rc1", "2.1.0-rc1", false}, {"minor+a", "3.0a", "3.0.0-a", false}, + {"alpha with dots inside", "1.2beta.3", "1.2.0-beta.3", false}, // Ensures regex catches dots - // 4+ segments collapsed. + // 4+ Dotted Segments Collapsing {"four zeros", "1.0.0.0", "1.0.0-0", false}, {"five segments", "1.2.3.4.5", "1.2.3-4.5", false}, - {"four segments alpha", "1.0.0.alpha.1", "1.0.0-alpha.1", false}, + {"many segments", "1.2.3.4.5.6.7.8.9", "1.2.3-4.5.6.7.8.9", false}, + {"prerelease leading zero stripped", "1.0.0.01", "1.0.0-1", false}, + {"prerelease all zeros becomes zero", "1.0.0.00", "1.0.0-0", false}, + {"four segments where 4th is alpha", "1.2.3.alpha", "1.2.3-alpha", false}, + {"prerelease alphanumeric 0beta untouched", "1.0.0.0beta", "1.0.0-0beta", false}, + {"prerelease multiple leading zeros stripped", "1.0.0.005.006", "1.0.0-5.6", false}, + {"prerelease mixed alpha+zero untouched", "1.0.0.alpha.01", "1.0.0-alpha.1", false}, - // The fix: leading zeros in prerelease. - {"prerelease leading zero", "1.0.0.01", "1.0.0-1", false}, - {"prerelease all zeros", "1.0.0.00", "1.0.0-0", false}, - {"prerelease multi leading zero", "1.0.0.01.02", "1.0.0-1.2", false}, - {"prerelease mixed alpha+zero", "1.0.0.alpha.01", "1.0.0-alpha.1", false}, - {"prerelease 0beta untouched", "1.0.0.0beta", "1.0.0-0beta", false}, + // Core vs Metadata Split logic + {"plus comes before hyphen", "1.0.0+build-1", "1.0.0+build-1", false}, + {"4+ segments with build only", "1.2.3.4+build", "1.2.3-4+build", false}, + {"patch+alpha with build", "1.0.0beta+build.1", "1.0.0-beta+build.1", false}, + {"4+ segments with existing prerelease", "1.2.3.4-alpha", "1.2.3-4.alpha", false}, + {"v prefix with prerelease and build", "v1.2.3-rc.1+build.1", "1.2.3-rc.1+build.1", false}, + {"4+ segments with prerelease and build", "1.2.3.4-alpha+bld", "1.2.3-4.alpha+bld", false}, - // Whitespace. - {"trim spaces", " 1.2.3 ", "1.2.3", false}, + // Complex / Mixed Normalizations + {"short form alpha+build", "1.2beta+bld", "1.2.0-beta+bld", false}, + {"v prefix, 4 segments, alpha+build", "v01.02.03.04beta+build", "1.2.3-04beta+build", false}, + {"all the weirdness combined", " v01.02.03.04.05beta+build ", "1.2.3-4.05beta+build", false}, + + // Length Constraints + { + name: "max length exact", + in: "1.2.3-alpha.x123456789012345678901234567890123456789012345678901", + want: "1.2.3-alpha.x123456789012345678901234567890123456789012345678901", + wantErr: false, + }, + { + name: "max length exceeded", + in: "1.2.3-alpha.01234567890123456789012345678901234567890123456789012", + want: "", + wantErr: true, + }, + { + name: "reducible long string passes", + in: "v000000000000000000000000000000000000000000000000000000000001.2.3", + want: "1.2.3", + wantErr: false, + }, + { + name: "expansion causes length failure", + in: "1-alpha.0123456789012345678901234567890123456789012345678901", + want: "", + wantErr: true, + }, - // Errors. - {"empty", "", "", true}, + // Errors {"only v", "v", "", true}, + {"empty string", "", "", true}, {"only whitespace", " ", "", true}, - {"garbage", "not-a-version", "", true}, + {"consecutive dots", "1..0", "", true}, + {"empty build metadata", "1.2.3+", "", true}, + {"garbage string", "not-a-version", "", true}, + {"wildcards (unsupported)", "1.2.x", "", true}, + {"invalid internal spaces", "1.2. 3", "", true}, + {"multiple build tags", "1.2.3+build1+build2", "", true}, // Semver only allows one '+' + { + name: "explicit invalid prerelease leading zero", + in: "1.2.3-01", + want: "", + wantErr: true, + }, } for _, tc := range cases { @@ -63,7 +116,7 @@ func TestNormalize(t *testing.T) { if (err != nil) != tc.wantErr { t.Fatalf("Normalize(%q) error = %v, wantErr = %v", tc.in, err, tc.wantErr) } - if got != tc.want { + if !tc.wantErr && got != tc.want { t.Errorf("Normalize(%q) = %q, want %q", tc.in, got, tc.want) } }) From 598fe32b386cd6ae2edaa38e55685698f9d68169 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 16 May 2026 21:53:52 +0530 Subject: [PATCH 10/11] Update version normalization to strip all v prefixes from version string --- pkg/version/version.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/version/version.go b/pkg/version/version.go index 69b02dc9..9e0b2975 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -41,12 +41,9 @@ func Normalize(version string) (string, error) { } } - // Strip a single leading 'v' or 'V'. - if version[0] == 'v' || version[0] == 'V' { - version = version[1:] - if version == "" { - return "", errors.New("version contains only a 'v' prefix") - } + version = strings.TrimLeft(version, "vV") + if version == "" { + return "", errors.New("version contains only 'v' prefixes") } // Isolate the core version from prerelease/build metadata to prevent From ebd6ee081163e49a3e281379d4db2dbdbb7e63b6 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 16 May 2026 21:54:24 +0530 Subject: [PATCH 11/11] Add version normalization edge cases tests --- pkg/version/version_test.go | 43 ++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go index f1e64f47..2babeb73 100644 --- a/pkg/version/version_test.go +++ b/pkg/version/version_test.go @@ -13,6 +13,7 @@ func TestNormalize(t *testing.T) { {"strict standard", "1.2.3", "1.2.3", false}, {"strict with build", "1.2.3+build.5", "1.2.3+build.5", false}, {"strict with prerelease", "1.2.3-beta.1", "1.2.3-beta.1", false}, + {"strict with dotted prerelease", "1.2.3-rc.1", "1.2.3-rc.1", false}, {"strict with both", "1.2.3-beta.1+build.123", "1.2.3-beta.1+build.123", false}, // Prefixes & Whitespace @@ -20,6 +21,8 @@ func TestNormalize(t *testing.T) { {"lowercase v prefix", "v1.2.3", "1.2.3", false}, {"uppercase V prefix", "V1.2.3", "1.2.3", false}, {"trim spaces with v", " v1.2.3 ", "1.2.3", false}, + {"v with prerelease", "v1.2.3-rc.1", "1.2.3-rc.1", false}, + {"trim tabs and newlines", "\t\n1.2.3\r\n", "1.2.3", false}, // Short Forms {"single segment", "1", "1.0.0", false}, @@ -34,13 +37,14 @@ func TestNormalize(t *testing.T) { {"leading zero with suffix", "01.0.01-beta", "1.0.1-beta", false}, // Alphabetic Suffixes + {"minor+a", "3.0a", "3.0.0-a", false}, + {"minor+rc", "2.1rc1", "2.1.0-rc1", false}, + {"patch+alpha", "1.0.0beta", "1.0.0-beta", false}, {"short form alpha", "1beta", "1.0.0-beta", false}, {"two segments alpha", "1.2beta", "1.2.0-beta", false}, - {"patch+alpha", "1.0.0beta", "1.0.0-beta", false}, {"patch+alpha+num", "1.0.0beta1", "1.0.0-beta1", false}, - {"minor+rc", "2.1rc1", "2.1.0-rc1", false}, - {"minor+a", "3.0a", "3.0.0-a", false}, {"alpha with dots inside", "1.2beta.3", "1.2.0-beta.3", false}, // Ensures regex catches dots + {"trailing alpha with leading-zero patch", "1.0.00beta", "1.0.0-beta", false}, // 4+ Dotted Segments Collapsing {"four zeros", "1.0.0.0", "1.0.0-0", false}, @@ -49,20 +53,30 @@ func TestNormalize(t *testing.T) { {"prerelease leading zero stripped", "1.0.0.01", "1.0.0-1", false}, {"prerelease all zeros becomes zero", "1.0.0.00", "1.0.0-0", false}, {"four segments where 4th is alpha", "1.2.3.alpha", "1.2.3-alpha", false}, + {"4 segments where 4th is alphanumeric", "1.2.3.4beta", "1.2.3-4beta", false}, {"prerelease alphanumeric 0beta untouched", "1.0.0.0beta", "1.0.0-0beta", false}, {"prerelease multiple leading zeros stripped", "1.0.0.005.006", "1.0.0-5.6", false}, {"prerelease mixed alpha+zero untouched", "1.0.0.alpha.01", "1.0.0-alpha.1", false}, + {"0beta in mid prerelease position", "1.0.0.alpha.0beta", "1.0.0-alpha.0beta", false}, // Core vs Metadata Split logic + {"4 segments + minimal build", "1.2.3.4+b", "1.2.3-4+b", false}, + {"hyphen in prerelease kept", "1.2.3-rc-1", "1.2.3-rc-1", false}, + {"4 segments + prerelease only", "1.2.3.4-rc", "1.2.3-4.rc", false}, {"plus comes before hyphen", "1.0.0+build-1", "1.0.0+build-1", false}, {"4+ segments with build only", "1.2.3.4+build", "1.2.3-4+build", false}, {"patch+alpha with build", "1.0.0beta+build.1", "1.0.0-beta+build.1", false}, + {"5 segments + dotted prerelease", "1.2.3.4.5-rc.1", "1.2.3-4.5.rc.1", false}, + {"plus before hyphen in suffix", "1.2.3+build-rc.1", "1.2.3+build-rc.1", false}, {"4+ segments with existing prerelease", "1.2.3.4-alpha", "1.2.3-4.alpha", false}, + {"4 segments + prerelease + build minimal", "1.2.3.4-rc+b", "1.2.3-4.rc+b", false}, + {"hyphen inside build metadata kept", "1.2.3+build-123", "1.2.3+build-123", false}, {"v prefix with prerelease and build", "v1.2.3-rc.1+build.1", "1.2.3-rc.1+build.1", false}, {"4+ segments with prerelease and build", "1.2.3.4-alpha+bld", "1.2.3-4.alpha+bld", false}, // Complex / Mixed Normalizations {"short form alpha+build", "1.2beta+bld", "1.2.0-beta+bld", false}, + {"V with full complex form", "V1.2.3.4-rc.1", "1.2.3-4.rc.1", false}, {"v prefix, 4 segments, alpha+build", "v01.02.03.04beta+build", "1.2.3-04beta+build", false}, {"all the weirdness combined", " v01.02.03.04.05beta+build ", "1.2.3-4.05beta+build", false}, @@ -91,16 +105,25 @@ func TestNormalize(t *testing.T) { want: "", wantErr: true, }, + { + name: "expansion stays under cap", + in: "1-alpha.x12345678901234567890123456789012345678901234567890", + want: "1.0.0-alpha.x12345678901234567890123456789012345678901234567890", + wantErr: false, + }, // Errors {"only v", "v", "", true}, {"empty string", "", "", true}, {"only whitespace", " ", "", true}, {"consecutive dots", "1..0", "", true}, + {"empty prerelease", "1.2.3-", "", true}, {"empty build metadata", "1.2.3+", "", true}, {"garbage string", "not-a-version", "", true}, {"wildcards (unsupported)", "1.2.x", "", true}, {"invalid internal spaces", "1.2. 3", "", true}, + {"numeric prerelease 3-digit leading zero", "1.0.0-001", "", true}, + {"dotted numeric prerelease with leading zero", "1.0.0-1.02", "", true}, {"multiple build tags", "1.2.3+build1+build2", "", true}, // Semver only allows one '+' { name: "explicit invalid prerelease leading zero", @@ -108,6 +131,20 @@ func TestNormalize(t *testing.T) { want: "", wantErr: true, }, + + // Non-ASCII characters + {"unicode digits rejected", "١.٢.٣", "", true}, + {"non-ASCII in prerelease", "1.2.3-α", "", true}, + {"zero-width space inside", "1.2.3\u200B", "", true}, + + // Adversarial / Edge Cases + {"only v's", "vvv", "", true}, + {"triple v", "vvv1.2.3", "1.2.3", false}, + {"v with no numerics", "vbeta", "", true}, + {"NUL byte rejected", "1.2.3\x00", "", true}, + {"mixed multiple v", "Vv1.2.3", "1.2.3", false}, + {"invalid UTF-8 rejected", "1.2.3\xff", "", true}, + {"double lowercase v", "vv1.2.3", "1.2.3", false}, } for _, tc := range cases {