From e027a5caebddc77840ea98713df632dab7ae735d Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 2 Mar 2026 13:47:34 -0800 Subject: [PATCH 1/5] add semver targeting to local evaluation --- .../posthog/server/internal/FlagEvaluator.kt | 259 ++++++++ .../server/internal/FlagEvaluatorTest.kt | 577 ++++++++++++++++++ .../internal/PostHogLocalEvaluationModels.kt | 18 + 3 files changed, 854 insertions(+) diff --git a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt index b6e0686d0..6d1a261af 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt @@ -189,6 +189,22 @@ internal class FlagEvaluator( propertyOperator, ) + PropertyOperator.SEMVER_EQ, + PropertyOperator.SEMVER_NEQ, + PropertyOperator.SEMVER_GT, + PropertyOperator.SEMVER_GTE, + PropertyOperator.SEMVER_LT, + PropertyOperator.SEMVER_LTE, + PropertyOperator.SEMVER_TILDE, + PropertyOperator.SEMVER_CARET, + PropertyOperator.SEMVER_WILDCARD, + -> + compareSemver( + overrideValue, + propertyValue, + propertyOperator, + ) + else -> throw InconclusiveMatchException("Unknown operator: $propertyOperator") } } @@ -435,6 +451,249 @@ internal class FlagEvaluator( throw DateTimeParseException("Unable to parse date: $propertyValue", propertyValue, 0) } + /** + * A parsed semver version as (major, minor, patch) tuple + */ + private data class SemverVersion( + val major: Int, + val minor: Int, + val patch: Int, + ) : Comparable { + override fun compareTo(other: SemverVersion): Int { + val majorCmp = major.compareTo(other.major) + if (majorCmp != 0) return majorCmp + val minorCmp = minor.compareTo(other.minor) + if (minorCmp != 0) return minorCmp + return patch.compareTo(other.patch) + } + } + + /** + * Parse a version string into a SemverVersion. + * + * Parsing rules: + * 1. Strip leading/trailing whitespace + * 2. Strip v/V prefix (e.g., "v1.2.3" → "1.2.3") + * 3. Strip pre-release and build metadata suffixes (split on - or +, take first part) + * 4. Split on . and parse first 3 components as integers + * 5. Default missing components to 0 (e.g., "1.2" → (1, 2, 0), "1" → (1, 0, 0)) + * 6. Ignore extra components beyond the third (e.g., "1.2.3.4" → (1, 2, 3)) + * 7. Throw an error for truly invalid input (empty string, non-numeric parts, leading dot) + */ + private fun parseSemver(version: String): SemverVersion { + // Step 1: Strip whitespace + var cleaned = version.trim() + + // Step 2: Strip v/V prefix + if (cleaned.startsWith("v", ignoreCase = true)) { + cleaned = cleaned.substring(1) + } + + // Step 3: Strip pre-release and build metadata suffixes + // Split on - or + and take the first part + val dashIndex = cleaned.indexOf('-') + val plusIndex = cleaned.indexOf('+') + val metadataIndex = + when { + dashIndex >= 0 && plusIndex >= 0 -> minOf(dashIndex, plusIndex) + dashIndex >= 0 -> dashIndex + plusIndex >= 0 -> plusIndex + else -> -1 + } + if (metadataIndex >= 0) { + cleaned = cleaned.substring(0, metadataIndex) + } + + // Check for empty string or leading dot + if (cleaned.isEmpty() || cleaned.startsWith(".")) { + throw InconclusiveMatchException("Invalid semver version: '$version'") + } + + // Step 4: Split on . and parse components + val parts = cleaned.split(".") + val components = mutableListOf() + + for (i in 0 until minOf(3, parts.size)) { + val part = parts[i] + if (part.isEmpty()) { + throw InconclusiveMatchException("Invalid semver version: '$version'") + } + val num = + part.toIntOrNull() + ?: throw InconclusiveMatchException("Invalid semver version: '$version'") + components.add(num) + } + + // Step 5: Default missing components to 0 + while (components.size < 3) { + components.add(0) + } + + return SemverVersion(components[0], components[1], components[2]) + } + + /** + * Compare two semver versions using the specified operator + */ + private fun compareSemver( + overrideValue: Any?, + propertyValue: Any?, + propertyOperator: PropertyOperator, + ): Boolean { + val overrideVersion = + try { + parseSemver(overrideValue.toString()) + } catch (e: InconclusiveMatchException) { + throw InconclusiveMatchException("The person property value is not a valid semver: ${e.message}") + } + + val propertyString = propertyValue.toString() + + return when (propertyOperator) { + PropertyOperator.SEMVER_EQ, + PropertyOperator.SEMVER_NEQ, + PropertyOperator.SEMVER_GT, + PropertyOperator.SEMVER_GTE, + PropertyOperator.SEMVER_LT, + PropertyOperator.SEMVER_LTE, + -> { + val conditionVersion = + try { + parseSemver(propertyString) + } catch (e: InconclusiveMatchException) { + throw InconclusiveMatchException("The flag condition value is not a valid semver: ${e.message}") + } + compareSemverVersions(overrideVersion, conditionVersion, propertyOperator) + } + + PropertyOperator.SEMVER_TILDE -> { + val (lower, upper) = computeTildeBounds(propertyString) + overrideVersion >= lower && overrideVersion < upper + } + + PropertyOperator.SEMVER_CARET -> { + val (lower, upper) = computeCaretBounds(propertyString) + overrideVersion >= lower && overrideVersion < upper + } + + PropertyOperator.SEMVER_WILDCARD -> { + val (lower, upper) = computeWildcardBounds(propertyString) + overrideVersion >= lower && overrideVersion < upper + } + + else -> throw InconclusiveMatchException("Unknown semver operator: $propertyOperator") + } + } + + /** + * Compare two parsed semver versions + */ + private fun compareSemverVersions( + override: SemverVersion, + condition: SemverVersion, + operator: PropertyOperator, + ): Boolean { + return when (operator) { + PropertyOperator.SEMVER_EQ -> override == condition + PropertyOperator.SEMVER_NEQ -> override != condition + PropertyOperator.SEMVER_GT -> override > condition + PropertyOperator.SEMVER_GTE -> override >= condition + PropertyOperator.SEMVER_LT -> override < condition + PropertyOperator.SEMVER_LTE -> override <= condition + else -> false + } + } + + /** + * Compute lower and upper bounds for tilde range (~X.Y.Z) + * ~X.Y.Z → lower=(X,Y,Z), upper=(X,Y+1,0) + */ + private fun computeTildeBounds(propertyValue: String): Pair { + val version = + try { + parseSemver(propertyValue) + } catch (e: InconclusiveMatchException) { + throw InconclusiveMatchException("The flag condition value is not a valid semver: ${e.message}") + } + val lower = version + val upper = SemverVersion(version.major, version.minor + 1, 0) + return Pair(lower, upper) + } + + /** + * Compute lower and upper bounds for caret range (^X.Y.Z) + * ^X.Y.Z where: + * - X > 0 → lower=(X,Y,Z), upper=(X+1,0,0) + * - X == 0, Y > 0 → lower=(0,Y,Z), upper=(0,Y+1,0) + * - X == 0, Y == 0 → lower=(0,0,Z), upper=(0,0,Z+1) + */ + private fun computeCaretBounds(propertyValue: String): Pair { + val version = + try { + parseSemver(propertyValue) + } catch (e: InconclusiveMatchException) { + throw InconclusiveMatchException("The flag condition value is not a valid semver: ${e.message}") + } + val lower = version + val upper = + when { + version.major > 0 -> SemverVersion(version.major + 1, 0, 0) + version.minor > 0 -> SemverVersion(0, version.minor + 1, 0) + else -> SemverVersion(0, 0, version.patch + 1) + } + return Pair(lower, upper) + } + + /** + * Compute lower and upper bounds for wildcard range + * - "X.*" or "X" with wildcard → lower=(X,0,0), upper=(X+1,0,0) + * - "X.Y.*" → lower=(X,Y,0), upper=(X,Y+1,0) + */ + private fun computeWildcardBounds(propertyValue: String): Pair { + var cleaned = propertyValue.trim() + + // Strip v/V prefix + if (cleaned.startsWith("v", ignoreCase = true)) { + cleaned = cleaned.substring(1) + } + + // Remove trailing .* wildcards + cleaned = cleaned.trimEnd('*', '.') + + if (cleaned.isEmpty()) { + throw InconclusiveMatchException("Invalid wildcard version: '$propertyValue'") + } + + val parts = cleaned.split(".") + val components = mutableListOf() + + for (part in parts) { + if (part.isEmpty()) continue + val num = + part.toIntOrNull() + ?: throw InconclusiveMatchException("Invalid wildcard version: '$propertyValue'") + components.add(num) + } + + if (components.isEmpty()) { + throw InconclusiveMatchException("Invalid wildcard version: '$propertyValue'") + } + + return when (components.size) { + 1 -> { + // "X.*" or just "X" → lower=(X,0,0), upper=(X+1,0,0) + val major = components[0] + Pair(SemverVersion(major, 0, 0), SemverVersion(major + 1, 0, 0)) + } + else -> { + // "X.Y.*" → lower=(X,Y,0), upper=(X,Y+1,0) + val major = components[0] + val minor = components[1] + Pair(SemverVersion(major, minor, 0), SemverVersion(major, minor + 1, 0)) + } + } + } + /** * Match a cohort property against property values */ diff --git a/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt index 0c5de3a01..b364eed3e 100644 --- a/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt @@ -1948,6 +1948,583 @@ internal class FlagEvaluatorTest { assertEquals(true, resultAbsent) } + // Semver Operator Tests + + @Test + internal fun testSemverEq() { + // Exact match cases + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + + // v-prefix handling + assertTrue(evaluator.matchProperty(property, mapOf("version" to "v1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "V1.2.3"))) + + // Pre-release suffix stripping + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3-alpha"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3+build123"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3-beta.1+build"))) + + // Whitespace handling + assertTrue(evaluator.matchProperty(property, mapOf("version" to " 1.2.3 "))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to " v1.2.3 "))) + } + + @Test + internal fun testSemverNeq() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_NEQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + } + + @Test + internal fun testSemverGt() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_GT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.1.9"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.9.9"))) + } + + @Test + internal fun testSemverGte() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_GTE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.1.9"))) + } + + @Test + internal fun testSemverLt() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_LT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.1.9"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "0.9.9"))) + } + + @Test + internal fun testSemverLte() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_LTE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "0.9.9"))) + } + + @Test + internal fun testSemverTilde() { + // ~1.2.3 means >=1.2.3 and <1.3.0 + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_TILDE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // Lower boundary (inclusive) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.99"))) + + // Upper boundary (exclusive) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.3.1"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + + // Below range + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.1.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.9.9"))) + } + + @Test + internal fun testSemverTildeWithZeroMinor() { + // ~1.0.0 means >=1.0.0 and <1.1.0 + val property = + FlagProperty( + key = "version", + propertyValue = "1.0.0", + propertyOperator = PropertyOperator.SEMVER_TILDE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.0.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.0.5"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.1.0"))) + } + + @Test + internal fun testSemverCaretMajorGreaterThanZero() { + // ^1.2.3 means >=1.2.3 <2.0.0 (when major > 0) + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_CARET, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // Lower boundary (inclusive) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.99.99"))) + + // Upper boundary (exclusive) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.1"))) + + // Below range + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.2.2"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.1.0"))) + } + + @Test + internal fun testSemverCaretMajorZeroMinorGreaterThanZero() { + // ^0.2.3 means >=0.2.3 <0.3.0 (when major=0, minor>0) + val property = + FlagProperty( + key = "version", + propertyValue = "0.2.3", + propertyOperator = PropertyOperator.SEMVER_CARET, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // In range + assertTrue(evaluator.matchProperty(property, mapOf("version" to "0.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "0.2.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "0.2.99"))) + + // Upper boundary (exclusive) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.3.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.0.0"))) + + // Below range + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.2.2"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.1.0"))) + } + + @Test + internal fun testSemverCaretMajorAndMinorZero() { + // ^0.0.3 means >=0.0.3 <0.0.4 (when major=0, minor=0) + val property = + FlagProperty( + key = "version", + propertyValue = "0.0.3", + propertyOperator = PropertyOperator.SEMVER_CARET, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // Only exact match (within the patch increment) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "0.0.3"))) + + // Upper boundary (exclusive) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.0.4"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.1.0"))) + + // Below range + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.0.2"))) + } + + @Test + internal fun testSemverWildcardMajorOnly() { + // 1.* means >=1.0.0 <2.0.0 + val property = + FlagProperty( + key = "version", + propertyValue = "1.*", + propertyOperator = PropertyOperator.SEMVER_WILDCARD, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // In range + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.0.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.5.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.99.99"))) + + // Upper boundary (exclusive) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + + // Below range + assertFalse(evaluator.matchProperty(property, mapOf("version" to "0.9.9"))) + } + + @Test + internal fun testSemverWildcardMajorAndMinor() { + // 1.2.* means >=1.2.0 <1.3.0 + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.*", + propertyOperator = PropertyOperator.SEMVER_WILDCARD, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // In range + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.5"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.99"))) + + // Upper boundary (exclusive) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.3.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + + // Below range + assertFalse(evaluator.matchProperty(property, mapOf("version" to "1.1.9"))) + } + + @Test + internal fun testSemverPartialVersions() { + // "1.2" should be parsed as "1.2.0" + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.0", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.0"))) + + // "1" should be parsed as "1.0.0" + val propertySingle = + FlagProperty( + key = "version", + propertyValue = "1.0.0", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(propertySingle, mapOf("version" to "1"))) + assertTrue(evaluator.matchProperty(propertySingle, mapOf("version" to "1.0"))) + assertTrue(evaluator.matchProperty(propertySingle, mapOf("version" to "1.0.0"))) + } + + @Test + internal fun testSemverFourPartVersions() { + // "1.2.3.4" should be parsed as "1.2.3" (extra parts ignored) + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3.4"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3.99"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3.0.0.0"))) + } + + @Test + internal fun testSemverLeadingZeros() { + // "01.02.03" should parse as (1, 2, 3) + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "01.02.03"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "001.002.003"))) + } + + @Test + internal fun testSemverInvalidEmptyString() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + try { + evaluator.matchProperty(property, mapOf("version" to "")) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("not a valid semver") ?: false) + } + } + + @Test + internal fun testSemverInvalidLeadingDot() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + try { + evaluator.matchProperty(property, mapOf("version" to ".1.2.3")) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("not a valid semver") ?: false) + } + } + + @Test + internal fun testSemverInvalidNonNumeric() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + try { + evaluator.matchProperty(property, mapOf("version" to "abc")) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("not a valid semver") ?: false) + } + + try { + evaluator.matchProperty(property, mapOf("version" to "1.x.3")) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("not a valid semver") ?: false) + } + } + + @Test + internal fun testSemverInvalidConditionValue() { + val property = + FlagProperty( + key = "version", + propertyValue = "invalid", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + try { + evaluator.matchProperty(property, mapOf("version" to "1.2.3")) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("not a valid semver") ?: false) + } + } + + @Test + internal fun testSemverMissingPropertyKey() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + try { + evaluator.matchProperty(property, mapOf("other_key" to "1.2.3")) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("without a given property value") ?: false) + } + } + + @Test + internal fun testSemverNullPropertyValue() { + val property = + FlagProperty( + key = "version", + propertyValue = "1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // Null values return false before reaching operator logic + assertFalse(evaluator.matchProperty(property, mapOf("version" to null))) + } + + @Test + internal fun testSemverWithVPrefixInCondition() { + // Condition value with v prefix should work + val property = + FlagProperty( + key = "version", + propertyValue = "v1.2.3", + propertyOperator = PropertyOperator.SEMVER_EQ, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.2.3"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "v1.2.3"))) + } + + @Test + internal fun testSemverWildcardWithVPrefix() { + val property = + FlagProperty( + key = "version", + propertyValue = "v1.*", + propertyOperator = PropertyOperator.SEMVER_WILDCARD, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.0.0"))) + assertTrue(evaluator.matchProperty(property, mapOf("version" to "1.5.0"))) + assertFalse(evaluator.matchProperty(property, mapOf("version" to "2.0.0"))) + } + + @Test + internal fun testSemverInFeatureFlag() { + // Test semver operator in a full flag evaluation + val json = + """ + { + "id": 1, + "name": "Semver Flag", + "key": "semver-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "app_version", + "value": "2.0.0", + "operator": "semver_gte", + "type": "person", + "negation": false + } + ], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val flag = config.serializer.gson.fromJson(json, FlagDefinition::class.java) + + // User with version >= 2.0.0 should match + val propsMatch = mapOf("app_version" to "2.1.0") + val resultMatch = evaluator.matchFeatureFlagProperties(flag, "user-123", propsMatch) + assertEquals(true, resultMatch) + + // User with version < 2.0.0 should not match + val propsNoMatch = mapOf("app_version" to "1.9.9") + val resultNoMatch = evaluator.matchFeatureFlagProperties(flag, "user-123", propsNoMatch) + assertEquals(false, resultNoMatch) + } + @Test internal fun testBooleanFlagDependencyMatchesFalse() { // Regression test for bug: Boolean flag should match false expectation diff --git a/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationModels.kt b/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationModels.kt index d55b3f7a2..f86991e12 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationModels.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationModels.kt @@ -133,6 +133,15 @@ public enum class PropertyOperator { IS_DATE_BEFORE, IS_DATE_AFTER, FLAG_EVALUATES_TO, + SEMVER_EQ, + SEMVER_NEQ, + SEMVER_GT, + SEMVER_GTE, + SEMVER_LT, + SEMVER_LTE, + SEMVER_TILDE, + SEMVER_CARET, + SEMVER_WILDCARD, ; public companion object { @@ -154,6 +163,15 @@ public enum class PropertyOperator { "is_date_before" -> IS_DATE_BEFORE "is_date_after" -> IS_DATE_AFTER "flag_evaluates_to" -> FLAG_EVALUATES_TO + "semver_eq" -> SEMVER_EQ + "semver_neq" -> SEMVER_NEQ + "semver_gt" -> SEMVER_GT + "semver_gte" -> SEMVER_GTE + "semver_lt" -> SEMVER_LT + "semver_lte" -> SEMVER_LTE + "semver_tilde" -> SEMVER_TILDE + "semver_caret" -> SEMVER_CARET + "semver_wildcard" -> SEMVER_WILDCARD else -> UNKNOWN } } From 65cc2a9394340839d7f312c2b59a1ec11f8aa8d6 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 2 Mar 2026 14:18:43 -0800 Subject: [PATCH 2/5] update API declarations for semver operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- posthog/api/posthog.api | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 22e23b3bb..ababa6f23 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -904,6 +904,15 @@ public final class com/posthog/internal/PropertyOperator : java/lang/Enum { public static final field NOT_ICONTAINS Lcom/posthog/internal/PropertyOperator; public static final field NOT_REGEX Lcom/posthog/internal/PropertyOperator; public static final field REGEX Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_CARET Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_EQ Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_GT Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_GTE Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_LT Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_LTE Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_NEQ Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_TILDE Lcom/posthog/internal/PropertyOperator; + public static final field SEMVER_WILDCARD Lcom/posthog/internal/PropertyOperator; public static final field UNKNOWN Lcom/posthog/internal/PropertyOperator; public static fun valueOf (Ljava/lang/String;)Lcom/posthog/internal/PropertyOperator; public static fun values ()[Lcom/posthog/internal/PropertyOperator; From 818c10611d357c239390fed234b1d0f3475f92e2 Mon Sep 17 00:00:00 2001 From: dylan Date: Tue, 3 Mar 2026 08:42:27 -0800 Subject: [PATCH 3/5] address PR feedback: refactor semver implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change SemverVersion from data class to regular class (avoids generating unnecessary methods) - Refactor parseSemver to use regex for cleaner parsing - Add @Throws annotations to methods that throw InconclusiveMatchException 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../posthog/server/internal/FlagEvaluator.kt | 75 ++++++++----------- 1 file changed, 30 insertions(+), 45 deletions(-) diff --git a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt index 6d1a261af..12610282a 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt @@ -31,6 +31,8 @@ internal class FlagEvaluator( private val NONE_VALUES_ALLOWED_OPERATORS = setOf(PropertyOperator.IS_NOT) private val REGEX_COMBINING_MARKS = "\\p{M}+".toRegex() private val REGEX_RELATIVE_DATE = "^-?([0-9]+)([hdwmy])$".toRegex() + private val REGEX_SEMVER = + Regex("""^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.\d+)*(?:[-+].*)?$""") private val DATE_FORMATTER_WITH_SPACE_TZ = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss XXX") @@ -454,7 +456,7 @@ internal class FlagEvaluator( /** * A parsed semver version as (major, minor, patch) tuple */ - private data class SemverVersion( + private class SemverVersion( val major: Int, val minor: Int, val patch: Int, @@ -466,6 +468,19 @@ internal class FlagEvaluator( if (minorCmp != 0) return minorCmp return patch.compareTo(other.patch) } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SemverVersion) return false + return major == other.major && minor == other.minor && patch == other.patch + } + + override fun hashCode(): Int { + var result = major + result = 31 * result + minor + result = 31 * result + patch + return result + } } /** @@ -474,67 +489,34 @@ internal class FlagEvaluator( * Parsing rules: * 1. Strip leading/trailing whitespace * 2. Strip v/V prefix (e.g., "v1.2.3" → "1.2.3") - * 3. Strip pre-release and build metadata suffixes (split on - or +, take first part) - * 4. Split on . and parse first 3 components as integers + * 3. Strip pre-release and build metadata suffixes (handled by regex) + * 4. Parse first 3 numeric components * 5. Default missing components to 0 (e.g., "1.2" → (1, 2, 0), "1" → (1, 0, 0)) * 6. Ignore extra components beyond the third (e.g., "1.2.3.4" → (1, 2, 3)) * 7. Throw an error for truly invalid input (empty string, non-numeric parts, leading dot) */ + @Throws(InconclusiveMatchException::class) private fun parseSemver(version: String): SemverVersion { - // Step 1: Strip whitespace var cleaned = version.trim() - - // Step 2: Strip v/V prefix if (cleaned.startsWith("v", ignoreCase = true)) { cleaned = cleaned.substring(1) } - // Step 3: Strip pre-release and build metadata suffixes - // Split on - or + and take the first part - val dashIndex = cleaned.indexOf('-') - val plusIndex = cleaned.indexOf('+') - val metadataIndex = - when { - dashIndex >= 0 && plusIndex >= 0 -> minOf(dashIndex, plusIndex) - dashIndex >= 0 -> dashIndex - plusIndex >= 0 -> plusIndex - else -> -1 - } - if (metadataIndex >= 0) { - cleaned = cleaned.substring(0, metadataIndex) - } - - // Check for empty string or leading dot - if (cleaned.isEmpty() || cleaned.startsWith(".")) { - throw InconclusiveMatchException("Invalid semver version: '$version'") - } - - // Step 4: Split on . and parse components - val parts = cleaned.split(".") - val components = mutableListOf() - - for (i in 0 until minOf(3, parts.size)) { - val part = parts[i] - if (part.isEmpty()) { - throw InconclusiveMatchException("Invalid semver version: '$version'") - } - val num = - part.toIntOrNull() - ?: throw InconclusiveMatchException("Invalid semver version: '$version'") - components.add(num) - } + val match = + REGEX_SEMVER.matchEntire(cleaned) + ?: throw InconclusiveMatchException("Invalid semver version: '$version'") - // Step 5: Default missing components to 0 - while (components.size < 3) { - components.add(0) - } + val major = match.groupValues[1].toInt() + val minor = match.groupValues[2].takeIf { it.isNotEmpty() }?.toInt() ?: 0 + val patch = match.groupValues[3].takeIf { it.isNotEmpty() }?.toInt() ?: 0 - return SemverVersion(components[0], components[1], components[2]) + return SemverVersion(major, minor, patch) } /** * Compare two semver versions using the specified operator */ + @Throws(InconclusiveMatchException::class) private fun compareSemver( overrideValue: Any?, propertyValue: Any?, @@ -608,6 +590,7 @@ internal class FlagEvaluator( * Compute lower and upper bounds for tilde range (~X.Y.Z) * ~X.Y.Z → lower=(X,Y,Z), upper=(X,Y+1,0) */ + @Throws(InconclusiveMatchException::class) private fun computeTildeBounds(propertyValue: String): Pair { val version = try { @@ -627,6 +610,7 @@ internal class FlagEvaluator( * - X == 0, Y > 0 → lower=(0,Y,Z), upper=(0,Y+1,0) * - X == 0, Y == 0 → lower=(0,0,Z), upper=(0,0,Z+1) */ + @Throws(InconclusiveMatchException::class) private fun computeCaretBounds(propertyValue: String): Pair { val version = try { @@ -649,6 +633,7 @@ internal class FlagEvaluator( * - "X.*" or "X" with wildcard → lower=(X,0,0), upper=(X+1,0,0) * - "X.Y.*" → lower=(X,Y,0), upper=(X,Y+1,0) */ + @Throws(InconclusiveMatchException::class) private fun computeWildcardBounds(propertyValue: String): Pair { var cleaned = propertyValue.trim() From 41ae99f005efe781b03e9e2a5ef52920bdb53632 Mon Sep 17 00:00:00 2001 From: dylan Date: Tue, 3 Mar 2026 13:13:58 -0800 Subject: [PATCH 4/5] add changeset for semver targeting feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/semver-targeting.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/semver-targeting.md diff --git a/.changeset/semver-targeting.md b/.changeset/semver-targeting.md new file mode 100644 index 000000000..8bbe3165e --- /dev/null +++ b/.changeset/semver-targeting.md @@ -0,0 +1,12 @@ +--- +"posthog-server": patch +--- + +Add semver comparison operators to local feature flag evaluation + +This adds 9 semver operators for targeting users based on app version: +- `semver_eq`, `semver_neq` — exact match / not equal +- `semver_gt`, `semver_gte`, `semver_lt`, `semver_lte` — comparison operators +- `semver_tilde` — patch-level range (~1.2.3 means >=1.2.3 <1.3.0) +- `semver_caret` — compatible-with range (^1.2.3 means >=1.2.3 <2.0.0) +- `semver_wildcard` — wildcard range (1.2.* means >=1.2.0 <1.3.0) From a065238ef52e57b3489cd351291722700ecb14e6 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 4 Mar 2026 14:50:21 -0800 Subject: [PATCH 5/5] include posthog core in changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/semver-targeting.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/semver-targeting.md b/.changeset/semver-targeting.md index 8bbe3165e..ea34c4850 100644 --- a/.changeset/semver-targeting.md +++ b/.changeset/semver-targeting.md @@ -1,4 +1,5 @@ --- +"posthog": patch "posthog-server": patch ---