Add JSON Logic string + array operators#6793
Merged
Merged
Conversation
Sets up the plumbing for an internal RulesEngine framework that the SDK
can ship as a separate library without coupling RevenueCat and
RevenueCatUI to each other through it. No actual rules logic yet —
this is just the wiring across all distribution methods so the upcoming
JSON Logic engine can land incrementally.
What's here:
- New `RulesEngine/` source dir with a `public enum RulesEngine {}`
placeholder, plus `Tests/RulesEngineTests/` with a smoke test.
- `Package.swift`: new `RulesEngine` library product, target, and
`RulesEngineTests` test target.
- Tuist: new `Projects/RulesEngine/Project.swift`, registered in
`Workspace.swift` under `localXcodeProject` mode (mirroring how
RevenueCat / RevenueCatUI are wired), and added to the
`productTypes` map in `Tuist/Package.swift`.
- Legacy `RevenueCat.xcodeproj`: new `RulesEngine` framework target +
`RulesEngineTests` test target + shared `RulesEngine.xcscheme`,
added programmatically via `scripts/add_rules_engine_to_xcodeproj.rb`
(kept under `scripts/` for repeatability). This is what enables
Carthage and any future xcframework export to see the framework.
- `RevenueCatRulesEngine.podspec` at the repo root, with
`s.module_name = 'RulesEngine'` so consumers `import RulesEngine`.
The pod has no dependency on `RevenueCat`.
- `Tests/TestPlans/CI-AllTests.xctestplan`: `RulesEngineTests` added
so the existing `test_ios` Fastlane / CI matrix executes them
automatically — no separate lane or job required for tests.
- `fastlane/Fastfile`: `RevenueCatRulesEngine.podspec` added to the
version-bump map and the `check_pods` lint matrix; new
`push_rules_engine_pod` lane.
- `.circleci/default_config.yml`: new `push-rules-engine-pod` job
wired into the release workflow alongside the existing pod pushes;
`make-release` now also requires it.
- `.swiftlint.yml`: `Tests/RulesEngineTests` added to the
`xctestcase_superclass` exclude list (same treatment as
`ReceiptParserTests`).
Decisions worth flagging:
- Pod name is `RevenueCatRulesEngine`, module name is `RulesEngine`.
The longer pod name avoids namespace collisions in the CocoaPods
global namespace; the module name keeps `import RulesEngine` clean.
- Wired into the legacy `.xcodeproj` from day one so Carthage works
the same day a real type lands here. The pbxproj edits live in a
scripted helper rather than hand-written diffs to keep the change
reviewable and reproducible.
- No consumer (`RevenueCat` / `RevenueCatUI`) depends on `RulesEngine`
yet. That choice is intentionally a follow-up so we can decide
ownership without churn here.
Verification:
- `swift build --target RulesEngine` ✔
- `swift test --filter RulesEngineTests` ✔
- `xcodebuild -project RevenueCat.xcodeproj -scheme RulesEngine ... build` ✔
- `xcodebuild -project RevenueCat.xcodeproj -scheme RulesEngine ... test` ✔
- `bundle exec pod lib lint RevenueCatRulesEngine.podspec --quick` ✔
- `tuist generate` (default `localSwiftPackage`) ✔
- `TUIST_RC_XCODE_PROJECT=true tuist generate` ✔
- `swiftlint RulesEngine Tests/RulesEngineTests` ✔
Co-authored-by: Cursor <cursoragent@cursor.com>
Every public declaration in this module is intended to be visible only to the rest of the SDK (RevenueCat / RevenueCatUI / hybrid bridges), not to app developers, so put the SPI gate in place from day one instead of bolting it on later. - `RulesEngine.RulesEngine` is now `@_spi(Internal) public`. Comment explains the convention so future declarations follow it. - The test bundle imports the module with `@_spi(Internal) @testable import RulesEngine` so it can reach SPI-public symbols. Verified: `swift test --filter RulesEngineTests`, `xcodebuild ... -scheme RulesEngine test`, `pod lib lint RevenueCatRulesEngine.podspec --quick`, `swiftlint RulesEngine Tests/RulesEngineTests` — all green. Co-authored-by: Cursor <cursoragent@cursor.com>
- Mirror the RulesEngine product + target wiring into `Package@swift-5.8.swift` so consumers on Swift 5.8 toolchains see the new module too. Caught by reviewer; my original SPM change only touched the main `Package.swift`. - Trim the verbose explainer doc on the `RulesEngine` placeholder type. The `@_spi(Internal)` annotation speaks for itself; future declarations don't need a per-file reminder. - Delete `scripts/add_rules_engine_to_xcodeproj.rb`. It was a one-shot helper used to add the framework / test targets / scheme to `RevenueCat.xcodeproj` without hand-editing 290 lines of pbxproj. The pbxproj is now the source of truth for those targets, and keeping the script around just risks drift between the script's hardcoded settings and the actual project file. Co-authored-by: Cursor <cursoragent@cursor.com>
Adds a fastlane lane (check_rules_engine_no_public_api) that builds RulesEngine for iphonesimulator with BUILD_LIBRARY_FOR_DISTRIBUTION=YES and asserts the public swiftinterface contains zero declarations. @_spi(Internal) public symbols only appear in the .private.swiftinterface, so they cannot leak through this check by construction. Unlike a baseline diff, no committed reference file exists for a contributor to regenerate to silence the check. Wires the lane into a new check-rules-engine-public-api CI job alongside the existing check-api-changes-* jobs in run-all-tests, release-or-main, all-tasks-passed, and all-tests-succeeded workflows. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
First slice of the JSON Logic rules engine in the new RulesEngine module (built on top of the framework skeleton from `pallares/rules-engine-skeleton`). Pure Swift, no public surface yet — every new declaration is module-internal so the engine can land incrementally without touching the SDK's exported API. What's in: - `Logger.swift` — internal `RulesEngineLogger` protocol + default `PrintLogger` (stderr) + test `CapturingLogger`. Kept internal so a future foreign-trait logger injected by the host SDK can be adapted to the same protocol without API churn. - `Value.swift` — typed `Value` enum (`null` / `bool` / `int` / `float` / `string` / `array` / `object`), JSON Logic truthiness + loose (`==`) / strict (`===`) equality with type coercion. - `RuleError.swift` — `RuleError` enum (`parse`, `typeMismatch`, `unsupportedOperator`). - `Evaluator.swift` — top-level dispatcher; takes a typed `Value` predicate (no JSON parsing in the engine) and returns `Bool`. - `Operators/` — MVP set: `var` (strict dot-path), `missing`, `==`, `!=`, `===`, `!==`, `!`, `!!`, `and`, `or`, `if`. - Test bundle includes a `Value+JSON.swift` helper (Foundation's `JSONSerialization` + `CFNumber` type sniffing) so test predicates can be expressed as JSON literals — production callers will construct `Value` trees from the host SDK's own JSON parser. Key decisions: - **JSON parsing lives outside the engine.** The engine accepts a typed `Value` tree (the JSON-shaped enum is what cross-language bridges can express across the boundary). The `Foundation`-backed JSON helper is gated behind the test target only. - **Missing variables** resolve to `null` and emit a warning, per JSON Logic spec. No `missingVariable` case in v1; reserved for a future strict mode. - **`var` lookup** uses strict dot-path navigation. Flat-key fallback considered and deferred — pure addition if we need it later. Verification: - `swift test --filter RulesEngineTests` — 63 tests, 0 failures. - `xcodebuild test -scheme RulesEngine -destination 'platform=macOS,arch=arm64'` — all RulesEngineTests pass. - `swiftlint lint RulesEngine Tests/RulesEngineTests` — clean. - `pod lib lint RevenueCatRulesEngine.podspec --quick` — passed. Files were added to the legacy `RevenueCat.xcodeproj` programmatically via a one-shot `xcodeproj`-gem helper (mirroring how the framework target itself was bootstrapped); the helper script was deleted after running so the pbxproj remains the source of truth. Co-authored-by: Cursor <cursoragent@cursor.com>
…rge)" This reverts commit a9018bf.
- Add the standard RevenueCat MIT license header to `RulesEngine.swift` and `RulesEngineTests.swift`, matching every other Swift source in `Sources/`, `Tests/`, and `RevenueCatUI/`. - Drop the explicit `Foundation.framework in Frameworks` entries (and the orphaned `Foundation.framework` PBXFileReference / `iOS` group) from the `RulesEngine` and `RulesEngineTests` targets. Swift auto-links Foundation, and the hardcoded `iPhoneOS18.0.sdk` path was inconsistent with `RevenueCat`, `RevenueCatUI`, and `ReceiptParser`, none of which link Foundation explicitly. Verified: `xcodebuild build`, `xcodebuild test`, `swift build`, `swift test`, and `pod lib lint --quick` all pass. Co-authored-by: Cursor <cursoragent@cursor.com>
- SPM: drop the `RulesEngine` library product. The target stays — it's consumed as an internal dependency of `RevenueCat` / `RevenueCatUI` (once we wire it in) and used by `RulesEngineTests` — but without a `.library(...)` product it can't be added directly by SPM integrators. Mirrored in `Package@swift-5.8.swift`. - CI: drop `push-rules-engine-pod` from the `deploy-tag` workflow and from `make-release`'s `requires`. The module is currently a skeleton with no functionality and no consumer, so publishing `RevenueCatRulesEngine` to CocoaPods trunk on every SDK release would ship an empty pod forever. The job definition, the `push_rules_engine_pod` fastlane lane, the `pod_lib_lint` inside `check_pods`, and the `version_replacements` entry are all kept, so the podspec keeps getting linted and version-bumped — a follow-up PR will re-add the two workflow lines alongside the first real consumer of `RulesEngine`. Verified: `swift build --target RulesEngine`, `swift test --filter RulesEngineTests`, `xcodebuild -scheme RulesEngine test`, `pod lib lint RevenueCatRulesEngine.podspec --quick`, and YAML parsing of `.circleci/default_config.yml` all pass. Co-authored-by: Cursor <cursoragent@cursor.com>
The `RulesEngine` module is currently a skeleton with no functionality and no consumer. Keeping the CocoaPods publishing pieces in this PR means we'd be carrying podspec / fastlane / CircleCI artifacts that nothing exercises end-to-end. Drop them entirely from this PR so the skeleton stays minimal: - Delete `RevenueCatRulesEngine.podspec`. - Remove `push_rules_engine_pod` fastlane lane, `version_replacements` entry, and the `pod_lib_lint` call from `check_pods`. - Remove the `push-rules-engine-pod` CircleCI job definition and the explanatory comment in the `deploy-tag` workflow. The full distribution wiring (including the workflow entry and `make-release` requirement) will land in a separate draft PR that we can keep open and only merge alongside the first real consumer of `RulesEngine`. Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the duplicated platform-independence note from the `check_pods` `RulesEngine` swiftinterface check; the remaining lines already cover why @_spi(Internal) can't leak through it. Co-authored-by: Cursor <cursoragent@cursor.com>
…into pallares/rules-engine-enforce-internal-api
Co-authored-by: Cursor <cursoragent@cursor.com>
…into pallares/rules-engine-enforce-internal-api
`RulesEngine` is intentionally never exposed as an SPM `.library` product nor as an `.external(name:)` target in any of our Tuist projects — it's only ever pulled in transitively as an internal target of `RevenueCat`/`RevenueCatUI`. That removes the duplicate- framework concern that gates `RevenueCat`/`RevenueCatUI` behind `TUIST_RC_XCODE_PROJECT=true`, because no workspace project links both the local Tuist `RulesEngine.framework` and the SPM-resolved transitive one into the same binary. Moving the `./Projects/RulesEngine` append out of the `localXcodeProject` switch lets developers run `tuist generate RulesEngine` (or pick the `RulesEngine` scheme from the workspace) in the default dependency mode, without needing to set any environment variables. `RevenueCat` / `RevenueCatUI` stay gated as before — they're exposed as SPM library products, so including the local Tuist projects alongside the SPM-resolved ones would still produce the "Multiple commands produce" duplicate-framework error. Co-authored-by: Cursor <cursoragent@cursor.com>
Define `RulesEngineTests` as a Tuist target alongside `RulesEngine` and add it to the `RulesEngine` scheme's `testAction` so the generated workspace can build and run the module's tests. Use `tuist generate RulesEngineTests` (or a full `tuist generate`) to get a workspace with both targets — `tuist generate RulesEngine` filters to the framework alone, since tests depend on the framework rather than the other way around. Co-authored-by: Cursor <cursoragent@cursor.com>
`TargetReference` is `ExpressibleByStringLiteral`, so the explicit `.init(stringLiteral:)` is unnecessary — passing the string directly produces the same result with less noise. Co-authored-by: Cursor <cursoragent@cursor.com>
…ine-enforce-internal-api
Match the established pattern used by every other framework target in `RevenueCat.xcodeproj` (`RevenueCat`, `RevenueCatUI`, `ReceiptParser`): Debug uses `SKIP_INSTALL = NO`, Release uses `SKIP_INSTALL = YES`. Leaving `SKIP_INSTALL = NO` on Release would copy `RulesEngine.framework` into the archive's Products directory during `xcodebuild archive`-based flows (e.g. XCFramework export), which is not the intended behavior for a framework that's meant to be embedded by consumers. Co-authored-by: Cursor <cursoragent@cursor.com>
…ine-enforce-internal-api
…lic_api` The lane previously invoked `xcodebuild -workspace . -scheme RulesEngine`, which only works when `RulesEngine` is an SPM library product. After the skeleton PR removed `RulesEngine` from `Package.swift`'s `products`, no SPM scheme is auto-generated and CI failed with `The workspace named "purchases-ios" does not contain a scheme named "RulesEngine"`. Switch to building via the Tuist-generated `RevenueCat-Tuist.xcworkspace`, where `RulesEngine` already has a shared scheme. CI now generates the workspace via the existing `tuist-generate-workspace` step before invoking the lane; locally, run `tuist install && tuist generate RulesEngine --no-open` first (the lane fails with a friendly message otherwise). Co-authored-by: Cursor <cursoragent@cursor.com>
…es/json-logic-evaluator Conflict in `RevenueCat.xcodeproj/project.pbxproj`: this branch was forked off the older skeleton state and (via Cursor/Xcode auto-edits) re-introduced the `Foundation.framework in Frameworks` references with a hardcoded `iPhoneOS18.0.sdk` path on the `RulesEngine` framework target and the `RulesEngineTests` target — the exact pollution that was cleaned up on the skeleton PR (commit `e639329c7 RulesEngine skeleton: PR feedback`). Resolution: keep all of this branch's legitimate additions (the new JSON Logic source files and tests, including `EqualityOperatorsTests.swift`) and re-apply the skeleton-PR cleanup — drop both `Foundation.framework` build file entries, the file reference with the hardcoded SDK path, the iOS PBXGroup, and empty out the now-unused entries in the corresponding Frameworks build phases. Foundation is auto-linked by Swift; no behavior change. Verified locally: `swift build --target RulesEngineTests` builds clean, `tuist generate RulesEngine --no-open` succeeds, and the `check_rules_engine_no_public_api` lane still passes. Co-authored-by: Cursor <cursoragent@cursor.com>
`RulesEngine` is not an SPM library product and isn't consumed via `.external(name: "RulesEngine")` in any Tuist project, so the `productTypes` mapping has nothing to apply to. Remove it. Co-authored-by: Cursor <cursoragent@cursor.com>
Add warn tag parameter with a default, replace the public logger setter with setLogger, and update tests to install loggers through the new API. Co-authored-by: Cursor <cursoragent@cursor.com>
Nothing in the MVP evaluator throws it yet; add it back in the PR that introduces the first call site. Co-authored-by: Cursor <cursoragent@cursor.com>
…-arithmetic-operators
…/json-logic-comparison-operators
Resolve conflicts: keep arithmetic operators and extended ToNumber coercion from this branch, take main's evaluator/logger updates and swiftlint rule, and restore RuleError.typeMismatch for zero-arg mul. Co-authored-by: Cursor <cursoragent@cursor.com>
Restore ExitOfferHelperTests and ButtonComponentViewTests entries from main and apply only the RulesEngineInternal arithmetic additions. Co-authored-by: Cursor <cursoragent@cursor.com>
…/json-logic-comparison-operators
Resolve conflicts: keep comparison operators alongside arithmetic from main, update logger API, and merge Xcode project references for both operator sets. Co-authored-by: Cursor <cursoragent@cursor.com>
Match json-logic-js: after ToPrimitive (number hint), lex compare only when both sides are strings; compound-vs-string uses jsString, not numeric coercion. Co-authored-by: Cursor <cursoragent@cursor.com>
…/json-logic-string-array-operators Co-authored-by: Cursor <cursoragent@cursor.com>
Use Array.prototype.join semantics for cat null operands and jsToNumber for missing_some threshold comparisons so NaN and unparseable need counts never satisfy per json-logic-js. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit abb68cb. Configure here.
Reject empty-string haystacks before substring search per json-logic-js, and add tests for null substr length, object cat operands, and empty haystack membership. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…tring-array-operators Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # RevenueCat.xcodeproj/project.pbxproj
ajpallares
added a commit
that referenced
this pull request
Jun 2, 2026
…son-logic-js
cat is Array.prototype.join(""), so a null operand renders as "" (not "null").
missing_some uses a raw JS >= comparison, so a NaN threshold is never satisfied
and the missing keys are returned. The operator source fix lands downstack in
Co-authored-by: Cursor <cursoragent@cursor.com>
#6793; this only updates the JSON predicate fixtures to match.
Member
Author
|
Bump on this @RevenueCat/coresdk 🙏 |
tonidero
approved these changes
Jun 2, 2026
This was referenced Jun 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Resolves SDK-4331
Motivation
Adds
in,cat,substr,merge, andmissing_someso v1 targeting predicates can express string-content, array-membership, and "any N of these fields populated" checks.Summary
StringArrayOperatorscoversin,cat,substr, andmergeper the JSON Logic spec.missing_somelives next tomissinginAccessorOperatorsto reuse the same dot-path lookup.Operatorsdispatch table extended with all five operators.Tests
StringArrayOperatorsTestscovers each operator (basic, coercion, edge cases, arity tolerance).AccessorOperatorsTestsextendsmissing_somecoverage (threshold met, below threshold, zero required, dot-paths).EvaluatorTestsadds two integration tests through dispatch: acountry in [...]membership check and amissing_somegate inside anif.Notes
substrslices by Unicode code points (SwiftString.Character) rather than UTF-16 code units; differs from JS only for surrogate-pair characters.mainonce that lands.Made with Cursor
Note
Low Risk
Isolated RulesEngineInternal operator additions with extensive spec-parity tests; affects targeting predicate evaluation but not purchases, auth, or network I/O.
Overview
Extends the internal JSON Logic rules engine with string/array operators (
in,cat,substr,merge) andmissing_some, wired throughOperators.dispatchand a newStringArrayOperatorsmodule.missing_somereusesmissingdot-path semantics and compares present-field count against a threshold via newjsToNumbercoercion (distinct fromasNumberfor NaN edge cases).substrusesclampedIntso NaN/±Infinity indices don’t trap when coercing toInt.Broad unit coverage (
StringArrayOperatorsTests,AccessorOperatorsTests,EvaluatorTestsintegration) documents json-logic-js parity, including empty-stringinhaystacks, strict array membership, and Unicode code-point slicing (noted divergence from JS only for surrogate pairs).Reviewed by Cursor Bugbot for commit fee8661. Bugbot is set up for automated code reviews on this repo. Configure here.