From a0ae546c36b2cdc1e6ba542fc8413e690571ebc8 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 2 Mar 2026 13:47:40 -0800 Subject: [PATCH 1/4] add semver targeting to local evaluation --- feature_flags_matching_test.go | 649 +++++++++++++++++++++++++++++++++ featureflags.go | 304 +++++++++++++++ 2 files changed, 953 insertions(+) diff --git a/feature_flags_matching_test.go b/feature_flags_matching_test.go index a328466..1869ed2 100644 --- a/feature_flags_matching_test.go +++ b/feature_flags_matching_test.go @@ -2,6 +2,7 @@ package posthog import ( "errors" + "strconv" "testing" "time" @@ -556,3 +557,651 @@ func TestMatchPropertyDateComparison(t *testing.T) { require.False(t, isMatch) }) } + +func TestParseSemver(t *testing.T) { + t.Run("basic parsing", func(t *testing.T) { + testCases := []struct { + input string + expected semverTuple + }{ + {"1.2.3", semverTuple{1, 2, 3}}, + {"0.0.0", semverTuple{0, 0, 0}}, + {"10.20.30", semverTuple{10, 20, 30}}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + result, err := parseSemver(tc.input) + require.NoError(t, err) + require.Equal(t, tc.expected, result) + }) + } + }) + + t.Run("v prefix", func(t *testing.T) { + result, err := parseSemver("v1.2.3") + require.NoError(t, err) + require.Equal(t, semverTuple{1, 2, 3}, result) + + result, err = parseSemver("V1.2.3") + require.NoError(t, err) + require.Equal(t, semverTuple{1, 2, 3}, result) + }) + + t.Run("whitespace", func(t *testing.T) { + result, err := parseSemver(" 1.2.3 ") + require.NoError(t, err) + require.Equal(t, semverTuple{1, 2, 3}, result) + + result, err = parseSemver(" v1.2.3 ") + require.NoError(t, err) + require.Equal(t, semverTuple{1, 2, 3}, result) + }) + + t.Run("pre-release suffixes stripped", func(t *testing.T) { + result, err := parseSemver("1.2.3-alpha") + require.NoError(t, err) + require.Equal(t, semverTuple{1, 2, 3}, result) + + result, err = parseSemver("1.2.3-alpha.1") + require.NoError(t, err) + require.Equal(t, semverTuple{1, 2, 3}, result) + + result, err = parseSemver("1.2.3-rc.1+build.123") + require.NoError(t, err) + require.Equal(t, semverTuple{1, 2, 3}, result) + }) + + t.Run("build metadata stripped", func(t *testing.T) { + result, err := parseSemver("1.2.3+build.123") + require.NoError(t, err) + require.Equal(t, semverTuple{1, 2, 3}, result) + }) + + t.Run("partial versions default to zero", func(t *testing.T) { + result, err := parseSemver("1.2") + require.NoError(t, err) + require.Equal(t, semverTuple{1, 2, 0}, result) + + result, err = parseSemver("1") + require.NoError(t, err) + require.Equal(t, semverTuple{1, 0, 0}, result) + }) + + t.Run("extra components ignored", func(t *testing.T) { + result, err := parseSemver("1.2.3.4") + require.NoError(t, err) + require.Equal(t, semverTuple{1, 2, 3}, result) + + result, err = parseSemver("1.2.3.4.5.6") + require.NoError(t, err) + require.Equal(t, semverTuple{1, 2, 3}, result) + }) + + t.Run("leading zeros parsed as decimal", func(t *testing.T) { + result, err := parseSemver("01.02.03") + require.NoError(t, err) + require.Equal(t, semverTuple{1, 2, 3}, result) + }) + + t.Run("invalid values", func(t *testing.T) { + invalidCases := []string{ + "", + " ", + "v", + "abc", + "1.2.abc", + ".1.2.3", + "a.b.c", + } + + for _, input := range invalidCases { + t.Run(input, func(t *testing.T) { + _, err := parseSemver(input) + require.Error(t, err) + }) + } + }) +} + +func TestMatchPropertySemverEq(t *testing.T) { + t.Run("exact match", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_eq", + } + + isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3")) + require.NoError(t, err) + require.True(t, isMatch) + + isMatch, err = matchProperty(property, NewProperties().Set("version", "1.2.4")) + require.NoError(t, err) + require.False(t, isMatch) + }) + + t.Run("pre-release equals base version", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_eq", + } + + isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3-alpha.1")) + require.NoError(t, err) + require.True(t, isMatch) + }) + + t.Run("partial versions", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2", + Operator: "semver_eq", + } + + isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.0")) + require.NoError(t, err) + require.True(t, isMatch) + }) + + t.Run("v prefix", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "v1.2.3", + Operator: "semver_eq", + } + + isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3")) + require.NoError(t, err) + require.True(t, isMatch) + + isMatch, err = matchProperty(property, NewProperties().Set("version", "v1.2.3")) + require.NoError(t, err) + require.True(t, isMatch) + }) +} + +func TestMatchPropertySemverNeq(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_neq", + } + + isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3")) + require.NoError(t, err) + require.False(t, isMatch) + + isMatch, err = matchProperty(property, NewProperties().Set("version", "1.2.4")) + require.NoError(t, err) + require.True(t, isMatch) + + isMatch, err = matchProperty(property, NewProperties().Set("version", "2.0.0")) + require.NoError(t, err) + require.True(t, isMatch) +} + +func TestMatchPropertySemverGt(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_gt", + } + + testCases := []struct { + version string + expected bool + }{ + {"1.2.4", true}, + {"1.3.0", true}, + {"2.0.0", true}, + {"1.2.3", false}, + {"1.2.2", false}, + {"1.1.9", false}, + {"0.9.9", false}, + } + + for _, tc := range testCases { + t.Run(tc.version, func(t *testing.T) { + isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version)) + require.NoError(t, err) + require.Equal(t, tc.expected, isMatch) + }) + } +} + +func TestMatchPropertySemverGte(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_gte", + } + + testCases := []struct { + version string + expected bool + }{ + {"1.2.4", true}, + {"1.3.0", true}, + {"2.0.0", true}, + {"1.2.3", true}, // Equal + {"1.2.2", false}, + {"1.1.9", false}, + {"0.9.9", false}, + } + + for _, tc := range testCases { + t.Run(tc.version, func(t *testing.T) { + isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version)) + require.NoError(t, err) + require.Equal(t, tc.expected, isMatch) + }) + } +} + +func TestMatchPropertySemverLt(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_lt", + } + + testCases := []struct { + version string + expected bool + }{ + {"1.2.2", true}, + {"1.1.9", true}, + {"0.9.9", true}, + {"1.2.3", false}, // Equal + {"1.2.4", false}, + {"1.3.0", false}, + {"2.0.0", false}, + } + + for _, tc := range testCases { + t.Run(tc.version, func(t *testing.T) { + isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version)) + require.NoError(t, err) + require.Equal(t, tc.expected, isMatch) + }) + } +} + +func TestMatchPropertySemverLte(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_lte", + } + + testCases := []struct { + version string + expected bool + }{ + {"1.2.2", true}, + {"1.1.9", true}, + {"0.9.9", true}, + {"1.2.3", true}, // Equal + {"1.2.4", false}, + {"1.3.0", false}, + {"2.0.0", false}, + } + + for _, tc := range testCases { + t.Run(tc.version, func(t *testing.T) { + isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version)) + require.NoError(t, err) + require.Equal(t, tc.expected, isMatch) + }) + } +} + +func TestMatchPropertySemverTilde(t *testing.T) { + // ~1.2.3 means >=1.2.3 and <1.3.0 + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_tilde", + } + + testCases := []struct { + version string + expected bool + }{ + // In range + {"1.2.3", true}, // Lower bound (inclusive) + {"1.2.4", true}, // Within range + {"1.2.99", true}, // Near upper bound + // Out of range + {"1.3.0", false}, // Upper bound (exclusive) + {"1.2.2", false}, // Below lower bound + {"1.1.0", false}, // Too low + {"2.0.0", false}, // Too high + } + + for _, tc := range testCases { + t.Run(tc.version, func(t *testing.T) { + isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version)) + require.NoError(t, err) + require.Equal(t, tc.expected, isMatch) + }) + } + + t.Run("tilde with partial version", func(t *testing.T) { + // ~1.2 means >=1.2.0 and <1.3.0 + property := FlagProperty{ + Key: "version", + Value: "1.2", + Operator: "semver_tilde", + } + + isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.0")) + require.NoError(t, err) + require.True(t, isMatch) + + isMatch, err = matchProperty(property, NewProperties().Set("version", "1.2.5")) + require.NoError(t, err) + require.True(t, isMatch) + + isMatch, err = matchProperty(property, NewProperties().Set("version", "1.3.0")) + require.NoError(t, err) + require.False(t, isMatch) + }) +} + +func TestMatchPropertySemverCaret(t *testing.T) { + t.Run("major > 0: ^1.2.3 means >=1.2.3 <2.0.0", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_caret", + } + + testCases := []struct { + version string + expected bool + }{ + {"1.2.3", true}, // Lower bound (inclusive) + {"1.2.4", true}, // Within range + {"1.3.0", true}, // Within range + {"1.99.99", true}, // Near upper bound + {"2.0.0", false}, // Upper bound (exclusive) + {"1.2.2", false}, // Below lower bound + {"0.9.9", false}, // Too low + {"3.0.0", false}, // Too high + } + + for _, tc := range testCases { + t.Run(tc.version, func(t *testing.T) { + isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version)) + require.NoError(t, err) + require.Equal(t, tc.expected, isMatch) + }) + } + }) + + t.Run("major == 0, minor > 0: ^0.2.3 means >=0.2.3 <0.3.0", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "0.2.3", + Operator: "semver_caret", + } + + testCases := []struct { + version string + expected bool + }{ + {"0.2.3", true}, // Lower bound (inclusive) + {"0.2.4", true}, // Within range + {"0.2.99", true}, // Near upper bound + {"0.3.0", false}, // Upper bound (exclusive) + {"0.2.2", false}, // Below lower bound + {"0.1.9", false}, // Too low + {"1.0.0", false}, // Too high + } + + for _, tc := range testCases { + t.Run(tc.version, func(t *testing.T) { + isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version)) + require.NoError(t, err) + require.Equal(t, tc.expected, isMatch) + }) + } + }) + + t.Run("major == 0, minor == 0: ^0.0.3 means >=0.0.3 <0.0.4", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "0.0.3", + Operator: "semver_caret", + } + + testCases := []struct { + version string + expected bool + }{ + {"0.0.3", true}, // Lower bound (inclusive) + {"0.0.4", false}, // Upper bound (exclusive) + {"0.0.2", false}, // Below lower bound + {"0.1.0", false}, // Too high + } + + for _, tc := range testCases { + t.Run(tc.version, func(t *testing.T) { + isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version)) + require.NoError(t, err) + require.Equal(t, tc.expected, isMatch) + }) + } + }) +} + +func TestMatchPropertySemverWildcard(t *testing.T) { + t.Run("1.* means >=1.0.0 <2.0.0", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.*", + Operator: "semver_wildcard", + } + + testCases := []struct { + version string + expected bool + }{ + {"1.0.0", true}, // Lower bound (inclusive) + {"1.0.1", true}, // Within range + {"1.5.0", true}, // Within range + {"1.99.99", true}, // Near upper bound + {"2.0.0", false}, // Upper bound (exclusive) + {"0.9.9", false}, // Too low + {"3.0.0", false}, // Too high + } + + for _, tc := range testCases { + t.Run(tc.version, func(t *testing.T) { + isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version)) + require.NoError(t, err) + require.Equal(t, tc.expected, isMatch) + }) + } + }) + + t.Run("1.2.* means >=1.2.0 <1.3.0", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.*", + Operator: "semver_wildcard", + } + + testCases := []struct { + version string + expected bool + }{ + {"1.2.0", true}, // Lower bound (inclusive) + {"1.2.5", true}, // Within range + {"1.2.99", true}, // Near upper bound + {"1.3.0", false}, // Upper bound (exclusive) + {"1.1.9", false}, // Too low + {"2.0.0", false}, // Too high + } + + for _, tc := range testCases { + t.Run(tc.version, func(t *testing.T) { + isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version)) + require.NoError(t, err) + require.Equal(t, tc.expected, isMatch) + }) + } + }) + + t.Run("wildcard without asterisk treated as major version match", func(t *testing.T) { + // Test that "1" alone matches same as "1.*" + property := FlagProperty{ + Key: "version", + Value: "1", + Operator: "semver_wildcard", + } + + isMatch, err := matchProperty(property, NewProperties().Set("version", "1.5.0")) + require.NoError(t, err) + require.True(t, isMatch) + + isMatch, err = matchProperty(property, NewProperties().Set("version", "2.0.0")) + require.NoError(t, err) + require.False(t, isMatch) + }) +} + +func TestMatchPropertySemverErrorHandling(t *testing.T) { + t.Run("missing property key", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_eq", + } + + isMatch, err := matchProperty(property, NewProperties().Set("other_key", "1.2.3")) + require.Error(t, err) + require.False(t, isMatch) + + var inconclusiveErr *InconclusiveMatchError + require.True(t, errors.As(err, &inconclusiveErr)) + }) + + t.Run("null/nil property value", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_eq", + } + + isMatch, err := matchProperty(property, NewProperties().Set("version", nil)) + require.Error(t, err) + require.False(t, isMatch) + }) + + t.Run("non-string property value", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_eq", + } + + isMatch, err := matchProperty(property, NewProperties().Set("version", 123)) + require.Error(t, err) + require.False(t, isMatch) + + var inconclusiveErr *InconclusiveMatchError + require.True(t, errors.As(err, &inconclusiveErr)) + }) + + t.Run("invalid semver in property value", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_eq", + } + + isMatch, err := matchProperty(property, NewProperties().Set("version", "not-a-semver")) + require.Error(t, err) + require.False(t, isMatch) + + var inconclusiveErr *InconclusiveMatchError + require.True(t, errors.As(err, &inconclusiveErr)) + }) + + t.Run("invalid semver in flag value", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "not-a-semver", + Operator: "semver_eq", + } + + isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3")) + require.Error(t, err) + require.False(t, isMatch) + + var inconclusiveErr *InconclusiveMatchError + require.True(t, errors.As(err, &inconclusiveErr)) + }) + + t.Run("empty string semver", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "1.2.3", + Operator: "semver_eq", + } + + isMatch, err := matchProperty(property, NewProperties().Set("version", "")) + require.Error(t, err) + require.False(t, isMatch) + }) + + t.Run("invalid wildcard pattern", func(t *testing.T) { + property := FlagProperty{ + Key: "version", + Value: "*", + Operator: "semver_wildcard", + } + + isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3")) + require.Error(t, err) + require.False(t, isMatch) + }) +} + +func TestSemverCompareTo(t *testing.T) { + testCases := []struct { + a semverTuple + b semverTuple + expected int + }{ + // Equal + {semverTuple{1, 2, 3}, semverTuple{1, 2, 3}, 0}, + {semverTuple{0, 0, 0}, semverTuple{0, 0, 0}, 0}, + // Major difference + {semverTuple{2, 0, 0}, semverTuple{1, 9, 9}, 1}, + {semverTuple{1, 9, 9}, semverTuple{2, 0, 0}, -1}, + // Minor difference + {semverTuple{1, 3, 0}, semverTuple{1, 2, 9}, 1}, + {semverTuple{1, 2, 9}, semverTuple{1, 3, 0}, -1}, + // Patch difference + {semverTuple{1, 2, 4}, semverTuple{1, 2, 3}, 1}, + {semverTuple{1, 2, 3}, semverTuple{1, 2, 4}, -1}, + } + + for _, tc := range testCases { + name := tc.a.String() + " vs " + tc.b.String() + t.Run(name, func(t *testing.T) { + result := tc.a.compareTo(tc.b) + require.Equal(t, tc.expected, result) + }) + } +} + +// Helper for test naming +func (s semverTuple) String() string { + return strconv.Itoa(s.major) + "." + strconv.Itoa(s.minor) + "." + strconv.Itoa(s.patch) +} diff --git a/featureflags.go b/featureflags.go index 903637a..96afcd8 100644 --- a/featureflags.go +++ b/featureflags.go @@ -1052,6 +1052,124 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) { return overrideTime.After(valueTime), nil } + // Semver comparison operators + if operator == "semver_eq" || operator == "semver_neq" || + operator == "semver_gt" || operator == "semver_gte" || + operator == "semver_lt" || operator == "semver_lte" { + + overrideStr, ok := override_value.(string) + if !ok { + return false, &InconclusiveMatchError{fmt.Sprintf("semver comparison requires string value, got %T", override_value)} + } + + overrideParsed, err := parseSemver(overrideStr) + if err != nil { + return false, &InconclusiveMatchError{fmt.Sprintf("person property value '%s' is not a valid semver", overrideStr)} + } + + valueStr, ok := value.(string) + if !ok { + return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value must be a string, got %T", value)} + } + + valueParsed, err := parseSemver(valueStr) + if err != nil { + return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value '%s' is not a valid semver", valueStr)} + } + + cmp := overrideParsed.compareTo(valueParsed) + + switch operator { + case "semver_eq": + return cmp == 0, nil + case "semver_neq": + return cmp != 0, nil + case "semver_gt": + return cmp > 0, nil + case "semver_gte": + return cmp >= 0, nil + case "semver_lt": + return cmp < 0, nil + case "semver_lte": + return cmp <= 0, nil + } + } + + if operator == "semver_tilde" { + overrideStr, ok := override_value.(string) + if !ok { + return false, &InconclusiveMatchError{fmt.Sprintf("semver comparison requires string value, got %T", override_value)} + } + + overrideParsed, err := parseSemver(overrideStr) + if err != nil { + return false, &InconclusiveMatchError{fmt.Sprintf("person property value '%s' is not a valid semver", overrideStr)} + } + + valueStr, ok := value.(string) + if !ok { + return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value must be a string, got %T", value)} + } + + lower, upper, err := computeTildeBounds(valueStr) + if err != nil { + return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value '%s' is not valid for tilde operator", valueStr)} + } + + // Check: lower <= override < upper + return overrideParsed.compareTo(lower) >= 0 && overrideParsed.compareTo(upper) < 0, nil + } + + if operator == "semver_caret" { + overrideStr, ok := override_value.(string) + if !ok { + return false, &InconclusiveMatchError{fmt.Sprintf("semver comparison requires string value, got %T", override_value)} + } + + overrideParsed, err := parseSemver(overrideStr) + if err != nil { + return false, &InconclusiveMatchError{fmt.Sprintf("person property value '%s' is not a valid semver", overrideStr)} + } + + valueStr, ok := value.(string) + if !ok { + return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value must be a string, got %T", value)} + } + + lower, upper, err := computeCaretBounds(valueStr) + if err != nil { + return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value '%s' is not valid for caret operator", valueStr)} + } + + // Check: lower <= override < upper + return overrideParsed.compareTo(lower) >= 0 && overrideParsed.compareTo(upper) < 0, nil + } + + if operator == "semver_wildcard" { + overrideStr, ok := override_value.(string) + if !ok { + return false, &InconclusiveMatchError{fmt.Sprintf("semver comparison requires string value, got %T", override_value)} + } + + overrideParsed, err := parseSemver(overrideStr) + if err != nil { + return false, &InconclusiveMatchError{fmt.Sprintf("person property value '%s' is not a valid semver", overrideStr)} + } + + valueStr, ok := value.(string) + if !ok { + return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value must be a string, got %T", value)} + } + + lower, upper, err := computeWildcardBounds(valueStr) + if err != nil { + return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value '%s' is not valid for wildcard operator", valueStr)} + } + + // Check: lower <= override < upper + return overrideParsed.compareTo(lower) >= 0 && overrideParsed.compareTo(upper) < 0, nil + } + return false, &InconclusiveMatchError{"Unknown operator: " + operator} } @@ -1153,6 +1271,192 @@ func parseRelativeDate(dateStr string) (time.Time, error) { } } +// semverTuple represents a parsed semantic version as (major, minor, patch). +type semverTuple struct { + major, minor, patch int +} + +// compareTo returns -1 if s < other, 0 if s == other, 1 if s > other. +func (s semverTuple) compareTo(other semverTuple) int { + if s.major != other.major { + if s.major < other.major { + return -1 + } + return 1 + } + if s.minor != other.minor { + if s.minor < other.minor { + return -1 + } + return 1 + } + if s.patch != other.patch { + if s.patch < other.patch { + return -1 + } + return 1 + } + return 0 +} + +// parseSemver parses a version string into a semverTuple. +// Parsing rules: +// 1. Strip leading/trailing whitespace +// 2. Strip v or V prefix +// 3. Strip pre-release and build metadata suffixes (split on - or +) +// 4. Split on . and parse first 3 components as integers +// 5. Default missing components to 0 +// 6. Ignore extra components beyond the third +// 7. Return error for invalid input +func parseSemver(value string) (semverTuple, error) { + text := strings.TrimSpace(value) + + // Strip v/V prefix + if len(text) > 0 && (text[0] == 'v' || text[0] == 'V') { + text = text[1:] + } + + // Strip pre-release (-) and build metadata (+) suffixes + if idx := strings.Index(text, "-"); idx >= 0 { + text = text[:idx] + } + if idx := strings.Index(text, "+"); idx >= 0 { + text = text[:idx] + } + + if text == "" { + return semverTuple{}, errors.New("invalid semver: empty string") + } + + parts := strings.Split(text, ".") + if len(parts) == 0 || parts[0] == "" { + return semverTuple{}, errors.New("invalid semver: no version components") + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return semverTuple{}, fmt.Errorf("invalid semver: major version '%s' is not a number", parts[0]) + } + + minor := 0 + if len(parts) > 1 && parts[1] != "" { + minor, err = strconv.Atoi(parts[1]) + if err != nil { + return semverTuple{}, fmt.Errorf("invalid semver: minor version '%s' is not a number", parts[1]) + } + } + + patch := 0 + if len(parts) > 2 && parts[2] != "" { + patch, err = strconv.Atoi(parts[2]) + if err != nil { + return semverTuple{}, fmt.Errorf("invalid semver: patch version '%s' is not a number", parts[2]) + } + } + + return semverTuple{major: major, minor: minor, patch: patch}, nil +} + +// computeTildeBounds computes the bounds for the tilde (~) operator. +// ~X.Y.Z means >=X.Y.Z and 0: >=X.Y.Z <(X+1).0.0 +// - X == 0, Y > 0: >=0.Y.Z <0.(Y+1).0 +// - X == 0, Y == 0: >=0.0.Z <0.0.(Z+1) +func computeCaretBounds(value string) (lower, upper semverTuple, err error) { + parsed, err := parseSemver(value) + if err != nil { + return semverTuple{}, semverTuple{}, err + } + lower = parsed + + if parsed.major > 0 { + upper = semverTuple{major: parsed.major + 1, minor: 0, patch: 0} + } else if parsed.minor > 0 { + upper = semverTuple{major: 0, minor: parsed.minor + 1, patch: 0} + } else { + upper = semverTuple{major: 0, minor: 0, patch: parsed.patch + 1} + } + return lower, upper, nil +} + +// computeWildcardBounds computes the bounds for the wildcard (*) operator. +// X.* means >=X.0.0 <(X+1).0.0 +// X.Y.* means >=X.Y.0 0 && (text[0] == 'v' || text[0] == 'V') { + text = text[1:] + } + + // Remove wildcards and trailing dots + text = strings.ReplaceAll(text, "*", "") + text = strings.TrimRight(text, ".") + + if text == "" { + return semverTuple{}, semverTuple{}, errors.New("invalid wildcard pattern: empty after removing wildcards") + } + + parts := strings.Split(text, ".") + // Filter out empty parts + var nonEmptyParts []string + for _, p := range parts { + if p != "" { + nonEmptyParts = append(nonEmptyParts, p) + } + } + + if len(nonEmptyParts) == 0 { + return semverTuple{}, semverTuple{}, errors.New("invalid wildcard pattern: no version components") + } + + major, err := strconv.Atoi(nonEmptyParts[0]) + if err != nil { + return semverTuple{}, semverTuple{}, fmt.Errorf("invalid wildcard pattern: major version '%s' is not a number", nonEmptyParts[0]) + } + + if len(nonEmptyParts) == 1 { + // X.* pattern + lower = semverTuple{major: major, minor: 0, patch: 0} + upper = semverTuple{major: major + 1, minor: 0, patch: 0} + return lower, upper, nil + } + + minor, err := strconv.Atoi(nonEmptyParts[1]) + if err != nil { + return semverTuple{}, semverTuple{}, fmt.Errorf("invalid wildcard pattern: minor version '%s' is not a number", nonEmptyParts[1]) + } + + if len(nonEmptyParts) == 2 { + // X.Y.* pattern + lower = semverTuple{major: major, minor: minor, patch: 0} + upper = semverTuple{major: major, minor: minor + 1, patch: 0} + return lower, upper, nil + } + + // X.Y.Z.* pattern - treat as X.Y.Z to X.Y.(Z+1) + patch, err := strconv.Atoi(nonEmptyParts[2]) + if err != nil { + return semverTuple{}, semverTuple{}, fmt.Errorf("invalid wildcard pattern: patch version '%s' is not a number", nonEmptyParts[2]) + } + lower = semverTuple{major: major, minor: minor, patch: patch} + upper = semverTuple{major: major, minor: minor, patch: patch + 1} + return lower, upper, nil +} + func interfaceToFloat(val interface{}) (float64, error) { var i float64 switch t := val.(type) { From 00dbb72be9339ce1405539ae9aee580a95c26433 Mon Sep 17 00:00:00 2001 From: dylan Date: Tue, 3 Mar 2026 13:13:37 -0800 Subject: [PATCH 2/4] chore: bump version to 1.11.0 [version bump] --- version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.go b/version.go index 66557a1..1da29ee 100644 --- a/version.go +++ b/version.go @@ -3,7 +3,7 @@ package posthog import "flag" // Version of the client. -const Version = "1.10.0" +const Version = "1.11.0" // make tests easier by using a constant version func getVersion() string { From 43b4dc2aaa72a74f5389b6650164eb78b4183c2b Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 4 Mar 2026 15:02:58 -0800 Subject: [PATCH 3/4] address PR feedback from Copilot - Change "person property value" to "property value" in error messages since matchProperty is used for both person and group properties - Validate core version format before stripping pre-release/build suffixes to reject malformed inputs like "1-2.3" or "-alpha" - Return error when a version component is present but empty (e.g., "1..3" or "1.") instead of silently defaulting to 0 - Add tests for newly rejected malformed inputs --- feature_flags_matching_test.go | 34 +++++++++++++++++++++++++++++ featureflags.go | 39 ++++++++++++++++++++++------------ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/feature_flags_matching_test.go b/feature_flags_matching_test.go index 1869ed2..5334877 100644 --- a/feature_flags_matching_test.go +++ b/feature_flags_matching_test.go @@ -662,6 +662,40 @@ func TestParseSemver(t *testing.T) { }) } }) + + t.Run("empty components rejected", func(t *testing.T) { + // These malformed inputs should be rejected, not silently parsed + invalidCases := []string{ + "1..3", // empty minor + "1.2.", // empty patch (trailing dot) + "1.", // trailing dot with no minor + "1.2..", // double trailing dot + ".1.2", // leading dot (empty major) + } + + for _, input := range invalidCases { + t.Run(input, func(t *testing.T) { + _, err := parseSemver(input) + require.Error(t, err, "expected error for input: %s", input) + }) + } + }) + + t.Run("malformed pre-release inputs rejected", func(t *testing.T) { + // Inputs where the core version is malformed should be rejected + invalidCases := []string{ + "-alpha", // no version before pre-release + "+build", // no version before build metadata + "1.-2.3", // negative in wrong position (looks like suffix delimiter) + } + + for _, input := range invalidCases { + t.Run(input, func(t *testing.T) { + _, err := parseSemver(input) + require.Error(t, err, "expected error for input: %s", input) + }) + } + }) } func TestMatchPropertySemverEq(t *testing.T) { diff --git a/featureflags.go b/featureflags.go index 96afcd8..8a34534 100644 --- a/featureflags.go +++ b/featureflags.go @@ -1064,7 +1064,7 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) { overrideParsed, err := parseSemver(overrideStr) if err != nil { - return false, &InconclusiveMatchError{fmt.Sprintf("person property value '%s' is not a valid semver", overrideStr)} + return false, &InconclusiveMatchError{fmt.Sprintf("property value '%s' is not a valid semver", overrideStr)} } valueStr, ok := value.(string) @@ -1103,7 +1103,7 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) { overrideParsed, err := parseSemver(overrideStr) if err != nil { - return false, &InconclusiveMatchError{fmt.Sprintf("person property value '%s' is not a valid semver", overrideStr)} + return false, &InconclusiveMatchError{fmt.Sprintf("property value '%s' is not a valid semver", overrideStr)} } valueStr, ok := value.(string) @@ -1128,7 +1128,7 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) { overrideParsed, err := parseSemver(overrideStr) if err != nil { - return false, &InconclusiveMatchError{fmt.Sprintf("person property value '%s' is not a valid semver", overrideStr)} + return false, &InconclusiveMatchError{fmt.Sprintf("property value '%s' is not a valid semver", overrideStr)} } valueStr, ok := value.(string) @@ -1153,7 +1153,7 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) { overrideParsed, err := parseSemver(overrideStr) if err != nil { - return false, &InconclusiveMatchError{fmt.Sprintf("person property value '%s' is not a valid semver", overrideStr)} + return false, &InconclusiveMatchError{fmt.Sprintf("property value '%s' is not a valid semver", overrideStr)} } valueStr, ok := value.(string) @@ -1316,19 +1316,26 @@ func parseSemver(value string) (semverTuple, error) { text = text[1:] } - // Strip pre-release (-) and build metadata (+) suffixes + if text == "" { + return semverTuple{}, errors.New("invalid semver: empty string") + } + + // Find the core version (before any - or +) + // We need to validate the core before stripping suffixes + coreEnd := len(text) if idx := strings.Index(text, "-"); idx >= 0 { - text = text[:idx] + coreEnd = idx } - if idx := strings.Index(text, "+"); idx >= 0 { - text = text[:idx] + if idx := strings.Index(text, "+"); idx >= 0 && idx < coreEnd { + coreEnd = idx } + core := text[:coreEnd] - if text == "" { - return semverTuple{}, errors.New("invalid semver: empty string") + if core == "" { + return semverTuple{}, errors.New("invalid semver: empty version before suffix") } - parts := strings.Split(text, ".") + parts := strings.Split(core, ".") if len(parts) == 0 || parts[0] == "" { return semverTuple{}, errors.New("invalid semver: no version components") } @@ -1339,7 +1346,10 @@ func parseSemver(value string) (semverTuple, error) { } minor := 0 - if len(parts) > 1 && parts[1] != "" { + if len(parts) > 1 { + if parts[1] == "" { + return semverTuple{}, errors.New("invalid semver: empty minor version component") + } minor, err = strconv.Atoi(parts[1]) if err != nil { return semverTuple{}, fmt.Errorf("invalid semver: minor version '%s' is not a number", parts[1]) @@ -1347,7 +1357,10 @@ func parseSemver(value string) (semverTuple, error) { } patch := 0 - if len(parts) > 2 && parts[2] != "" { + if len(parts) > 2 { + if parts[2] == "" { + return semverTuple{}, errors.New("invalid semver: empty patch version component") + } patch, err = strconv.Atoi(parts[2]) if err != nil { return semverTuple{}, fmt.Errorf("invalid semver: patch version '%s' is not a number", parts[2]) From 0fe8af1133ce362b733982c86473541891598652 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 4 Mar 2026 15:13:10 -0800 Subject: [PATCH 4/4] fix: run go fmt --- feature_flags_matching_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/feature_flags_matching_test.go b/feature_flags_matching_test.go index 5334877..5a494c6 100644 --- a/feature_flags_matching_test.go +++ b/feature_flags_matching_test.go @@ -666,11 +666,11 @@ func TestParseSemver(t *testing.T) { t.Run("empty components rejected", func(t *testing.T) { // These malformed inputs should be rejected, not silently parsed invalidCases := []string{ - "1..3", // empty minor - "1.2.", // empty patch (trailing dot) - "1.", // trailing dot with no minor - "1.2..", // double trailing dot - ".1.2", // leading dot (empty major) + "1..3", // empty minor + "1.2.", // empty patch (trailing dot) + "1.", // trailing dot with no minor + "1.2..", // double trailing dot + ".1.2", // leading dot (empty major) } for _, input := range invalidCases { @@ -684,9 +684,9 @@ func TestParseSemver(t *testing.T) { t.Run("malformed pre-release inputs rejected", func(t *testing.T) { // Inputs where the core version is malformed should be rejected invalidCases := []string{ - "-alpha", // no version before pre-release - "+build", // no version before build metadata - "1.-2.3", // negative in wrong position (looks like suffix delimiter) + "-alpha", // no version before pre-release + "+build", // no version before build metadata + "1.-2.3", // negative in wrong position (looks like suffix delimiter) } for _, input := range invalidCases {