From 742c42a8bb3714175d41fa30b8914bcbee4b4873 Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:38:05 +0000 Subject: [PATCH 1/7] Implement semver and date custom ops for flags runtime props --- package-lock.json | 9 +- .../mixpanel/lib/flags/custom_operators.js | 129 +++++++++ packages/mixpanel/lib/flags/local_flags.js | 1 + packages/mixpanel/package.json | 1 + .../mixpanel/test/flags/custom_operators.js | 274 ++++++++++++++++++ packages/mixpanel/test/flags/local_flags.js | 56 ++++ 6 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 packages/mixpanel/lib/flags/custom_operators.js create mode 100644 packages/mixpanel/test/flags/custom_operators.js diff --git a/package-lock.json b/package-lock.json index ba1e49e..746f256 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1060,6 +1060,12 @@ "node": ">=18" } }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2242,6 +2248,7 @@ "version": "0.23.0", "license": "MIT", "dependencies": { + "compare-versions": "6.1.1", "https-proxy-agent": "7.0.6", "json-logic-js": "2.0.5" }, @@ -2255,7 +2262,7 @@ }, "packages/openfeature-server-provider": { "name": "@mixpanel/openfeature-server-provider", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@openfeature/core": "^1.9.2", diff --git a/packages/mixpanel/lib/flags/custom_operators.js b/packages/mixpanel/lib/flags/custom_operators.js new file mode 100644 index 0000000..023bfc3 --- /dev/null +++ b/packages/mixpanel/lib/flags/custom_operators.js @@ -0,0 +1,129 @@ +const jsonLogic = require("json-logic-js"); +const { compareVersions } = require("compare-versions"); + +// Strict RFC3339 guard for datetime strings. +const RFC3339_PATTERN = + /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/; + +// SemVer 2.0.0 requires major.minor.patch; partial versions are zero-padded to this. +const SEMVER_PARTS = 3; + +// Epoch milliseconds are compared as int64 elsewhere, so anything at or beyond this is out of range. +const MAX_EPOCH_MS = 9223372036854775808; + +// Using the official semantic versioning 2.0.0 regular expression to handle cross-platform validation +// differences on other SDK's. For example, some platforms allow leading zeros even though it is not valid +// as part of the Semver 2.0.0 spec. See https://semver.org/ +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +jsonLogic.add_operation("semver_compare", semverCompare); +jsonLogic.add_operation("datetime_compare", datetimeCompare); + +// Implements a custom operation for semantic versioning comparison that conforms to the semver 2.0.0 standard. +// Prior to comparison, any leading version prefix is stripped. +function semverCompare(actual, symbol, target) { + if (arguments.length !== 3) { + return false; + } + if (typeof actual !== "string" || typeof target !== "string") { + return false; + } + const actualVersion = normalizeSemver(actual); + const targetVersion = normalizeSemver(target); + if (!SEMVER_PATTERN.test(actualVersion) || !SEMVER_PATTERN.test(targetVersion)) { + return false; + } + const cmp = compareVersions(actualVersion, targetVersion); + return comparatorMatches(cmp, symbol); +} + +// Implements a custom operation for datetime comparison. +// The target value stored on the feature flag is the millisecond epoch, whereas the actual value provided at evaluation time must be RFC-3339 formatted. +function datetimeCompare(actual, symbol, target) { + if (arguments.length !== 3) { + return false; + } + const actualSec = convertRfc3339ToUnixSeconds(actual); + const targetSec = convertUnixMillisecondsToSeconds(target); + if (actualSec === null || targetSec === null) { + return false; + } + const cmp = actualSec - targetSec; + return comparatorMatches(cmp, symbol); +} + +function comparatorMatches(cmp, symbol) { + switch (symbol) { + case "=": + return cmp === 0; + case "!=": + return cmp !== 0; + case "<": + return cmp < 0; + case "<=": + return cmp <= 0; + case ">": + return cmp > 0; + case ">=": + return cmp >= 0; + default: + return false; + } +} + +function normalizeSemver(version) { + const stripped = version.trim().replace(/^[vV]/, ""); + + let suffixStart = stripped.length; + for (const separator of ["-", "+"]) { + const index = stripped.indexOf(separator); + if (index !== -1 && index < suffixStart) { + suffixStart = index; + } + } + + const core = stripped.slice(0, suffixStart); + const suffix = stripped.slice(suffixStart); + + const parts = core.split("."); + while (parts.length < SEMVER_PARTS) { + parts.push("0"); + } + return parts.join(".") + suffix; +} + +function convertRfc3339ToUnixSeconds(value) { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toUpperCase(); + if (!RFC3339_PATTERN.test(normalized)) { + return null; + } + const ms = Date.parse(normalized); + if (Number.isNaN(ms)) { + return null; + } + return Math.floor(ms / 1000); +} + +function convertUnixMillisecondsToSeconds(value) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + // A value int64 cannot represent is not a real timestamp; treating one as a bound would let a + // nonsense target define a rollout window. + if (value >= MAX_EPOCH_MS || value <= -MAX_EPOCH_MS) { + return null; + } + return Math.trunc(value / 1000); +} + +module.exports = { + comparatorMatches, + semverCompare, + convertRfc3339ToUnixSeconds, + convertUnixMillisecondsToSeconds, + datetimeCompare, +}; diff --git a/packages/mixpanel/lib/flags/local_flags.js b/packages/mixpanel/lib/flags/local_flags.js index fc834ef..15bd81d 100644 --- a/packages/mixpanel/lib/flags/local_flags.js +++ b/packages/mixpanel/lib/flags/local_flags.js @@ -24,6 +24,7 @@ const { asFallback, } = require("./variant_source"); const { apply } = require("json-logic-js"); +require("./custom_operators"); class LocalFeatureFlagsProvider extends FeatureFlagsProvider { /** diff --git a/packages/mixpanel/package.json b/packages/mixpanel/package.json index 39b7d60..46057c4 100644 --- a/packages/mixpanel/package.json +++ b/packages/mixpanel/package.json @@ -35,6 +35,7 @@ "build": "tsc --build" }, "dependencies": { + "compare-versions": "6.1.1", "https-proxy-agent": "7.0.6", "json-logic-js": "2.0.5" }, diff --git a/packages/mixpanel/test/flags/custom_operators.js b/packages/mixpanel/test/flags/custom_operators.js new file mode 100644 index 0000000..ac4607a --- /dev/null +++ b/packages/mixpanel/test/flags/custom_operators.js @@ -0,0 +1,274 @@ +const { apply } = require("json-logic-js"); +// Requiring the module registers semver_compare and datetime_compare on the shared +// json-logic-js instance used by apply(). +require("../../lib/flags/custom_operators"); + +const varNode = (key) => ({ var: key }); + +const semverRule = (key, sym, target) => ({ + semver_compare: [varNode(key), sym, target], +}); + +const datetimeRule = (key, sym, target) => ({ + datetime_compare: [varNode(key), sym, target], +}); + +const customBetween = (op, key, lo, hi) => ({ + and: [ + { [op]: [varNode(key), ">=", lo] }, + { [op]: [varNode(key), "<=", hi] }, + ], +}); + +const datetimeBetween = (key, lo, hi) => ({ + and: [ + { datetime_compare: [varNode(key), ">=", lo] }, + { datetime_compare: [varNode(key), "<=", hi] }, + ], +}); + +// Epoch-millisecond constants (UTC instants) used as datetime targets, matching the UI format. +const JUL16_MS = 1_784_160_000_000; // 2026-07-16T00:00:00Z +const JAN1_MS = 1_767_225_600_000; // 2026-01-01T00:00:00Z +const DEC31_MS = 1_798_675_200_000; // 2026-12-31T00:00:00Z +const JUL16_END_MS = 1_784_246_399_999; // 2026-07-16T23:59:59.999Z +const LEAP_DAY_MS = 1_709_164_800_000; // 2024-02-29T00:00:00Z +const JUL16_INDIA_MS = 1_784_140_200_000; // 2026-07-16T00:00:00+05:30 +const JUL16_PACIFIC_MS = 1_784_188_800_000; // 2026-07-16T00:00:00-08:00 + +describe("semver_compare operator", () => { + it.each([ + ["is, equal", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3" }, true], + ["is, not equal", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.4" }, false], + ["is not", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.4" }, true], + ["less than, patch", semverRule("app_version", "<", "1.2.3"), { app_version: "1.2.2" }, true], + ["less than, false", semverRule("app_version", "<", "1.2.3"), { app_version: "1.2.3" }, false], + ["less or equal, boundary", semverRule("app_version", "<=", "1.2.3"), { app_version: "1.2.3" }, true], + ["greater than, minor", semverRule("app_version", ">", "1.2.3"), { app_version: "1.3.0" }, true], + ["greater or equal, boundary", semverRule("app_version", ">=", "1.2.3"), { app_version: "1.2.3" }, true], + ["double-digit ordering (not lexical)", semverRule("app_version", ">", "1.9.0"), { app_version: "1.10.0" }, true], + ["prerelease precedes release", semverRule("app_version", "<", "1.0.0"), { app_version: "1.0.0-alpha" }, true], + ["lenient v-prefix", semverRule("app_version", "=", "1.2.3"), { app_version: "v1.2.3" }, true], + ["lenient uppercase V-prefix", semverRule("app_version", "=", "1.2.3"), { app_version: "V1.2.3" }, true], + ["v-prefix keeps prerelease", semverRule("app_version", "<", "1.0.0"), { app_version: "v1.0.0-alpha" }, true], + ["v-prefix, not equal", semverRule("app_version", "!=", "1.2.3"), { app_version: "v1.2.4" }, true], + ["v-prefix, at or below", semverRule("app_version", "<=", "1.2.3"), { app_version: "v1.2.3" }, true], + ["v-prefix, greater", semverRule("app_version", ">", "1.2.3"), { app_version: "v1.2.4" }, true], + ["v-prefix, at or above", semverRule("app_version", ">=", "1.2.3"), { app_version: "v1.2.3" }, true], + ["lenient minor-only target", semverRule("app_version", "=", "1.2"), { app_version: "1.2.0" }, true], + // Every symbol is asserted in both directions. + ["is not, equal", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3" }, false], + ["less or equal, above", semverRule("app_version", "<=", "1.2.3"), { app_version: "1.2.4" }, false], + ["greater than, below", semverRule("app_version", ">", "1.2.3"), { app_version: "1.2.2" }, false], + ["greater or equal, below", semverRule("app_version", ">=", "1.2.3"), { app_version: "1.2.2" }, false], + // Prerelease precedence, SemVer 2.0.0 section 11. + ["prerelease alpha before beta", semverRule("app_version", "<", "1.0.0-beta"), { app_version: "1.0.0-alpha" }, true], + ["prerelease beta before rc1", semverRule("app_version", "<", "1.0.0-rc1"), { app_version: "1.0.0-beta" }, true], + ["prerelease rc1 before rc2", semverRule("app_version", "<", "1.0.0-rc2"), { app_version: "1.0.0-rc1" }, true], + ["more prerelease fields wins", semverRule("app_version", "<", "1.0.0-alpha.1"), { app_version: "1.0.0-alpha" }, true], + ["numeric identifier below alphanumeric", semverRule("app_version", "<", "1.0.0-alpha.beta"), { app_version: "1.0.0-alpha.1" }, true], + ["fewer fields below alphanumeric", semverRule("app_version", "<", "1.0.0-alpha.beta"), { app_version: "1.0.0-alpha" }, true], + ["numeric identifiers compare numerically", semverRule("app_version", "<", "1.0.0-beta.11"), { app_version: "1.0.0-beta.2" }, true], + ["dotted identifier ordering, letters", semverRule("app_version", "<", "1.0.0-b.1"), { app_version: "1.0.0-a.1" }, true], + ["dotted identifier ordering, digits", semverRule("app_version", "<", "1.0.0-a.2"), { app_version: "1.0.0-a.1" }, true], + ["identical prereleases are equal", semverRule("app_version", "=", "1.0.0-rc1"), { app_version: "1.0.0-rc1" }, true], + ["rc1 outranks dotted rc.1", semverRule("app_version", ">", "1.0.0-rc.1"), { app_version: "1.0.0-rc1" }, true], + ["core version dominates prerelease", semverRule("app_version", ">", "1.9.9"), { app_version: "2.0.0-alpha" }, true], + // A release outranks its own prerelease, asserted from both sides and under every symbol. + ["release outranks its prerelease", semverRule("app_version", ">", "1.0.0-alpha"), { app_version: "1.0.0" }, true], + ["release at or above its prerelease", semverRule("app_version", ">=", "1.0.0-rc1"), { app_version: "1.0.0" }, true], + ["release differs from its prerelease", semverRule("app_version", "!=", "1.0.0-alpha"), { app_version: "1.0.0" }, true], + ["prerelease differs from its release", semverRule("app_version", "!=", "1.0.0"), { app_version: "1.0.0-alpha" }, true], + ["prerelease at or below its release", semverRule("app_version", "<=", "1.0.0"), { app_version: "1.0.0-alpha" }, true], + ["prerelease of a higher core still wins", semverRule("app_version", ">", "0.9.9"), { app_version: "1.0.0-alpha" }, true], + ["prerelease below the next patch", semverRule("app_version", "<", "1.0.1"), { app_version: "1.0.0-rc1" }, true], + // Prerelease identifier comparison, SemVer 2.0.0 section 11.4. + ["numeric identifiers are not compared lexically", semverRule("app_version", "<", "1.0.0-10"), { app_version: "1.0.0-2" }, true], + ["numeric identifier ranks below alphanumeric", semverRule("app_version", "<", "1.0.0-alpha"), { app_version: "1.0.0-1" }, true], + ["hyphen inside an identifier sorts by ascii", semverRule("app_version", "<", "1.0.0-alpha-1"), { app_version: "1.0.0-alpha" }, true], + ["beta ranks below rc", semverRule("app_version", "<", "1.0.0-rc.1"), { app_version: "1.0.0-beta.11" }, true], + ["last prerelease ranks below the release", semverRule("app_version", "<", "1.0.0"), { app_version: "1.0.0-rc.1" }, true], + // Build metadata carries no precedence. + ["build metadata ignored", semverRule("app_version", "=", "1.0.0+build2"), { app_version: "1.0.0+build1" }, true], + ["build metadata ignored with prerelease", semverRule("app_version", "=", "1.0.0-alpha"), { app_version: "1.0.0-alpha+build" }, true], + ["build metadata with hyphen ignored", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3+build.1-2" }, true], + // Ignored means equal, so every symbol has to agree with that. + ["build metadata leaves versions equal", semverRule("app_version", "!=", "1.0.0+build2"), { app_version: "1.0.0+build1" }, false], + ["build metadata is not less", semverRule("app_version", "<", "1.0.0+build2"), { app_version: "1.0.0+build1" }, false], + ["build metadata is not greater", semverRule("app_version", ">", "1.0.0+build2"), { app_version: "1.0.0+build1" }, false], + ["build metadata at or below", semverRule("app_version", "<=", "1.0.0+build2"), { app_version: "1.0.0+build1" }, true], + ["build metadata at or above", semverRule("app_version", ">=", "1.0.0+build2"), { app_version: "1.0.0+build1" }, true], + ["build metadata does not block ordering", semverRule("app_version", "<", "1.0.1+build1"), { app_version: "1.0.0+build9" }, true], + ["build metadata does not block reverse ordering", semverRule("app_version", ">", "1.0.0+build9"), { app_version: "1.0.1+build1" }, true], + // Partial versions keep their prerelease once zero-padded. + ["partial version with prerelease", semverRule("app_version", "=", "1.2.0-alpha"), { app_version: "1.2-alpha" }, true], + ["partial prerelease below later minor", semverRule("app_version", "<", "1.3.1"), { app_version: "1.2-alpha" }, true], + ["partial prerelease below its release", semverRule("app_version", "<", "1.2.0"), { app_version: "1.2-alpha" }, true], + ["major-only with prerelease", semverRule("app_version", "<", "1.0.0"), { app_version: "1-rc1" }, true], + // An empty prerelease is invalid, so it is rejected rather than treated as the bare release. + ["empty prerelease, no match", semverRule("app_version", "=", "1.0.0"), { app_version: "1.0.0-" }, false], + ["empty prerelease, not-equal also false", semverRule("app_version", "!=", "1.0.0"), { app_version: "1.0.0-" }, false], + ["empty prerelease on partial version, no match", semverRule("app_version", "=", "1.2.0"), { app_version: "1.2-" }, false], + ["empty prerelease on partial version, not-equal also false", semverRule("app_version", "!=", "1.2.0"), { app_version: "1.2-" }, false], + // Hyphens are legal inside a prerelease identifier, so these are NOT empty prereleases. + ["trailing hyphen inside identifier", semverRule("app_version", "<", "1.0.0"), { app_version: "1.0.0-alpha-" }, true], + // SemVer 2.0.0 forbids leading zeros in the core, so these are rejected rather than normalized. + ["leading zero in major, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "01.2.3" }, false], + ["leading zero in major, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "01.2.3" }, false], + ["leading zero in minor, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.02.3" }, false], + ["leading zero in minor, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.02.3" }, false], + ["leading zero in patch, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.03" }, false], + ["leading zero in patch, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.03" }, false], + ["leading zeros throughout, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "01.02.03" }, false], + ["leading zeros throughout, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "01.02.03" }, false], + // A numeric prerelease identifier may not carry a leading zero either (section 9). + ["numeric prerelease with leading zero, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3-01" }, false], + ["numeric prerelease with leading zero, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3-01" }, false], + ["dotted numeric prerelease with leading zero, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3-rc.01" }, false], + ["dotted numeric prerelease with leading zero, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3-rc.01" }, false], + // An alphanumeric identifier may contain digits, so this one stays valid. + ["alphanumeric prerelease with digits", semverRule("app_version", "<", "1.2.3"), { app_version: "1.2.3-rc01" }, true], + ["between, inside", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "1.5.0" }, true], + ["between, low boundary inclusive", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "1.2.3" }, true], + ["between, high boundary inclusive", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "2.0.0" }, true], + ["between, below", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "1.0.0" }, false], + ["between, above", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "2.0.1" }, false], + // A prerelease sits below its own release, which decides both boundary cases. + ["between, prerelease inside", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "1.5.0-rc1" }, true], + ["between, prerelease below the high bound", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "2.0.0-rc1" }, true], + ["between, prerelease of the low bound falls out", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "1.2.3-rc1" }, false], + ["between, invalid version", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "not-a-version" }, false], + ["between, single-point range", customBetween("semver_compare", "app_version", "1.2.3", "1.2.3"), { app_version: "1.2.3" }, true], + // Fail-closed: unparseable or missing values never match. + ["invalid actual, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "not-a-version" }, false], + ["non-string actual, no match", semverRule("app_version", "=", "1.2.3"), { app_version: 123 }, false], + ["missing property, no match", semverRule("app_version", "=", "1.2.3"), {}, false], + // A malformed version must never be padded or coerced into a real one. Both symbols are + // asserted so that "accepted at all" is observable rather than masked by a single false. + ["empty version, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "" }, false], + ["empty version, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "" }, false], + ["bare v, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "v" }, false], + ["bare v, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "v" }, false], + ["leading separator, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "-1.2.3" }, false], + ["leading separator, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "-1.2.3" }, false], + ["trailing dot, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1." }, false], + ["trailing dot, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1." }, false], + ["trailing dot after patch, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3." }, false], + ["trailing dot after patch, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3." }, false], + ["empty middle segment, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1..2" }, false], + ["empty middle segment, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1..2" }, false], + ["four components, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3.4" }, false], + ["four components, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3.4" }, false], + ["range prefix, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "^1.2.3" }, false], + ["range prefix, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "^1.2.3" }, false], + ["version inside text, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "abc1.2.3" }, false], + ["version inside text, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "abc1.2.3" }, false], + ["empty build metadata, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3+" }, false], + ["empty build metadata, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3+" }, false], + ["empty prerelease identifier, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3-alpha..1" }, false], + ["empty prerelease identifier, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3-alpha..1" }, false], + ["lone dot prerelease, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3-." }, false], + ["lone dot prerelease, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3-." }, false], + ["underscore in prerelease, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3-ALPHA_BETA" }, false], + ["underscore in prerelease, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3-ALPHA_BETA" }, false], + ["doubled v-prefix, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "vv1.2.3" }, false], + ["doubled v-prefix, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "vv1.2.3" }, false], + ["missing operand, no match", { semver_compare: [varNode("app_version"), "="] }, { app_version: "1.2.3" }, false], + ])("%s", (_name, rule, data, want) => { + expect(apply(rule, data)).toBe(want); + }); +}); + +describe("datetime_compare operator", () => { + it.each([ + // Asymmetric contract: subject (runtime var) is a strict RFC3339 string, target is epoch ms. + ["before, true", datetimeRule("signup", "<", JUL16_MS), { signup: "2026-07-15T00:00:00Z" }, true], + ["before, false", datetimeRule("signup", "<", JUL16_MS), { signup: "2026-07-16T00:00:00Z" }, false], + ["on (equal), true", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00Z" }, true], + ["not on, true", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-17T00:00:00Z" }, true], + ["since (>=), boundary", datetimeRule("signup", ">=", JUL16_MS), { signup: "2026-07-16T00:00:00Z" }, true], + ["after (>), true", datetimeRule("signup", ">", JUL16_MS), { signup: "2026-07-17T00:00:00Z" }, true], + ["after (>), false", datetimeRule("signup", ">", JUL16_MS), { signup: "2026-07-15T00:00:00Z" }, false], + // Every symbol is asserted in both directions. + ["at or before, boundary", datetimeRule("signup", "<=", JUL16_MS), { signup: "2026-07-16T00:00:00Z" }, true], + ["at or before, after", datetimeRule("signup", "<=", JUL16_MS), { signup: "2026-07-17T00:00:00Z" }, false], + ["on (equal), false", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-17T00:00:00Z" }, false], + ["not on, equal", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00Z" }, false], + ["since (>=), before", datetimeRule("signup", ">=", JUL16_MS), { signup: "2026-07-15T00:00:00Z" }, false], + ["between, inside", datetimeBetween("signup", JAN1_MS, DEC31_MS), { signup: "2026-06-15T00:00:00Z" }, true], + ["between, low boundary inclusive", datetimeBetween("signup", JAN1_MS, DEC31_MS), { signup: "2026-01-01T00:00:00Z" }, true], + ["between, high boundary inclusive", datetimeBetween("signup", JAN1_MS, DEC31_MS), { signup: "2026-12-31T00:00:00Z" }, true], + ["between, before range", datetimeBetween("signup", JAN1_MS, DEC31_MS), { signup: "2025-12-31T00:00:00Z" }, false], + ["between, after range", datetimeBetween("signup", JAN1_MS, DEC31_MS), { signup: "2027-01-01T00:00:00Z" }, false], + // A leap day is a real date. + ["leap day", datetimeRule("signup", "=", LEAP_DAY_MS), { signup: "2024-02-29T00:00:00Z" }, true], + // Time-zone offsets change the instant. + ["offset with half-hour minutes", datetimeRule("signup", "=", JUL16_INDIA_MS), { signup: "2026-07-16T00:00:00+05:30" }, true], + ["rfc3339 subject with offset", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T02:00:00+02:00" }, true], + ["positive offset precedes utc midnight", datetimeRule("signup", "<", JUL16_MS), { signup: "2026-07-16T00:00:00+05:30" }, true], + ["negative offset", datetimeRule("signup", "=", JUL16_PACIFIC_MS), { signup: "2026-07-16T00:00:00-08:00" }, true], + ["negative offset follows utc midnight", datetimeRule("signup", ">", JUL16_MS), { signup: "2026-07-16T00:00:00-08:00" }, true], + ["zero offset equals Z", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00+00:00" }, true], + // Sub-second precision is dropped, on both sides. The end-of-day rows are the window the UI + // emits for a single date, whose upper bound carries .999. + ["one-digit fraction", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00.5Z" }, true], + ["three-digit fraction", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00.500Z" }, true], + ["six-digit fraction", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00.123456Z" }, true], + ["nine-digit fraction", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00.999999999Z" }, true], + ["zero fraction", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00.0Z" }, true], + ["fractional seconds truncated", datetimeRule("signup", ">=", JUL16_MS), { signup: "2026-07-16T00:00:00.500Z" }, true], + ["end-of-day target drops its .999", datetimeRule("signup", "=", JUL16_END_MS), { signup: "2026-07-16T23:59:59Z" }, true], + ["end-of-day target is an inclusive bound", datetimeRule("signup", "<=", JUL16_END_MS), { signup: "2026-07-16T23:59:59Z" }, true], + ["end-of-day, fractional subject too", datetimeRule("signup", "=", JUL16_END_MS), { signup: "2026-07-16T23:59:59.999Z" }, true], + ["end-of-day inclusive, fractional subject", datetimeRule("signup", "<=", JUL16_END_MS), { signup: "2026-07-16T23:59:59.999Z" }, true], + // Fractional on both sides: the shape the UI actually round-trips. + // Trimming and lowercasing. + ["lowercased subject with fraction", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16t00:00:00.500z" }, true], + ["lowercased subject with offset", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16t02:00:00+02:00" }, true], + ["whitespace-padded subject", datetimeRule("signup", "=", JUL16_MS), { signup: " 2026-07-16T00:00:00Z " }, true], + ["lowercased rfc3339 subject", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16t00:00:00z" }, true], + // Shape violations, asserted under both = and != so that "accepted at all" is observable. + // RFC 3339 also permits 24:00:00 as end-of-day. Platforms disagree on it, so no vector + // asserts it either way. + ["one-digit month, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-7-16T00:00:00Z" }, false], + ["one-digit month, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-7-16T00:00:00Z" }, false], + ["space separator, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16 00:00:00Z" }, false], + ["space separator, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16 00:00:00Z" }, false], + ["missing zone, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00" }, false], + ["missing zone, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00" }, false], + ["empty fraction, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00.Z" }, false], + ["empty fraction, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00.Z" }, false], + ["offset without colon, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00+0200" }, false], + ["offset without colon, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00+0200" }, false], + ["short offset, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00+02" }, false], + ["short offset, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00+02" }, false], + ["trailing junk, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00Zextra" }, false], + ["trailing junk, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00Zextra" }, false], + ["basic format, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "20260716T000000Z" }, false], + ["basic format, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "20260716T000000Z" }, false], + ["zone after lowercase z, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00z00:00" }, false], + ["zone after lowercase z, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00z00:00" }, false], + ["comma fractional separator, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00,5Z" }, false], + ["comma fractional separator, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00,5Z" }, false], + // Fail-closed: subject must be an RFC3339 string, target must be an epoch-ms number. + ["numeric subject, no match", datetimeRule("signup", "=", JUL16_MS), { signup: JUL16_MS }, false], + ["negative epoch-ms target resolves to -1s", datetimeRule("signup", "=", -1500), { signup: "1969-12-31T23:59:59Z" }, true], + ["negative epoch-ms target, not equal", datetimeRule("signup", "!=", -1500), { signup: "1969-12-31T23:59:59Z" }, false], + ["negative epoch-ms target, at or after", datetimeRule("signup", ">=", -1500), { signup: "1969-12-31T23:59:59Z" }, true], + ["negative epoch-ms target, before", datetimeRule("signup", "<", -1500), { signup: "1969-12-31T23:59:58Z" }, true], + ["negative epoch-ms target, after", datetimeRule("signup", ">", -2500), { signup: "1969-12-31T23:59:59Z" }, true], + ["subject floors, it does not truncate", datetimeRule("signup", "=", -2000), { signup: "1969-12-31T23:59:58.500Z" }, true], + ["subject floors, not to -1s", datetimeRule("signup", "!=", -1000), { signup: "1969-12-31T23:59:58.500Z" }, true], + ["target beyond representable range, no match", { datetime_compare: [varNode("signup"), "=", 1e308] }, { signup: "2026-07-16T00:00:00Z" }, false], + ["target beyond representable range, greater-than also false", { datetime_compare: [varNode("signup"), ">", 1e308] }, { signup: "2026-07-16T00:00:00Z" }, false], + ["target beyond representable range, less-than also false", { datetime_compare: [varNode("signup"), "<", 1e308] }, { signup: "2026-07-16T00:00:00Z" }, false], + ["bare date subject, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16" }, false], + ["bare date subject, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16" }, false], + ["zoneless datetime subject, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00" }, false], + ["non-datetime string, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "yesterday" }, false], + ["missing property, no match", datetimeRule("signup", "=", JUL16_MS), {}, false], + ["missing operand, no match", { datetime_compare: [varNode("signup"), "="] }, { signup: "2026-07-16T00:00:00Z" }, false], + ])("%s", (_name, rule, data, want) => { + expect(apply(rule, data)).toBe(want); + }); +}); diff --git a/packages/mixpanel/test/flags/local_flags.js b/packages/mixpanel/test/flags/local_flags.js index 312f6fe..2d122af 100644 --- a/packages/mixpanel/test/flags/local_flags.js +++ b/packages/mixpanel/test/flags/local_flags.js @@ -517,6 +517,62 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).toBe(FALLBACK_NAME); }); + it("should return variant when semver_compare runtime rule satisfied", async () => { + const runtimeEvaluationRule = { + semver_compare: [{ var: "app_version" }, ">=", "1.2.3"], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + app_version: "1.5.0", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); + }); + + it("should return fallback when semver_compare runtime rule not satisfied", async () => { + const runtimeEvaluationRule = { + semver_compare: [{ var: "app_version" }, ">=", "1.2.3"], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + app_version: "1.0.0", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return variant when datetime_compare runtime rule satisfied", async () => { + const runtimeEvaluationRule = { + datetime_compare: [{ var: "signup" }, "<", 1_784_160_000_000], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + signup: "2026-07-15T00:00:00Z", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); + }); + + it("should return fallback when datetime_compare runtime rule not satisfied", async () => { + const runtimeEvaluationRule = { + datetime_compare: [{ var: "signup" }, "<", 1_784_160_000_000], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + signup: "2026-07-17T00:00:00Z", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + it("should respect legacy runtime evaluation when satisfied", async () => { const legacyRuntimeRule = { plan: "premium", region: "US" }; await createFlagAndLoadItIntoSDK( From 3b6e3ff5f40a2288ec9bdf8dab9923038047ae0b Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:39:32 +0000 Subject: [PATCH 2/7] linting --- .../mixpanel/lib/flags/custom_operators.js | 5 +- .../mixpanel/test/flags/custom_operators.js | 1370 ++++++++++++++--- 2 files changed, 1175 insertions(+), 200 deletions(-) diff --git a/packages/mixpanel/lib/flags/custom_operators.js b/packages/mixpanel/lib/flags/custom_operators.js index 023bfc3..ee79042 100644 --- a/packages/mixpanel/lib/flags/custom_operators.js +++ b/packages/mixpanel/lib/flags/custom_operators.js @@ -31,7 +31,10 @@ function semverCompare(actual, symbol, target) { } const actualVersion = normalizeSemver(actual); const targetVersion = normalizeSemver(target); - if (!SEMVER_PATTERN.test(actualVersion) || !SEMVER_PATTERN.test(targetVersion)) { + if ( + !SEMVER_PATTERN.test(actualVersion) || + !SEMVER_PATTERN.test(targetVersion) + ) { return false; } const cmp = compareVersions(actualVersion, targetVersion); diff --git a/packages/mixpanel/test/flags/custom_operators.js b/packages/mixpanel/test/flags/custom_operators.js index ac4607a..c0ebbff 100644 --- a/packages/mixpanel/test/flags/custom_operators.js +++ b/packages/mixpanel/test/flags/custom_operators.js @@ -14,10 +14,7 @@ const datetimeRule = (key, sym, target) => ({ }); const customBetween = (op, key, lo, hi) => ({ - and: [ - { [op]: [varNode(key), ">=", lo] }, - { [op]: [varNode(key), "<=", hi] }, - ], + and: [{ [op]: [varNode(key), ">=", lo] }, { [op]: [varNode(key), "<=", hi] }], }); const datetimeBetween = (key, lo, hi) => ({ @@ -38,142 +35,742 @@ const JUL16_PACIFIC_MS = 1_784_188_800_000; // 2026-07-16T00:00:00-08:00 describe("semver_compare operator", () => { it.each([ - ["is, equal", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3" }, true], - ["is, not equal", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.4" }, false], - ["is not", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.4" }, true], - ["less than, patch", semverRule("app_version", "<", "1.2.3"), { app_version: "1.2.2" }, true], - ["less than, false", semverRule("app_version", "<", "1.2.3"), { app_version: "1.2.3" }, false], - ["less or equal, boundary", semverRule("app_version", "<=", "1.2.3"), { app_version: "1.2.3" }, true], - ["greater than, minor", semverRule("app_version", ">", "1.2.3"), { app_version: "1.3.0" }, true], - ["greater or equal, boundary", semverRule("app_version", ">=", "1.2.3"), { app_version: "1.2.3" }, true], - ["double-digit ordering (not lexical)", semverRule("app_version", ">", "1.9.0"), { app_version: "1.10.0" }, true], - ["prerelease precedes release", semverRule("app_version", "<", "1.0.0"), { app_version: "1.0.0-alpha" }, true], - ["lenient v-prefix", semverRule("app_version", "=", "1.2.3"), { app_version: "v1.2.3" }, true], - ["lenient uppercase V-prefix", semverRule("app_version", "=", "1.2.3"), { app_version: "V1.2.3" }, true], - ["v-prefix keeps prerelease", semverRule("app_version", "<", "1.0.0"), { app_version: "v1.0.0-alpha" }, true], - ["v-prefix, not equal", semverRule("app_version", "!=", "1.2.3"), { app_version: "v1.2.4" }, true], - ["v-prefix, at or below", semverRule("app_version", "<=", "1.2.3"), { app_version: "v1.2.3" }, true], - ["v-prefix, greater", semverRule("app_version", ">", "1.2.3"), { app_version: "v1.2.4" }, true], - ["v-prefix, at or above", semverRule("app_version", ">=", "1.2.3"), { app_version: "v1.2.3" }, true], - ["lenient minor-only target", semverRule("app_version", "=", "1.2"), { app_version: "1.2.0" }, true], + [ + "is, equal", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1.2.3" }, + true, + ], + [ + "is, not equal", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1.2.4" }, + false, + ], + [ + "is not", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1.2.4" }, + true, + ], + [ + "less than, patch", + semverRule("app_version", "<", "1.2.3"), + { app_version: "1.2.2" }, + true, + ], + [ + "less than, false", + semverRule("app_version", "<", "1.2.3"), + { app_version: "1.2.3" }, + false, + ], + [ + "less or equal, boundary", + semverRule("app_version", "<=", "1.2.3"), + { app_version: "1.2.3" }, + true, + ], + [ + "greater than, minor", + semverRule("app_version", ">", "1.2.3"), + { app_version: "1.3.0" }, + true, + ], + [ + "greater or equal, boundary", + semverRule("app_version", ">=", "1.2.3"), + { app_version: "1.2.3" }, + true, + ], + [ + "double-digit ordering (not lexical)", + semverRule("app_version", ">", "1.9.0"), + { app_version: "1.10.0" }, + true, + ], + [ + "prerelease precedes release", + semverRule("app_version", "<", "1.0.0"), + { app_version: "1.0.0-alpha" }, + true, + ], + [ + "lenient v-prefix", + semverRule("app_version", "=", "1.2.3"), + { app_version: "v1.2.3" }, + true, + ], + [ + "lenient uppercase V-prefix", + semverRule("app_version", "=", "1.2.3"), + { app_version: "V1.2.3" }, + true, + ], + [ + "v-prefix keeps prerelease", + semverRule("app_version", "<", "1.0.0"), + { app_version: "v1.0.0-alpha" }, + true, + ], + [ + "v-prefix, not equal", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "v1.2.4" }, + true, + ], + [ + "v-prefix, at or below", + semverRule("app_version", "<=", "1.2.3"), + { app_version: "v1.2.3" }, + true, + ], + [ + "v-prefix, greater", + semverRule("app_version", ">", "1.2.3"), + { app_version: "v1.2.4" }, + true, + ], + [ + "v-prefix, at or above", + semverRule("app_version", ">=", "1.2.3"), + { app_version: "v1.2.3" }, + true, + ], + [ + "lenient minor-only target", + semverRule("app_version", "=", "1.2"), + { app_version: "1.2.0" }, + true, + ], // Every symbol is asserted in both directions. - ["is not, equal", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3" }, false], - ["less or equal, above", semverRule("app_version", "<=", "1.2.3"), { app_version: "1.2.4" }, false], - ["greater than, below", semverRule("app_version", ">", "1.2.3"), { app_version: "1.2.2" }, false], - ["greater or equal, below", semverRule("app_version", ">=", "1.2.3"), { app_version: "1.2.2" }, false], + [ + "is not, equal", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1.2.3" }, + false, + ], + [ + "less or equal, above", + semverRule("app_version", "<=", "1.2.3"), + { app_version: "1.2.4" }, + false, + ], + [ + "greater than, below", + semverRule("app_version", ">", "1.2.3"), + { app_version: "1.2.2" }, + false, + ], + [ + "greater or equal, below", + semverRule("app_version", ">=", "1.2.3"), + { app_version: "1.2.2" }, + false, + ], // Prerelease precedence, SemVer 2.0.0 section 11. - ["prerelease alpha before beta", semverRule("app_version", "<", "1.0.0-beta"), { app_version: "1.0.0-alpha" }, true], - ["prerelease beta before rc1", semverRule("app_version", "<", "1.0.0-rc1"), { app_version: "1.0.0-beta" }, true], - ["prerelease rc1 before rc2", semverRule("app_version", "<", "1.0.0-rc2"), { app_version: "1.0.0-rc1" }, true], - ["more prerelease fields wins", semverRule("app_version", "<", "1.0.0-alpha.1"), { app_version: "1.0.0-alpha" }, true], - ["numeric identifier below alphanumeric", semverRule("app_version", "<", "1.0.0-alpha.beta"), { app_version: "1.0.0-alpha.1" }, true], - ["fewer fields below alphanumeric", semverRule("app_version", "<", "1.0.0-alpha.beta"), { app_version: "1.0.0-alpha" }, true], - ["numeric identifiers compare numerically", semverRule("app_version", "<", "1.0.0-beta.11"), { app_version: "1.0.0-beta.2" }, true], - ["dotted identifier ordering, letters", semverRule("app_version", "<", "1.0.0-b.1"), { app_version: "1.0.0-a.1" }, true], - ["dotted identifier ordering, digits", semverRule("app_version", "<", "1.0.0-a.2"), { app_version: "1.0.0-a.1" }, true], - ["identical prereleases are equal", semverRule("app_version", "=", "1.0.0-rc1"), { app_version: "1.0.0-rc1" }, true], - ["rc1 outranks dotted rc.1", semverRule("app_version", ">", "1.0.0-rc.1"), { app_version: "1.0.0-rc1" }, true], - ["core version dominates prerelease", semverRule("app_version", ">", "1.9.9"), { app_version: "2.0.0-alpha" }, true], + [ + "prerelease alpha before beta", + semverRule("app_version", "<", "1.0.0-beta"), + { app_version: "1.0.0-alpha" }, + true, + ], + [ + "prerelease beta before rc1", + semverRule("app_version", "<", "1.0.0-rc1"), + { app_version: "1.0.0-beta" }, + true, + ], + [ + "prerelease rc1 before rc2", + semverRule("app_version", "<", "1.0.0-rc2"), + { app_version: "1.0.0-rc1" }, + true, + ], + [ + "more prerelease fields wins", + semverRule("app_version", "<", "1.0.0-alpha.1"), + { app_version: "1.0.0-alpha" }, + true, + ], + [ + "numeric identifier below alphanumeric", + semverRule("app_version", "<", "1.0.0-alpha.beta"), + { app_version: "1.0.0-alpha.1" }, + true, + ], + [ + "fewer fields below alphanumeric", + semverRule("app_version", "<", "1.0.0-alpha.beta"), + { app_version: "1.0.0-alpha" }, + true, + ], + [ + "numeric identifiers compare numerically", + semverRule("app_version", "<", "1.0.0-beta.11"), + { app_version: "1.0.0-beta.2" }, + true, + ], + [ + "dotted identifier ordering, letters", + semverRule("app_version", "<", "1.0.0-b.1"), + { app_version: "1.0.0-a.1" }, + true, + ], + [ + "dotted identifier ordering, digits", + semverRule("app_version", "<", "1.0.0-a.2"), + { app_version: "1.0.0-a.1" }, + true, + ], + [ + "identical prereleases are equal", + semverRule("app_version", "=", "1.0.0-rc1"), + { app_version: "1.0.0-rc1" }, + true, + ], + [ + "rc1 outranks dotted rc.1", + semverRule("app_version", ">", "1.0.0-rc.1"), + { app_version: "1.0.0-rc1" }, + true, + ], + [ + "core version dominates prerelease", + semverRule("app_version", ">", "1.9.9"), + { app_version: "2.0.0-alpha" }, + true, + ], // A release outranks its own prerelease, asserted from both sides and under every symbol. - ["release outranks its prerelease", semverRule("app_version", ">", "1.0.0-alpha"), { app_version: "1.0.0" }, true], - ["release at or above its prerelease", semverRule("app_version", ">=", "1.0.0-rc1"), { app_version: "1.0.0" }, true], - ["release differs from its prerelease", semverRule("app_version", "!=", "1.0.0-alpha"), { app_version: "1.0.0" }, true], - ["prerelease differs from its release", semverRule("app_version", "!=", "1.0.0"), { app_version: "1.0.0-alpha" }, true], - ["prerelease at or below its release", semverRule("app_version", "<=", "1.0.0"), { app_version: "1.0.0-alpha" }, true], - ["prerelease of a higher core still wins", semverRule("app_version", ">", "0.9.9"), { app_version: "1.0.0-alpha" }, true], - ["prerelease below the next patch", semverRule("app_version", "<", "1.0.1"), { app_version: "1.0.0-rc1" }, true], + [ + "release outranks its prerelease", + semverRule("app_version", ">", "1.0.0-alpha"), + { app_version: "1.0.0" }, + true, + ], + [ + "release at or above its prerelease", + semverRule("app_version", ">=", "1.0.0-rc1"), + { app_version: "1.0.0" }, + true, + ], + [ + "release differs from its prerelease", + semverRule("app_version", "!=", "1.0.0-alpha"), + { app_version: "1.0.0" }, + true, + ], + [ + "prerelease differs from its release", + semverRule("app_version", "!=", "1.0.0"), + { app_version: "1.0.0-alpha" }, + true, + ], + [ + "prerelease at or below its release", + semverRule("app_version", "<=", "1.0.0"), + { app_version: "1.0.0-alpha" }, + true, + ], + [ + "prerelease of a higher core still wins", + semverRule("app_version", ">", "0.9.9"), + { app_version: "1.0.0-alpha" }, + true, + ], + [ + "prerelease below the next patch", + semverRule("app_version", "<", "1.0.1"), + { app_version: "1.0.0-rc1" }, + true, + ], // Prerelease identifier comparison, SemVer 2.0.0 section 11.4. - ["numeric identifiers are not compared lexically", semverRule("app_version", "<", "1.0.0-10"), { app_version: "1.0.0-2" }, true], - ["numeric identifier ranks below alphanumeric", semverRule("app_version", "<", "1.0.0-alpha"), { app_version: "1.0.0-1" }, true], - ["hyphen inside an identifier sorts by ascii", semverRule("app_version", "<", "1.0.0-alpha-1"), { app_version: "1.0.0-alpha" }, true], - ["beta ranks below rc", semverRule("app_version", "<", "1.0.0-rc.1"), { app_version: "1.0.0-beta.11" }, true], - ["last prerelease ranks below the release", semverRule("app_version", "<", "1.0.0"), { app_version: "1.0.0-rc.1" }, true], + [ + "numeric identifiers are not compared lexically", + semverRule("app_version", "<", "1.0.0-10"), + { app_version: "1.0.0-2" }, + true, + ], + [ + "numeric identifier ranks below alphanumeric", + semverRule("app_version", "<", "1.0.0-alpha"), + { app_version: "1.0.0-1" }, + true, + ], + [ + "hyphen inside an identifier sorts by ascii", + semverRule("app_version", "<", "1.0.0-alpha-1"), + { app_version: "1.0.0-alpha" }, + true, + ], + [ + "beta ranks below rc", + semverRule("app_version", "<", "1.0.0-rc.1"), + { app_version: "1.0.0-beta.11" }, + true, + ], + [ + "last prerelease ranks below the release", + semverRule("app_version", "<", "1.0.0"), + { app_version: "1.0.0-rc.1" }, + true, + ], // Build metadata carries no precedence. - ["build metadata ignored", semverRule("app_version", "=", "1.0.0+build2"), { app_version: "1.0.0+build1" }, true], - ["build metadata ignored with prerelease", semverRule("app_version", "=", "1.0.0-alpha"), { app_version: "1.0.0-alpha+build" }, true], - ["build metadata with hyphen ignored", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3+build.1-2" }, true], + [ + "build metadata ignored", + semverRule("app_version", "=", "1.0.0+build2"), + { app_version: "1.0.0+build1" }, + true, + ], + [ + "build metadata ignored with prerelease", + semverRule("app_version", "=", "1.0.0-alpha"), + { app_version: "1.0.0-alpha+build" }, + true, + ], + [ + "build metadata with hyphen ignored", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1.2.3+build.1-2" }, + true, + ], // Ignored means equal, so every symbol has to agree with that. - ["build metadata leaves versions equal", semverRule("app_version", "!=", "1.0.0+build2"), { app_version: "1.0.0+build1" }, false], - ["build metadata is not less", semverRule("app_version", "<", "1.0.0+build2"), { app_version: "1.0.0+build1" }, false], - ["build metadata is not greater", semverRule("app_version", ">", "1.0.0+build2"), { app_version: "1.0.0+build1" }, false], - ["build metadata at or below", semverRule("app_version", "<=", "1.0.0+build2"), { app_version: "1.0.0+build1" }, true], - ["build metadata at or above", semverRule("app_version", ">=", "1.0.0+build2"), { app_version: "1.0.0+build1" }, true], - ["build metadata does not block ordering", semverRule("app_version", "<", "1.0.1+build1"), { app_version: "1.0.0+build9" }, true], - ["build metadata does not block reverse ordering", semverRule("app_version", ">", "1.0.0+build9"), { app_version: "1.0.1+build1" }, true], + [ + "build metadata leaves versions equal", + semverRule("app_version", "!=", "1.0.0+build2"), + { app_version: "1.0.0+build1" }, + false, + ], + [ + "build metadata is not less", + semverRule("app_version", "<", "1.0.0+build2"), + { app_version: "1.0.0+build1" }, + false, + ], + [ + "build metadata is not greater", + semverRule("app_version", ">", "1.0.0+build2"), + { app_version: "1.0.0+build1" }, + false, + ], + [ + "build metadata at or below", + semverRule("app_version", "<=", "1.0.0+build2"), + { app_version: "1.0.0+build1" }, + true, + ], + [ + "build metadata at or above", + semverRule("app_version", ">=", "1.0.0+build2"), + { app_version: "1.0.0+build1" }, + true, + ], + [ + "build metadata does not block ordering", + semverRule("app_version", "<", "1.0.1+build1"), + { app_version: "1.0.0+build9" }, + true, + ], + [ + "build metadata does not block reverse ordering", + semverRule("app_version", ">", "1.0.0+build9"), + { app_version: "1.0.1+build1" }, + true, + ], // Partial versions keep their prerelease once zero-padded. - ["partial version with prerelease", semverRule("app_version", "=", "1.2.0-alpha"), { app_version: "1.2-alpha" }, true], - ["partial prerelease below later minor", semverRule("app_version", "<", "1.3.1"), { app_version: "1.2-alpha" }, true], - ["partial prerelease below its release", semverRule("app_version", "<", "1.2.0"), { app_version: "1.2-alpha" }, true], - ["major-only with prerelease", semverRule("app_version", "<", "1.0.0"), { app_version: "1-rc1" }, true], + [ + "partial version with prerelease", + semverRule("app_version", "=", "1.2.0-alpha"), + { app_version: "1.2-alpha" }, + true, + ], + [ + "partial prerelease below later minor", + semverRule("app_version", "<", "1.3.1"), + { app_version: "1.2-alpha" }, + true, + ], + [ + "partial prerelease below its release", + semverRule("app_version", "<", "1.2.0"), + { app_version: "1.2-alpha" }, + true, + ], + [ + "major-only with prerelease", + semverRule("app_version", "<", "1.0.0"), + { app_version: "1-rc1" }, + true, + ], // An empty prerelease is invalid, so it is rejected rather than treated as the bare release. - ["empty prerelease, no match", semverRule("app_version", "=", "1.0.0"), { app_version: "1.0.0-" }, false], - ["empty prerelease, not-equal also false", semverRule("app_version", "!=", "1.0.0"), { app_version: "1.0.0-" }, false], - ["empty prerelease on partial version, no match", semverRule("app_version", "=", "1.2.0"), { app_version: "1.2-" }, false], - ["empty prerelease on partial version, not-equal also false", semverRule("app_version", "!=", "1.2.0"), { app_version: "1.2-" }, false], + [ + "empty prerelease, no match", + semverRule("app_version", "=", "1.0.0"), + { app_version: "1.0.0-" }, + false, + ], + [ + "empty prerelease, not-equal also false", + semverRule("app_version", "!=", "1.0.0"), + { app_version: "1.0.0-" }, + false, + ], + [ + "empty prerelease on partial version, no match", + semverRule("app_version", "=", "1.2.0"), + { app_version: "1.2-" }, + false, + ], + [ + "empty prerelease on partial version, not-equal also false", + semverRule("app_version", "!=", "1.2.0"), + { app_version: "1.2-" }, + false, + ], // Hyphens are legal inside a prerelease identifier, so these are NOT empty prereleases. - ["trailing hyphen inside identifier", semverRule("app_version", "<", "1.0.0"), { app_version: "1.0.0-alpha-" }, true], + [ + "trailing hyphen inside identifier", + semverRule("app_version", "<", "1.0.0"), + { app_version: "1.0.0-alpha-" }, + true, + ], // SemVer 2.0.0 forbids leading zeros in the core, so these are rejected rather than normalized. - ["leading zero in major, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "01.2.3" }, false], - ["leading zero in major, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "01.2.3" }, false], - ["leading zero in minor, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.02.3" }, false], - ["leading zero in minor, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.02.3" }, false], - ["leading zero in patch, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.03" }, false], - ["leading zero in patch, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.03" }, false], - ["leading zeros throughout, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "01.02.03" }, false], - ["leading zeros throughout, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "01.02.03" }, false], + [ + "leading zero in major, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "01.2.3" }, + false, + ], + [ + "leading zero in major, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "01.2.3" }, + false, + ], + [ + "leading zero in minor, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1.02.3" }, + false, + ], + [ + "leading zero in minor, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1.02.3" }, + false, + ], + [ + "leading zero in patch, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1.2.03" }, + false, + ], + [ + "leading zero in patch, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1.2.03" }, + false, + ], + [ + "leading zeros throughout, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "01.02.03" }, + false, + ], + [ + "leading zeros throughout, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "01.02.03" }, + false, + ], // A numeric prerelease identifier may not carry a leading zero either (section 9). - ["numeric prerelease with leading zero, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3-01" }, false], - ["numeric prerelease with leading zero, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3-01" }, false], - ["dotted numeric prerelease with leading zero, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3-rc.01" }, false], - ["dotted numeric prerelease with leading zero, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3-rc.01" }, false], + [ + "numeric prerelease with leading zero, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1.2.3-01" }, + false, + ], + [ + "numeric prerelease with leading zero, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1.2.3-01" }, + false, + ], + [ + "dotted numeric prerelease with leading zero, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1.2.3-rc.01" }, + false, + ], + [ + "dotted numeric prerelease with leading zero, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1.2.3-rc.01" }, + false, + ], // An alphanumeric identifier may contain digits, so this one stays valid. - ["alphanumeric prerelease with digits", semverRule("app_version", "<", "1.2.3"), { app_version: "1.2.3-rc01" }, true], - ["between, inside", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "1.5.0" }, true], - ["between, low boundary inclusive", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "1.2.3" }, true], - ["between, high boundary inclusive", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "2.0.0" }, true], - ["between, below", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "1.0.0" }, false], - ["between, above", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "2.0.1" }, false], + [ + "alphanumeric prerelease with digits", + semverRule("app_version", "<", "1.2.3"), + { app_version: "1.2.3-rc01" }, + true, + ], + [ + "between, inside", + customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), + { app_version: "1.5.0" }, + true, + ], + [ + "between, low boundary inclusive", + customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), + { app_version: "1.2.3" }, + true, + ], + [ + "between, high boundary inclusive", + customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), + { app_version: "2.0.0" }, + true, + ], + [ + "between, below", + customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), + { app_version: "1.0.0" }, + false, + ], + [ + "between, above", + customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), + { app_version: "2.0.1" }, + false, + ], // A prerelease sits below its own release, which decides both boundary cases. - ["between, prerelease inside", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "1.5.0-rc1" }, true], - ["between, prerelease below the high bound", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "2.0.0-rc1" }, true], - ["between, prerelease of the low bound falls out", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "1.2.3-rc1" }, false], - ["between, invalid version", customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), { app_version: "not-a-version" }, false], - ["between, single-point range", customBetween("semver_compare", "app_version", "1.2.3", "1.2.3"), { app_version: "1.2.3" }, true], + [ + "between, prerelease inside", + customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), + { app_version: "1.5.0-rc1" }, + true, + ], + [ + "between, prerelease below the high bound", + customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), + { app_version: "2.0.0-rc1" }, + true, + ], + [ + "between, prerelease of the low bound falls out", + customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), + { app_version: "1.2.3-rc1" }, + false, + ], + [ + "between, invalid version", + customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), + { app_version: "not-a-version" }, + false, + ], + [ + "between, single-point range", + customBetween("semver_compare", "app_version", "1.2.3", "1.2.3"), + { app_version: "1.2.3" }, + true, + ], // Fail-closed: unparseable or missing values never match. - ["invalid actual, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "not-a-version" }, false], - ["non-string actual, no match", semverRule("app_version", "=", "1.2.3"), { app_version: 123 }, false], - ["missing property, no match", semverRule("app_version", "=", "1.2.3"), {}, false], + [ + "invalid actual, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "not-a-version" }, + false, + ], + [ + "non-string actual, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: 123 }, + false, + ], + [ + "missing property, no match", + semverRule("app_version", "=", "1.2.3"), + {}, + false, + ], // A malformed version must never be padded or coerced into a real one. Both symbols are // asserted so that "accepted at all" is observable rather than masked by a single false. - ["empty version, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "" }, false], - ["empty version, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "" }, false], - ["bare v, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "v" }, false], - ["bare v, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "v" }, false], - ["leading separator, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "-1.2.3" }, false], - ["leading separator, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "-1.2.3" }, false], - ["trailing dot, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1." }, false], - ["trailing dot, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1." }, false], - ["trailing dot after patch, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3." }, false], - ["trailing dot after patch, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3." }, false], - ["empty middle segment, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1..2" }, false], - ["empty middle segment, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1..2" }, false], - ["four components, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3.4" }, false], - ["four components, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3.4" }, false], - ["range prefix, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "^1.2.3" }, false], - ["range prefix, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "^1.2.3" }, false], - ["version inside text, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "abc1.2.3" }, false], - ["version inside text, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "abc1.2.3" }, false], - ["empty build metadata, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3+" }, false], - ["empty build metadata, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3+" }, false], - ["empty prerelease identifier, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3-alpha..1" }, false], - ["empty prerelease identifier, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3-alpha..1" }, false], - ["lone dot prerelease, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3-." }, false], - ["lone dot prerelease, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3-." }, false], - ["underscore in prerelease, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "1.2.3-ALPHA_BETA" }, false], - ["underscore in prerelease, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "1.2.3-ALPHA_BETA" }, false], - ["doubled v-prefix, no match", semverRule("app_version", "=", "1.2.3"), { app_version: "vv1.2.3" }, false], - ["doubled v-prefix, not-equal also false", semverRule("app_version", "!=", "1.2.3"), { app_version: "vv1.2.3" }, false], - ["missing operand, no match", { semver_compare: [varNode("app_version"), "="] }, { app_version: "1.2.3" }, false], + [ + "empty version, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "" }, + false, + ], + [ + "empty version, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "" }, + false, + ], + [ + "bare v, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "v" }, + false, + ], + [ + "bare v, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "v" }, + false, + ], + [ + "leading separator, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "-1.2.3" }, + false, + ], + [ + "leading separator, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "-1.2.3" }, + false, + ], + [ + "trailing dot, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1." }, + false, + ], + [ + "trailing dot, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1." }, + false, + ], + [ + "trailing dot after patch, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1.2.3." }, + false, + ], + [ + "trailing dot after patch, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1.2.3." }, + false, + ], + [ + "empty middle segment, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1..2" }, + false, + ], + [ + "empty middle segment, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1..2" }, + false, + ], + [ + "four components, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1.2.3.4" }, + false, + ], + [ + "four components, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1.2.3.4" }, + false, + ], + [ + "range prefix, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "^1.2.3" }, + false, + ], + [ + "range prefix, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "^1.2.3" }, + false, + ], + [ + "version inside text, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "abc1.2.3" }, + false, + ], + [ + "version inside text, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "abc1.2.3" }, + false, + ], + [ + "empty build metadata, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1.2.3+" }, + false, + ], + [ + "empty build metadata, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1.2.3+" }, + false, + ], + [ + "empty prerelease identifier, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1.2.3-alpha..1" }, + false, + ], + [ + "empty prerelease identifier, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1.2.3-alpha..1" }, + false, + ], + [ + "lone dot prerelease, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1.2.3-." }, + false, + ], + [ + "lone dot prerelease, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1.2.3-." }, + false, + ], + [ + "underscore in prerelease, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "1.2.3-ALPHA_BETA" }, + false, + ], + [ + "underscore in prerelease, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "1.2.3-ALPHA_BETA" }, + false, + ], + [ + "doubled v-prefix, no match", + semverRule("app_version", "=", "1.2.3"), + { app_version: "vv1.2.3" }, + false, + ], + [ + "doubled v-prefix, not-equal also false", + semverRule("app_version", "!=", "1.2.3"), + { app_version: "vv1.2.3" }, + false, + ], + [ + "missing operand, no match", + { semver_compare: [varNode("app_version"), "="] }, + { app_version: "1.2.3" }, + false, + ], ])("%s", (_name, rule, data, want) => { expect(apply(rule, data)).toBe(want); }); @@ -182,92 +779,467 @@ describe("semver_compare operator", () => { describe("datetime_compare operator", () => { it.each([ // Asymmetric contract: subject (runtime var) is a strict RFC3339 string, target is epoch ms. - ["before, true", datetimeRule("signup", "<", JUL16_MS), { signup: "2026-07-15T00:00:00Z" }, true], - ["before, false", datetimeRule("signup", "<", JUL16_MS), { signup: "2026-07-16T00:00:00Z" }, false], - ["on (equal), true", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00Z" }, true], - ["not on, true", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-17T00:00:00Z" }, true], - ["since (>=), boundary", datetimeRule("signup", ">=", JUL16_MS), { signup: "2026-07-16T00:00:00Z" }, true], - ["after (>), true", datetimeRule("signup", ">", JUL16_MS), { signup: "2026-07-17T00:00:00Z" }, true], - ["after (>), false", datetimeRule("signup", ">", JUL16_MS), { signup: "2026-07-15T00:00:00Z" }, false], + [ + "before, true", + datetimeRule("signup", "<", JUL16_MS), + { signup: "2026-07-15T00:00:00Z" }, + true, + ], + [ + "before, false", + datetimeRule("signup", "<", JUL16_MS), + { signup: "2026-07-16T00:00:00Z" }, + false, + ], + [ + "on (equal), true", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00Z" }, + true, + ], + [ + "not on, true", + datetimeRule("signup", "!=", JUL16_MS), + { signup: "2026-07-17T00:00:00Z" }, + true, + ], + [ + "since (>=), boundary", + datetimeRule("signup", ">=", JUL16_MS), + { signup: "2026-07-16T00:00:00Z" }, + true, + ], + [ + "after (>), true", + datetimeRule("signup", ">", JUL16_MS), + { signup: "2026-07-17T00:00:00Z" }, + true, + ], + [ + "after (>), false", + datetimeRule("signup", ">", JUL16_MS), + { signup: "2026-07-15T00:00:00Z" }, + false, + ], // Every symbol is asserted in both directions. - ["at or before, boundary", datetimeRule("signup", "<=", JUL16_MS), { signup: "2026-07-16T00:00:00Z" }, true], - ["at or before, after", datetimeRule("signup", "<=", JUL16_MS), { signup: "2026-07-17T00:00:00Z" }, false], - ["on (equal), false", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-17T00:00:00Z" }, false], - ["not on, equal", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00Z" }, false], - ["since (>=), before", datetimeRule("signup", ">=", JUL16_MS), { signup: "2026-07-15T00:00:00Z" }, false], - ["between, inside", datetimeBetween("signup", JAN1_MS, DEC31_MS), { signup: "2026-06-15T00:00:00Z" }, true], - ["between, low boundary inclusive", datetimeBetween("signup", JAN1_MS, DEC31_MS), { signup: "2026-01-01T00:00:00Z" }, true], - ["between, high boundary inclusive", datetimeBetween("signup", JAN1_MS, DEC31_MS), { signup: "2026-12-31T00:00:00Z" }, true], - ["between, before range", datetimeBetween("signup", JAN1_MS, DEC31_MS), { signup: "2025-12-31T00:00:00Z" }, false], - ["between, after range", datetimeBetween("signup", JAN1_MS, DEC31_MS), { signup: "2027-01-01T00:00:00Z" }, false], + [ + "at or before, boundary", + datetimeRule("signup", "<=", JUL16_MS), + { signup: "2026-07-16T00:00:00Z" }, + true, + ], + [ + "at or before, after", + datetimeRule("signup", "<=", JUL16_MS), + { signup: "2026-07-17T00:00:00Z" }, + false, + ], + [ + "on (equal), false", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-17T00:00:00Z" }, + false, + ], + [ + "not on, equal", + datetimeRule("signup", "!=", JUL16_MS), + { signup: "2026-07-16T00:00:00Z" }, + false, + ], + [ + "since (>=), before", + datetimeRule("signup", ">=", JUL16_MS), + { signup: "2026-07-15T00:00:00Z" }, + false, + ], + [ + "between, inside", + datetimeBetween("signup", JAN1_MS, DEC31_MS), + { signup: "2026-06-15T00:00:00Z" }, + true, + ], + [ + "between, low boundary inclusive", + datetimeBetween("signup", JAN1_MS, DEC31_MS), + { signup: "2026-01-01T00:00:00Z" }, + true, + ], + [ + "between, high boundary inclusive", + datetimeBetween("signup", JAN1_MS, DEC31_MS), + { signup: "2026-12-31T00:00:00Z" }, + true, + ], + [ + "between, before range", + datetimeBetween("signup", JAN1_MS, DEC31_MS), + { signup: "2025-12-31T00:00:00Z" }, + false, + ], + [ + "between, after range", + datetimeBetween("signup", JAN1_MS, DEC31_MS), + { signup: "2027-01-01T00:00:00Z" }, + false, + ], // A leap day is a real date. - ["leap day", datetimeRule("signup", "=", LEAP_DAY_MS), { signup: "2024-02-29T00:00:00Z" }, true], + [ + "leap day", + datetimeRule("signup", "=", LEAP_DAY_MS), + { signup: "2024-02-29T00:00:00Z" }, + true, + ], // Time-zone offsets change the instant. - ["offset with half-hour minutes", datetimeRule("signup", "=", JUL16_INDIA_MS), { signup: "2026-07-16T00:00:00+05:30" }, true], - ["rfc3339 subject with offset", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T02:00:00+02:00" }, true], - ["positive offset precedes utc midnight", datetimeRule("signup", "<", JUL16_MS), { signup: "2026-07-16T00:00:00+05:30" }, true], - ["negative offset", datetimeRule("signup", "=", JUL16_PACIFIC_MS), { signup: "2026-07-16T00:00:00-08:00" }, true], - ["negative offset follows utc midnight", datetimeRule("signup", ">", JUL16_MS), { signup: "2026-07-16T00:00:00-08:00" }, true], - ["zero offset equals Z", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00+00:00" }, true], + [ + "offset with half-hour minutes", + datetimeRule("signup", "=", JUL16_INDIA_MS), + { signup: "2026-07-16T00:00:00+05:30" }, + true, + ], + [ + "rfc3339 subject with offset", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T02:00:00+02:00" }, + true, + ], + [ + "positive offset precedes utc midnight", + datetimeRule("signup", "<", JUL16_MS), + { signup: "2026-07-16T00:00:00+05:30" }, + true, + ], + [ + "negative offset", + datetimeRule("signup", "=", JUL16_PACIFIC_MS), + { signup: "2026-07-16T00:00:00-08:00" }, + true, + ], + [ + "negative offset follows utc midnight", + datetimeRule("signup", ">", JUL16_MS), + { signup: "2026-07-16T00:00:00-08:00" }, + true, + ], + [ + "zero offset equals Z", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00+00:00" }, + true, + ], // Sub-second precision is dropped, on both sides. The end-of-day rows are the window the UI // emits for a single date, whose upper bound carries .999. - ["one-digit fraction", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00.5Z" }, true], - ["three-digit fraction", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00.500Z" }, true], - ["six-digit fraction", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00.123456Z" }, true], - ["nine-digit fraction", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00.999999999Z" }, true], - ["zero fraction", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00.0Z" }, true], - ["fractional seconds truncated", datetimeRule("signup", ">=", JUL16_MS), { signup: "2026-07-16T00:00:00.500Z" }, true], - ["end-of-day target drops its .999", datetimeRule("signup", "=", JUL16_END_MS), { signup: "2026-07-16T23:59:59Z" }, true], - ["end-of-day target is an inclusive bound", datetimeRule("signup", "<=", JUL16_END_MS), { signup: "2026-07-16T23:59:59Z" }, true], - ["end-of-day, fractional subject too", datetimeRule("signup", "=", JUL16_END_MS), { signup: "2026-07-16T23:59:59.999Z" }, true], - ["end-of-day inclusive, fractional subject", datetimeRule("signup", "<=", JUL16_END_MS), { signup: "2026-07-16T23:59:59.999Z" }, true], + [ + "one-digit fraction", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00.5Z" }, + true, + ], + [ + "three-digit fraction", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00.500Z" }, + true, + ], + [ + "six-digit fraction", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00.123456Z" }, + true, + ], + [ + "nine-digit fraction", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00.999999999Z" }, + true, + ], + [ + "zero fraction", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00.0Z" }, + true, + ], + [ + "fractional seconds truncated", + datetimeRule("signup", ">=", JUL16_MS), + { signup: "2026-07-16T00:00:00.500Z" }, + true, + ], + [ + "end-of-day target drops its .999", + datetimeRule("signup", "=", JUL16_END_MS), + { signup: "2026-07-16T23:59:59Z" }, + true, + ], + [ + "end-of-day target is an inclusive bound", + datetimeRule("signup", "<=", JUL16_END_MS), + { signup: "2026-07-16T23:59:59Z" }, + true, + ], + [ + "end-of-day, fractional subject too", + datetimeRule("signup", "=", JUL16_END_MS), + { signup: "2026-07-16T23:59:59.999Z" }, + true, + ], + [ + "end-of-day inclusive, fractional subject", + datetimeRule("signup", "<=", JUL16_END_MS), + { signup: "2026-07-16T23:59:59.999Z" }, + true, + ], // Fractional on both sides: the shape the UI actually round-trips. // Trimming and lowercasing. - ["lowercased subject with fraction", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16t00:00:00.500z" }, true], - ["lowercased subject with offset", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16t02:00:00+02:00" }, true], - ["whitespace-padded subject", datetimeRule("signup", "=", JUL16_MS), { signup: " 2026-07-16T00:00:00Z " }, true], - ["lowercased rfc3339 subject", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16t00:00:00z" }, true], + [ + "lowercased subject with fraction", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16t00:00:00.500z" }, + true, + ], + [ + "lowercased subject with offset", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16t02:00:00+02:00" }, + true, + ], + [ + "whitespace-padded subject", + datetimeRule("signup", "=", JUL16_MS), + { signup: " 2026-07-16T00:00:00Z " }, + true, + ], + [ + "lowercased rfc3339 subject", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16t00:00:00z" }, + true, + ], // Shape violations, asserted under both = and != so that "accepted at all" is observable. // RFC 3339 also permits 24:00:00 as end-of-day. Platforms disagree on it, so no vector // asserts it either way. - ["one-digit month, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-7-16T00:00:00Z" }, false], - ["one-digit month, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-7-16T00:00:00Z" }, false], - ["space separator, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16 00:00:00Z" }, false], - ["space separator, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16 00:00:00Z" }, false], - ["missing zone, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00" }, false], - ["missing zone, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00" }, false], - ["empty fraction, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00.Z" }, false], - ["empty fraction, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00.Z" }, false], - ["offset without colon, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00+0200" }, false], - ["offset without colon, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00+0200" }, false], - ["short offset, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00+02" }, false], - ["short offset, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00+02" }, false], - ["trailing junk, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00Zextra" }, false], - ["trailing junk, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00Zextra" }, false], - ["basic format, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "20260716T000000Z" }, false], - ["basic format, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "20260716T000000Z" }, false], - ["zone after lowercase z, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00z00:00" }, false], - ["zone after lowercase z, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00z00:00" }, false], - ["comma fractional separator, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00,5Z" }, false], - ["comma fractional separator, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16T00:00:00,5Z" }, false], + [ + "one-digit month, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-7-16T00:00:00Z" }, + false, + ], + [ + "one-digit month, not-equal also false", + datetimeRule("signup", "!=", JUL16_MS), + { signup: "2026-7-16T00:00:00Z" }, + false, + ], + [ + "space separator, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16 00:00:00Z" }, + false, + ], + [ + "space separator, not-equal also false", + datetimeRule("signup", "!=", JUL16_MS), + { signup: "2026-07-16 00:00:00Z" }, + false, + ], + [ + "missing zone, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00" }, + false, + ], + [ + "missing zone, not-equal also false", + datetimeRule("signup", "!=", JUL16_MS), + { signup: "2026-07-16T00:00:00" }, + false, + ], + [ + "empty fraction, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00.Z" }, + false, + ], + [ + "empty fraction, not-equal also false", + datetimeRule("signup", "!=", JUL16_MS), + { signup: "2026-07-16T00:00:00.Z" }, + false, + ], + [ + "offset without colon, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00+0200" }, + false, + ], + [ + "offset without colon, not-equal also false", + datetimeRule("signup", "!=", JUL16_MS), + { signup: "2026-07-16T00:00:00+0200" }, + false, + ], + [ + "short offset, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00+02" }, + false, + ], + [ + "short offset, not-equal also false", + datetimeRule("signup", "!=", JUL16_MS), + { signup: "2026-07-16T00:00:00+02" }, + false, + ], + [ + "trailing junk, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00Zextra" }, + false, + ], + [ + "trailing junk, not-equal also false", + datetimeRule("signup", "!=", JUL16_MS), + { signup: "2026-07-16T00:00:00Zextra" }, + false, + ], + [ + "basic format, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: "20260716T000000Z" }, + false, + ], + [ + "basic format, not-equal also false", + datetimeRule("signup", "!=", JUL16_MS), + { signup: "20260716T000000Z" }, + false, + ], + [ + "zone after lowercase z, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00z00:00" }, + false, + ], + [ + "zone after lowercase z, not-equal also false", + datetimeRule("signup", "!=", JUL16_MS), + { signup: "2026-07-16T00:00:00z00:00" }, + false, + ], + [ + "comma fractional separator, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00,5Z" }, + false, + ], + [ + "comma fractional separator, not-equal also false", + datetimeRule("signup", "!=", JUL16_MS), + { signup: "2026-07-16T00:00:00,5Z" }, + false, + ], // Fail-closed: subject must be an RFC3339 string, target must be an epoch-ms number. - ["numeric subject, no match", datetimeRule("signup", "=", JUL16_MS), { signup: JUL16_MS }, false], - ["negative epoch-ms target resolves to -1s", datetimeRule("signup", "=", -1500), { signup: "1969-12-31T23:59:59Z" }, true], - ["negative epoch-ms target, not equal", datetimeRule("signup", "!=", -1500), { signup: "1969-12-31T23:59:59Z" }, false], - ["negative epoch-ms target, at or after", datetimeRule("signup", ">=", -1500), { signup: "1969-12-31T23:59:59Z" }, true], - ["negative epoch-ms target, before", datetimeRule("signup", "<", -1500), { signup: "1969-12-31T23:59:58Z" }, true], - ["negative epoch-ms target, after", datetimeRule("signup", ">", -2500), { signup: "1969-12-31T23:59:59Z" }, true], - ["subject floors, it does not truncate", datetimeRule("signup", "=", -2000), { signup: "1969-12-31T23:59:58.500Z" }, true], - ["subject floors, not to -1s", datetimeRule("signup", "!=", -1000), { signup: "1969-12-31T23:59:58.500Z" }, true], - ["target beyond representable range, no match", { datetime_compare: [varNode("signup"), "=", 1e308] }, { signup: "2026-07-16T00:00:00Z" }, false], - ["target beyond representable range, greater-than also false", { datetime_compare: [varNode("signup"), ">", 1e308] }, { signup: "2026-07-16T00:00:00Z" }, false], - ["target beyond representable range, less-than also false", { datetime_compare: [varNode("signup"), "<", 1e308] }, { signup: "2026-07-16T00:00:00Z" }, false], - ["bare date subject, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16" }, false], - ["bare date subject, not-equal also false", datetimeRule("signup", "!=", JUL16_MS), { signup: "2026-07-16" }, false], - ["zoneless datetime subject, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "2026-07-16T00:00:00" }, false], - ["non-datetime string, no match", datetimeRule("signup", "=", JUL16_MS), { signup: "yesterday" }, false], - ["missing property, no match", datetimeRule("signup", "=", JUL16_MS), {}, false], - ["missing operand, no match", { datetime_compare: [varNode("signup"), "="] }, { signup: "2026-07-16T00:00:00Z" }, false], + [ + "numeric subject, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: JUL16_MS }, + false, + ], + [ + "negative epoch-ms target resolves to -1s", + datetimeRule("signup", "=", -1500), + { signup: "1969-12-31T23:59:59Z" }, + true, + ], + [ + "negative epoch-ms target, not equal", + datetimeRule("signup", "!=", -1500), + { signup: "1969-12-31T23:59:59Z" }, + false, + ], + [ + "negative epoch-ms target, at or after", + datetimeRule("signup", ">=", -1500), + { signup: "1969-12-31T23:59:59Z" }, + true, + ], + [ + "negative epoch-ms target, before", + datetimeRule("signup", "<", -1500), + { signup: "1969-12-31T23:59:58Z" }, + true, + ], + [ + "negative epoch-ms target, after", + datetimeRule("signup", ">", -2500), + { signup: "1969-12-31T23:59:59Z" }, + true, + ], + [ + "subject floors, it does not truncate", + datetimeRule("signup", "=", -2000), + { signup: "1969-12-31T23:59:58.500Z" }, + true, + ], + [ + "subject floors, not to -1s", + datetimeRule("signup", "!=", -1000), + { signup: "1969-12-31T23:59:58.500Z" }, + true, + ], + [ + "target beyond representable range, no match", + { datetime_compare: [varNode("signup"), "=", 1e308] }, + { signup: "2026-07-16T00:00:00Z" }, + false, + ], + [ + "target beyond representable range, greater-than also false", + { datetime_compare: [varNode("signup"), ">", 1e308] }, + { signup: "2026-07-16T00:00:00Z" }, + false, + ], + [ + "target beyond representable range, less-than also false", + { datetime_compare: [varNode("signup"), "<", 1e308] }, + { signup: "2026-07-16T00:00:00Z" }, + false, + ], + [ + "bare date subject, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16" }, + false, + ], + [ + "bare date subject, not-equal also false", + datetimeRule("signup", "!=", JUL16_MS), + { signup: "2026-07-16" }, + false, + ], + [ + "zoneless datetime subject, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: "2026-07-16T00:00:00" }, + false, + ], + [ + "non-datetime string, no match", + datetimeRule("signup", "=", JUL16_MS), + { signup: "yesterday" }, + false, + ], + [ + "missing property, no match", + datetimeRule("signup", "=", JUL16_MS), + {}, + false, + ], + [ + "missing operand, no match", + { datetime_compare: [varNode("signup"), "="] }, + { signup: "2026-07-16T00:00:00Z" }, + false, + ], ])("%s", (_name, rule, data, want) => { expect(apply(rule, data)).toBe(want); }); From 9acef3887a0162686b78702747bf7c0bc5b62b91 Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:04:21 +0000 Subject: [PATCH 3/7] hand roll semver and update tests --- .../mixpanel/lib/flags/custom_operators.js | 96 +- packages/mixpanel/package.json | 1 - .../mixpanel/test/flags/custom_operators.js | 1282 +---------------- .../test-data/datetime_compare_tests.json | 89 ++ .../flags/test-data/semver_compare_tests.json | 155 ++ 5 files changed, 391 insertions(+), 1232 deletions(-) create mode 100644 packages/mixpanel/test/flags/test-data/datetime_compare_tests.json create mode 100644 packages/mixpanel/test/flags/test-data/semver_compare_tests.json diff --git a/packages/mixpanel/lib/flags/custom_operators.js b/packages/mixpanel/lib/flags/custom_operators.js index ee79042..7089b8d 100644 --- a/packages/mixpanel/lib/flags/custom_operators.js +++ b/packages/mixpanel/lib/flags/custom_operators.js @@ -1,5 +1,4 @@ const jsonLogic = require("json-logic-js"); -const { compareVersions } = require("compare-versions"); // Strict RFC3339 guard for datetime strings. const RFC3339_PATTERN = @@ -37,10 +36,99 @@ function semverCompare(actual, symbol, target) { ) { return false; } - const cmp = compareVersions(actualVersion, targetVersion); + const cmp = compareSemver(actualVersion, targetVersion); return comparatorMatches(cmp, symbol); } +// Strip optional build metadata and separate the core version from pre-release identifiers +function splitSemver(version) { + const plus = version.indexOf("+"); + if (plus !== -1) { + version = version.slice(0, plus); + } + const dash = version.indexOf("-"); + if (dash === -1) { + return { core: version.split("."), prerelease: [] }; + } + return { + core: version.slice(0, dash).split("."), + prerelease: version.slice(dash + 1).split("."), + }; +} + +function isNumericIdentifier(identifier) { + return /^[0-9]+$/.test(identifier); +} + +// Numeric identifiers carry no leading zeros, so the longer run of digits is the larger number. +// Comparing them as digits rather than as numbers keeps versions past Number.MAX_SAFE_INTEGER +// ordered correctly. +function compareNumeric(a, b) { + if (a.length !== b.length) { + return a.length < b.length ? -1 : 1; + } + return a < b ? -1 : a > b ? 1 : 0; +} + +// SemVer 2.0.0 section 11.4: digits compare numerically, a numeric identifier ranks below an +// alphanumeric one, and anything else compares by ASCII order. +function comparePrereleaseIdentifier(a, b) { + const aNumeric = isNumericIdentifier(a); + const bNumeric = isNumericIdentifier(b); + if (aNumeric && bNumeric) { + return compareNumeric(a, b); + } + if (aNumeric) { + return -1; + } + if (bNumeric) { + return 1; + } + return a < b ? -1 : a > b ? 1 : 0; +} + +// Ordering per SemVer 2.0.0 section 11. Both operands have already been normalized and matched +// against the official regex, so the core holds exactly three numeric identifiers and every +// prerelease field is well-formed; the split needs no error path. +function compareSemver(actualVersion, targetVersion) { + const actual = splitSemver(actualVersion); + const target = splitSemver(targetVersion); + + for (let i = 0; i < actual.core.length; i++) { + const result = compareNumeric(actual.core[i], target.core[i]); + if (result !== 0) { + return result; + } + } + + // A prerelease ranks below the release it belongs to (section 11.3). + if (!actual.prerelease.length && !target.prerelease.length) { + return 0; + } + if (!actual.prerelease.length) { + return 1; + } + if (!target.prerelease.length) { + return -1; + } + + const shared = Math.min(actual.prerelease.length, target.prerelease.length); + for (let i = 0; i < shared; i++) { + const result = comparePrereleaseIdentifier( + actual.prerelease[i], + target.prerelease[i], + ); + if (result !== 0) { + return result; + } + } + // Every field so far is equal, so the longer list wins (section 11.4.4). + if (actual.prerelease.length !== target.prerelease.length) { + return actual.prerelease.length < target.prerelease.length ? -1 : 1; + } + return 0; +} + // Implements a custom operation for datetime comparison. // The target value stored on the feature flag is the millisecond epoch, whereas the actual value provided at evaluation time must be RFC-3339 formatted. function datetimeCompare(actual, symbol, target) { @@ -58,9 +146,9 @@ function datetimeCompare(actual, symbol, target) { function comparatorMatches(cmp, symbol) { switch (symbol) { - case "=": + case "===": return cmp === 0; - case "!=": + case "!==": return cmp !== 0; case "<": return cmp < 0; diff --git a/packages/mixpanel/package.json b/packages/mixpanel/package.json index 46057c4..39b7d60 100644 --- a/packages/mixpanel/package.json +++ b/packages/mixpanel/package.json @@ -35,7 +35,6 @@ "build": "tsc --build" }, "dependencies": { - "compare-versions": "6.1.1", "https-proxy-agent": "7.0.6", "json-logic-js": "2.0.5" }, diff --git a/packages/mixpanel/test/flags/custom_operators.js b/packages/mixpanel/test/flags/custom_operators.js index c0ebbff..bdff362 100644 --- a/packages/mixpanel/test/flags/custom_operators.js +++ b/packages/mixpanel/test/flags/custom_operators.js @@ -1,1246 +1,74 @@ +const fs = require("fs"); +const path = require("path"); + const { apply } = require("json-logic-js"); // Requiring the module registers semver_compare and datetime_compare on the shared // json-logic-js instance used by apply(). require("../../lib/flags/custom_operators"); -const varNode = (key) => ({ var: key }); - -const semverRule = (key, sym, target) => ({ - semver_compare: [varNode(key), sym, target], -}); +// The golden vectors are the cross-SDK contract for the custom operators; the canonical copy and +// its README live in the analytics monorepo. Cases run through apply() so that operator +// registration is covered alongside the comparison itself. +const TEST_DATA = path.join(__dirname, "test-data"); -const datetimeRule = (key, sym, target) => ({ - datetime_compare: [varNode(key), sym, target], -}); +// The property key the vectors are evaluated against. It is plumbing the test supplies, so any name +// works as long as the rule and the data agree on it. +const VECTOR_KEY = "value"; -const customBetween = (op, key, lo, hi) => ({ - and: [{ [op]: [varNode(key), ">=", lo] }, { [op]: [varNode(key), "<=", hi] }], -}); +// Build the event the rule reads from, omitting the key entirely for an unset property. +function dataFor(subject) { + return subject === null ? {} : { [VECTOR_KEY]: subject }; +} -const datetimeBetween = (key, lo, hi) => ({ - and: [ - { datetime_compare: [varNode(key), ">=", lo] }, - { datetime_compare: [varNode(key), "<=", hi] }, - ], -}); +// Read a golden-vector file. String entries are headings, array entries are cases. +function loadVectors(operator) { + const entries = JSON.parse( + fs.readFileSync( + path.join(TEST_DATA, `${operator}_compare_tests.json`), + "utf8", + ), + ); -// Epoch-millisecond constants (UTC instants) used as datetime targets, matching the UI format. -const JUL16_MS = 1_784_160_000_000; // 2026-07-16T00:00:00Z -const JAN1_MS = 1_767_225_600_000; // 2026-01-01T00:00:00Z -const DEC31_MS = 1_798_675_200_000; // 2026-12-31T00:00:00Z -const JUL16_END_MS = 1_784_246_399_999; // 2026-07-16T23:59:59.999Z -const LEAP_DAY_MS = 1_709_164_800_000; // 2024-02-29T00:00:00Z -const JUL16_INDIA_MS = 1_784_140_200_000; // 2026-07-16T00:00:00+05:30 -const JUL16_PACIFIC_MS = 1_784_188_800_000; // 2026-07-16T00:00:00-08:00 + let section = ""; + const cases = []; + entries.forEach((entry, index) => { + if (typeof entry === "string") { + section = entry; + return; + } + const [subject, symbol, target, want] = entry; + const rule = { + [`${operator}_compare`]: [{ var: VECTOR_KEY }, symbol, target], + }; + const name = `${index} ${section}: ${JSON.stringify(subject)} ${symbol} ${JSON.stringify(target)}`; + cases.push([name, rule, dataFor(subject), want]); + }); + return cases; +} describe("semver_compare operator", () => { - it.each([ - [ - "is, equal", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1.2.3" }, - true, - ], - [ - "is, not equal", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1.2.4" }, - false, - ], - [ - "is not", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1.2.4" }, - true, - ], - [ - "less than, patch", - semverRule("app_version", "<", "1.2.3"), - { app_version: "1.2.2" }, - true, - ], - [ - "less than, false", - semverRule("app_version", "<", "1.2.3"), - { app_version: "1.2.3" }, - false, - ], - [ - "less or equal, boundary", - semverRule("app_version", "<=", "1.2.3"), - { app_version: "1.2.3" }, - true, - ], - [ - "greater than, minor", - semverRule("app_version", ">", "1.2.3"), - { app_version: "1.3.0" }, - true, - ], - [ - "greater or equal, boundary", - semverRule("app_version", ">=", "1.2.3"), - { app_version: "1.2.3" }, - true, - ], - [ - "double-digit ordering (not lexical)", - semverRule("app_version", ">", "1.9.0"), - { app_version: "1.10.0" }, - true, - ], - [ - "prerelease precedes release", - semverRule("app_version", "<", "1.0.0"), - { app_version: "1.0.0-alpha" }, - true, - ], - [ - "lenient v-prefix", - semverRule("app_version", "=", "1.2.3"), - { app_version: "v1.2.3" }, - true, - ], - [ - "lenient uppercase V-prefix", - semverRule("app_version", "=", "1.2.3"), - { app_version: "V1.2.3" }, - true, - ], - [ - "v-prefix keeps prerelease", - semverRule("app_version", "<", "1.0.0"), - { app_version: "v1.0.0-alpha" }, - true, - ], - [ - "v-prefix, not equal", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "v1.2.4" }, - true, - ], - [ - "v-prefix, at or below", - semverRule("app_version", "<=", "1.2.3"), - { app_version: "v1.2.3" }, - true, - ], - [ - "v-prefix, greater", - semverRule("app_version", ">", "1.2.3"), - { app_version: "v1.2.4" }, - true, - ], - [ - "v-prefix, at or above", - semverRule("app_version", ">=", "1.2.3"), - { app_version: "v1.2.3" }, - true, - ], - [ - "lenient minor-only target", - semverRule("app_version", "=", "1.2"), - { app_version: "1.2.0" }, - true, - ], - // Every symbol is asserted in both directions. - [ - "is not, equal", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1.2.3" }, - false, - ], - [ - "less or equal, above", - semverRule("app_version", "<=", "1.2.3"), - { app_version: "1.2.4" }, - false, - ], - [ - "greater than, below", - semverRule("app_version", ">", "1.2.3"), - { app_version: "1.2.2" }, - false, - ], - [ - "greater or equal, below", - semverRule("app_version", ">=", "1.2.3"), - { app_version: "1.2.2" }, - false, - ], - // Prerelease precedence, SemVer 2.0.0 section 11. - [ - "prerelease alpha before beta", - semverRule("app_version", "<", "1.0.0-beta"), - { app_version: "1.0.0-alpha" }, - true, - ], - [ - "prerelease beta before rc1", - semverRule("app_version", "<", "1.0.0-rc1"), - { app_version: "1.0.0-beta" }, - true, - ], - [ - "prerelease rc1 before rc2", - semverRule("app_version", "<", "1.0.0-rc2"), - { app_version: "1.0.0-rc1" }, - true, - ], - [ - "more prerelease fields wins", - semverRule("app_version", "<", "1.0.0-alpha.1"), - { app_version: "1.0.0-alpha" }, - true, - ], - [ - "numeric identifier below alphanumeric", - semverRule("app_version", "<", "1.0.0-alpha.beta"), - { app_version: "1.0.0-alpha.1" }, - true, - ], - [ - "fewer fields below alphanumeric", - semverRule("app_version", "<", "1.0.0-alpha.beta"), - { app_version: "1.0.0-alpha" }, - true, - ], - [ - "numeric identifiers compare numerically", - semverRule("app_version", "<", "1.0.0-beta.11"), - { app_version: "1.0.0-beta.2" }, - true, - ], - [ - "dotted identifier ordering, letters", - semverRule("app_version", "<", "1.0.0-b.1"), - { app_version: "1.0.0-a.1" }, - true, - ], - [ - "dotted identifier ordering, digits", - semverRule("app_version", "<", "1.0.0-a.2"), - { app_version: "1.0.0-a.1" }, - true, - ], - [ - "identical prereleases are equal", - semverRule("app_version", "=", "1.0.0-rc1"), - { app_version: "1.0.0-rc1" }, - true, - ], - [ - "rc1 outranks dotted rc.1", - semverRule("app_version", ">", "1.0.0-rc.1"), - { app_version: "1.0.0-rc1" }, - true, - ], - [ - "core version dominates prerelease", - semverRule("app_version", ">", "1.9.9"), - { app_version: "2.0.0-alpha" }, - true, - ], - // A release outranks its own prerelease, asserted from both sides and under every symbol. - [ - "release outranks its prerelease", - semverRule("app_version", ">", "1.0.0-alpha"), - { app_version: "1.0.0" }, - true, - ], - [ - "release at or above its prerelease", - semverRule("app_version", ">=", "1.0.0-rc1"), - { app_version: "1.0.0" }, - true, - ], - [ - "release differs from its prerelease", - semverRule("app_version", "!=", "1.0.0-alpha"), - { app_version: "1.0.0" }, - true, - ], - [ - "prerelease differs from its release", - semverRule("app_version", "!=", "1.0.0"), - { app_version: "1.0.0-alpha" }, - true, - ], - [ - "prerelease at or below its release", - semverRule("app_version", "<=", "1.0.0"), - { app_version: "1.0.0-alpha" }, - true, - ], - [ - "prerelease of a higher core still wins", - semverRule("app_version", ">", "0.9.9"), - { app_version: "1.0.0-alpha" }, - true, - ], - [ - "prerelease below the next patch", - semverRule("app_version", "<", "1.0.1"), - { app_version: "1.0.0-rc1" }, - true, - ], - // Prerelease identifier comparison, SemVer 2.0.0 section 11.4. - [ - "numeric identifiers are not compared lexically", - semverRule("app_version", "<", "1.0.0-10"), - { app_version: "1.0.0-2" }, - true, - ], - [ - "numeric identifier ranks below alphanumeric", - semverRule("app_version", "<", "1.0.0-alpha"), - { app_version: "1.0.0-1" }, - true, - ], - [ - "hyphen inside an identifier sorts by ascii", - semverRule("app_version", "<", "1.0.0-alpha-1"), - { app_version: "1.0.0-alpha" }, - true, - ], - [ - "beta ranks below rc", - semverRule("app_version", "<", "1.0.0-rc.1"), - { app_version: "1.0.0-beta.11" }, - true, - ], - [ - "last prerelease ranks below the release", - semverRule("app_version", "<", "1.0.0"), - { app_version: "1.0.0-rc.1" }, - true, - ], - // Build metadata carries no precedence. - [ - "build metadata ignored", - semverRule("app_version", "=", "1.0.0+build2"), - { app_version: "1.0.0+build1" }, - true, - ], - [ - "build metadata ignored with prerelease", - semverRule("app_version", "=", "1.0.0-alpha"), - { app_version: "1.0.0-alpha+build" }, - true, - ], - [ - "build metadata with hyphen ignored", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1.2.3+build.1-2" }, - true, - ], - // Ignored means equal, so every symbol has to agree with that. - [ - "build metadata leaves versions equal", - semverRule("app_version", "!=", "1.0.0+build2"), - { app_version: "1.0.0+build1" }, - false, - ], - [ - "build metadata is not less", - semverRule("app_version", "<", "1.0.0+build2"), - { app_version: "1.0.0+build1" }, - false, - ], - [ - "build metadata is not greater", - semverRule("app_version", ">", "1.0.0+build2"), - { app_version: "1.0.0+build1" }, - false, - ], - [ - "build metadata at or below", - semverRule("app_version", "<=", "1.0.0+build2"), - { app_version: "1.0.0+build1" }, - true, - ], - [ - "build metadata at or above", - semverRule("app_version", ">=", "1.0.0+build2"), - { app_version: "1.0.0+build1" }, - true, - ], - [ - "build metadata does not block ordering", - semverRule("app_version", "<", "1.0.1+build1"), - { app_version: "1.0.0+build9" }, - true, - ], - [ - "build metadata does not block reverse ordering", - semverRule("app_version", ">", "1.0.0+build9"), - { app_version: "1.0.1+build1" }, - true, - ], - // Partial versions keep their prerelease once zero-padded. - [ - "partial version with prerelease", - semverRule("app_version", "=", "1.2.0-alpha"), - { app_version: "1.2-alpha" }, - true, - ], - [ - "partial prerelease below later minor", - semverRule("app_version", "<", "1.3.1"), - { app_version: "1.2-alpha" }, - true, - ], - [ - "partial prerelease below its release", - semverRule("app_version", "<", "1.2.0"), - { app_version: "1.2-alpha" }, - true, - ], - [ - "major-only with prerelease", - semverRule("app_version", "<", "1.0.0"), - { app_version: "1-rc1" }, - true, - ], - // An empty prerelease is invalid, so it is rejected rather than treated as the bare release. - [ - "empty prerelease, no match", - semverRule("app_version", "=", "1.0.0"), - { app_version: "1.0.0-" }, - false, - ], - [ - "empty prerelease, not-equal also false", - semverRule("app_version", "!=", "1.0.0"), - { app_version: "1.0.0-" }, - false, - ], - [ - "empty prerelease on partial version, no match", - semverRule("app_version", "=", "1.2.0"), - { app_version: "1.2-" }, - false, - ], - [ - "empty prerelease on partial version, not-equal also false", - semverRule("app_version", "!=", "1.2.0"), - { app_version: "1.2-" }, - false, - ], - // Hyphens are legal inside a prerelease identifier, so these are NOT empty prereleases. - [ - "trailing hyphen inside identifier", - semverRule("app_version", "<", "1.0.0"), - { app_version: "1.0.0-alpha-" }, - true, - ], - // SemVer 2.0.0 forbids leading zeros in the core, so these are rejected rather than normalized. - [ - "leading zero in major, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "01.2.3" }, - false, - ], - [ - "leading zero in major, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "01.2.3" }, - false, - ], - [ - "leading zero in minor, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1.02.3" }, - false, - ], - [ - "leading zero in minor, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1.02.3" }, - false, - ], - [ - "leading zero in patch, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1.2.03" }, - false, - ], - [ - "leading zero in patch, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1.2.03" }, - false, - ], - [ - "leading zeros throughout, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "01.02.03" }, - false, - ], - [ - "leading zeros throughout, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "01.02.03" }, - false, - ], - // A numeric prerelease identifier may not carry a leading zero either (section 9). - [ - "numeric prerelease with leading zero, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1.2.3-01" }, - false, - ], - [ - "numeric prerelease with leading zero, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1.2.3-01" }, - false, - ], - [ - "dotted numeric prerelease with leading zero, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1.2.3-rc.01" }, - false, - ], - [ - "dotted numeric prerelease with leading zero, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1.2.3-rc.01" }, - false, - ], - // An alphanumeric identifier may contain digits, so this one stays valid. - [ - "alphanumeric prerelease with digits", - semverRule("app_version", "<", "1.2.3"), - { app_version: "1.2.3-rc01" }, - true, - ], - [ - "between, inside", - customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), - { app_version: "1.5.0" }, - true, - ], - [ - "between, low boundary inclusive", - customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), - { app_version: "1.2.3" }, - true, - ], - [ - "between, high boundary inclusive", - customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), - { app_version: "2.0.0" }, - true, - ], - [ - "between, below", - customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), - { app_version: "1.0.0" }, - false, - ], - [ - "between, above", - customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), - { app_version: "2.0.1" }, - false, - ], - // A prerelease sits below its own release, which decides both boundary cases. - [ - "between, prerelease inside", - customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), - { app_version: "1.5.0-rc1" }, - true, - ], - [ - "between, prerelease below the high bound", - customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), - { app_version: "2.0.0-rc1" }, - true, - ], - [ - "between, prerelease of the low bound falls out", - customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), - { app_version: "1.2.3-rc1" }, - false, - ], - [ - "between, invalid version", - customBetween("semver_compare", "app_version", "1.2.3", "2.0.0"), - { app_version: "not-a-version" }, - false, - ], - [ - "between, single-point range", - customBetween("semver_compare", "app_version", "1.2.3", "1.2.3"), - { app_version: "1.2.3" }, - true, - ], - // Fail-closed: unparseable or missing values never match. - [ - "invalid actual, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "not-a-version" }, - false, - ], - [ - "non-string actual, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: 123 }, - false, - ], - [ - "missing property, no match", - semverRule("app_version", "=", "1.2.3"), - {}, - false, - ], - // A malformed version must never be padded or coerced into a real one. Both symbols are - // asserted so that "accepted at all" is observable rather than masked by a single false. - [ - "empty version, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "" }, - false, - ], - [ - "empty version, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "" }, - false, - ], - [ - "bare v, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "v" }, - false, - ], - [ - "bare v, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "v" }, - false, - ], - [ - "leading separator, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "-1.2.3" }, - false, - ], - [ - "leading separator, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "-1.2.3" }, - false, - ], - [ - "trailing dot, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1." }, - false, - ], - [ - "trailing dot, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1." }, - false, - ], - [ - "trailing dot after patch, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1.2.3." }, - false, - ], - [ - "trailing dot after patch, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1.2.3." }, - false, - ], - [ - "empty middle segment, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1..2" }, - false, - ], - [ - "empty middle segment, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1..2" }, - false, - ], - [ - "four components, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1.2.3.4" }, - false, - ], - [ - "four components, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1.2.3.4" }, - false, - ], - [ - "range prefix, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "^1.2.3" }, - false, - ], - [ - "range prefix, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "^1.2.3" }, - false, - ], - [ - "version inside text, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "abc1.2.3" }, - false, - ], - [ - "version inside text, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "abc1.2.3" }, - false, - ], - [ - "empty build metadata, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1.2.3+" }, - false, - ], - [ - "empty build metadata, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1.2.3+" }, - false, - ], - [ - "empty prerelease identifier, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1.2.3-alpha..1" }, - false, - ], - [ - "empty prerelease identifier, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1.2.3-alpha..1" }, - false, - ], - [ - "lone dot prerelease, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1.2.3-." }, - false, - ], - [ - "lone dot prerelease, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1.2.3-." }, - false, - ], - [ - "underscore in prerelease, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "1.2.3-ALPHA_BETA" }, - false, - ], - [ - "underscore in prerelease, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "1.2.3-ALPHA_BETA" }, - false, - ], - [ - "doubled v-prefix, no match", - semverRule("app_version", "=", "1.2.3"), - { app_version: "vv1.2.3" }, - false, - ], - [ - "doubled v-prefix, not-equal also false", - semverRule("app_version", "!=", "1.2.3"), - { app_version: "vv1.2.3" }, - false, - ], - [ - "missing operand, no match", - { semver_compare: [varNode("app_version"), "="] }, - { app_version: "1.2.3" }, - false, - ], - ])("%s", (_name, rule, data, want) => { + it.each(loadVectors("semver"))("%s", (_name, rule, data, want) => { expect(apply(rule, data)).toBe(want); }); }); describe("datetime_compare operator", () => { - it.each([ - // Asymmetric contract: subject (runtime var) is a strict RFC3339 string, target is epoch ms. - [ - "before, true", - datetimeRule("signup", "<", JUL16_MS), - { signup: "2026-07-15T00:00:00Z" }, - true, - ], - [ - "before, false", - datetimeRule("signup", "<", JUL16_MS), - { signup: "2026-07-16T00:00:00Z" }, - false, - ], - [ - "on (equal), true", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00Z" }, - true, - ], - [ - "not on, true", - datetimeRule("signup", "!=", JUL16_MS), - { signup: "2026-07-17T00:00:00Z" }, - true, - ], - [ - "since (>=), boundary", - datetimeRule("signup", ">=", JUL16_MS), - { signup: "2026-07-16T00:00:00Z" }, - true, - ], - [ - "after (>), true", - datetimeRule("signup", ">", JUL16_MS), - { signup: "2026-07-17T00:00:00Z" }, - true, - ], - [ - "after (>), false", - datetimeRule("signup", ">", JUL16_MS), - { signup: "2026-07-15T00:00:00Z" }, - false, - ], - // Every symbol is asserted in both directions. - [ - "at or before, boundary", - datetimeRule("signup", "<=", JUL16_MS), - { signup: "2026-07-16T00:00:00Z" }, - true, - ], - [ - "at or before, after", - datetimeRule("signup", "<=", JUL16_MS), - { signup: "2026-07-17T00:00:00Z" }, - false, - ], - [ - "on (equal), false", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-17T00:00:00Z" }, - false, - ], - [ - "not on, equal", - datetimeRule("signup", "!=", JUL16_MS), - { signup: "2026-07-16T00:00:00Z" }, - false, - ], - [ - "since (>=), before", - datetimeRule("signup", ">=", JUL16_MS), - { signup: "2026-07-15T00:00:00Z" }, - false, - ], - [ - "between, inside", - datetimeBetween("signup", JAN1_MS, DEC31_MS), - { signup: "2026-06-15T00:00:00Z" }, - true, - ], - [ - "between, low boundary inclusive", - datetimeBetween("signup", JAN1_MS, DEC31_MS), - { signup: "2026-01-01T00:00:00Z" }, - true, - ], - [ - "between, high boundary inclusive", - datetimeBetween("signup", JAN1_MS, DEC31_MS), - { signup: "2026-12-31T00:00:00Z" }, - true, - ], - [ - "between, before range", - datetimeBetween("signup", JAN1_MS, DEC31_MS), - { signup: "2025-12-31T00:00:00Z" }, - false, - ], - [ - "between, after range", - datetimeBetween("signup", JAN1_MS, DEC31_MS), - { signup: "2027-01-01T00:00:00Z" }, - false, - ], - // A leap day is a real date. - [ - "leap day", - datetimeRule("signup", "=", LEAP_DAY_MS), - { signup: "2024-02-29T00:00:00Z" }, - true, - ], - // Time-zone offsets change the instant. - [ - "offset with half-hour minutes", - datetimeRule("signup", "=", JUL16_INDIA_MS), - { signup: "2026-07-16T00:00:00+05:30" }, - true, - ], - [ - "rfc3339 subject with offset", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T02:00:00+02:00" }, - true, - ], - [ - "positive offset precedes utc midnight", - datetimeRule("signup", "<", JUL16_MS), - { signup: "2026-07-16T00:00:00+05:30" }, - true, - ], - [ - "negative offset", - datetimeRule("signup", "=", JUL16_PACIFIC_MS), - { signup: "2026-07-16T00:00:00-08:00" }, - true, - ], - [ - "negative offset follows utc midnight", - datetimeRule("signup", ">", JUL16_MS), - { signup: "2026-07-16T00:00:00-08:00" }, - true, - ], - [ - "zero offset equals Z", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00+00:00" }, - true, - ], - // Sub-second precision is dropped, on both sides. The end-of-day rows are the window the UI - // emits for a single date, whose upper bound carries .999. - [ - "one-digit fraction", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00.5Z" }, - true, - ], - [ - "three-digit fraction", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00.500Z" }, - true, - ], - [ - "six-digit fraction", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00.123456Z" }, - true, - ], - [ - "nine-digit fraction", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00.999999999Z" }, - true, - ], - [ - "zero fraction", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00.0Z" }, - true, - ], - [ - "fractional seconds truncated", - datetimeRule("signup", ">=", JUL16_MS), - { signup: "2026-07-16T00:00:00.500Z" }, - true, - ], - [ - "end-of-day target drops its .999", - datetimeRule("signup", "=", JUL16_END_MS), - { signup: "2026-07-16T23:59:59Z" }, - true, - ], - [ - "end-of-day target is an inclusive bound", - datetimeRule("signup", "<=", JUL16_END_MS), - { signup: "2026-07-16T23:59:59Z" }, - true, - ], - [ - "end-of-day, fractional subject too", - datetimeRule("signup", "=", JUL16_END_MS), - { signup: "2026-07-16T23:59:59.999Z" }, - true, - ], - [ - "end-of-day inclusive, fractional subject", - datetimeRule("signup", "<=", JUL16_END_MS), - { signup: "2026-07-16T23:59:59.999Z" }, - true, - ], - // Fractional on both sides: the shape the UI actually round-trips. - // Trimming and lowercasing. - [ - "lowercased subject with fraction", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16t00:00:00.500z" }, - true, - ], - [ - "lowercased subject with offset", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16t02:00:00+02:00" }, - true, - ], - [ - "whitespace-padded subject", - datetimeRule("signup", "=", JUL16_MS), - { signup: " 2026-07-16T00:00:00Z " }, - true, - ], - [ - "lowercased rfc3339 subject", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16t00:00:00z" }, - true, - ], - // Shape violations, asserted under both = and != so that "accepted at all" is observable. - // RFC 3339 also permits 24:00:00 as end-of-day. Platforms disagree on it, so no vector - // asserts it either way. - [ - "one-digit month, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-7-16T00:00:00Z" }, - false, - ], - [ - "one-digit month, not-equal also false", - datetimeRule("signup", "!=", JUL16_MS), - { signup: "2026-7-16T00:00:00Z" }, - false, - ], - [ - "space separator, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16 00:00:00Z" }, - false, - ], - [ - "space separator, not-equal also false", - datetimeRule("signup", "!=", JUL16_MS), - { signup: "2026-07-16 00:00:00Z" }, - false, - ], - [ - "missing zone, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00" }, - false, - ], - [ - "missing zone, not-equal also false", - datetimeRule("signup", "!=", JUL16_MS), - { signup: "2026-07-16T00:00:00" }, - false, - ], - [ - "empty fraction, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00.Z" }, - false, - ], - [ - "empty fraction, not-equal also false", - datetimeRule("signup", "!=", JUL16_MS), - { signup: "2026-07-16T00:00:00.Z" }, - false, - ], - [ - "offset without colon, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00+0200" }, - false, - ], - [ - "offset without colon, not-equal also false", - datetimeRule("signup", "!=", JUL16_MS), - { signup: "2026-07-16T00:00:00+0200" }, - false, - ], - [ - "short offset, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00+02" }, - false, - ], - [ - "short offset, not-equal also false", - datetimeRule("signup", "!=", JUL16_MS), - { signup: "2026-07-16T00:00:00+02" }, - false, - ], - [ - "trailing junk, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00Zextra" }, - false, - ], - [ - "trailing junk, not-equal also false", - datetimeRule("signup", "!=", JUL16_MS), - { signup: "2026-07-16T00:00:00Zextra" }, - false, - ], - [ - "basic format, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: "20260716T000000Z" }, - false, - ], - [ - "basic format, not-equal also false", - datetimeRule("signup", "!=", JUL16_MS), - { signup: "20260716T000000Z" }, - false, - ], - [ - "zone after lowercase z, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00z00:00" }, - false, - ], - [ - "zone after lowercase z, not-equal also false", - datetimeRule("signup", "!=", JUL16_MS), - { signup: "2026-07-16T00:00:00z00:00" }, - false, - ], - [ - "comma fractional separator, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00,5Z" }, - false, - ], - [ - "comma fractional separator, not-equal also false", - datetimeRule("signup", "!=", JUL16_MS), - { signup: "2026-07-16T00:00:00,5Z" }, - false, - ], - // Fail-closed: subject must be an RFC3339 string, target must be an epoch-ms number. - [ - "numeric subject, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: JUL16_MS }, - false, - ], - [ - "negative epoch-ms target resolves to -1s", - datetimeRule("signup", "=", -1500), - { signup: "1969-12-31T23:59:59Z" }, - true, - ], - [ - "negative epoch-ms target, not equal", - datetimeRule("signup", "!=", -1500), - { signup: "1969-12-31T23:59:59Z" }, - false, - ], - [ - "negative epoch-ms target, at or after", - datetimeRule("signup", ">=", -1500), - { signup: "1969-12-31T23:59:59Z" }, - true, - ], - [ - "negative epoch-ms target, before", - datetimeRule("signup", "<", -1500), - { signup: "1969-12-31T23:59:58Z" }, - true, - ], - [ - "negative epoch-ms target, after", - datetimeRule("signup", ">", -2500), - { signup: "1969-12-31T23:59:59Z" }, - true, - ], - [ - "subject floors, it does not truncate", - datetimeRule("signup", "=", -2000), - { signup: "1969-12-31T23:59:58.500Z" }, - true, - ], - [ - "subject floors, not to -1s", - datetimeRule("signup", "!=", -1000), - { signup: "1969-12-31T23:59:58.500Z" }, - true, - ], - [ - "target beyond representable range, no match", - { datetime_compare: [varNode("signup"), "=", 1e308] }, - { signup: "2026-07-16T00:00:00Z" }, - false, - ], - [ - "target beyond representable range, greater-than also false", - { datetime_compare: [varNode("signup"), ">", 1e308] }, - { signup: "2026-07-16T00:00:00Z" }, - false, - ], - [ - "target beyond representable range, less-than also false", - { datetime_compare: [varNode("signup"), "<", 1e308] }, - { signup: "2026-07-16T00:00:00Z" }, - false, - ], - [ - "bare date subject, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16" }, - false, - ], - [ - "bare date subject, not-equal also false", - datetimeRule("signup", "!=", JUL16_MS), - { signup: "2026-07-16" }, - false, - ], - [ - "zoneless datetime subject, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: "2026-07-16T00:00:00" }, - false, - ], - [ - "non-datetime string, no match", - datetimeRule("signup", "=", JUL16_MS), - { signup: "yesterday" }, - false, - ], - [ - "missing property, no match", - datetimeRule("signup", "=", JUL16_MS), - {}, - false, - ], - [ - "missing operand, no match", - { datetime_compare: [varNode("signup"), "="] }, - { signup: "2026-07-16T00:00:00Z" }, - false, - ], - ])("%s", (_name, rule, data, want) => { + it.each(loadVectors("datetime"))("%s", (_name, rule, data, want) => { expect(apply(rule, data)).toBe(want); }); }); + +// The cases below are not golden vectors. They pin behaviour the shared files cannot express: a +// rule shape the engine would never produce, and the difference between an absent property and one +// holding a null, which both fail closed. +describe("fail-closed guards", () => { + it("refuses a rule that is missing an operand", () => { + const rule = { datetime_compare: [{ var: "signup" }, "==="] }; + expect(apply(rule, { signup: "2026-07-16T00:00:00Z" })).toBe(false); + }); + + it("omits the property for an unset subject", () => { + expect(dataFor(null)).toEqual({}); + expect(dataFor("1.2.3")).toEqual({ [VECTOR_KEY]: "1.2.3" }); + }); +}); diff --git a/packages/mixpanel/test/flags/test-data/datetime_compare_tests.json b/packages/mixpanel/test/flags/test-data/datetime_compare_tests.json new file mode 100644 index 0000000..48fb8f5 --- /dev/null +++ b/packages/mixpanel/test/flags/test-data/datetime_compare_tests.json @@ -0,0 +1,89 @@ +[ + "A list of golden vectors for custom operators, to ensure logic parity across platforms", + + "# Ordering and the six symbols", + ["2026-07-15T00:00:00Z", "<", 1784160000000, true], + ["2026-07-16T00:00:00Z", "<", 1784160000000, false], + ["2026-07-16T00:00:00Z", "===", 1784160000000, true], + ["2026-07-17T00:00:00Z", "!==", 1784160000000, true], + ["2026-07-16T00:00:00Z", ">=", 1784160000000, true], + ["2026-07-17T00:00:00Z", ">", 1784160000000, true], + ["2026-07-15T00:00:00Z", ">", 1784160000000, false], + ["2026-07-16T00:00:00Z", "<=", 1784160000000, true], + ["2026-07-17T00:00:00Z", "<=", 1784160000000, false], + ["2026-07-17T00:00:00Z", "===", 1784160000000, false], + ["2026-07-16T00:00:00Z", "!==", 1784160000000, false], + ["2026-07-15T00:00:00Z", ">=", 1784160000000, false], + + "# Leap day", + ["2024-02-29T00:00:00Z", "===", 1709164800000, true], + + "# Time-zone offsets change the instant", + ["2026-07-16T00:00:00+05:30", "===", 1784140200000, true], + ["2026-07-16T02:00:00+02:00", "===", 1784160000000, true], + ["2026-07-16T00:00:00+05:30", "<", 1784160000000, true], + ["2026-07-16T00:00:00-08:00", "===", 1784188800000, true], + ["2026-07-16T00:00:00-08:00", ">", 1784160000000, true], + ["2026-07-16T00:00:00+00:00", "===", 1784160000000, true], + + "# Sub-second precision is dropped", + ["2026-07-16T00:00:00.5Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.500Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.123456Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.999999999Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.0Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.500Z", ">=", 1784160000000, true], + ["2026-07-16T23:59:59Z", "===", 1784246399999, true], + ["2026-07-16T23:59:59Z", "<=", 1784246399999, true], + ["2026-07-16T23:59:59.999Z", "===", 1784246399999, true], + ["2026-07-16T23:59:59.999Z", "<=", 1784246399999, true], + + "# Trimming and lowercasing", + ["2026-07-16t00:00:00.500z", "===", 1784160000000, true], + ["2026-07-16t02:00:00+02:00", "===", 1784160000000, true], + [" 2026-07-16T00:00:00Z ", "===", 1784160000000, true], + ["2026-07-16t00:00:00z", "===", 1784160000000, true], + + "# Wrong shapes, checked under both symbols", + ["2026-7-16T00:00:00Z", "===", 1784160000000, false], + ["2026-7-16T00:00:00Z", "!==", 1784160000000, false], + ["2026-07-16 00:00:00Z", "===", 1784160000000, false], + ["2026-07-16 00:00:00Z", "!==", 1784160000000, false], + ["2026-07-16T00:00:00", "===", 1784160000000, false], + ["2026-07-16T00:00:00", "!==", 1784160000000, false], + ["2026-07-16T00:00:00.Z", "===", 1784160000000, false], + ["2026-07-16T00:00:00.Z", "!==", 1784160000000, false], + ["2026-07-16T00:00:00+0200", "===", 1784160000000, false], + ["2026-07-16T00:00:00+0200", "!==", 1784160000000, false], + ["2026-07-16T00:00:00+02", "===", 1784160000000, false], + ["2026-07-16T00:00:00+02", "!==", 1784160000000, false], + ["2026-07-16T00:00:00Zextra", "===", 1784160000000, false], + ["2026-07-16T00:00:00Zextra", "!==", 1784160000000, false], + ["2026-07-16", "===", 1784160000000, false], + ["2026-07-16", "!==", 1784160000000, false], + ["20260716T000000Z", "===", 1784160000000, false], + ["20260716T000000Z", "!==", 1784160000000, false], + ["2026-07-16T00:00:00z00:00", "===", 1784160000000, false], + ["2026-07-16T00:00:00z00:00", "!==", 1784160000000, false], + ["2026-07-16T00:00:00,5Z", "===", 1784160000000, false], + ["2026-07-16T00:00:00,5Z", "!==", 1784160000000, false], + + "# Missing or wrong-typed values", + [1784160000000, "===", 1784160000000, false], + ["2026-07-16T00:00:00Z", "===", 1e308, false], + ["2026-07-16T00:00:00Z", ">", 1e308, false], + ["2026-07-16T00:00:00Z", "<", 1e308, false], + ["2026-07-16", "===", 1784160000000, false], + ["2026-07-16T00:00:00", "===", 1784160000000, false], + ["yesterday", "===", 1784160000000, false], + [null, "===", 1784160000000, false], + + "# Targets before 1970", + ["1969-12-31T23:59:59Z", "===", -1500, true], + ["1969-12-31T23:59:59Z", "!==", -1500, false], + ["1969-12-31T23:59:59Z", ">=", -1500, true], + ["1969-12-31T23:59:58Z", "<", -1500, true], + ["1969-12-31T23:59:59Z", ">", -2500, true], + ["1969-12-31T23:59:58.500Z", "===", -2000, true], + ["1969-12-31T23:59:58.500Z", "!==", -1000, true] +] diff --git a/packages/mixpanel/test/flags/test-data/semver_compare_tests.json b/packages/mixpanel/test/flags/test-data/semver_compare_tests.json new file mode 100644 index 0000000..9054963 --- /dev/null +++ b/packages/mixpanel/test/flags/test-data/semver_compare_tests.json @@ -0,0 +1,155 @@ +[ + "A list of golden vectors for custom operators, to ensure logic parity across platforms", + + "# Ordering and the six symbols", + ["1.2.3", "===", "1.2.3", true], + ["1.2.4", "===", "1.2.3", false], + ["1.2.4", "!==", "1.2.3", true], + ["1.2.2", "<", "1.2.3", true], + ["1.2.3", "<", "1.2.3", false], + ["1.2.3", "<=", "1.2.3", true], + ["1.3.0", ">", "1.2.3", true], + ["1.2.3", ">=", "1.2.3", true], + ["1.10.0", ">", "1.9.0", true], + ["10.0.0", ">", "9.0.0", true], + ["1.0.10", ">", "1.0.9", true], + ["2.0.0", ">", "1.9.9", true], + ["1.0.0-alpha", "<", "1.0.0", true], + ["v1.2.3", "===", "1.2.3", true], + ["1.2.0", "===", "1.2", true], + [" 1.2.3 ", "===", "1.2.3", true], + ["1.2.3", "!==", "1.2.3", false], + ["1.2.4", "<=", "1.2.3", false], + ["1.2.2", ">", "1.2.3", false], + ["1.2.2", ">=", "1.2.3", false], + + "# Pre-release ordering", + ["1.0.0-alpha", "<", "1.0.0-beta", true], + ["1.0.0-beta", "<", "1.0.0-rc1", true], + ["1.0.0-rc1", "<", "1.0.0-rc2", true], + ["1.0.0-alpha", "<", "1.0.0-alpha.1", true], + ["1.0.0-alpha.1", "<", "1.0.0-alpha.beta", true], + ["1.0.0-alpha", "<", "1.0.0-alpha.beta", true], + ["1.0.0-beta.2", "<", "1.0.0-beta.11", true], + ["1.0.0-a.1", "<", "1.0.0-b.1", true], + ["1.0.0-a.1", "<", "1.0.0-a.2", true], + ["1.0.0-rc1", "===", "1.0.0-rc1", true], + ["1.0.0-rc1", ">", "1.0.0-rc.1", true], + ["2.0.0-alpha", ">", "1.9.9", true], + + "# A pre-release and a plain release, compared directly", + ["1.0.0", ">", "1.0.0-alpha", true], + ["1.0.0", ">=", "1.0.0-rc1", true], + ["1.0.0", "!==", "1.0.0-alpha", true], + ["1.0.0-alpha", "!==", "1.0.0", true], + ["1.0.0-alpha", "<=", "1.0.0", true], + ["1.0.0-alpha", ">", "0.9.9", true], + ["1.0.0-rc1", "<", "1.0.1", true], + + "# How pre-release identifiers compare, SemVer 2.0.0 item 11", + ["1.0.0-2", "<", "1.0.0-10", true], + ["1.0.0-1", "<", "1.0.0-alpha", true], + ["1.0.0-alpha", "<", "1.0.0-alpha-1", true], + ["1.0.0-beta.11", "<", "1.0.0-rc.1", true], + ["1.0.0-rc.1", "<", "1.0.0", true], + ["1.0.0-alpha.1.2.3", "<", "1.0.0-beta", true], + ["1.0.0-beta", ">", "1.0.0-alpha.1", true], + + "# Build metadata is ignored", + ["1.0.0+build1", "===", "1.0.0+build2", true], + ["1.0.0-alpha+build", "===", "1.0.0-alpha", true], + ["1.2.3+build.1-2", "===", "1.2.3", true], + ["1.0.0+build1", "!==", "1.0.0+build2", false], + ["1.0.0+build1", "<", "1.0.0+build2", false], + ["1.0.0+build1", ">", "1.0.0+build2", false], + ["1.0.0+build1", "<=", "1.0.0+build2", true], + ["1.0.0+build1", ">=", "1.0.0+build2", true], + ["1.0.0+build9", "<", "1.0.1+build1", true], + ["1.0.1+build1", ">", "1.0.0+build9", true], + + "# Partial versions", + ["1.2-alpha", "===", "1.2.0-alpha", true], + ["1.2-alpha", "<", "1.3.1", true], + ["1.2-alpha", "<", "1.2.0", true], + ["1-rc1", "<", "1.0.0", true], + ["1.2+build", "===", "1.2.0", true], + + "# Zero versions", + ["0.0.0", "===", "0.0.0", true], + ["0.0.0", "<", "0.0.1", true], + ["0", "===", "0.0.0", true], + + "# A version ending in a bare hyphen is rejected", + ["1.0.0-", "===", "1.0.0", false], + ["1.0.0-", "!==", "1.0.0", false], + ["1.2-", "===", "1.2.0", false], + ["1.2-", "!==", "1.2.0", false], + + "# Hyphens inside a pre-release are fine, since they are part of the pre-release identifier", + ["1.0.0-alpha-", "<", "1.0.0", true], + + "# Leading zeros are rejected", + ["01.2.3", "===", "1.2.3", false], + ["01.2.3", "!==", "1.2.3", false], + ["1.02.3", "===", "1.2.3", false], + ["1.02.3", "!==", "1.2.3", false], + ["1.2.03", "===", "1.2.3", false], + ["1.2.03", "!==", "1.2.3", false], + ["01.02.03", "===", "1.2.3", false], + ["01.02.03", "!==", "1.2.3", false], + + "# Leading zeros in a numeric pre-release are rejected too, SemVer 2.0.0 item 9", + ["1.2.3-01", "===", "1.2.3", false], + ["1.2.3-01", "!==", "1.2.3", false], + ["1.2.3-rc.01", "===", "1.2.3", false], + ["1.2.3-rc.01", "!==", "1.2.3", false], + + "# A lone zero is a legal pre-release identifier", + ["1.2.3-0", "<", "1.2.3", true], + + "# Digits inside a word are still valid", + ["1.2.3-rc01", "<", "1.2.3", true], + + "# A leading v is accepted, in either case", + ["V1.2.3", "===", "1.2.3", true], + ["v1.0.0-alpha", "<", "1.0.0", true], + ["v1.2.4", "!==", "1.2.3", true], + ["v1.2.3", "<=", "1.2.3", true], + ["v1.2.4", ">", "1.2.3", true], + ["v1.2.3", ">=", "1.2.3", true], + + "# Missing or wrong-typed values", + ["not-a-version", "===", "1.2.3", false], + [123, "===", "1.2.3", false], + [null, "===", "1.2.3", false], + + "# Malformed versions, checked under both symbols", + ["", "===", "1.2.3", false], + ["", "!==", "1.2.3", false], + ["v", "===", "1.2.3", false], + ["v", "!==", "1.2.3", false], + ["-1.2.3", "===", "1.2.3", false], + ["-1.2.3", "!==", "1.2.3", false], + ["1.", "===", "1.2.3", false], + ["1.", "!==", "1.2.3", false], + ["1.2.3.", "===", "1.2.3", false], + ["1.2.3.", "!==", "1.2.3", false], + ["1..2", "===", "1.2.3", false], + ["1..2", "!==", "1.2.3", false], + ["1.2.3.4", "===", "1.2.3", false], + ["1.2.3.4", "!==", "1.2.3", false], + ["^1.2.3", "===", "1.2.3", false], + ["^1.2.3", "!==", "1.2.3", false], + ["abc1.2.3", "===", "1.2.3", false], + ["abc1.2.3", "!==", "1.2.3", false], + ["1.2.3+", "===", "1.2.3", false], + ["1.2.3+", "!==", "1.2.3", false], + ["1.2.3-alpha..1", "===", "1.2.3", false], + ["1.2.3-alpha..1", "!==", "1.2.3", false], + ["1.2.3-.", "===", "1.2.3", false], + ["1.2.3-.", "!==", "1.2.3", false], + ["1.2.3-ALPHA_BETA", "===", "1.2.3", false], + ["1.2.3-ALPHA_BETA", "!==", "1.2.3", false], + ["vv1.2.3", "===", "1.2.3", false], + ["vv1.2.3", "!==", "1.2.3", false] +] From aaaa49c5edaacf0d38bb5079861d5d7644dbde2d Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:00:58 +0000 Subject: [PATCH 4/7] updating tests and adding semver limits --- .../mixpanel/lib/flags/custom_operators.js | 41 +++++++++++++++++-- .../test-data/datetime_compare_tests.json | 37 +++++++++++------ .../flags/test-data/semver_compare_tests.json | 8 +++- 3 files changed, 69 insertions(+), 17 deletions(-) diff --git a/packages/mixpanel/lib/flags/custom_operators.js b/packages/mixpanel/lib/flags/custom_operators.js index 7089b8d..dcc8532 100644 --- a/packages/mixpanel/lib/flags/custom_operators.js +++ b/packages/mixpanel/lib/flags/custom_operators.js @@ -1,12 +1,18 @@ const jsonLogic = require("json-logic-js"); -// Strict RFC3339 guard for datetime strings. +// Strict RFC3339 guard for datetime strings. The date and hour fields are captured so the calendar +// can be validated separately; the pattern only constrains their shape. const RFC3339_PATTERN = - /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/; + /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/; // SemVer 2.0.0 requires major.minor.patch; partial versions are zero-padded to this. const SEMVER_PARTS = 3; +// Longest operand the semver regex is allowed to see. A real version never approaches this; the +// bound matches MAX_LENGTH in node-semver, and keeps an arbitrarily long property value off the +// regex regardless of how the engine schedules backtracking. +const MAX_SEMVER_LENGTH = 256; + // Epoch milliseconds are compared as int64 elsewhere, so anything at or beyond this is out of range. const MAX_EPOCH_MS = 9223372036854775808; @@ -28,6 +34,9 @@ function semverCompare(actual, symbol, target) { if (typeof actual !== "string" || typeof target !== "string") { return false; } + if (actual.length > MAX_SEMVER_LENGTH || target.length > MAX_SEMVER_LENGTH) { + return false; + } const actualVersion = normalizeSemver(actual); const targetVersion = normalizeSemver(target); if ( @@ -184,12 +193,38 @@ function normalizeSemver(version) { return parts.join(".") + suffix; } +function daysInMonth(year, month) { + if (month === 2) { + const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + return isLeapYear ? 29 : 28; + } + return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31; +} + +// The pattern constrains each field to two digits, which still admits a date that cannot exist, such +// as 2026-02-30 or 29 February in a common year. Date.parse rolls those forward into a real instant +// instead of rejecting them, and hour 24 likewise becomes the following midnight, so the calendar is +// checked here rather than left to the engine. RFC 3339 section 5.6 allows hours 00 through 23. +function isRealCalendarDate(year, month, day, hour) { + if (month < 1 || month > 12 || day < 1 || hour > 23) { + return false; + } + return day <= daysInMonth(year, month); +} + function convertRfc3339ToUnixSeconds(value) { if (typeof value !== "string") { return null; } const normalized = value.trim().toUpperCase(); - if (!RFC3339_PATTERN.test(normalized)) { + const fields = RFC3339_PATTERN.exec(normalized); + if (!fields) { + return null; + } + const [, year, month, day, hour] = fields; + if ( + !isRealCalendarDate(Number(year), Number(month), Number(day), Number(hour)) + ) { return null; } const ms = Date.parse(normalized); diff --git a/packages/mixpanel/test/flags/test-data/datetime_compare_tests.json b/packages/mixpanel/test/flags/test-data/datetime_compare_tests.json index 48fb8f5..5e8633d 100644 --- a/packages/mixpanel/test/flags/test-data/datetime_compare_tests.json +++ b/packages/mixpanel/test/flags/test-data/datetime_compare_tests.json @@ -33,10 +33,10 @@ ["2026-07-16T00:00:00.999999999Z", "===", 1784160000000, true], ["2026-07-16T00:00:00.0Z", "===", 1784160000000, true], ["2026-07-16T00:00:00.500Z", ">=", 1784160000000, true], - ["2026-07-16T23:59:59Z", "===", 1784246399999, true], - ["2026-07-16T23:59:59Z", "<=", 1784246399999, true], - ["2026-07-16T23:59:59.999Z", "===", 1784246399999, true], - ["2026-07-16T23:59:59.999Z", "<=", 1784246399999, true], + ["2026-07-16T23:59:59Z", "===", 1784246399000, true], + ["2026-07-16T23:59:59Z", "<=", 1784246399000, true], + ["2026-07-16T23:59:59.999Z", "===", 1784246399000, true], + ["2026-07-16T23:59:59.999Z", "<=", 1784246399000, true], "# Trimming and lowercasing", ["2026-07-16t00:00:00.500z", "===", 1784160000000, true], @@ -70,20 +70,31 @@ "# Missing or wrong-typed values", [1784160000000, "===", 1784160000000, false], - ["2026-07-16T00:00:00Z", "===", 1e308, false], - ["2026-07-16T00:00:00Z", ">", 1e308, false], - ["2026-07-16T00:00:00Z", "<", 1e308, false], + ["2026-07-16T00:00:00Z", "===", 1e19, false], + ["2026-07-16T00:00:00Z", ">", 1e19, false], + ["2026-07-16T00:00:00Z", "<", 1e19, false], ["2026-07-16", "===", 1784160000000, false], ["2026-07-16T00:00:00", "===", 1784160000000, false], ["yesterday", "===", 1784160000000, false], [null, "===", 1784160000000, false], "# Targets before 1970", - ["1969-12-31T23:59:59Z", "===", -1500, true], - ["1969-12-31T23:59:59Z", "!==", -1500, false], - ["1969-12-31T23:59:59Z", ">=", -1500, true], - ["1969-12-31T23:59:58Z", "<", -1500, true], - ["1969-12-31T23:59:59Z", ">", -2500, true], + ["1969-12-31T23:59:59Z", "===", -1000, true], + ["1969-12-31T23:59:59Z", "!==", -1000, false], + ["1969-12-31T23:59:59Z", ">=", -1000, true], + ["1969-12-31T23:59:58Z", "<", -1000, true], + ["1969-12-31T23:59:59Z", ">", -2000, true], ["1969-12-31T23:59:58.500Z", "===", -2000, true], - ["1969-12-31T23:59:58.500Z", "!==", -1000, true] + ["1969-12-31T23:59:58.500Z", "!==", -1000, true], + + "# Impossible dates and out-of-range fields", + ["2026-02-30T00:00:00Z", "===", 1784160000000, false], + ["2026-02-30T00:00:00Z", "!==", 1784160000000, false], + ["2026-02-29T00:00:00Z", "!==", 1784160000000, false], + ["2025-02-29T00:00:00Z", "!==", 1784160000000, false], + ["2026-04-31T00:00:00Z", "!==", 1784160000000, false], + ["2026-06-31T00:00:00Z", "!==", 1784160000000, false], + ["2026-07-16T24:00:00Z", "!==", 1784160000000, false], + ["2026-13-01T00:00:00Z", "!==", 1784160000000, false], + ["2026-01-32T00:00:00Z", "!==", 1784160000000, false] ] diff --git a/packages/mixpanel/test/flags/test-data/semver_compare_tests.json b/packages/mixpanel/test/flags/test-data/semver_compare_tests.json index 9054963..42c6c75 100644 --- a/packages/mixpanel/test/flags/test-data/semver_compare_tests.json +++ b/packages/mixpanel/test/flags/test-data/semver_compare_tests.json @@ -151,5 +151,11 @@ ["1.2.3-ALPHA_BETA", "===", "1.2.3", false], ["1.2.3-ALPHA_BETA", "!==", "1.2.3", false], ["vv1.2.3", "===", "1.2.3", false], - ["vv1.2.3", "!==", "1.2.3", false] + ["vv1.2.3", "!==", "1.2.3", false], + + "# Operand length cap", + ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "===", "1.2.3", false], + ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "!==", "1.2.3", false], + ["1.2.3", "!==", "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false], + ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "!==", "1.2.3", true] ] From d53241901c1de2a47bfe107897a8c5a91d36c2cc Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:23:23 +0000 Subject: [PATCH 5/7] lint and fixing lock file --- package-lock.json | 7 ----- .../flags/test-data/semver_compare_tests.json | 28 ++++++++++++++++--- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 746f256..b13940e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1060,12 +1060,6 @@ "node": ">=18" } }, - "node_modules/compare-versions": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", - "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2248,7 +2242,6 @@ "version": "0.23.0", "license": "MIT", "dependencies": { - "compare-versions": "6.1.1", "https-proxy-agent": "7.0.6", "json-logic-js": "2.0.5" }, diff --git a/packages/mixpanel/test/flags/test-data/semver_compare_tests.json b/packages/mixpanel/test/flags/test-data/semver_compare_tests.json index 42c6c75..ae475e6 100644 --- a/packages/mixpanel/test/flags/test-data/semver_compare_tests.json +++ b/packages/mixpanel/test/flags/test-data/semver_compare_tests.json @@ -154,8 +154,28 @@ ["vv1.2.3", "!==", "1.2.3", false], "# Operand length cap", - ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "===", "1.2.3", false], - ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "!==", "1.2.3", false], - ["1.2.3", "!==", "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false], - ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "!==", "1.2.3", true] + [ + "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "===", + "1.2.3", + false + ], + [ + "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "!==", + "1.2.3", + false + ], + [ + "1.2.3", + "!==", + "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + false + ], + [ + "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "!==", + "1.2.3", + true + ] ] From 0b28f18720587967e684964c3ef3e2928ffd504f Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:32:12 +0000 Subject: [PATCH 6/7] simplifying date check --- .../mixpanel/lib/flags/custom_operators.js | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/mixpanel/lib/flags/custom_operators.js b/packages/mixpanel/lib/flags/custom_operators.js index dcc8532..00f597c 100644 --- a/packages/mixpanel/lib/flags/custom_operators.js +++ b/packages/mixpanel/lib/flags/custom_operators.js @@ -193,23 +193,20 @@ function normalizeSemver(version) { return parts.join(".") + suffix; } -function daysInMonth(year, month) { - if (month === 2) { - const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; - return isLeapYear ? 29 : 28; - } - return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31; -} - // The pattern constrains each field to two digits, which still admits a date that cannot exist, such -// as 2026-02-30 or 29 February in a common year. Date.parse rolls those forward into a real instant -// instead of rejecting them, and hour 24 likewise becomes the following midnight, so the calendar is -// checked here rather than left to the engine. RFC 3339 section 5.6 allows hours 00 through 23. +// as 2026-02-30 or 29 February in a common year. Writing the fields into a Date and reading them back +// settles it: out-of-range fields are normalized into a real instant, so a date that does not exist +// comes back carrying different fields than it went in with. The three-argument setUTCFullYear sets +// all three at once, which judges 29 February against the year given rather than a placeholder, and +// leaves years 0 through 99 alone where Date.UTC would map them into the 1900s. The hour is checked +// separately because it is not part of the round trip; RFC 3339 section 5.6 allows hours 00 through 23. function isRealCalendarDate(year, month, day, hour) { - if (month < 1 || month > 12 || day < 1 || hour > 23) { + if (hour > 23) { return false; } - return day <= daysInMonth(year, month); + const dt = new Date(); + dt.setUTCFullYear(year, month - 1, day); + return dt.getUTCFullYear() === year && dt.getUTCMonth() === month - 1 && dt.getUTCDate() === day; } function convertRfc3339ToUnixSeconds(value) { From 2327e710118884aedd4c9a0f646ab6bdc8d09d0e Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:52:28 +0000 Subject: [PATCH 7/7] lint --- packages/mixpanel/lib/flags/custom_operators.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/mixpanel/lib/flags/custom_operators.js b/packages/mixpanel/lib/flags/custom_operators.js index 00f597c..061ff82 100644 --- a/packages/mixpanel/lib/flags/custom_operators.js +++ b/packages/mixpanel/lib/flags/custom_operators.js @@ -206,7 +206,11 @@ function isRealCalendarDate(year, month, day, hour) { } const dt = new Date(); dt.setUTCFullYear(year, month - 1, day); - return dt.getUTCFullYear() === year && dt.getUTCMonth() === month - 1 && dt.getUTCDate() === day; + return ( + dt.getUTCFullYear() === year && + dt.getUTCMonth() === month - 1 && + dt.getUTCDate() === day + ); } function convertRfc3339ToUnixSeconds(value) {