Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions packages/core/src/evaluation/evaluateForSubject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type TimeStamp, timeStampNow } from '../time'
import { TargetingKeyMissingError } from './errors'
import { createEvaluationTimestampMetadata } from './evaluationMetadata'
import { matchesShard } from './matchesShard'
import { isValidRule, matchesRule, type Rule } from './rules'
import { hasInvalidSemverComparand, isValidRule, matchesRule, type Rule } from './rules'
import { type Flag, type Split, type VariantType, variantTypeToFlagValueType } from './ufc-v1'

export function evaluateForSubject<T extends FlagValueType>(
Expand Down Expand Up @@ -157,15 +157,25 @@ function validateTypeMatch(expectedType: FlagValueType, variantType: VariantType
}

function isValidFlag(flag: Flag): boolean {
return (
Array.isArray(flag.allocations) &&
flag.allocations.every(
if (!Array.isArray(flag.allocations)) {
return false
}

if (
flag.allocations.some(
(allocation) =>
Array.isArray(allocation.splits) &&
allocation.splits.every((split) => Array.isArray(split.shards)) &&
(allocation.rules === undefined ||
(Array.isArray(allocation.rules) && allocation.rules.every((rule) => isValidRule(rule))))
Array.isArray(allocation.rules) && allocation.rules.some((rule) => hasInvalidSemverComparand(rule))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: shall this be moved to isValidRule?

)
) {
throw new Error('invalid semantic version comparand')
}

return flag.allocations.every(
(allocation) =>
Array.isArray(allocation.splits) &&
allocation.splits.every((split) => Array.isArray(split.shards)) &&
(allocation.rules === undefined ||
(Array.isArray(allocation.rules) && allocation.rules.every((rule) => isValidRule(rule))))
)
}

Expand Down
81 changes: 81 additions & 0 deletions packages/core/src/evaluation/rules.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EvaluationContext, EvaluationContextValue } from '@openfeature/core'
import { compareSemver, parseSemver } from './semver'

export type ConditionValueType = EvaluationContextValue | EvaluationContextValue[]

Expand All @@ -12,6 +13,12 @@ export enum OperatorType {
ONE_OF = 'ONE_OF',
NOT_ONE_OF = 'NOT_ONE_OF',
IS_NULL = 'IS_NULL',
SEMVER_EQ = 'SEMVER_EQ',
SEMVER_NEQ = 'SEMVER_NEQ',
SEMVER_LT = 'SEMVER_LT',
SEMVER_LTE = 'SEMVER_LTE',
SEMVER_GT = 'SEMVER_GT',
SEMVER_GTE = 'SEMVER_GTE',
}

const supportedOperators = new Set<string>(Object.values(OperatorType))
Expand Down Expand Up @@ -54,13 +61,28 @@ type NullCondition = {
value: boolean
}

type SemverOperator =
| OperatorType.SEMVER_EQ
| OperatorType.SEMVER_NEQ
| OperatorType.SEMVER_LT
| OperatorType.SEMVER_LTE
| OperatorType.SEMVER_GT
| OperatorType.SEMVER_GTE

type SemverCondition = {
operator: SemverOperator
attribute: string
value: string
}

export type Condition =
| MatchesCondition
| NotMatchesCondition
| OneOfCondition
| NotOneOfCondition
| NumericCondition
| NullCondition
| SemverCondition

export interface Rule {
conditions: Condition[]
Expand All @@ -75,6 +97,9 @@ export function isValidRule(rule: Rule): boolean {
if (!supportedOperators.has(condition.operator)) {
return false
}
if (isSemverOperator(condition.operator)) {
return parseSemver(condition.value) !== null
}
if (condition.operator !== OperatorType.MATCHES && condition.operator !== OperatorType.NOT_MATCHES) {
return true
}
Expand Down Expand Up @@ -132,11 +157,67 @@ function evaluateCondition(subjectAttributes: EvaluationContext, condition: Cond
return isOneOf(value.toString(), condition.value)
case OperatorType.NOT_ONE_OF:
return isNotOneOf(value.toString(), condition.value)
case OperatorType.SEMVER_EQ:
case OperatorType.SEMVER_NEQ:
case OperatorType.SEMVER_LT:
case OperatorType.SEMVER_LTE:
case OperatorType.SEMVER_GT:
case OperatorType.SEMVER_GTE:
return evaluateSemverCondition(value, condition.value, condition.operator)
}
}
return false
}

export function isSemverOperator(operator: string): operator is SemverOperator {
return (
operator === OperatorType.SEMVER_EQ ||
operator === OperatorType.SEMVER_NEQ ||
operator === OperatorType.SEMVER_LT ||
operator === OperatorType.SEMVER_LTE ||
operator === OperatorType.SEMVER_GT ||
operator === OperatorType.SEMVER_GTE
)
}

export function hasInvalidSemverComparand(rule: Rule): boolean {
return rule.conditions.some(
(condition) => isSemverOperator(condition.operator) && parseSemver(condition.value) === null
)
}

function evaluateSemverCondition(
attributeValue: EvaluationContextValue,
comparandValue: string,
operator: SemverOperator
): boolean {
if (typeof attributeValue !== 'string') {
return false
}

const attribute = parseSemver(attributeValue)
const comparand = parseSemver(comparandValue)
if (!attribute || !comparand) {
return false
}

const ordering = compareSemver(attribute, comparand)
switch (operator) {
case OperatorType.SEMVER_EQ:
return ordering === 0
case OperatorType.SEMVER_NEQ:
return ordering !== 0
case OperatorType.SEMVER_LT:
return ordering < 0
case OperatorType.SEMVER_LTE:
return ordering <= 0
case OperatorType.SEMVER_GT:
return ordering > 0
case OperatorType.SEMVER_GTE:
return ordering >= 0
}
}

function compileRegex(pattern: string): RegExp {
const inlineFlags = pattern.match(/^\(\?([imsu]+)\)/)
const flags = inlineFlags ? [...new Set(inlineFlags[1])].join('') : ''
Expand Down
231 changes: 231 additions & 0 deletions packages/core/src/evaluation/semver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
const MAX_UINT64 = '18446744073709551615'

/**
* The language-neutral SemVer representation used by the FFE evaluator.
* Build metadata is validated while parsing but is intentionally not retained,
* because it does not affect SemVer precedence.
*/
export interface ParsedSemver {
major: string
minor: string
patch: string
prerelease: string
}

/**
* Parse the SemVer subset.
* Core identifiers are limited to uint64; numeric prerelease identifiers may
* be arbitrarily large.
*/
export function parseSemver(version: unknown): ParsedSemver | null {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: the current implementation seems to be verbose and likely slow'ish

I have a feeling that a regex could be faster and shorter. Another option is to split on . and - and validate components afterwards

@greghuels greghuels Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2026-08-13 at 9 11 00 AM

I had an LLM run through some benchmarks on the 2 implementations you suggested along with the current implementation. It looks like the current implementation is still the fastest.

if (typeof version !== 'string') {
return null
}

const major = parseCoreIdentifier(version, 0)
if (!major || major.next >= version.length || version[major.next] !== '.') {
return null
}

const minor = parseCoreIdentifier(version, major.next + 1)
if (!minor || minor.next >= version.length || version[minor.next] !== '.') {
return null
}

const patch = parseCoreIdentifier(version, minor.next + 1)
if (!patch) {
return null
}

const parsed: ParsedSemver = {
major: major.value,
minor: minor.value,
patch: patch.value,
prerelease: '',
}

if (patch.next === version.length) {
return parsed
}

let remainder = version.slice(patch.next)
if (remainder.startsWith('-')) {
remainder = remainder.slice(1)
const buildStart = remainder.indexOf('+')
if (buildStart === -1) {
return isValidSemverIdentifiers(remainder, false) ? { ...parsed, prerelease: remainder } : null
}

const prerelease = remainder.slice(0, buildStart)
if (!isValidSemverIdentifiers(prerelease, false)) {
return null
}
parsed.prerelease = prerelease
remainder = remainder.slice(buildStart + 1)
} else if (remainder.startsWith('+')) {
remainder = remainder.slice(1)
} else {
return null
}

return isValidSemverIdentifiers(remainder, true) ? parsed : null
}

/** Compare SemVer precedence. Build metadata is intentionally ignored. */
export function compareSemver(left: ParsedSemver, right: ParsedSemver): number {
for (const [leftValue, rightValue] of [
[left.major, right.major],
[left.minor, right.minor],
[left.patch, right.patch],
]) {
const ordering = compareNumericStrings(leftValue, rightValue)
if (ordering !== 0) {
return ordering
}
}

return compareSemverPrerelease(left.prerelease, right.prerelease)
}

function parseCoreIdentifier(version: string, start: number): { value: string; next: number } | null {
if (start >= version.length || !isAsciiDigit(version.charCodeAt(start))) {
return null
}

if (version[start] === '0') {
return { value: '0', next: start + 1 }
}

let end = start
while (end < version.length && isAsciiDigit(version.charCodeAt(end))) {
end++
}

const value = version.slice(start, end)
if (value.length > MAX_UINT64.length || (value.length === MAX_UINT64.length && value > MAX_UINT64)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: why do we artificially limit the max value?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For parity with implementation in other dd-trace libraries. It shouldn't matter too much either way, since this won't be a real-world use case.

return null
}
return { value, next: end }
}

function isValidSemverIdentifiers(value: string, allowLeadingZeros: boolean): boolean {
let identifierStart = 0
let identifierNumeric = true

for (let i = 0; i <= value.length; i++) {
if (i === value.length || value[i] === '.') {
if (i === identifierStart) {
return false
}
if (!allowLeadingZeros && identifierNumeric && i - identifierStart > 1 && value[identifierStart] === '0') {
return false
}
identifierStart = i + 1
identifierNumeric = true
continue
}

const code = value.charCodeAt(i)
if (!isAsciiAlphanumeric(code) && value[i] !== '-') {
return false
}
if (!isAsciiDigit(code)) {
identifierNumeric = false
}
}

return true
}

function compareSemverPrerelease(left: string, right: string): number {
if (left === right) {
return 0
}
if (left === '') {
return 1
}
if (right === '') {
return -1
}

let leftRemaining = left
let rightRemaining = right
while (true) {
const [leftIdentifier, nextLeft] = nextSemverIdentifier(leftRemaining)
const [rightIdentifier, nextRight] = nextSemverIdentifier(rightRemaining)
const ordering = compareSemverIdentifier(leftIdentifier, rightIdentifier)
if (ordering !== 0) {
return ordering
}

if (nextLeft === '' || nextRight === '') {
if (nextLeft === '' && nextRight === '') {
return 0
}
return nextLeft === '' ? -1 : 1
}

leftRemaining = nextLeft.slice(1)
rightRemaining = nextRight.slice(1)
}
}

function nextSemverIdentifier(value: string): [string, string] {
const dot = value.indexOf('.')
return dot === -1 ? [value, ''] : [value.slice(0, dot), value.slice(dot)]
}

function compareSemverIdentifier(left: string, right: string): number {
const leftNumeric = isSemverNumericIdentifier(left)
const rightNumeric = isSemverNumericIdentifier(right)

if (leftNumeric && rightNumeric) {
return compareNumericStrings(left, right)
}
if (leftNumeric) {
return -1
}
if (rightNumeric) {
return 1
}
return compareAsciiStrings(left, right)
}

function isSemverNumericIdentifier(value: string): boolean {
for (let i = 0; i < value.length; i++) {
if (!isAsciiDigit(value.charCodeAt(i))) {
return false
}
}
return true
}

function compareNumericStrings(left: string, right: string): number {
if (left.length !== right.length) {
return left.length < right.length ? -1 : 1
}
return compareAsciiStrings(left, right)
}

function compareAsciiStrings(left: string, right: string): number {
const length = Math.min(left.length, right.length)
for (let i = 0; i < length; i++) {
const leftCode = left.charCodeAt(i)
const rightCode = right.charCodeAt(i)
if (leftCode !== rightCode) {
return leftCode < rightCode ? -1 : 1
}
}
if (left.length === right.length) {
return 0
}
return left.length < right.length ? -1 : 1
}

function isAsciiDigit(code: number): boolean {
return code >= 48 && code <= 57
}

function isAsciiAlphanumeric(code: number): boolean {
return isAsciiDigit(code) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
}
Loading