From 01e56e4b8cb5925a7992852f12ff9b59c3ce9d33 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 2 Mar 2026 13:47:45 -0800 Subject: [PATCH 1/3] 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/3] 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 From a61850da0cd7aae9fd4dccc4bfa3b3d4d1ae13d3 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 20 May 2026 13:36:14 -0700 Subject: [PATCH 3/3] Reject semver values with leading zeros in local evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per semver 2.0.0 §2, numeric identifiers must not include leading zeros. Values like "1.07.3" are not valid semver and should not match targeting conditions. Both override values and flag values are validated; invalid inputs surface an InconclusiveMatchError so the condition does not match. --- lib/FeatureFlag.php | 55 ++++++++---------- test/FeatureFlagLocalEvaluationTest.php | 74 ++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 40 deletions(-) diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index 4fd451f..dd3a137 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -291,6 +291,23 @@ public static function relativeDateParseForFeatureFlagMatching($value) } } + /** + * Parse a semver numeric identifier (e.g., the "1" in "1.2.3"). + * + * @throws InconclusiveMatchException If the part is empty, non-numeric, or has a leading zero. + */ + private static function parseSemverNumeric(string $part, string $component, $value): int + { + // Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros. + if ($part === "" || !ctype_digit($part)) { + throw new InconclusiveMatchException("Cannot parse semver: invalid {$component} version in {$value}"); + } + if (strlen($part) > 1 && $part[0] === "0") { + throw new InconclusiveMatchException("Cannot parse semver: {$component} version has leading zero in {$value}"); + } + return intval($part); + } + /** * Parse a semver string into a tuple of [major, minor, patch]. * @@ -301,7 +318,8 @@ public static function relativeDateParseForFeatureFlagMatching($value) * 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) + * 7. Reject numeric identifiers with leading zeros per semver 2.0.0 §2 + * 8. 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] @@ -337,34 +355,16 @@ public static function parseSemver($value): array // 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]); - } + $major = FeatureFlag::parseSemverNumeric($parts[0] ?? "", "major", $value); - // 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]); + $minor = FeatureFlag::parseSemverNumeric($parts[1], "minor", $value); } - // 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]); + $patch = FeatureFlag::parseSemverNumeric($parts[2], "patch", $value); } return [$major, $minor, $patch]; @@ -460,11 +460,7 @@ private static function wildcardBounds($value): array 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]); + $major = FeatureFlag::parseSemverNumeric($parts[0], "major", $value); if (count($parts) === 1) { // X.* pattern @@ -472,10 +468,7 @@ private static function wildcardBounds($value): array $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]); + $minor = FeatureFlag::parseSemverNumeric($parts[1], "minor", $value); $lower = [$major, $minor, 0]; $upper = [$major, $minor + 1, 0]; } diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index 0a48f40..89624c9 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -3988,11 +3988,22 @@ public function testParseSemverExtraParts(): void self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("1.2.3.4.5")); } - public function testParseSemverLeadingZeros(): void + public function testParseSemverRejectsLeadingZeros(): 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")); + // Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros. + foreach (["01.2.3", "1.02.3", "1.2.03", "01.02.03", "1.07.3", "001.2.3", "00.00.01"] as $bad) { + try { + FeatureFlag::parseSemver($bad); + self::fail("Expected InconclusiveMatchException for value: {$bad}"); + } catch (InconclusiveMatchException $e) { + // expected + } + } + + // Literal "0" components remain valid. + self::assertEquals([0, 1, 0], FeatureFlag::parseSemver("0.1.0")); + self::assertEquals([1, 0, 0], FeatureFlag::parseSemver("1.0.0")); + self::assertEquals([0, 0, 0], FeatureFlag::parseSemver("0.0.0")); } public function testParseSemverInvalidEmpty(): void @@ -4286,14 +4297,59 @@ public function testMatchPropertySemverPreReleaseSuffixesStripped(): void self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3-alpha+build"])); } - public function testMatchPropertySemverLeadingZeros(): void + public function testMatchPropertySemverRejectsLeadingZeros(): void { - // Leading zeros are parsed correctly + // Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros. $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"])); + foreach (["01.2.3", "1.02.3", "1.2.03", "01.02.03", "1.07.3", "001.2.3"] as $bad) { + try { + FeatureFlag::matchProperty($prop, ["version" => $bad]); + self::fail("Expected InconclusiveMatchException for override value: {$bad}"); + } catch (InconclusiveMatchException $e) { + // expected + } + } + + // Literal "0" components still match. + $propZeroMinor = ["key" => "version", "value" => "0.1.0", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($propZeroMinor, ["version" => "0.1.0"])); + + $propZeroPatch = ["key" => "version", "value" => "1.0.0", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($propZeroPatch, ["version" => "1.0.0"])); + + // Flag values with leading zeros are also rejected across range operators. + $propGt = ["key" => "version", "value" => "01.2.3", "operator" => "semver_gt"]; + try { + FeatureFlag::matchProperty($propGt, ["version" => "2.0.0"]); + self::fail("Expected InconclusiveMatchException for semver_gt flag value"); + } catch (InconclusiveMatchException $e) { + // expected + } + + $propCaret = ["key" => "version", "value" => "1.07.0", "operator" => "semver_caret"]; + try { + FeatureFlag::matchProperty($propCaret, ["version" => "1.2.0"]); + self::fail("Expected InconclusiveMatchException for semver_caret flag value"); + } catch (InconclusiveMatchException $e) { + // expected + } + + $propTilde = ["key" => "version", "value" => "1.07.0", "operator" => "semver_tilde"]; + try { + FeatureFlag::matchProperty($propTilde, ["version" => "1.2.0"]); + self::fail("Expected InconclusiveMatchException for semver_tilde flag value"); + } catch (InconclusiveMatchException $e) { + // expected + } + + $propWild = ["key" => "version", "value" => "01.*", "operator" => "semver_wildcard"]; + try { + FeatureFlag::matchProperty($propWild, ["version" => "1.2.0"]); + self::fail("Expected InconclusiveMatchException for semver_wildcard flag value"); + } catch (InconclusiveMatchException $e) { + // expected + } } public function testMatchPropertySemverPartialVersions(): void