From 01e56e4b8cb5925a7992852f12ff9b59c3ce9d33 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 2 Mar 2026 13:47:45 -0800 Subject: [PATCH 1/2] add semver targeting to local evaluation --- lib/FeatureFlag.php | 229 +++++++++++ test/FeatureFlagLocalEvaluationTest.php | 488 ++++++++++++++++++++++++ 2 files changed, 717 insertions(+) diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index 125a720..56ed416 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -98,6 +98,52 @@ public static function matchProperty($property, $propertyValues) } } + // Semver operators + if (in_array($operator, ["semver_eq", "semver_neq", "semver_gt", "semver_gte", "semver_lt", "semver_lte"])) { + $overrideTuple = FeatureFlag::parseSemver($overrideValue); + $valueTuple = FeatureFlag::parseSemver($value); + + $comparison = FeatureFlag::compareSemverTuples($overrideTuple, $valueTuple); + + if ($operator === "semver_eq") { + return $comparison === 0; + } elseif ($operator === "semver_neq") { + return $comparison !== 0; + } elseif ($operator === "semver_gt") { + return $comparison > 0; + } elseif ($operator === "semver_gte") { + return $comparison >= 0; + } elseif ($operator === "semver_lt") { + return $comparison < 0; + } elseif ($operator === "semver_lte") { + return $comparison <= 0; + } + } + + if ($operator === "semver_tilde") { + $overrideTuple = FeatureFlag::parseSemver($overrideValue); + list($lower, $upper) = FeatureFlag::tildeBounds($value); + + return FeatureFlag::compareSemverTuples($overrideTuple, $lower) >= 0 + && FeatureFlag::compareSemverTuples($overrideTuple, $upper) < 0; + } + + if ($operator === "semver_caret") { + $overrideTuple = FeatureFlag::parseSemver($overrideValue); + list($lower, $upper) = FeatureFlag::caretBounds($value); + + return FeatureFlag::compareSemverTuples($overrideTuple, $lower) >= 0 + && FeatureFlag::compareSemverTuples($overrideTuple, $upper) < 0; + } + + if ($operator === "semver_wildcard") { + $overrideTuple = FeatureFlag::parseSemver($overrideValue); + list($lower, $upper) = FeatureFlag::wildcardBounds($value); + + return FeatureFlag::compareSemverTuples($overrideTuple, $lower) >= 0 + && FeatureFlag::compareSemverTuples($overrideTuple, $upper) < 0; + } + return false; } @@ -245,6 +291,189 @@ public static function relativeDateParseForFeatureFlagMatching($value) } } + /** + * Parse a semver string into a tuple of [major, minor, patch]. + * + * @param mixed $value The semver string to parse + * @return array{int, int, int} The parsed tuple [major, minor, patch] + * @throws InconclusiveMatchException If the value cannot be parsed as semver + */ + public static function parseSemver($value): array + { + if ($value === null || $value === "") { + throw new InconclusiveMatchException("Cannot parse empty or null value as semver"); + } + + $text = trim(strval($value)); + + if ($text === "") { + throw new InconclusiveMatchException("Cannot parse empty value as semver"); + } + + // Strip v/V prefix + $text = ltrim($text, "vV"); + + if ($text === "") { + throw new InconclusiveMatchException("Cannot parse semver: only prefix found"); + } + + // Strip pre-release and build metadata (split on - or +, take first part) + $text = preg_split('/[-+]/', $text, 2)[0]; + + // Check for leading dot + if (str_starts_with($text, ".")) { + throw new InconclusiveMatchException("Cannot parse semver with leading dot: {$value}"); + } + + // Split on dots + $parts = explode(".", $text); + + // Parse major + if (!isset($parts[0]) || $parts[0] === "" || !ctype_digit(ltrim($parts[0], "0") ?: "0")) { + // Allow pure zeros or numeric strings + if (isset($parts[0]) && preg_match('/^[0-9]+$/', $parts[0])) { + $major = intval($parts[0]); + } else { + throw new InconclusiveMatchException("Cannot parse semver: invalid major version in {$value}"); + } + } else { + $major = intval($parts[0]); + } + + // Parse minor (default to 0 if not present or empty) + $minor = 0; + if (isset($parts[1]) && $parts[1] !== "") { + if (!preg_match('/^[0-9]+$/', $parts[1])) { + throw new InconclusiveMatchException("Cannot parse semver: invalid minor version in {$value}"); + } + $minor = intval($parts[1]); + } + + // Parse patch (default to 0 if not present or empty) + $patch = 0; + if (isset($parts[2]) && $parts[2] !== "") { + if (!preg_match('/^[0-9]+$/', $parts[2])) { + throw new InconclusiveMatchException("Cannot parse semver: invalid patch version in {$value}"); + } + $patch = intval($parts[2]); + } + + return [$major, $minor, $patch]; + } + + /** + * Compare two semver tuples. + * + * @param array{int, int, int} $a First tuple + * @param array{int, int, int} $b Second tuple + * @return int -1 if a < b, 0 if a == b, 1 if a > b + */ + private static function compareSemverTuples(array $a, array $b): int + { + if ($a[0] !== $b[0]) { + return $a[0] <=> $b[0]; + } + if ($a[1] !== $b[1]) { + return $a[1] <=> $b[1]; + } + return $a[2] <=> $b[2]; + } + + /** + * Calculate tilde bounds for semver matching. + * ~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) + * + * @param mixed $value The semver pattern + * @return array{array{int, int, int}, array{int, int, int}} [lower, upper] bounds + */ + private static function caretBounds($value): array + { + $tuple = FeatureFlag::parseSemver($value); + $lower = $tuple; + + if ($tuple[0] > 0) { + $upper = [$tuple[0] + 1, 0, 0]; + } elseif ($tuple[1] > 0) { + $upper = [0, $tuple[1] + 1, 0]; + } else { + $upper = [0, 0, $tuple[2] + 1]; + } + + return [$lower, $upper]; + } + + /** + * Calculate wildcard bounds for semver matching. + * X.Y.* means >=X.Y.0 =X.0.0 <(X+1).0.0 + * + * @param mixed $value The semver pattern with wildcard + * @return array{array{int, int, int}, array{int, int, int}} [lower, upper] bounds + */ + private static function wildcardBounds($value): array + { + if ($value === null || $value === "") { + throw new InconclusiveMatchException("Cannot parse empty or null value as semver wildcard"); + } + + $text = trim(strval($value)); + + // Strip v/V prefix + $text = ltrim($text, "vV"); + + // Split on dots + $parts = explode(".", $text); + + // Remove trailing wildcard parts and empty parts + while (count($parts) > 0 && (end($parts) === "*" || end($parts) === "x" || end($parts) === "X" || end($parts) === "")) { + array_pop($parts); + } + + if (count($parts) === 0) { + throw new InconclusiveMatchException("Cannot parse semver wildcard: no version components found in {$value}"); + } + + // Parse major + if (!preg_match('/^[0-9]+$/', $parts[0])) { + throw new InconclusiveMatchException("Cannot parse semver wildcard: invalid major version in {$value}"); + } + $major = intval($parts[0]); + + if (count($parts) === 1) { + // X.* pattern + $lower = [$major, 0, 0]; + $upper = [$major + 1, 0, 0]; + } else { + // X.Y.* pattern + if (!preg_match('/^[0-9]+$/', $parts[1])) { + throw new InconclusiveMatchException("Cannot parse semver wildcard: invalid minor version in {$value}"); + } + $minor = intval($parts[1]); + $lower = [$major, $minor, 0]; + $upper = [$major, $minor + 1, 0]; + } + + return [$lower, $upper]; + } + private static function convertToDateTime($value) { if ($value instanceof \DateTime) { diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index e9b6adc..0a48f40 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -3932,4 +3932,492 @@ public function testFallsBackToAPIInGetFeatureFlagPayloadWhenFlagHasStaticCohort $this->checkEmptyErrorLogs(); } + + // ==================== Semver Operator Tests ==================== + + public function testParseSemverBasic(): void + { + // Basic parsing + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("1.2.3")); + self::assertEquals([0, 0, 0], FeatureFlag::parseSemver("0.0.0")); + self::assertEquals([10, 20, 30], FeatureFlag::parseSemver("10.20.30")); + } + + public function testParseSemverVPrefix(): void + { + // v prefix handling + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("v1.2.3")); + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("V1.2.3")); + } + + public function testParseSemverWhitespace(): void + { + // Whitespace handling + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver(" 1.2.3 ")); + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver(" v1.2.3 ")); + } + + public function testParseSemverPreRelease(): void + { + // Pre-release suffixes are stripped + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("1.2.3-alpha")); + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("1.2.3-alpha.1")); + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("1.2.3-beta.2")); + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("1.2.3-rc.1")); + } + + public function testParseSemverBuildMetadata(): void + { + // Build metadata is stripped + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("1.2.3+build")); + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("1.2.3+build.123")); + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("1.2.3-alpha+build")); + } + + public function testParseSemverPartialVersions(): void + { + // Partial versions default missing parts to 0 + self::assertEquals([1, 2, 0], FeatureFlag::parseSemver("1.2")); + self::assertEquals([1, 0, 0], FeatureFlag::parseSemver("1")); + } + + public function testParseSemverExtraParts(): void + { + // Extra parts beyond 3 are ignored + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("1.2.3.4")); + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("1.2.3.4.5")); + } + + public function testParseSemverLeadingZeros(): void + { + // Leading zeros are parsed correctly + self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("01.02.03")); + self::assertEquals([0, 0, 1], FeatureFlag::parseSemver("00.00.01")); + } + + public function testParseSemverInvalidEmpty(): void + { + self::expectException(InconclusiveMatchException::class); + FeatureFlag::parseSemver(""); + } + + public function testParseSemverInvalidNull(): void + { + self::expectException(InconclusiveMatchException::class); + FeatureFlag::parseSemver(null); + } + + public function testParseSemverInvalidLeadingDot(): void + { + self::expectException(InconclusiveMatchException::class); + FeatureFlag::parseSemver(".1.2.3"); + } + + public function testParseSemverInvalidNonNumeric(): void + { + self::expectException(InconclusiveMatchException::class); + FeatureFlag::parseSemver("abc.def.ghi"); + } + + public function testParseSemverInvalidMixedNonNumeric(): void + { + self::expectException(InconclusiveMatchException::class); + FeatureFlag::parseSemver("1.2.abc"); + } + + public function testParseSemverInvalidOnlyV(): void + { + self::expectException(InconclusiveMatchException::class); + FeatureFlag::parseSemver("v"); + } + + public function testMatchPropertySemverEq(): void + { + // Basic equality + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.2.4"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.2.2"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.3.3"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "2.2.3"])); + + // With v prefix + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "v1.2.3"])); + + // With pre-release (stripped, so equals base version) + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3-alpha"])); + + // Partial version comparison + $prop2 = ["key" => "version", "value" => "1.2", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($prop2, ["version" => "1.2.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop2, ["version" => "1.2.1"])); + } + + public function testMatchPropertySemverNeq(): void + { + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_neq"]; + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.2.3"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.4"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.2"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "2.0.0"])); + } + + public function testMatchPropertySemverGt(): void + { + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_gt"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.4"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.3.0"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "2.0.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.2.3"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.2.2"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.1.9"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.9.9"])); + } + + public function testMatchPropertySemverGte(): void + { + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_gte"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.4"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.3.0"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "2.0.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.2.2"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.1.9"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.9.9"])); + } + + public function testMatchPropertySemverLt(): void + { + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_lt"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.2"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.1.9"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "0.9.9"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.2.3"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.2.4"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "2.0.0"])); + } + + public function testMatchPropertySemverLte(): void + { + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_lte"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.2"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.1.9"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "0.9.9"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.2.4"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "2.0.0"])); + } + + public function testMatchPropertySemverTilde(): void + { + // ~1.2.3 means >=1.2.3 <1.3.0 + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_tilde"]; + + // Within range + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.4"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.99"])); + + // Below range + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.2.2"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.1.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.9.9"])); + + // Above range (>=1.3.0) + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.3.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.3.1"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "2.0.0"])); + } + + public function testMatchPropertySemverTildeEdgeCases(): void + { + // ~0.0.3 means >=0.0.3 <0.1.0 + $prop1 = ["key" => "version", "value" => "0.0.3", "operator" => "semver_tilde"]; + self::assertTrue(FeatureFlag::matchProperty($prop1, ["version" => "0.0.3"])); + self::assertTrue(FeatureFlag::matchProperty($prop1, ["version" => "0.0.99"])); + self::assertFalse(FeatureFlag::matchProperty($prop1, ["version" => "0.0.2"])); + self::assertFalse(FeatureFlag::matchProperty($prop1, ["version" => "0.1.0"])); + + // ~1.0.0 means >=1.0.0 <1.1.0 + $prop2 = ["key" => "version", "value" => "1.0.0", "operator" => "semver_tilde"]; + self::assertTrue(FeatureFlag::matchProperty($prop2, ["version" => "1.0.0"])); + self::assertTrue(FeatureFlag::matchProperty($prop2, ["version" => "1.0.99"])); + self::assertFalse(FeatureFlag::matchProperty($prop2, ["version" => "1.1.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop2, ["version" => "0.9.9"])); + } + + public function testMatchPropertySemverCaret(): void + { + // ^1.2.3 means >=1.2.3 <2.0.0 (major > 0) + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_caret"]; + + // Within range + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.4"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.3.0"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.99.99"])); + + // Below range + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.2.2"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.1.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.9.9"])); + + // Above range (>=2.0.0) + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "2.0.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "2.0.1"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "3.0.0"])); + } + + public function testMatchPropertySemverCaretMajorZeroMinorNonZero(): void + { + // ^0.2.3 means >=0.2.3 <0.3.0 (major == 0, minor > 0) + $prop = ["key" => "version", "value" => "0.2.3", "operator" => "semver_caret"]; + + // Within range + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "0.2.3"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "0.2.4"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "0.2.99"])); + + // Below range + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.2.2"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.1.0"])); + + // Above range (>=0.3.0) + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.3.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.0.0"])); + } + + public function testMatchPropertySemverCaretMajorZeroMinorZero(): void + { + // ^0.0.3 means >=0.0.3 <0.0.4 (major == 0, minor == 0) + $prop = ["key" => "version", "value" => "0.0.3", "operator" => "semver_caret"]; + + // Within range (only 0.0.3) + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "0.0.3"])); + + // Below range + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.0.2"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.0.1"])); + + // Above range (>=0.0.4) + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.0.4"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.1.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.0.0"])); + } + + public function testMatchPropertySemverWildcardMinor(): void + { + // 1.2.* means >=1.2.0 <1.3.0 + $prop = ["key" => "version", "value" => "1.2.*", "operator" => "semver_wildcard"]; + + // Within range + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.0"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.1"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.99"])); + + // Below range + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.1.9"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.1.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.9.9"])); + + // Above range (>=1.3.0) + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.3.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.3.1"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "2.0.0"])); + } + + public function testMatchPropertySemverWildcardMajor(): void + { + // 1.* means >=1.0.0 <2.0.0 + $prop = ["key" => "version", "value" => "1.*", "operator" => "semver_wildcard"]; + + // Within range + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.0.0"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.99.99"])); + + // Below range + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.9.9"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.0.1"])); + + // Above range (>=2.0.0) + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "2.0.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "2.0.1"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "3.0.0"])); + } + + public function testMatchPropertySemverWildcardXFormat(): void + { + // Test x and X wildcards + $prop1 = ["key" => "version", "value" => "1.2.x", "operator" => "semver_wildcard"]; + self::assertTrue(FeatureFlag::matchProperty($prop1, ["version" => "1.2.0"])); + self::assertTrue(FeatureFlag::matchProperty($prop1, ["version" => "1.2.99"])); + self::assertFalse(FeatureFlag::matchProperty($prop1, ["version" => "1.3.0"])); + + $prop2 = ["key" => "version", "value" => "1.X", "operator" => "semver_wildcard"]; + self::assertTrue(FeatureFlag::matchProperty($prop2, ["version" => "1.0.0"])); + self::assertTrue(FeatureFlag::matchProperty($prop2, ["version" => "1.99.99"])); + self::assertFalse(FeatureFlag::matchProperty($prop2, ["version" => "2.0.0"])); + } + + public function testMatchPropertySemverWithVPrefix(): void + { + $prop = ["key" => "version", "value" => "v1.2.3", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "v1.2.3"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "V1.2.3"])); + } + + public function testMatchPropertySemverWithWhitespace(): void + { + $prop = ["key" => "version", "value" => " 1.2.3 ", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => " 1.2.3 "])); + } + + public function testMatchPropertySemverPreReleaseSuffixesStripped(): void + { + // Pre-release suffixes are stripped before comparison + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3-alpha"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3-beta.1"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3-rc.2"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3+build.456"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3-alpha+build"])); + } + + public function testMatchPropertySemverLeadingZeros(): void + { + // Leading zeros are parsed correctly + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "01.02.03"])); + + $prop2 = ["key" => "version", "value" => "01.02.03", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($prop2, ["version" => "1.2.3"])); + } + + public function testMatchPropertySemverPartialVersions(): void + { + // Partial versions default missing parts to 0 + $prop = ["key" => "version", "value" => "1.2", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "1.2.1"])); + + $prop2 = ["key" => "version", "value" => "1", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($prop2, ["version" => "1.0.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop2, ["version" => "1.0.1"])); + } + + public function testMatchPropertySemverFourPartVersions(): void + { + // Extra parts beyond 3 are ignored + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3.4"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3.4.5"])); + + $prop2 = ["key" => "version", "value" => "1.2.3.4", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($prop2, ["version" => "1.2.3"])); + } + + public function testMatchPropertySemverInvalidOverrideValue(): void + { + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_eq"]; + self::expectException(InconclusiveMatchException::class); + FeatureFlag::matchProperty($prop, ["version" => "not-a-version"]); + } + + public function testMatchPropertySemverInvalidFlagValue(): void + { + $prop = ["key" => "version", "value" => "not-a-version", "operator" => "semver_eq"]; + self::expectException(InconclusiveMatchException::class); + FeatureFlag::matchProperty($prop, ["version" => "1.2.3"]); + } + + public function testMatchPropertySemverMissingKey(): void + { + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_eq"]; + self::expectException(InconclusiveMatchException::class); + FeatureFlag::matchProperty($prop, ["other_key" => "1.2.3"]); + } + + public function testMatchPropertySemverNullOverrideValue(): void + { + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_eq"]; + self::expectException(InconclusiveMatchException::class); + FeatureFlag::matchProperty($prop, ["version" => null]); + } + + public function testMatchPropertySemverEmptyOverrideValue(): void + { + $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_eq"]; + self::expectException(InconclusiveMatchException::class); + FeatureFlag::matchProperty($prop, ["version" => ""]); + } + + public function testMatchPropertySemverComparisonOrder(): void + { + // Verify correct ordering of versions + $prop_gt = ["key" => "version", "value" => "1.0.0", "operator" => "semver_gt"]; + + // Major version comparison + self::assertTrue(FeatureFlag::matchProperty($prop_gt, ["version" => "2.0.0"])); + self::assertTrue(FeatureFlag::matchProperty($prop_gt, ["version" => "10.0.0"])); + + // Minor version comparison + self::assertTrue(FeatureFlag::matchProperty($prop_gt, ["version" => "1.1.0"])); + self::assertTrue(FeatureFlag::matchProperty($prop_gt, ["version" => "1.10.0"])); + + // Patch version comparison + self::assertTrue(FeatureFlag::matchProperty($prop_gt, ["version" => "1.0.1"])); + self::assertTrue(FeatureFlag::matchProperty($prop_gt, ["version" => "1.0.10"])); + } + + public function testMatchPropertySemverZeroVersions(): void + { + // Test 0.0.0 edge cases + $prop = ["key" => "version", "value" => "0.0.0", "operator" => "semver_gt"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "0.0.1"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "0.1.0"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.0.0"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "0.0.0"])); + } + + public function testMatchPropertySemverRangeEdgeCases(): void + { + // Test exact boundary conditions for tilde + $prop_tilde = ["key" => "version", "value" => "1.2.3", "operator" => "semver_tilde"]; + // Lower bound is inclusive + self::assertTrue(FeatureFlag::matchProperty($prop_tilde, ["version" => "1.2.3"])); + // Just below lower bound + self::assertFalse(FeatureFlag::matchProperty($prop_tilde, ["version" => "1.2.2"])); + // Just above lower bound + self::assertTrue(FeatureFlag::matchProperty($prop_tilde, ["version" => "1.2.4"])); + // Upper bound is exclusive + self::assertFalse(FeatureFlag::matchProperty($prop_tilde, ["version" => "1.3.0"])); + // Just below upper bound + self::assertTrue(FeatureFlag::matchProperty($prop_tilde, ["version" => "1.2.999"])); + } + + public function testMatchPropertySemverWildcardInvalidPattern(): void + { + $prop = ["key" => "version", "value" => "*", "operator" => "semver_wildcard"]; + self::expectException(InconclusiveMatchException::class); + FeatureFlag::matchProperty($prop, ["version" => "1.2.3"]); + } + + public function testMatchPropertySemverWildcardWithVPrefix(): void + { + $prop = ["key" => "version", "value" => "v1.*", "operator" => "semver_wildcard"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.0.0"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.99.99"])); + self::assertFalse(FeatureFlag::matchProperty($prop, ["version" => "2.0.0"])); + } + + public function testMatchPropertySemverNumericValues(): void + { + // Test that numeric values are converted to strings properly + $prop = ["key" => "version", "value" => 1, "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.0.0"])); + self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => 1])); + } } From 38d5a9355fe301a5c09f129cfed39714203bd650 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 4 Mar 2026 15:03:04 -0800 Subject: [PATCH 2/2] docs: add parsing rules to parseSemver docblock --- lib/FeatureFlag.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index 56ed416..4fd451f 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -294,6 +294,15 @@ public static function relativeDateParseForFeatureFlagMatching($value) /** * Parse a semver string into a tuple of [major, minor, patch]. * + * Rules: + * 1. Strip leading/trailing whitespace + * 2. Strip `v` or `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 InconclusiveMatchException for invalid input (empty string, non-numeric parts, leading dot) + * * @param mixed $value The semver string to parse * @return array{int, int, int} The parsed tuple [major, minor, patch] * @throws InconclusiveMatchException If the value cannot be parsed as semver