Skip to content

Commit 11da413

Browse files
mfrachetMastra Code (anthropic/claude-opus-4-6)
andcommitted
feat: add semver operators (semver_equal, semver_greater_than, semver_less_than)
Add the final 3 missing operators to reach full parity with mainstream feature flag tools (LaunchDarkly, Unleash, Flagsmith). Uses the semver npm package for spec-compliant version comparison. 49 new tests covering equality, ordering, prerelease, build metadata, invalid versions, and edge cases. 370 total tests passing. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-6) <noreply@mastra.ai>
1 parent 63f51bd commit 11da413

7 files changed

Lines changed: 258 additions & 7 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ Rules support the following operators:
124124
| `is_set` || User field exists and is not `null`/`undefined` |
125125
| `is_not_set` || User field is missing, `null`, or `undefined` |
126126
| `modulo` | `{ divisor, remainder }` | `fieldValue % divisor === remainder` |
127+
| `semver_equal` | `string` | User field equals the semver version |
128+
| `semver_greater_than` | `string` | User field is a greater semver version |
129+
| `semver_less_than` | `string` | User field is a lesser semver version |
127130

128131
### Segments
129132

packages/core/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@rollup/plugin-typescript": "^12.1.3",
3434
"@types/eslint__js": "^9.14.0",
3535
"@types/murmurhash-js": "^1.0.6",
36+
"@types/semver": "^7.7.1",
3637
"@vitest/coverage-v8": "3.2.4",
3738
"bundlesize": "^0.18.2",
3839
"eslint": "^9.29.0",
@@ -45,7 +46,8 @@
4546
"vitest": "^3.2.4"
4647
},
4748
"dependencies": {
48-
"murmurhash-js": "^1.0.0"
49+
"murmurhash-js": "^1.0.0",
50+
"semver": "^7.7.4"
4951
},
5052
"bundlesize": [
5153
{

packages/core/src/__tests__/isEligibleForStrategy.test.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,205 @@ describe("isEligibleForStrategy", () => {
10311031
).toBe(false);
10321032
});
10331033

1034+
// ── semver_equal ─────────────────────────────────────────────────────
1035+
1036+
it.each([
1037+
["1.0.0", "1.0.0", true],
1038+
["1.0.0", "2.0.0", false],
1039+
["1.2.3", "1.2.3", true],
1040+
["2.0.0", "1.0.0", false],
1041+
["1.0.0-alpha", "1.0.0-alpha", true],
1042+
["1.0.0-alpha", "1.0.0-beta", false],
1043+
["1.0.0-alpha", "1.0.0", false],
1044+
["1.0.0+build.1", "1.0.0+build.2", true], // build metadata ignored per semver spec
1045+
["1.0.0+build.1", "1.0.0", true], // build metadata ignored
1046+
["0.0.1", "0.0.1", true],
1047+
["10.20.30", "10.20.30", true],
1048+
["1.0.0-alpha.1", "1.0.0-alpha.1", true],
1049+
["1.0.0-alpha.1", "1.0.0-alpha.2", false],
1050+
])(
1051+
"when field is '%s' and rule value is '%s', then it should return '%s' for operator semver_equal",
1052+
(userValue, ruleValue, expected) => {
1053+
const rules: Rule[] = [
1054+
{
1055+
operator: "semver_equal",
1056+
field: "version",
1057+
value: ruleValue,
1058+
},
1059+
];
1060+
1061+
expect(
1062+
isEligibleForStrategy(rules, { __id: "yo", version: userValue })
1063+
).toBe(expected);
1064+
}
1065+
);
1066+
1067+
it("semver_equal returns false for invalid user version", () => {
1068+
const rules: Rule[] = [
1069+
{ operator: "semver_equal", field: "version", value: "1.0.0" },
1070+
];
1071+
expect(
1072+
isEligibleForStrategy(rules, { __id: "yo", version: "not-semver" })
1073+
).toBe(false);
1074+
});
1075+
1076+
it("semver_equal returns false for invalid rule version", () => {
1077+
const rules: Rule[] = [
1078+
{ operator: "semver_equal", field: "version", value: "not-semver" },
1079+
];
1080+
expect(
1081+
isEligibleForStrategy(rules, { __id: "yo", version: "1.0.0" })
1082+
).toBe(false);
1083+
});
1084+
1085+
it("semver_equal returns false when field is non-string", () => {
1086+
const rules: Rule[] = [
1087+
{ operator: "semver_equal", field: "version", value: "1.0.0" },
1088+
];
1089+
expect(
1090+
isEligibleForStrategy(rules, { __id: "yo", version: 100 })
1091+
).toBe(false);
1092+
});
1093+
1094+
it("semver_equal returns false when field is missing", () => {
1095+
const rules: Rule[] = [
1096+
{ operator: "semver_equal", field: "version", value: "1.0.0" },
1097+
];
1098+
expect(isEligibleForStrategy(rules, { __id: "yo" })).toBe(false);
1099+
});
1100+
1101+
// ── semver_greater_than ─────────────────────────────────────────────
1102+
1103+
it.each([
1104+
["2.0.0", "1.0.0", true],
1105+
["1.0.0", "2.0.0", false],
1106+
["1.0.0", "1.0.0", false], // equal, not greater
1107+
["1.1.0", "1.0.0", true], // minor bump
1108+
["1.0.1", "1.0.0", true], // patch bump
1109+
["1.0.0", "1.0.0-alpha", true], // release > prerelease
1110+
["1.0.0-beta", "1.0.0-alpha", true], // prerelease ordering
1111+
["1.0.0-alpha", "1.0.0-beta", false],
1112+
["1.0.0-alpha.2", "1.0.0-alpha.1", true],
1113+
["10.0.0", "9.99.99", true],
1114+
["1.0.0+build.1", "1.0.0+build.2", false], // build metadata ignored, versions are equal
1115+
["0.0.2", "0.0.1", true],
1116+
])(
1117+
"when field is '%s' and rule value is '%s', then it should return '%s' for operator semver_greater_than",
1118+
(userValue, ruleValue, expected) => {
1119+
const rules: Rule[] = [
1120+
{
1121+
operator: "semver_greater_than",
1122+
field: "version",
1123+
value: ruleValue,
1124+
},
1125+
];
1126+
1127+
expect(
1128+
isEligibleForStrategy(rules, { __id: "yo", version: userValue })
1129+
).toBe(expected);
1130+
}
1131+
);
1132+
1133+
it("semver_greater_than returns false for invalid user version", () => {
1134+
const rules: Rule[] = [
1135+
{ operator: "semver_greater_than", field: "version", value: "1.0.0" },
1136+
];
1137+
expect(
1138+
isEligibleForStrategy(rules, { __id: "yo", version: "abc" })
1139+
).toBe(false);
1140+
});
1141+
1142+
it("semver_greater_than returns false for invalid rule version", () => {
1143+
const rules: Rule[] = [
1144+
{ operator: "semver_greater_than", field: "version", value: "xyz" },
1145+
];
1146+
expect(
1147+
isEligibleForStrategy(rules, { __id: "yo", version: "2.0.0" })
1148+
).toBe(false);
1149+
});
1150+
1151+
it("semver_greater_than returns false when field is non-string", () => {
1152+
const rules: Rule[] = [
1153+
{ operator: "semver_greater_than", field: "version", value: "1.0.0" },
1154+
];
1155+
expect(
1156+
isEligibleForStrategy(rules, { __id: "yo", version: 200 })
1157+
).toBe(false);
1158+
});
1159+
1160+
it("semver_greater_than returns false when field is missing", () => {
1161+
const rules: Rule[] = [
1162+
{ operator: "semver_greater_than", field: "version", value: "1.0.0" },
1163+
];
1164+
expect(isEligibleForStrategy(rules, { __id: "yo" })).toBe(false);
1165+
});
1166+
1167+
// ── semver_less_than ────────────────────────────────────────────────
1168+
1169+
it.each([
1170+
["1.0.0", "2.0.0", true],
1171+
["2.0.0", "1.0.0", false],
1172+
["1.0.0", "1.0.0", false], // equal, not less
1173+
["1.0.0", "1.1.0", true], // minor bump
1174+
["1.0.0", "1.0.1", true], // patch bump
1175+
["1.0.0-alpha", "1.0.0", true], // prerelease < release
1176+
["1.0.0-alpha", "1.0.0-beta", true], // prerelease ordering
1177+
["1.0.0-beta", "1.0.0-alpha", false],
1178+
["1.0.0-alpha.1", "1.0.0-alpha.2", true],
1179+
["9.99.99", "10.0.0", true],
1180+
["1.0.0+build.1", "1.0.0+build.2", false], // build metadata ignored, versions are equal
1181+
["0.0.1", "0.0.2", true],
1182+
])(
1183+
"when field is '%s' and rule value is '%s', then it should return '%s' for operator semver_less_than",
1184+
(userValue, ruleValue, expected) => {
1185+
const rules: Rule[] = [
1186+
{
1187+
operator: "semver_less_than",
1188+
field: "version",
1189+
value: ruleValue,
1190+
},
1191+
];
1192+
1193+
expect(
1194+
isEligibleForStrategy(rules, { __id: "yo", version: userValue })
1195+
).toBe(expected);
1196+
}
1197+
);
1198+
1199+
it("semver_less_than returns false for invalid user version", () => {
1200+
const rules: Rule[] = [
1201+
{ operator: "semver_less_than", field: "version", value: "2.0.0" },
1202+
];
1203+
expect(
1204+
isEligibleForStrategy(rules, { __id: "yo", version: "abc" })
1205+
).toBe(false);
1206+
});
1207+
1208+
it("semver_less_than returns false for invalid rule version", () => {
1209+
const rules: Rule[] = [
1210+
{ operator: "semver_less_than", field: "version", value: "xyz" },
1211+
];
1212+
expect(
1213+
isEligibleForStrategy(rules, { __id: "yo", version: "1.0.0" })
1214+
).toBe(false);
1215+
});
1216+
1217+
it("semver_less_than returns false when field is non-string", () => {
1218+
const rules: Rule[] = [
1219+
{ operator: "semver_less_than", field: "version", value: "2.0.0" },
1220+
];
1221+
expect(
1222+
isEligibleForStrategy(rules, { __id: "yo", version: 100 })
1223+
).toBe(false);
1224+
});
1225+
1226+
it("semver_less_than returns false when field is missing", () => {
1227+
const rules: Rule[] = [
1228+
{ operator: "semver_less_than", field: "version", value: "2.0.0" },
1229+
];
1230+
expect(isEligibleForStrategy(rules, { __id: "yo" })).toBe(false);
1231+
});
1232+
10341233
describe("greater_than / less_than edge cases", () => {
10351234
it("greater_than with Infinity", () => {
10361235
const rules: Rule[] = [

packages/core/src/getEligibleStrategy.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { eq as semverEq, gt as semverGt, lt as semverLt, valid as semverValid } from "semver";
12
import { FlagConfiguration, Rule, Strategy, UserConfiguration } from "./types";
23

34
const MAX_SEGMENT_DEPTH = 10;
@@ -155,6 +156,30 @@ export const isEligibleForStrategy = (
155156
return result === rule.value.remainder;
156157
}
157158

159+
case "semver_equal": {
160+
const fieldValue = userConfiguration[rule.field];
161+
162+
if (!isString(fieldValue)) return false;
163+
if (!semverValid(fieldValue) || !semverValid(rule.value)) return false;
164+
return semverEq(fieldValue, rule.value);
165+
}
166+
167+
case "semver_greater_than": {
168+
const fieldValue = userConfiguration[rule.field];
169+
170+
if (!isString(fieldValue)) return false;
171+
if (!semverValid(fieldValue) || !semverValid(rule.value)) return false;
172+
return semverGt(fieldValue, rule.value);
173+
}
174+
175+
case "semver_less_than": {
176+
const fieldValue = userConfiguration[rule.field];
177+
178+
if (!isString(fieldValue)) return false;
179+
if (!semverValid(fieldValue) || !semverValid(rule.value)) return false;
180+
return semverLt(fieldValue, rule.value);
181+
}
182+
158183
default:
159184
return false;
160185
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export type {
8181
RuleValuePrimitive,
8282
Segment,
8383
SegmentRule,
84+
SemVerRule,
8485
Strategy,
8586
StringMatchRule,
8687
UserConfiguration,

packages/core/src/types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ export type ConditionOperator =
1919
| "date_after"
2020
| "is_set"
2121
| "is_not_set"
22-
| "modulo";
22+
| "modulo"
23+
| "semver_equal"
24+
| "semver_greater_than"
25+
| "semver_less_than";
2326

2427
export type RuleValuePrimitive =
2528
| object
@@ -71,6 +74,12 @@ export type ModuloRule = {
7174
value: { divisor: number; remainder: number };
7275
};
7376

77+
export type SemVerRule = {
78+
field: string;
79+
operator: "semver_equal" | "semver_greater_than" | "semver_less_than";
80+
value: string;
81+
};
82+
7483
export type SegmentRule = {
7584
inSegment: Segment;
7685
};
@@ -83,6 +92,7 @@ export type Rule =
8392
| DateComparisonRule
8493
| ExistenceRule
8594
| ModuloRule
95+
| SemVerRule
8696
| SegmentRule;
8797

8898
export type Segment = {

pnpm-lock.yaml

Lines changed: 16 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)