From 50283497cf2bd708aeee3950a6420c96b2a81e22 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 09:58:57 +0000 Subject: [PATCH 01/10] feat(js): add versionCompare to evaluator --- src/__tests__/jsonexpr/evaluator.test.js | 115 +++++++++++++++++++++++ src/jsonexpr/evaluator.ts | 67 +++++++++++++ 2 files changed, 182 insertions(+) diff --git a/src/__tests__/jsonexpr/evaluator.test.js b/src/__tests__/jsonexpr/evaluator.test.js index bda52a1..da98bf4 100644 --- a/src/__tests__/jsonexpr/evaluator.test.js +++ b/src/__tests__/jsonexpr/evaluator.test.js @@ -310,4 +310,119 @@ describe("Evaluator", () => { expect(evaluator.compare("100", "9")).toBe(-1); }); }); + + describe("versionCompare()", () => { + it("should return 0 for equal versions", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("1.0.0", "1.0.0")).toBe(0); + expect(evaluator.versionCompare("0.0.0", "0.0.0")).toBe(0); + expect(evaluator.versionCompare("999.999.999", "999.999.999")).toBe(0); + }); + + it("should compare major versions", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("2.0.0", "1.0.0")).toBe(1); + expect(evaluator.versionCompare("1.0.0", "2.0.0")).toBe(-1); + }); + + it("should compare minor versions", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("1.2.0", "1.1.0")).toBe(1); + expect(evaluator.versionCompare("1.1.0", "1.2.0")).toBe(-1); + }); + + it("should compare patch versions", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("1.0.2", "1.0.1")).toBe(1); + expect(evaluator.versionCompare("1.0.1", "1.0.2")).toBe(-1); + }); + + it("should compare numerically not lexicographically", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("1.10.0", "1.9.0")).toBe(1); + expect(evaluator.versionCompare("1.9.0", "1.10.0")).toBe(-1); + expect(evaluator.versionCompare("10.0.0", "9.0.0")).toBe(1); + }); + + it("should treat missing parts as 0", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("1.2", "1.2.0")).toBe(0); + expect(evaluator.versionCompare("1", "1.0.0")).toBe(0); + expect(evaluator.versionCompare("1.2.0", "1.2")).toBe(0); + expect(evaluator.versionCompare("1.0.0", "1")).toBe(0); + }); + + it("should handle pre-release versions", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("1.0.0", "1.0.0-alpha")).toBe(1); + expect(evaluator.versionCompare("1.0.0-alpha", "1.0.0")).toBe(-1); + expect(evaluator.versionCompare("1.0.0-alpha", "1.0.0-alpha")).toBe(0); + expect(evaluator.versionCompare("1.0.0-alpha", "1.0.0-beta")).toBe(-1); + expect(evaluator.versionCompare("1.0.0-beta", "1.0.0-alpha")).toBe(1); + expect(evaluator.versionCompare("1.0.0-alpha.1", "1.0.0-alpha.2")).toBe(-1); + expect(evaluator.versionCompare("1.0.0-alpha.2", "1.0.0-alpha.1")).toBe(1); + expect(evaluator.versionCompare("1.0.0-1", "1.0.0-2")).toBe(-1); + expect(evaluator.versionCompare("1.0.0-2", "1.0.0-1")).toBe(1); + }); + + it("should compare numeric pre-release identifiers as less than string identifiers", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("1.0.0-1", "1.0.0-alpha")).toBe(-1); + expect(evaluator.versionCompare("1.0.0-alpha", "1.0.0-1")).toBe(1); + }); + + it("should compare pre-release with fewer identifiers as less", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("1.0.0-alpha", "1.0.0-alpha.1")).toBe(-1); + expect(evaluator.versionCompare("1.0.0-alpha.1", "1.0.0-alpha")).toBe(1); + }); + + it("should strip v prefix", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("v1.0.0", "1.0.0")).toBe(0); + expect(evaluator.versionCompare("V1.0.0", "1.0.0")).toBe(0); + expect(evaluator.versionCompare("v1.0.0", "V1.0.0")).toBe(0); + }); + + it("should return null for null inputs", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare(null, "1.0.0")).toBe(null); + expect(evaluator.versionCompare("1.0.0", null)).toBe(null); + expect(evaluator.versionCompare(null, null)).toBe(null); + }); + + it("should coerce non-string inputs via stringConvert", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare(1, "1.0.0")).toBe(0); + expect(evaluator.versionCompare("1.0.0", 1)).toBe(0); + expect(evaluator.versionCompare(true, "true")).toBe(0); + }); + + it("should return null for unconvertible inputs", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare({}, "1.0.0")).toBe(null); + expect(evaluator.versionCompare("1.0.0", {})).toBe(null); + expect(evaluator.versionCompare([], "1.0.0")).toBe(null); + }); + + it("should ignore build metadata", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("1.0.0+build1", "1.0.0+build2")).toBe(0); + expect(evaluator.versionCompare("1.0.0+build1", "1.0.0")).toBe(0); + }); + }); }); diff --git a/src/jsonexpr/evaluator.ts b/src/jsonexpr/evaluator.ts index 17d87d0..99aca81 100644 --- a/src/jsonexpr/evaluator.ts +++ b/src/jsonexpr/evaluator.ts @@ -87,6 +87,73 @@ export class Evaluator { return target; } + versionCompare(lhs: TData, rhs: TData) { + const lhsStr = this.stringConvert(lhs); + const rhsStr = this.stringConvert(rhs); + if (lhsStr === null || rhsStr === null) { + return null; + } + + const parseSemver = (version: string) => { + let v = version; + if (v.startsWith("v") || v.startsWith("V")) { + v = v.substring(1); + } + + const plusIndex = v.indexOf("+"); + if (plusIndex >= 0) { + v = v.substring(0, plusIndex); + } + + const [core, ...preReleaseParts] = v.split("-"); + const preRelease = preReleaseParts.join("-"); + const parts = core.split("."); + + return { parts, preRelease }; + }; + + const compareIdentifiers = (a: string, b: string) => { + const aNum = parseInt(a, 10); + const bNum = parseInt(b, 10); + const aIsNum = !isNaN(aNum) && String(aNum) === a; + const bIsNum = !isNaN(bNum) && String(bNum) === b; + + if (aIsNum && bIsNum) { + return aNum === bNum ? 0 : aNum > bNum ? 1 : -1; + } + if (aIsNum) return -1; + if (bIsNum) return 1; + return a === b ? 0 : a > b ? 1 : -1; + }; + + const l = parseSemver(lhsStr); + const r = parseSemver(rhsStr); + + const maxLen = Math.max(l.parts.length, r.parts.length); + for (let i = 0; i < maxLen; i++) { + const lPart = l.parts[i] || "0"; + const rPart = r.parts[i] || "0"; + const result = compareIdentifiers(lPart, rPart); + if (result !== 0) return result; + } + + if (!l.preRelease && !r.preRelease) return 0; + if (!l.preRelease) return 1; + if (!r.preRelease) return -1; + + const lPreParts = l.preRelease.split("."); + const rPreParts = r.preRelease.split("."); + const preLen = Math.max(lPreParts.length, rPreParts.length); + for (let i = 0; i < preLen; i++) { + if (i >= lPreParts.length) return -1; + if (i >= rPreParts.length) return 1; + const result = compareIdentifiers(lPreParts[i], rPreParts[i]); + if (result !== 0) return result; + } + + return 0; + } + compare(lhs: TData, rhs: TData) { if (lhs === null) { return rhs === null ? 0 : null; From 6922a49d21281ec45774c3afc1cc570aec084f38 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 10:00:10 +0000 Subject: [PATCH 02/10] feat(js): add semver comparison operators --- src/__tests__/jsonexpr/operators/evaluator.js | 9 +++- .../jsonexpr/operators/semver_eq.test.js | 36 ++++++++++++++++ .../jsonexpr/operators/semver_gt.test.js | 41 +++++++++++++++++++ .../jsonexpr/operators/semver_gte.test.js | 41 +++++++++++++++++++ .../jsonexpr/operators/semver_lt.test.js | 41 +++++++++++++++++++ .../jsonexpr/operators/semver_lte.test.js | 41 +++++++++++++++++++ src/jsonexpr/jsonexpr.ts | 10 +++++ src/jsonexpr/operators/semver_eq.ts | 9 ++++ src/jsonexpr/operators/semver_gt.ts | 9 ++++ src/jsonexpr/operators/semver_gte.ts | 9 ++++ src/jsonexpr/operators/semver_lt.ts | 9 ++++ src/jsonexpr/operators/semver_lte.ts | 9 ++++ 12 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/jsonexpr/operators/semver_eq.test.js create mode 100644 src/__tests__/jsonexpr/operators/semver_gt.test.js create mode 100644 src/__tests__/jsonexpr/operators/semver_gte.test.js create mode 100644 src/__tests__/jsonexpr/operators/semver_lt.test.js create mode 100644 src/__tests__/jsonexpr/operators/semver_lte.test.js create mode 100644 src/jsonexpr/operators/semver_eq.ts create mode 100644 src/jsonexpr/operators/semver_gt.ts create mode 100644 src/jsonexpr/operators/semver_gte.ts create mode 100644 src/jsonexpr/operators/semver_lt.ts create mode 100644 src/jsonexpr/operators/semver_lte.ts diff --git a/src/__tests__/jsonexpr/operators/evaluator.js b/src/__tests__/jsonexpr/operators/evaluator.js index 0fd3482..69981e6 100644 --- a/src/__tests__/jsonexpr/operators/evaluator.js +++ b/src/__tests__/jsonexpr/operators/evaluator.js @@ -18,7 +18,14 @@ export function mockEvaluator() { return expr; }), - compare: jest.fn((lhs, rhs) => { + versionCompare: jest.fn((lhs, rhs) => { + const lhsStr = typeof lhs === "string" ? lhs : null; + const rhsStr = typeof rhs === "string" ? rhs : null; + if (lhsStr === null || rhsStr === null) return null; + return lhsStr === rhsStr ? 0 : lhsStr > rhsStr ? 1 : -1; + }), + + compare: jest.fn((lhs, rhs) => { switch (typeof lhs) { case "boolean": case "number": diff --git a/src/__tests__/jsonexpr/operators/semver_eq.test.js b/src/__tests__/jsonexpr/operators/semver_eq.test.js new file mode 100644 index 0000000..84868a2 --- /dev/null +++ b/src/__tests__/jsonexpr/operators/semver_eq.test.js @@ -0,0 +1,36 @@ +import { mockEvaluator } from "./evaluator"; +import { SemverEqualsOperator } from "../../../jsonexpr/operators/semver_eq"; + +describe("SemverEqualsOperator", () => { + const operator = new SemverEqualsOperator(); + + describe("evaluate", () => { + const evaluator = mockEvaluator(); + + it("should return true when versions are equal", () => { + expect(operator.evaluate(evaluator, ["1.0.0", "1.0.0"])).toBe(true); + expect(evaluator.evaluate).toHaveBeenCalledTimes(2); + expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "1.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return false when versions are not equal", () => { + expect(operator.evaluate(evaluator, ["1.0.0", "2.0.0"])).toBe(false); + expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "2.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return null when versionCompare returns null", () => { + expect(operator.evaluate(evaluator, [null, null])).toBe(null); + expect(evaluator.evaluate).toHaveBeenCalledTimes(1); + expect(evaluator.versionCompare).not.toHaveBeenCalled(); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + }); +}); diff --git a/src/__tests__/jsonexpr/operators/semver_gt.test.js b/src/__tests__/jsonexpr/operators/semver_gt.test.js new file mode 100644 index 0000000..997b459 --- /dev/null +++ b/src/__tests__/jsonexpr/operators/semver_gt.test.js @@ -0,0 +1,41 @@ +import { mockEvaluator } from "./evaluator"; +import { SemverGreaterThanOperator } from "../../../jsonexpr/operators/semver_gt"; + +describe("SemverGreaterThanOperator", () => { + const operator = new SemverGreaterThanOperator(); + + describe("evaluate", () => { + const evaluator = mockEvaluator(); + + it("should return true when left version is greater", () => { + expect(operator.evaluate(evaluator, ["2.0.0", "1.0.0"])).toBe(true); + expect(evaluator.versionCompare).toHaveBeenCalledWith("2.0.0", "1.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return false when versions are equal", () => { + expect(operator.evaluate(evaluator, ["1.0.0", "1.0.0"])).toBe(false); + expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "1.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return false when left version is less", () => { + expect(operator.evaluate(evaluator, ["1.0.0", "2.0.0"])).toBe(false); + expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "2.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return null for null inputs", () => { + expect(operator.evaluate(evaluator, [null, null])).toBe(null); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + }); +}); diff --git a/src/__tests__/jsonexpr/operators/semver_gte.test.js b/src/__tests__/jsonexpr/operators/semver_gte.test.js new file mode 100644 index 0000000..8aafac1 --- /dev/null +++ b/src/__tests__/jsonexpr/operators/semver_gte.test.js @@ -0,0 +1,41 @@ +import { mockEvaluator } from "./evaluator"; +import { SemverGreaterThanOrEqualOperator } from "../../../jsonexpr/operators/semver_gte"; + +describe("SemverGreaterThanOrEqualOperator", () => { + const operator = new SemverGreaterThanOrEqualOperator(); + + describe("evaluate", () => { + const evaluator = mockEvaluator(); + + it("should return true when left version is greater", () => { + expect(operator.evaluate(evaluator, ["2.0.0", "1.0.0"])).toBe(true); + expect(evaluator.versionCompare).toHaveBeenCalledWith("2.0.0", "1.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return true when versions are equal", () => { + expect(operator.evaluate(evaluator, ["1.0.0", "1.0.0"])).toBe(true); + expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "1.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return false when left version is less", () => { + expect(operator.evaluate(evaluator, ["1.0.0", "2.0.0"])).toBe(false); + expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "2.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return null for null inputs", () => { + expect(operator.evaluate(evaluator, [null, null])).toBe(null); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + }); +}); diff --git a/src/__tests__/jsonexpr/operators/semver_lt.test.js b/src/__tests__/jsonexpr/operators/semver_lt.test.js new file mode 100644 index 0000000..f209b8f --- /dev/null +++ b/src/__tests__/jsonexpr/operators/semver_lt.test.js @@ -0,0 +1,41 @@ +import { mockEvaluator } from "./evaluator"; +import { SemverLessThanOperator } from "../../../jsonexpr/operators/semver_lt"; + +describe("SemverLessThanOperator", () => { + const operator = new SemverLessThanOperator(); + + describe("evaluate", () => { + const evaluator = mockEvaluator(); + + it("should return true when left version is less", () => { + expect(operator.evaluate(evaluator, ["1.0.0", "2.0.0"])).toBe(true); + expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "2.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return false when versions are equal", () => { + expect(operator.evaluate(evaluator, ["1.0.0", "1.0.0"])).toBe(false); + expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "1.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return false when left version is greater", () => { + expect(operator.evaluate(evaluator, ["2.0.0", "1.0.0"])).toBe(false); + expect(evaluator.versionCompare).toHaveBeenCalledWith("2.0.0", "1.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return null for null inputs", () => { + expect(operator.evaluate(evaluator, [null, null])).toBe(null); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + }); +}); diff --git a/src/__tests__/jsonexpr/operators/semver_lte.test.js b/src/__tests__/jsonexpr/operators/semver_lte.test.js new file mode 100644 index 0000000..8ee5920 --- /dev/null +++ b/src/__tests__/jsonexpr/operators/semver_lte.test.js @@ -0,0 +1,41 @@ +import { mockEvaluator } from "./evaluator"; +import { SemverLessThanOrEqualOperator } from "../../../jsonexpr/operators/semver_lte"; + +describe("SemverLessThanOrEqualOperator", () => { + const operator = new SemverLessThanOrEqualOperator(); + + describe("evaluate", () => { + const evaluator = mockEvaluator(); + + it("should return true when left version is less", () => { + expect(operator.evaluate(evaluator, ["1.0.0", "2.0.0"])).toBe(true); + expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "2.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return true when versions are equal", () => { + expect(operator.evaluate(evaluator, ["1.0.0", "1.0.0"])).toBe(true); + expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "1.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return false when left version is greater", () => { + expect(operator.evaluate(evaluator, ["2.0.0", "1.0.0"])).toBe(false); + expect(evaluator.versionCompare).toHaveBeenCalledWith("2.0.0", "1.0.0"); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + + it("should return null for null inputs", () => { + expect(operator.evaluate(evaluator, [null, null])).toBe(null); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + }); +}); diff --git a/src/jsonexpr/jsonexpr.ts b/src/jsonexpr/jsonexpr.ts index 67e1ab3..84414c7 100644 --- a/src/jsonexpr/jsonexpr.ts +++ b/src/jsonexpr/jsonexpr.ts @@ -12,6 +12,11 @@ import { GreaterThanOperator } from "./operators/gt"; import { GreaterThanOrEqualOperator } from "./operators/gte"; import { LessThanOperator } from "./operators/lt"; import { LessThanOrEqualOperator } from "./operators/lte"; +import { SemverEqualsOperator } from "./operators/semver_eq"; +import { SemverGreaterThanOperator } from "./operators/semver_gt"; +import { SemverGreaterThanOrEqualOperator } from "./operators/semver_gte"; +import { SemverLessThanOperator } from "./operators/semver_lt"; +import { SemverLessThanOrEqualOperator } from "./operators/semver_lte"; const operators = { and: new AndCombinator(), @@ -27,6 +32,11 @@ const operators = { gte: new GreaterThanOrEqualOperator(), lt: new LessThanOperator(), lte: new LessThanOrEqualOperator(), + semver_eq: new SemverEqualsOperator(), + semver_gt: new SemverGreaterThanOperator(), + semver_gte: new SemverGreaterThanOrEqualOperator(), + semver_lt: new SemverLessThanOperator(), + semver_lte: new SemverLessThanOrEqualOperator(), }; export class JsonExpr { diff --git a/src/jsonexpr/operators/semver_eq.ts b/src/jsonexpr/operators/semver_eq.ts new file mode 100644 index 0000000..d35dfc2 --- /dev/null +++ b/src/jsonexpr/operators/semver_eq.ts @@ -0,0 +1,9 @@ +import { Evaluator } from "../evaluator"; +import { BinaryOperator } from "./binary"; + +export class SemverEqualsOperator extends BinaryOperator { + binary(evaluator: Evaluator, lhs: unknown, rhs: unknown) { + const result = evaluator.versionCompare(lhs, rhs); + return result !== null ? result === 0 : null; + } +} diff --git a/src/jsonexpr/operators/semver_gt.ts b/src/jsonexpr/operators/semver_gt.ts new file mode 100644 index 0000000..1b41166 --- /dev/null +++ b/src/jsonexpr/operators/semver_gt.ts @@ -0,0 +1,9 @@ +import { Evaluator } from "../evaluator"; +import { BinaryOperator } from "./binary"; + +export class SemverGreaterThanOperator extends BinaryOperator { + binary(evaluator: Evaluator, lhs: unknown, rhs: unknown) { + const result = evaluator.versionCompare(lhs, rhs); + return result !== null ? result > 0 : null; + } +} diff --git a/src/jsonexpr/operators/semver_gte.ts b/src/jsonexpr/operators/semver_gte.ts new file mode 100644 index 0000000..8525453 --- /dev/null +++ b/src/jsonexpr/operators/semver_gte.ts @@ -0,0 +1,9 @@ +import { Evaluator } from "../evaluator"; +import { BinaryOperator } from "./binary"; + +export class SemverGreaterThanOrEqualOperator extends BinaryOperator { + binary(evaluator: Evaluator, lhs: unknown, rhs: unknown) { + const result = evaluator.versionCompare(lhs, rhs); + return result !== null ? result >= 0 : null; + } +} diff --git a/src/jsonexpr/operators/semver_lt.ts b/src/jsonexpr/operators/semver_lt.ts new file mode 100644 index 0000000..aa44efd --- /dev/null +++ b/src/jsonexpr/operators/semver_lt.ts @@ -0,0 +1,9 @@ +import { Evaluator } from "../evaluator"; +import { BinaryOperator } from "./binary"; + +export class SemverLessThanOperator extends BinaryOperator { + binary(evaluator: Evaluator, lhs: unknown, rhs: unknown) { + const result = evaluator.versionCompare(lhs, rhs); + return result !== null ? result < 0 : null; + } +} diff --git a/src/jsonexpr/operators/semver_lte.ts b/src/jsonexpr/operators/semver_lte.ts new file mode 100644 index 0000000..8d6261f --- /dev/null +++ b/src/jsonexpr/operators/semver_lte.ts @@ -0,0 +1,9 @@ +import { Evaluator } from "../evaluator"; +import { BinaryOperator } from "./binary"; + +export class SemverLessThanOrEqualOperator extends BinaryOperator { + binary(evaluator: Evaluator, lhs: unknown, rhs: unknown) { + const result = evaluator.versionCompare(lhs, rhs); + return result !== null ? result <= 0 : null; + } +} From b3d6fd9fc729107becb795af4d9f97de017d0e36 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 10:00:45 +0000 Subject: [PATCH 03/10] test: add semver operator scenarios to cross-SDK tests --- cross-sdk-tests/test_scenarios_complete.json | 180 +++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 cross-sdk-tests/test_scenarios_complete.json diff --git a/cross-sdk-tests/test_scenarios_complete.json b/cross-sdk-tests/test_scenarios_complete.json new file mode 100644 index 0000000..50f0dd4 --- /dev/null +++ b/cross-sdk-tests/test_scenarios_complete.json @@ -0,0 +1,180 @@ +{ + "semver_operators": [ + { + "name": "semver_gte match - app version >= required version", + "expression": { + "semver_gte": [ + { "var": "app_version" }, + { "value": "2.0.0" } + ] + }, + "vars": { "app_version": "2.1.0" }, + "expected": true + }, + { + "name": "semver_gte no match - app version below required", + "expression": { + "semver_gte": [ + { "var": "app_version" }, + { "value": "2.0.0" } + ] + }, + "vars": { "app_version": "1.9.0" }, + "expected": false + }, + { + "name": "semver_gte match - exact version", + "expression": { + "semver_gte": [ + { "var": "app_version" }, + { "value": "2.0.0" } + ] + }, + "vars": { "app_version": "2.0.0" }, + "expected": true + }, + { + "name": "semver_lt match - version below threshold", + "expression": { + "semver_lt": [ + { "var": "app_version" }, + { "value": "3.0.0" } + ] + }, + "vars": { "app_version": "2.5.0" }, + "expected": true + }, + { + "name": "semver_lt no match - version at threshold", + "expression": { + "semver_lt": [ + { "var": "app_version" }, + { "value": "3.0.0" } + ] + }, + "vars": { "app_version": "3.0.0" }, + "expected": false + }, + { + "name": "semver_eq match - equal versions", + "expression": { + "semver_eq": [ + { "var": "app_version" }, + { "value": "1.2.3" } + ] + }, + "vars": { "app_version": "1.2.3" }, + "expected": true + }, + { + "name": "semver_eq no match - different versions", + "expression": { + "semver_eq": [ + { "var": "app_version" }, + { "value": "1.2.3" } + ] + }, + "vars": { "app_version": "1.2.4" }, + "expected": false + }, + { + "name": "semver_gt with pre-release - release > pre-release", + "expression": { + "semver_gt": [ + { "var": "app_version" }, + { "value": "1.0.0-alpha" } + ] + }, + "vars": { "app_version": "1.0.0" }, + "expected": true + }, + { + "name": "semver_gt with pre-release - pre-release not > release", + "expression": { + "semver_gt": [ + { "var": "app_version" }, + { "value": "1.0.0" } + ] + }, + "vars": { "app_version": "1.0.0-alpha" }, + "expected": false + }, + { + "name": "semver_lte match - version equal", + "expression": { + "semver_lte": [ + { "var": "app_version" }, + { "value": "5.0.0" } + ] + }, + "vars": { "app_version": "5.0.0" }, + "expected": true + }, + { + "name": "semver_lte match - version below", + "expression": { + "semver_lte": [ + { "var": "app_version" }, + { "value": "5.0.0" } + ] + }, + "vars": { "app_version": "4.9.9" }, + "expected": true + }, + { + "name": "semver_lte no match - version above", + "expression": { + "semver_lte": [ + { "var": "app_version" }, + { "value": "5.0.0" } + ] + }, + "vars": { "app_version": "5.0.1" }, + "expected": false + }, + { + "name": "semver_gte with missing parts - 1.2 treated as 1.2.0", + "expression": { + "semver_gte": [ + { "var": "app_version" }, + { "value": "1.2.0" } + ] + }, + "vars": { "app_version": "1.2" }, + "expected": true + }, + { + "name": "semver_eq with v prefix stripped", + "expression": { + "semver_eq": [ + { "var": "app_version" }, + { "value": "1.0.0" } + ] + }, + "vars": { "app_version": "v1.0.0" }, + "expected": true + }, + { + "name": "semver_eq ignores build metadata", + "expression": { + "semver_eq": [ + { "var": "app_version" }, + { "value": "1.0.0+build1" } + ] + }, + "vars": { "app_version": "1.0.0+build2" }, + "expected": true + }, + { + "name": "semver_gte with missing attribute - returns null (falsy)", + "expression": { + "semver_gte": [ + { "var": "app_version" }, + { "value": "2.0.0" } + ] + }, + "vars": {}, + "expected": false + } + ] +} From a6f071d650d861241d3190fbbeb4506baf15b89f Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 14:13:36 +0000 Subject: [PATCH 04/10] fix: handle empty strings in versionCompare and address review feedback - Return null for empty string inputs in versionCompare - Extract parseSemver/compareIdentifiers to module-level functions - Add explicit return type annotation to versionCompare - Add tests for empty string, undefined, pre-release+build metadata - Add rhs null test for semver_eq operator - Fix mock evaluator indentation - Add basic semver_gt scenarios to cross-SDK tests --- cross-sdk-tests/test_scenarios_complete.json | 33 +++++++++ src/__tests__/jsonexpr/evaluator.test.js | 23 +++++++ src/__tests__/jsonexpr/operators/evaluator.js | 14 ++-- .../jsonexpr/operators/semver_eq.test.js | 13 +++- src/jsonexpr/evaluator.ts | 68 +++++++++---------- 5 files changed, 108 insertions(+), 43 deletions(-) diff --git a/cross-sdk-tests/test_scenarios_complete.json b/cross-sdk-tests/test_scenarios_complete.json index 50f0dd4..450762c 100644 --- a/cross-sdk-tests/test_scenarios_complete.json +++ b/cross-sdk-tests/test_scenarios_complete.json @@ -77,6 +77,39 @@ "vars": { "app_version": "1.2.4" }, "expected": false }, + { + "name": "semver_gt match - version above", + "expression": { + "semver_gt": [ + { "var": "app_version" }, + { "value": "1.0.0" } + ] + }, + "vars": { "app_version": "2.0.0" }, + "expected": true + }, + { + "name": "semver_gt no match - version equal", + "expression": { + "semver_gt": [ + { "var": "app_version" }, + { "value": "1.0.0" } + ] + }, + "vars": { "app_version": "1.0.0" }, + "expected": false + }, + { + "name": "semver_gt no match - version below", + "expression": { + "semver_gt": [ + { "var": "app_version" }, + { "value": "2.0.0" } + ] + }, + "vars": { "app_version": "1.0.0" }, + "expected": false + }, { "name": "semver_gt with pre-release - release > pre-release", "expression": { diff --git a/src/__tests__/jsonexpr/evaluator.test.js b/src/__tests__/jsonexpr/evaluator.test.js index da98bf4..6b1e940 100644 --- a/src/__tests__/jsonexpr/evaluator.test.js +++ b/src/__tests__/jsonexpr/evaluator.test.js @@ -424,5 +424,28 @@ describe("Evaluator", () => { expect(evaluator.versionCompare("1.0.0+build1", "1.0.0+build2")).toBe(0); expect(evaluator.versionCompare("1.0.0+build1", "1.0.0")).toBe(0); }); + + it("should handle pre-release combined with build metadata", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("1.0.0-alpha+build1", "1.0.0-alpha+build2")).toBe(0); + expect(evaluator.versionCompare("1.0.0-alpha+build1", "1.0.0-beta")).toBe(-1); + expect(evaluator.versionCompare("1.0.0-alpha+build1", "1.0.0")).toBe(-1); + }); + + it("should return null for empty string inputs", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("", "1.0.0")).toBe(null); + expect(evaluator.versionCompare("1.0.0", "")).toBe(null); + expect(evaluator.versionCompare("", "")).toBe(null); + }); + + it("should return null for undefined inputs", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare(undefined, "1.0.0")).toBe(null); + expect(evaluator.versionCompare("1.0.0", undefined)).toBe(null); + }); }); }); diff --git a/src/__tests__/jsonexpr/operators/evaluator.js b/src/__tests__/jsonexpr/operators/evaluator.js index 69981e6..5acde03 100644 --- a/src/__tests__/jsonexpr/operators/evaluator.js +++ b/src/__tests__/jsonexpr/operators/evaluator.js @@ -18,14 +18,14 @@ export function mockEvaluator() { return expr; }), - versionCompare: jest.fn((lhs, rhs) => { - const lhsStr = typeof lhs === "string" ? lhs : null; - const rhsStr = typeof rhs === "string" ? rhs : null; - if (lhsStr === null || rhsStr === null) return null; - return lhsStr === rhsStr ? 0 : lhsStr > rhsStr ? 1 : -1; - }), + versionCompare: jest.fn((lhs, rhs) => { + const lhsStr = typeof lhs === "string" ? lhs : null; + const rhsStr = typeof rhs === "string" ? rhs : null; + if (lhsStr === null || rhsStr === null) return null; + return lhsStr === rhsStr ? 0 : lhsStr > rhsStr ? 1 : -1; + }), - compare: jest.fn((lhs, rhs) => { + compare: jest.fn((lhs, rhs) => { switch (typeof lhs) { case "boolean": case "number": diff --git a/src/__tests__/jsonexpr/operators/semver_eq.test.js b/src/__tests__/jsonexpr/operators/semver_eq.test.js index 84868a2..f4e032f 100644 --- a/src/__tests__/jsonexpr/operators/semver_eq.test.js +++ b/src/__tests__/jsonexpr/operators/semver_eq.test.js @@ -24,13 +24,22 @@ describe("SemverEqualsOperator", () => { evaluator.versionCompare.mockClear(); }); - it("should return null when versionCompare returns null", () => { - expect(operator.evaluate(evaluator, [null, null])).toBe(null); + it("should return null when lhs is null", () => { + expect(operator.evaluate(evaluator, [null, "1.0.0"])).toBe(null); expect(evaluator.evaluate).toHaveBeenCalledTimes(1); expect(evaluator.versionCompare).not.toHaveBeenCalled(); evaluator.evaluate.mockClear(); evaluator.versionCompare.mockClear(); }); + + it("should return null when rhs is null", () => { + expect(operator.evaluate(evaluator, ["1.0.0", null])).toBe(null); + expect(evaluator.evaluate).toHaveBeenCalledTimes(2); + expect(evaluator.versionCompare).not.toHaveBeenCalled(); + + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); }); }); diff --git a/src/jsonexpr/evaluator.ts b/src/jsonexpr/evaluator.ts index 99aca81..885b15e 100644 --- a/src/jsonexpr/evaluator.ts +++ b/src/jsonexpr/evaluator.ts @@ -1,6 +1,38 @@ /* eslint-disable */ import { isEqualsDeep, isObject } from "../utils"; +function parseSemver(version: string) { + let v = version; + if (v.startsWith("v") || v.startsWith("V")) { + v = v.substring(1); + } + + const plusIndex = v.indexOf("+"); + if (plusIndex >= 0) { + v = v.substring(0, plusIndex); + } + + const [core, ...preReleaseParts] = v.split("-"); + const preRelease = preReleaseParts.join("-"); + const parts = core.split("."); + + return { parts, preRelease }; +} + +function compareIdentifiers(a: string, b: string) { + const aNum = parseInt(a, 10); + const bNum = parseInt(b, 10); + const aIsNum = !isNaN(aNum) && String(aNum) === a; + const bIsNum = !isNaN(bNum) && String(bNum) === b; + + if (aIsNum && bIsNum) { + return aNum === bNum ? 0 : aNum > bNum ? 1 : -1; + } + if (aIsNum) return -1; + if (bIsNum) return 1; + return a === b ? 0 : a > b ? 1 : -1; +} + export class Evaluator { private readonly operators: any; private readonly vars: any; @@ -87,45 +119,13 @@ export class Evaluator { return target; } - versionCompare(lhs: TData, rhs: TData) { + versionCompare(lhs: TData, rhs: TData): number | null { const lhsStr = this.stringConvert(lhs); const rhsStr = this.stringConvert(rhs); - if (lhsStr === null || rhsStr === null) { + if (lhsStr === null || rhsStr === null || lhsStr === "" || rhsStr === "") { return null; } - const parseSemver = (version: string) => { - let v = version; - if (v.startsWith("v") || v.startsWith("V")) { - v = v.substring(1); - } - - const plusIndex = v.indexOf("+"); - if (plusIndex >= 0) { - v = v.substring(0, plusIndex); - } - - const [core, ...preReleaseParts] = v.split("-"); - const preRelease = preReleaseParts.join("-"); - const parts = core.split("."); - - return { parts, preRelease }; - }; - - const compareIdentifiers = (a: string, b: string) => { - const aNum = parseInt(a, 10); - const bNum = parseInt(b, 10); - const aIsNum = !isNaN(aNum) && String(aNum) === a; - const bIsNum = !isNaN(bNum) && String(bNum) === b; - - if (aIsNum && bIsNum) { - return aNum === bNum ? 0 : aNum > bNum ? 1 : -1; - } - if (aIsNum) return -1; - if (bIsNum) return 1; - return a === b ? 0 : a > b ? 1 : -1; - }; - const l = parseSemver(lhsStr); const r = parseSemver(rhsStr); From ec34b9a76c5b1250a5a6066650271a2df0bcfbef Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 24 Mar 2026 09:30:31 +0000 Subject: [PATCH 05/10] fix: address all code review findings - Reject inputs with empty core after stripping (e.g., "v", "+build") - Use regex + string-length comparison for numeric identifiers to avoid large integer precision loss with parseInt - Use nullish coalescing (??) instead of || for missing version parts - Fix mock versionCompare to use numeric segment comparison - Switch all operator tests from manual mockClear to afterEach - Add versionCompare not-called assertions to all null tests - Split null tests into separate lhs/rhs cases for gt/gte/lt/lte - Add tests for empty-core inputs --- src/__tests__/jsonexpr/evaluator.test.js | 10 +++++++ src/__tests__/jsonexpr/operators/evaluator.js | 12 ++++++-- .../jsonexpr/operators/semver_eq.test.js | 17 ++++------- .../jsonexpr/operators/semver_gt.test.js | 27 ++++++++--------- .../jsonexpr/operators/semver_gte.test.js | 27 ++++++++--------- .../jsonexpr/operators/semver_lt.test.js | 27 ++++++++--------- .../jsonexpr/operators/semver_lte.test.js | 27 ++++++++--------- src/jsonexpr/evaluator.ts | 29 ++++++++++++++----- 8 files changed, 103 insertions(+), 73 deletions(-) diff --git a/src/__tests__/jsonexpr/evaluator.test.js b/src/__tests__/jsonexpr/evaluator.test.js index 6b1e940..90699dd 100644 --- a/src/__tests__/jsonexpr/evaluator.test.js +++ b/src/__tests__/jsonexpr/evaluator.test.js @@ -447,5 +447,15 @@ describe("Evaluator", () => { expect(evaluator.versionCompare(undefined, "1.0.0")).toBe(null); expect(evaluator.versionCompare("1.0.0", undefined)).toBe(null); }); + + it("should return null for inputs that normalize to empty core", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("v", "1.0.0")).toBe(null); + expect(evaluator.versionCompare("V", "1.0.0")).toBe(null); + expect(evaluator.versionCompare("+build", "1.0.0")).toBe(null); + expect(evaluator.versionCompare("v+build", "1.0.0")).toBe(null); + expect(evaluator.versionCompare("1.0.0", "v")).toBe(null); + }); }); }); diff --git a/src/__tests__/jsonexpr/operators/evaluator.js b/src/__tests__/jsonexpr/operators/evaluator.js index 5acde03..2c67b58 100644 --- a/src/__tests__/jsonexpr/operators/evaluator.js +++ b/src/__tests__/jsonexpr/operators/evaluator.js @@ -18,11 +18,19 @@ export function mockEvaluator() { return expr; }), - versionCompare: jest.fn((lhs, rhs) => { + versionCompare: jest.fn((lhs, rhs) => { const lhsStr = typeof lhs === "string" ? lhs : null; const rhsStr = typeof rhs === "string" ? rhs : null; if (lhsStr === null || rhsStr === null) return null; - return lhsStr === rhsStr ? 0 : lhsStr > rhsStr ? 1 : -1; + const lParts = lhsStr.split(".").map(Number); + const rParts = rhsStr.split(".").map(Number); + const len = Math.max(lParts.length, rParts.length); + for (let i = 0; i < len; i++) { + const l = lParts[i] || 0; + const r = rParts[i] || 0; + if (l !== r) return l > r ? 1 : -1; + } + return 0; }), compare: jest.fn((lhs, rhs) => { diff --git a/src/__tests__/jsonexpr/operators/semver_eq.test.js b/src/__tests__/jsonexpr/operators/semver_eq.test.js index f4e032f..187747b 100644 --- a/src/__tests__/jsonexpr/operators/semver_eq.test.js +++ b/src/__tests__/jsonexpr/operators/semver_eq.test.js @@ -7,39 +7,32 @@ describe("SemverEqualsOperator", () => { describe("evaluate", () => { const evaluator = mockEvaluator(); + afterEach(() => { + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + it("should return true when versions are equal", () => { expect(operator.evaluate(evaluator, ["1.0.0", "1.0.0"])).toBe(true); expect(evaluator.evaluate).toHaveBeenCalledTimes(2); expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "1.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); it("should return false when versions are not equal", () => { expect(operator.evaluate(evaluator, ["1.0.0", "2.0.0"])).toBe(false); expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "2.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); it("should return null when lhs is null", () => { expect(operator.evaluate(evaluator, [null, "1.0.0"])).toBe(null); expect(evaluator.evaluate).toHaveBeenCalledTimes(1); expect(evaluator.versionCompare).not.toHaveBeenCalled(); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); it("should return null when rhs is null", () => { expect(operator.evaluate(evaluator, ["1.0.0", null])).toBe(null); expect(evaluator.evaluate).toHaveBeenCalledTimes(2); expect(evaluator.versionCompare).not.toHaveBeenCalled(); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); }); }); diff --git a/src/__tests__/jsonexpr/operators/semver_gt.test.js b/src/__tests__/jsonexpr/operators/semver_gt.test.js index 997b459..bb63d6f 100644 --- a/src/__tests__/jsonexpr/operators/semver_gt.test.js +++ b/src/__tests__/jsonexpr/operators/semver_gt.test.js @@ -7,35 +7,36 @@ describe("SemverGreaterThanOperator", () => { describe("evaluate", () => { const evaluator = mockEvaluator(); + afterEach(() => { + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + it("should return true when left version is greater", () => { expect(operator.evaluate(evaluator, ["2.0.0", "1.0.0"])).toBe(true); expect(evaluator.versionCompare).toHaveBeenCalledWith("2.0.0", "1.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); it("should return false when versions are equal", () => { expect(operator.evaluate(evaluator, ["1.0.0", "1.0.0"])).toBe(false); expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "1.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); it("should return false when left version is less", () => { expect(operator.evaluate(evaluator, ["1.0.0", "2.0.0"])).toBe(false); expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "2.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); - it("should return null for null inputs", () => { - expect(operator.evaluate(evaluator, [null, null])).toBe(null); + it("should return null when lhs is null", () => { + expect(operator.evaluate(evaluator, [null, "1.0.0"])).toBe(null); + expect(evaluator.evaluate).toHaveBeenCalledTimes(1); + expect(evaluator.versionCompare).not.toHaveBeenCalled(); + }); - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); + it("should return null when rhs is null", () => { + expect(operator.evaluate(evaluator, ["1.0.0", null])).toBe(null); + expect(evaluator.evaluate).toHaveBeenCalledTimes(2); + expect(evaluator.versionCompare).not.toHaveBeenCalled(); }); }); }); diff --git a/src/__tests__/jsonexpr/operators/semver_gte.test.js b/src/__tests__/jsonexpr/operators/semver_gte.test.js index 8aafac1..38e6e84 100644 --- a/src/__tests__/jsonexpr/operators/semver_gte.test.js +++ b/src/__tests__/jsonexpr/operators/semver_gte.test.js @@ -7,35 +7,36 @@ describe("SemverGreaterThanOrEqualOperator", () => { describe("evaluate", () => { const evaluator = mockEvaluator(); + afterEach(() => { + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + it("should return true when left version is greater", () => { expect(operator.evaluate(evaluator, ["2.0.0", "1.0.0"])).toBe(true); expect(evaluator.versionCompare).toHaveBeenCalledWith("2.0.0", "1.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); it("should return true when versions are equal", () => { expect(operator.evaluate(evaluator, ["1.0.0", "1.0.0"])).toBe(true); expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "1.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); it("should return false when left version is less", () => { expect(operator.evaluate(evaluator, ["1.0.0", "2.0.0"])).toBe(false); expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "2.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); - it("should return null for null inputs", () => { - expect(operator.evaluate(evaluator, [null, null])).toBe(null); + it("should return null when lhs is null", () => { + expect(operator.evaluate(evaluator, [null, "1.0.0"])).toBe(null); + expect(evaluator.evaluate).toHaveBeenCalledTimes(1); + expect(evaluator.versionCompare).not.toHaveBeenCalled(); + }); - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); + it("should return null when rhs is null", () => { + expect(operator.evaluate(evaluator, ["1.0.0", null])).toBe(null); + expect(evaluator.evaluate).toHaveBeenCalledTimes(2); + expect(evaluator.versionCompare).not.toHaveBeenCalled(); }); }); }); diff --git a/src/__tests__/jsonexpr/operators/semver_lt.test.js b/src/__tests__/jsonexpr/operators/semver_lt.test.js index f209b8f..410619f 100644 --- a/src/__tests__/jsonexpr/operators/semver_lt.test.js +++ b/src/__tests__/jsonexpr/operators/semver_lt.test.js @@ -7,35 +7,36 @@ describe("SemverLessThanOperator", () => { describe("evaluate", () => { const evaluator = mockEvaluator(); + afterEach(() => { + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + it("should return true when left version is less", () => { expect(operator.evaluate(evaluator, ["1.0.0", "2.0.0"])).toBe(true); expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "2.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); it("should return false when versions are equal", () => { expect(operator.evaluate(evaluator, ["1.0.0", "1.0.0"])).toBe(false); expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "1.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); it("should return false when left version is greater", () => { expect(operator.evaluate(evaluator, ["2.0.0", "1.0.0"])).toBe(false); expect(evaluator.versionCompare).toHaveBeenCalledWith("2.0.0", "1.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); - it("should return null for null inputs", () => { - expect(operator.evaluate(evaluator, [null, null])).toBe(null); + it("should return null when lhs is null", () => { + expect(operator.evaluate(evaluator, [null, "1.0.0"])).toBe(null); + expect(evaluator.evaluate).toHaveBeenCalledTimes(1); + expect(evaluator.versionCompare).not.toHaveBeenCalled(); + }); - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); + it("should return null when rhs is null", () => { + expect(operator.evaluate(evaluator, ["1.0.0", null])).toBe(null); + expect(evaluator.evaluate).toHaveBeenCalledTimes(2); + expect(evaluator.versionCompare).not.toHaveBeenCalled(); }); }); }); diff --git a/src/__tests__/jsonexpr/operators/semver_lte.test.js b/src/__tests__/jsonexpr/operators/semver_lte.test.js index 8ee5920..b4a870f 100644 --- a/src/__tests__/jsonexpr/operators/semver_lte.test.js +++ b/src/__tests__/jsonexpr/operators/semver_lte.test.js @@ -7,35 +7,36 @@ describe("SemverLessThanOrEqualOperator", () => { describe("evaluate", () => { const evaluator = mockEvaluator(); + afterEach(() => { + evaluator.evaluate.mockClear(); + evaluator.versionCompare.mockClear(); + }); + it("should return true when left version is less", () => { expect(operator.evaluate(evaluator, ["1.0.0", "2.0.0"])).toBe(true); expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "2.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); it("should return true when versions are equal", () => { expect(operator.evaluate(evaluator, ["1.0.0", "1.0.0"])).toBe(true); expect(evaluator.versionCompare).toHaveBeenCalledWith("1.0.0", "1.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); it("should return false when left version is greater", () => { expect(operator.evaluate(evaluator, ["2.0.0", "1.0.0"])).toBe(false); expect(evaluator.versionCompare).toHaveBeenCalledWith("2.0.0", "1.0.0"); - - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); }); - it("should return null for null inputs", () => { - expect(operator.evaluate(evaluator, [null, null])).toBe(null); + it("should return null when lhs is null", () => { + expect(operator.evaluate(evaluator, [null, "1.0.0"])).toBe(null); + expect(evaluator.evaluate).toHaveBeenCalledTimes(1); + expect(evaluator.versionCompare).not.toHaveBeenCalled(); + }); - evaluator.evaluate.mockClear(); - evaluator.versionCompare.mockClear(); + it("should return null when rhs is null", () => { + expect(operator.evaluate(evaluator, ["1.0.0", null])).toBe(null); + expect(evaluator.evaluate).toHaveBeenCalledTimes(2); + expect(evaluator.versionCompare).not.toHaveBeenCalled(); }); }); }); diff --git a/src/jsonexpr/evaluator.ts b/src/jsonexpr/evaluator.ts index 885b15e..0d21073 100644 --- a/src/jsonexpr/evaluator.ts +++ b/src/jsonexpr/evaluator.ts @@ -12,21 +12,33 @@ function parseSemver(version: string) { v = v.substring(0, plusIndex); } + if (v === "") { + return null; + } + const [core, ...preReleaseParts] = v.split("-"); const preRelease = preReleaseParts.join("-"); + + if (core === "") { + return null; + } + const parts = core.split("."); return { parts, preRelease }; } +const NUMERIC_IDENTIFIER = /^(0|[1-9]\d*)$/; + function compareIdentifiers(a: string, b: string) { - const aNum = parseInt(a, 10); - const bNum = parseInt(b, 10); - const aIsNum = !isNaN(aNum) && String(aNum) === a; - const bIsNum = !isNaN(bNum) && String(bNum) === b; + const aIsNum = NUMERIC_IDENTIFIER.test(a); + const bIsNum = NUMERIC_IDENTIFIER.test(b); if (aIsNum && bIsNum) { - return aNum === bNum ? 0 : aNum > bNum ? 1 : -1; + if (a.length !== b.length) { + return a.length > b.length ? 1 : -1; + } + return a === b ? 0 : a > b ? 1 : -1; } if (aIsNum) return -1; if (bIsNum) return 1; @@ -128,11 +140,14 @@ export class Evaluator { const l = parseSemver(lhsStr); const r = parseSemver(rhsStr); + if (l === null || r === null) { + return null; + } const maxLen = Math.max(l.parts.length, r.parts.length); for (let i = 0; i < maxLen; i++) { - const lPart = l.parts[i] || "0"; - const rPart = r.parts[i] || "0"; + const lPart = l.parts[i] ?? "0"; + const rPart = r.parts[i] ?? "0"; const result = compareIdentifiers(lPart, rPart); if (result !== 0) return result; } From 897faa2f1b72d1cfc78650c9896d575dcb2c6285 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 24 Mar 2026 15:12:38 +0000 Subject: [PATCH 06/10] refactor: remove cross-sdk-tests from JS SDK repo Scenarios moved to the dedicated cross-sdk-tests repository. --- cross-sdk-tests/test_scenarios_complete.json | 213 ------------------- 1 file changed, 213 deletions(-) delete mode 100644 cross-sdk-tests/test_scenarios_complete.json diff --git a/cross-sdk-tests/test_scenarios_complete.json b/cross-sdk-tests/test_scenarios_complete.json deleted file mode 100644 index 450762c..0000000 --- a/cross-sdk-tests/test_scenarios_complete.json +++ /dev/null @@ -1,213 +0,0 @@ -{ - "semver_operators": [ - { - "name": "semver_gte match - app version >= required version", - "expression": { - "semver_gte": [ - { "var": "app_version" }, - { "value": "2.0.0" } - ] - }, - "vars": { "app_version": "2.1.0" }, - "expected": true - }, - { - "name": "semver_gte no match - app version below required", - "expression": { - "semver_gte": [ - { "var": "app_version" }, - { "value": "2.0.0" } - ] - }, - "vars": { "app_version": "1.9.0" }, - "expected": false - }, - { - "name": "semver_gte match - exact version", - "expression": { - "semver_gte": [ - { "var": "app_version" }, - { "value": "2.0.0" } - ] - }, - "vars": { "app_version": "2.0.0" }, - "expected": true - }, - { - "name": "semver_lt match - version below threshold", - "expression": { - "semver_lt": [ - { "var": "app_version" }, - { "value": "3.0.0" } - ] - }, - "vars": { "app_version": "2.5.0" }, - "expected": true - }, - { - "name": "semver_lt no match - version at threshold", - "expression": { - "semver_lt": [ - { "var": "app_version" }, - { "value": "3.0.0" } - ] - }, - "vars": { "app_version": "3.0.0" }, - "expected": false - }, - { - "name": "semver_eq match - equal versions", - "expression": { - "semver_eq": [ - { "var": "app_version" }, - { "value": "1.2.3" } - ] - }, - "vars": { "app_version": "1.2.3" }, - "expected": true - }, - { - "name": "semver_eq no match - different versions", - "expression": { - "semver_eq": [ - { "var": "app_version" }, - { "value": "1.2.3" } - ] - }, - "vars": { "app_version": "1.2.4" }, - "expected": false - }, - { - "name": "semver_gt match - version above", - "expression": { - "semver_gt": [ - { "var": "app_version" }, - { "value": "1.0.0" } - ] - }, - "vars": { "app_version": "2.0.0" }, - "expected": true - }, - { - "name": "semver_gt no match - version equal", - "expression": { - "semver_gt": [ - { "var": "app_version" }, - { "value": "1.0.0" } - ] - }, - "vars": { "app_version": "1.0.0" }, - "expected": false - }, - { - "name": "semver_gt no match - version below", - "expression": { - "semver_gt": [ - { "var": "app_version" }, - { "value": "2.0.0" } - ] - }, - "vars": { "app_version": "1.0.0" }, - "expected": false - }, - { - "name": "semver_gt with pre-release - release > pre-release", - "expression": { - "semver_gt": [ - { "var": "app_version" }, - { "value": "1.0.0-alpha" } - ] - }, - "vars": { "app_version": "1.0.0" }, - "expected": true - }, - { - "name": "semver_gt with pre-release - pre-release not > release", - "expression": { - "semver_gt": [ - { "var": "app_version" }, - { "value": "1.0.0" } - ] - }, - "vars": { "app_version": "1.0.0-alpha" }, - "expected": false - }, - { - "name": "semver_lte match - version equal", - "expression": { - "semver_lte": [ - { "var": "app_version" }, - { "value": "5.0.0" } - ] - }, - "vars": { "app_version": "5.0.0" }, - "expected": true - }, - { - "name": "semver_lte match - version below", - "expression": { - "semver_lte": [ - { "var": "app_version" }, - { "value": "5.0.0" } - ] - }, - "vars": { "app_version": "4.9.9" }, - "expected": true - }, - { - "name": "semver_lte no match - version above", - "expression": { - "semver_lte": [ - { "var": "app_version" }, - { "value": "5.0.0" } - ] - }, - "vars": { "app_version": "5.0.1" }, - "expected": false - }, - { - "name": "semver_gte with missing parts - 1.2 treated as 1.2.0", - "expression": { - "semver_gte": [ - { "var": "app_version" }, - { "value": "1.2.0" } - ] - }, - "vars": { "app_version": "1.2" }, - "expected": true - }, - { - "name": "semver_eq with v prefix stripped", - "expression": { - "semver_eq": [ - { "var": "app_version" }, - { "value": "1.0.0" } - ] - }, - "vars": { "app_version": "v1.0.0" }, - "expected": true - }, - { - "name": "semver_eq ignores build metadata", - "expression": { - "semver_eq": [ - { "var": "app_version" }, - { "value": "1.0.0+build1" } - ] - }, - "vars": { "app_version": "1.0.0+build2" }, - "expected": true - }, - { - "name": "semver_gte with missing attribute - returns null (falsy)", - "expression": { - "semver_gte": [ - { "var": "app_version" }, - { "value": "2.0.0" } - ] - }, - "vars": {}, - "expected": false - } - ] -} From fd43a80451348effa63e473a8b87c7377f4a83c8 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 24 Mar 2026 15:44:39 +0000 Subject: [PATCH 07/10] fix: handle leading zeros in semver version parts Leading zeros like "1.02.30" are now treated as their numeric value (02 == 2). Uses permissive regex /^\d+$/ with leading zero stripping instead of strict semver regex that rejected them. --- src/__tests__/jsonexpr/evaluator.test.js | 9 +++++++++ src/jsonexpr/evaluator.ts | 15 +++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/__tests__/jsonexpr/evaluator.test.js b/src/__tests__/jsonexpr/evaluator.test.js index 90699dd..53b1927 100644 --- a/src/__tests__/jsonexpr/evaluator.test.js +++ b/src/__tests__/jsonexpr/evaluator.test.js @@ -358,6 +358,15 @@ describe("Evaluator", () => { expect(evaluator.versionCompare("1.0.0", "1")).toBe(0); }); + it("should handle leading zeros in version parts", () => { + const evaluator = new Evaluator({}, {}); + + expect(evaluator.versionCompare("1.02.0", "1.2.0")).toBe(0); + expect(evaluator.versionCompare("1.002.030", "1.2.30")).toBe(0); + expect(evaluator.versionCompare("01.0.0", "1.0.0")).toBe(0); + expect(evaluator.versionCompare("1.02.0", "1.3.0")).toBe(-1); + }); + it("should handle pre-release versions", () => { const evaluator = new Evaluator({}, {}); diff --git a/src/jsonexpr/evaluator.ts b/src/jsonexpr/evaluator.ts index 0d21073..5c52ff0 100644 --- a/src/jsonexpr/evaluator.ts +++ b/src/jsonexpr/evaluator.ts @@ -28,17 +28,24 @@ function parseSemver(version: string) { return { parts, preRelease }; } -const NUMERIC_IDENTIFIER = /^(0|[1-9]\d*)$/; +const NUMERIC_IDENTIFIER = /^\d+$/; + +function stripLeadingZeros(s: string) { + const stripped = s.replace(/^0+/, ""); + return stripped === "" ? "0" : stripped; +} function compareIdentifiers(a: string, b: string) { const aIsNum = NUMERIC_IDENTIFIER.test(a); const bIsNum = NUMERIC_IDENTIFIER.test(b); if (aIsNum && bIsNum) { - if (a.length !== b.length) { - return a.length > b.length ? 1 : -1; + const aNorm = stripLeadingZeros(a); + const bNorm = stripLeadingZeros(b); + if (aNorm.length !== bNorm.length) { + return aNorm.length > bNorm.length ? 1 : -1; } - return a === b ? 0 : a > b ? 1 : -1; + return aNorm === bNorm ? 0 : aNorm > bNorm ? 1 : -1; } if (aIsNum) return -1; if (bIsNum) return 1; From 072c6d66e75a1008003ba374152321ead1631952 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 24 Mar 2026 17:10:03 +0000 Subject: [PATCH 08/10] ci: trigger build on all PR events, not just opened --- .github/workflows/build.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c50a6a7..e2dc544 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,8 +2,6 @@ name: Build on: pull_request: - types: - - opened push: branches: - main @@ -14,7 +12,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install dependencies run: npm install From 665e19de6b237fb1a8e9fd44db4b1dcc6c20d2e0 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 24 Mar 2026 17:21:05 +0000 Subject: [PATCH 09/10] chore: bump webpack maxAssetSize to 160 KiB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5 new semver operator modules + evaluator versionCompare method add ~5.6 KiB of source which translates to ~14 KiB in the minified bundle due to per-module webpack overhead. No bloat or unnecessary polyfills — the increase is proportional to the code added. --- webpack.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpack.config.js b/webpack.config.js index 15ff2da..268ac3e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -36,7 +36,7 @@ module.exports = function () { config.output.filename = "[name].min.js"; config.performance = { hints: "error", - maxAssetSize: 131072, + maxAssetSize: 163840, }; config.optimization = { From 8ffb14123c212d6e6de22655390ce8ce7b1452be Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 24 Mar 2026 19:56:46 +0000 Subject: [PATCH 10/10] perf: enable Babel runtime helpers to deduplicate bundle Enable helpers: true in @babel/plugin-transform-runtime so Babel helpers (_classCallCheck, _createClass, _slicedToArray, etc.) are imported from a shared runtime instead of being inlined into every module. Reduces minified browser bundle from 132 KiB to 97.7 KiB. Also reverts the maxAssetSize bump since the bundle is now well under the 128 KiB limit. --- babel.config.js | 2 +- webpack.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/babel.config.js b/babel.config.js index 8fddd57..815eb05 100644 --- a/babel.config.js +++ b/babel.config.js @@ -32,7 +32,7 @@ module.exports = function (api) { regenerator: false, useESModules: false, // don't output es-modules by default corejs: false, - helpers: false, + helpers: true, }, ]; diff --git a/webpack.config.js b/webpack.config.js index 268ac3e..15ff2da 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -36,7 +36,7 @@ module.exports = function () { config.output.filename = "[name].min.js"; config.performance = { hints: "error", - maxAssetSize: 163840, + maxAssetSize: 131072, }; config.optimization = {