Add RulesEngine skeleton module#6787
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>
- 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>
Co-authored-by: Cursor <cursoragent@cursor.com>
`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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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 7e4473a. Configure here.
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>
`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>
The module is called `RulesEngine`, so an `enum RulesEngine` inside it collides with the module name from the test target's perspective — `@testable import RulesEngine` makes the bare identifier resolve to the module, forcing callers to write `RulesEngine.RulesEngine.something` to reach the namespace. Renaming the namespace to `Rules` keeps the module name descriptive while letting consumers write `Rules.something` cleanly. Doing it now (before any real implementation lands) avoids a churny rename in the follow-up PRs. Co-authored-by: Cursor <cursoragent@cursor.com>
The collision-with-module-name rationale was useful while deciding on the name but doesn't add long-term value at the call site. Reduce to a one-line summary. Co-authored-by: Cursor <cursoragent@cursor.com>
The skeleton PR (#6787) intentionally flipped this from `NO` to `YES` in 42e362c to match every other framework target in the project (`RevenueCat`, `RevenueCatUI`, `ReceiptParser` — all `YES` on Release, `NO` on Debug). Leaving it `NO` on Release would copy `RulesEngine.framework` into the archive's Products directory during `xcodebuild archive` flows (e.g. XCFramework export) and risks "Invalid Bundle" App Store submission failures for downstream archives that embed the framework. That fix was silently dropped during one of the pbxproj merge-conflict resolutions while cascading the skeleton into #6789. Re-apply it. Reported by Cursor Bugbot: #6789 (comment) Co-authored-by: Cursor <cursoragent@cursor.com>
rickvdl
left a comment
There was a problem hiding this comment.
Great start on this 💪 Some small comments
| import Foundation | ||
|
|
||
| /// Namespace for the RevenueCat rules engine. | ||
| @_spi(Internal) public enum Rules {} |
There was a problem hiding this comment.
Will this be just for the engine or for everything related to the rules 'package'?
Thinking about Rules.Engine, Rules.Rule etc? If just for the engine maybe RulesEngine would make more sense?
There was a problem hiding this comment.
I started this as RulesEngine precisely, but I was getting some conflicts because the module name RulesEngine would clash with the RulesEngine module.
Maybe the easiest solution is to not use any namespace at all... WDYT?
There was a problem hiding this comment.
I believe we could just expose the Evaluator.evaluate... method (or a variant) and the Value enum from #6789. So I'm leaning towards dropping the namespace
There was a problem hiding this comment.
Agree, that sounds fine as well :)
tonidero
left a comment
There was a problem hiding this comment.
Looking good! Only Rick's comments + the spi API question. Thank you!!
…LICATION_EXTENSION_API_ONLY The `RulesEngine` framework target in `RevenueCat.xcodeproj` was missing two settings that the rest of the SDK relies on: - `TARGETED_DEVICE_FAMILY = "1,2,3,4,7"` was missing `6` (Mac), even though `SUPPORTED_PLATFORMS` includes `macosx`, `destinations` is `.allRevenueCat` (which covers `.mac` / `.macWithiPadDesign` / `.macCatalyst`), and the sibling `RulesEngineTests` target already has `1,2,3,4,6,7`. Bring the framework in line with the test target and with `ReceiptParser` (which is the closest analog and uses the full `1,2,3,4,6,7`). - `APPLICATION_EXTENSION_API_ONLY` wasn't set. Both `RevenueCat` and `ReceiptParser` set it to `YES`. Once `RulesEngine` is wired in as a dependency of `RevenueCat` (the planned follow-up), Xcode would have rejected the build because an extension-only framework can't embed a framework that doesn't also opt in. Set it now to keep the extension-safe guarantee and avoid a surprise blocker later. Mirror the `APPLICATION_EXTENSION_API_ONLY` change in `Projects/RulesEngine/Project.swift` so the Tuist-generated project matches the legacy `.xcodeproj`. The Tuist destinations already cover all device families, so no `TARGETED_DEVICE_FAMILY` change is needed on that side. Reported by @rickvdl on #6787. Verified: - `xcodebuild -scheme RulesEngine` Debug + Release on iOS - `xcodebuild -scheme RulesEngine` Debug on macOS - `tuist generate RulesEngine` + `xcodebuild` against the generated workspace - `swift test --filter RulesEngineTests`
…-skeleton # Conflicts: # RevenueCat.xcodeproj/project.pbxproj
Per @tonidero on #6787: requiring every declaration in `RulesEngine` to be `@_spi(Internal)` is noisy and easy to forget. The module is an internal implementation dependency of the SDK — consumers should not be able to reach its public API surface from outside the SDK at all. Drop `@_spi(Internal)` from: - `RulesEngine/RulesEngine.swift` (the `Rules` namespace stays plain `public`, since the consumer-side import form will guarantee that no RulesEngine symbol leaks into the SDK's public API surface). - `Tests/RulesEngineTests/RulesEngineTests.swift` (a plain `@testable import RulesEngine` is enough now that there's no SPI gate to traverse). Enforcement of "no plain `import RulesEngine` from anywhere in the SDK" moves to a SwiftLint custom rule landing in #6788, which replaces the previous swiftinterface-based check there. The rule allows the canonical extension-safe import block: #if compiler(>=6) internal import RulesEngine #else @_implementationOnly import RulesEngine #endif (plus `private import` / `fileprivate import`), and rejects everything else. That guarantee is what previously needed `@_spi(Internal)` to hold — without those markers, a plain `import RulesEngine` in any SDK source would re-expose every `public` declaration. The lint rule makes that impossible. Verified: `xcodebuild -scheme RulesEngine` Debug + Release on iOS, `xcodebuild -scheme RulesEngine` Debug on macOS, and `swift test --filter RulesEngineTests` — all green.
Per @tonidero on #6787: requiring every declaration in RulesEngine to be `@_spi(Internal)` is noisy and easy to forget, but we still need a hard guarantee that no RulesEngine symbol leaks into the SDK's public API surface. The cleanest way to achieve that is to constrain the *import* form on the consumer side instead of every declaration on the producer side. The previous approach (`check_rules_engine_no_public_api` Fastlane lane + `check-rules-engine-public-api` CircleCI job) built RulesEngine for five SDKs and asserted the public swiftinterface contained zero declarations — i.e. it required everything to be `@_spi(Internal)`. With the SPI markers gone (#6787), that lane fails by definition, and its premise no longer matches the design. Replace it with a SwiftLint custom rule, `no_plain_rules_engine_import`, that flags any plain `import RulesEngine` and points the contributor at the canonical extension-safe form. With this in place, RulesEngine's symbols can stay plain `public` (no per-declaration SPI noise) because the only allowed import forms strip the implicit `@_exported public` re-export anyway: #if compiler(>=6) internal import RulesEngine #else @_implementationOnly import RulesEngine #endif `private import RulesEngine`, `fileprivate import RulesEngine`, and `@testable import RulesEngine` (for the test target) are also accepted naturally — the regex anchors on `^[ \t]*import\s+RulesEngine\b`, so any qualifier or attribute prefix bypasses it. Changes: - `.swiftlint.yml`: new `no_plain_rules_engine_import` custom rule with an actionable message that tells the contributor exactly which form to use. - `fastlane/Fastfile`: drop the `check_rules_engine_no_public_api` lane. The shared `setup_swiftinterface_xcconfig` / `cleanup_swiftinterface_xcconfig` private lanes stay — they're still used by `generate_swiftinterface`. - `.circleci/default_config.yml`: drop the `check-rules-engine-public-api` job, its two workflow references (`run-all-tests`, `release-or-main`), and the two `requires` entries in the `all-tasks-passed` / `all-tests-succeeded` summary gates. - `fastlane/README.md`: regenerated to drop the deleted lane. Verified locally: - The custom rule fires on every plain form (`import RulesEngine`, indented variants, `import RulesEngine.Submodule`) and stays silent on `internal` / `private` / `fileprivate` / `@_implementationOnly` / `@_exported` / `@testable` imports, line/doc/block comments, and unrelated modules (`RulesEngineMath`, `OtherRulesEngine`). - `swiftlint` against the full repo (1315 files): 0 violations from the new rule. - `ruby -c fastlane/Fastfile` and Ruby-YAML-load of `.circleci/default_config.yml` both pass. Co-authored-by: Cursor <cursoragent@cursor.com>
Per @tonidero on #6787: developers can transitively `import RulesEngine` from the SDK product even though the module is meant to be an internal implementation detail of the SDK. Naming the module `RulesEngineInternal` makes that intent explicit at the import site so consumers don't reach for it accidentally and don't expect API stability from it. This is a producer-side rename only — the previously-renamed `Rules` namespace inside the module is unchanged, so call sites still write `Rules.something`. Consumers' canonical extension-safe import (gated by the SwiftLint rule in #6788) becomes: #if compiler(>=6) internal import RulesEngineInternal #else @_implementationOnly import RulesEngineInternal #endif Renamed (`git mv`): - `RulesEngine/RulesEngine.swift` → `RulesEngineInternal/RulesEngineInternal.swift` - `Tests/RulesEngineTests/RulesEngineTests.swift` → `Tests/RulesEngineInternalTests/RulesEngineInternalTests.swift` - `Projects/RulesEngine/` → `Projects/RulesEngineInternal/` (Tuist project dir) - `RevenueCat.xcodeproj/xcshareddata/xcschemes/RulesEngine.xcscheme` → `RulesEngineInternal.xcscheme` Updated text references in: - `Package.swift` / `Package@swift-5.8.swift` (target + test target names + paths) - `Workspace.swift` (Tuist project path + comment) - `Projects/RulesEngineInternal/Project.swift` (target / scheme / bundle IDs / source paths) - `RevenueCat.xcodeproj/project.pbxproj` (target names, file refs, group names, build phase entries, bundle IDs `com.revenuecat.RulesEngineInternal[Tests]`) - `RevenueCat.xcodeproj/.../xcschemes/RulesEngineInternal.xcscheme` (BlueprintName / BuildableName) - `Tests/TestPlans/CI-AllTests.xctestplan` (target name) - `.swiftlint.yml` (`xctestcase_superclass` exclude path) - File header comments inside the two Swift sources The substitutions ran as `\bRulesEngine\b → RulesEngineInternal` first (safe because `RulesEngineTests` has no word boundary between `e` and `T`), then `\bRulesEngineTests\b → RulesEngineInternalTests`. The `Rules` namespace token was untouched because it isn't `RulesEngine`. Verified locally: - `swift build --target RulesEngineInternal` ✔ - `swift test --filter RulesEngineInternalTests` ✔ (1 test passed) - `xcodebuild -project RevenueCat.xcodeproj -scheme RulesEngineInternal -destination 'generic/platform=iOS' -configuration Debug build` ✔ - `swiftlint` ✔ (0 violations across 1316 files) - `git ls-files | xargs perl -ne 'if (/\bRulesEngine\b/ && !/RulesEngineInternal/) { ... }'` returns no hits. Co-authored-by: Cursor <cursoragent@cursor.com>
Mirror the producer-side rename from #6787 in this PR's podspec: - `s.module_name = 'RulesEngineInternal'` so `pod install` produces an `import RulesEngineInternal` module that matches the SPM / `.xcodeproj` target name. - `s.source_files = 'RulesEngineInternal/**/*.swift'` to track the new source directory. The CocoaPods-trunk pod name (`RevenueCatRulesEngine`) is unchanged — it's already RevenueCat-prefixed (so no global namespace collision risk) and changing it would invalidate any future pod-trunk version continuity. The `Internal` signal is on the import-site `module_name`, which is what consumers actually see in their `Podfile` / `import` lines. The `push_rules_engine_pod` Fastlane lane and `push-rules-engine-pod` CircleCI job are internal-only names and stay as-is. Verified: `bundle exec pod lib lint RevenueCatRulesEngine.podspec --quick` ✔ Co-authored-by: Cursor <cursoragent@cursor.com>
Mirror the producer-side rename from #6787: - Rule id: `no_leaking_rules_engine_import` → `no_leaking_rules_engine_internal_import`. - Rule name: "No leaking `import RulesEngine`" → "No leaking `import RulesEngineInternal`". - Regex: `import\s+RulesEngine\b` → `import\s+RulesEngineInternal\b`. - Violation message: every reference to `RulesEngine` (in disallowed-form examples, the canonical extension-safe import block, and the `private` / `fileprivate` fallbacks) updated to `RulesEngineInternal`. Verified locally: - Positive cases (`/tmp` test file): the rule fires on `import RulesEngineInternal`, `@_exported import RulesEngineInternal`, `public import RulesEngineInternal`, `package import RulesEngineInternal`, and indented `import RulesEngineInternal`. - Negative cases: `internal` / `private` / `fileprivate` / `@_implementationOnly` / `@testable` imports, the `#if compiler(>=6) internal import ... #else @_implementationOnly ... #endif` block, commented-out imports, unrelated modules (`RulesEngineInternalMath`, `OtherRulesEngineInternal`), and a plain `import RulesEngine` (the pre-rename name) all stay silent. - `swiftlint --no-cache` against the full repo: 0 violations across 1316 files. Co-authored-by: Cursor <cursoragent@cursor.com>
…es/json-logic-evaluator Brings in: - The `RulesEngine` → `RulesEngineInternal` module rename from #6787. - The `no_leaking_rules_engine_internal_import` SwiftLint rule update from #6788. - Plus the `main`-merge cascade carried by #6788. Conflicts resolved: - `RulesEngineInternal/RulesEngineInternal.swift`: kept this branch's `Rules.logger` / `LoggerStorage` storage, dropped the `@_spi(Internal)` marker on `public enum Rules` (since #6787 dropped those markers; the new SwiftLint rule replaces that gate). - `RevenueCat.xcodeproj/project.pbxproj`: kept this branch's full file list (Evaluator, Logger, Value, RuleError, Operators/*, plus the new test files and helpers), then applied the producer-side rename to every `RulesEngine\b` / `RulesEngineTests\b` token (target name, group name, path, productName, bundle IDs `com.revenuecat.RulesEngineInternal[Tests]`, framework / xctest output names, and the `RulesEngine.swift` → `RulesEngineInternal.swift` file ref). Other rename follow-ups in this branch's added files: - `Tests/RulesEngineInternalTests/*.swift` (8 files) — every `[@_spi(Internal) ]@testable import RulesEngine` updated to `@testable import RulesEngineInternal`. The `@_spi(Internal)` attribute is no longer needed because the producer no longer uses SPI markers. - `Projects/RulesEngineInternal/`, `RulesEngineInternal/`, and `Tests/RulesEngineInternalTests/` directories were moved automatically by git's directory-rename detection (15 file renames). Left intentionally unchanged: - `RulesEngineInternal/Logger.swift` — the development-only `PrintLogger` keeps its `[RulesEngine]` log prefix because that string identifies the subsystem (the rules engine) for log readers, not the producer-side module name. The injected production logger replaces this struct entirely once the engine is wired into the SDK, so the prefix is throwaway. - `RulesEngineLogger` protocol name — internal type name within the module, not part of the import-site signal that motivated the module rename. - The `Rules` namespace name — separate identifier from the module name, kept stable so call sites still write `Rules.something`. Verified locally: - `swift build --target RulesEngineInternal` ✔ - `swift test --filter RulesEngineInternalTests` ✔ — 75 tests passed - `xcodebuild -scheme RulesEngineInternal -destination 'generic/platform=iOS' build` ✔ - `swiftlint --no-cache` ✔ — 0 violations across 1332 files - `git ls-files | xargs perl -ne 'if (/\bRulesEngine\b/ && !/RulesEngineInternal/) { ... }'` returns only the `[RulesEngine]` log prefix mentioned above. Co-authored-by: Cursor <cursoragent@cursor.com>
The entry-point enum is internal for now; it will become public once wired into the SDK public surface. Co-authored-by: Cursor <cursoragent@cursor.com>
…6788) * Add RulesEngine skeleton module 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> * Gate RulesEngine API behind `@_spi(Internal)` 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> * RulesEngine skeleton: cleanups - 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> * Enforce RulesEngine has no public API outside @_spi(Internal) 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> * TEST: add leaked public API to verify CI guardrail (do not merge) Co-authored-by: Cursor <cursoragent@cursor.com> * Revert "TEST: add leaked public API to verify CI guardrail (do not merge)" This reverts commit a9018bf. * RulesEngine skeleton: PR feedback - 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> * RulesEngine skeleton: PR feedback - 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> * Move RulesEngine CocoaPods distribution wiring out of skeleton 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> * Trim verbose RulesEngine swiftinterface comment 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> * Drop RulesEngine SPM target explanatory comment Co-authored-by: Cursor <cursoragent@cursor.com> * Always include `./Projects/RulesEngine` in the Tuist workspace `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> * Wire `RulesEngineTests` into the Tuist `RulesEngine` project 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> * Simplify `RulesEngineTests` testable target reference `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> * Set `SKIP_INSTALL = YES` for `RulesEngine` Release config 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> * Build `RulesEngine` via Tuist workspace in `check_rules_engine_no_public_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> * Drop unused RulesEngine entry from Tuist/Package.swift productTypes `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> * Document strict swiftinterface filter in check_rules_engine_no_public_api A future Xcode bump could introduce new top-of-file constructs (`@_exported import ...`, `#if compiler(...)`, etc.) that the strict filter would flag as a public-API leak. Leave a note so the next person debugging a spurious failure extends the rejection list rather than hunting for a leaked symbol that isn't there. Co-authored-by: Cursor <cursoragent@cursor.com> * Extend RulesEngine public-API check to 4 simulator SDKs + macOS Iterate over the four simulator SDKs (iOS / watchOS / tvOS / visionOS) plus macOS instead of building for `iphonesimulator` only. Skipping the matching device SDKs is intentional: for a Swift-only module built with `BUILD_LIBRARY_FOR_DISTRIBUTION=YES` and no `targetEnvironment(simulator)` gates, the public swiftinterface is identical to the simulator counterpart, and our "declaration count == 0" assertion is invariant to that diff. macOS stays in the set because `#if os(macOS)` can expose Mac-only public symbols invisible to any iOS-family simulator. Expected CI cost: ~5x xcodebuild time but only one Tuist install/generate, so total job duration grows from ~57s to ~110s — still a fraction of the existing per-module `check-api-changes-*` jobs. Co-authored-by: Cursor <cursoragent@cursor.com> * Rename RulesEngine namespace to Rules The module is called `RulesEngine`, so an `enum RulesEngine` inside it collides with the module name from the test target's perspective — `@testable import RulesEngine` makes the bare identifier resolve to the module, forcing callers to write `RulesEngine.RulesEngine.something` to reach the namespace. Renaming the namespace to `Rules` keeps the module name descriptive while letting consumers write `Rules.something` cleanly. Doing it now (before any real implementation lands) avoids a churny rename in the follow-up PRs. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim Rules namespace doc comment The collision-with-module-name rationale was useful while deciding on the name but doesn't add long-term value at the call site. Reduce to a one-line summary. Co-authored-by: Cursor <cursoragent@cursor.com> * RulesEngine target: include Mac in TARGETED_DEVICE_FAMILY and set APPLICATION_EXTENSION_API_ONLY The `RulesEngine` framework target in `RevenueCat.xcodeproj` was missing two settings that the rest of the SDK relies on: - `TARGETED_DEVICE_FAMILY = "1,2,3,4,7"` was missing `6` (Mac), even though `SUPPORTED_PLATFORMS` includes `macosx`, `destinations` is `.allRevenueCat` (which covers `.mac` / `.macWithiPadDesign` / `.macCatalyst`), and the sibling `RulesEngineTests` target already has `1,2,3,4,6,7`. Bring the framework in line with the test target and with `ReceiptParser` (which is the closest analog and uses the full `1,2,3,4,6,7`). - `APPLICATION_EXTENSION_API_ONLY` wasn't set. Both `RevenueCat` and `ReceiptParser` set it to `YES`. Once `RulesEngine` is wired in as a dependency of `RevenueCat` (the planned follow-up), Xcode would have rejected the build because an extension-only framework can't embed a framework that doesn't also opt in. Set it now to keep the extension-safe guarantee and avoid a surprise blocker later. Mirror the `APPLICATION_EXTENSION_API_ONLY` change in `Projects/RulesEngine/Project.swift` so the Tuist-generated project matches the legacy `.xcodeproj`. The Tuist destinations already cover all device families, so no `TARGETED_DEVICE_FAMILY` change is needed on that side. Reported by @rickvdl on #6787. Verified: - `xcodebuild -scheme RulesEngine` Debug + Release on iOS - `xcodebuild -scheme RulesEngine` Debug on macOS - `tuist generate RulesEngine` + `xcodebuild` against the generated workspace - `swift test --filter RulesEngineTests` * Drop @_spi(Internal) markers from RulesEngine skeleton Per @tonidero on #6787: requiring every declaration in `RulesEngine` to be `@_spi(Internal)` is noisy and easy to forget. The module is an internal implementation dependency of the SDK — consumers should not be able to reach its public API surface from outside the SDK at all. Drop `@_spi(Internal)` from: - `RulesEngine/RulesEngine.swift` (the `Rules` namespace stays plain `public`, since the consumer-side import form will guarantee that no RulesEngine symbol leaks into the SDK's public API surface). - `Tests/RulesEngineTests/RulesEngineTests.swift` (a plain `@testable import RulesEngine` is enough now that there's no SPI gate to traverse). Enforcement of "no plain `import RulesEngine` from anywhere in the SDK" moves to a SwiftLint custom rule landing in #6788, which replaces the previous swiftinterface-based check there. The rule allows the canonical extension-safe import block: #if compiler(>=6) internal import RulesEngine #else @_implementationOnly import RulesEngine #endif (plus `private import` / `fileprivate import`), and rejects everything else. That guarantee is what previously needed `@_spi(Internal)` to hold — without those markers, a plain `import RulesEngine` in any SDK source would re-expose every `public` declaration. The lint rule makes that impossible. Verified: `xcodebuild -scheme RulesEngine` Debug + Release on iOS, `xcodebuild -scheme RulesEngine` Debug on macOS, and `swift test --filter RulesEngineTests` — all green. * Replace RulesEngine swiftinterface check with SwiftLint import rule Per @tonidero on #6787: requiring every declaration in RulesEngine to be `@_spi(Internal)` is noisy and easy to forget, but we still need a hard guarantee that no RulesEngine symbol leaks into the SDK's public API surface. The cleanest way to achieve that is to constrain the *import* form on the consumer side instead of every declaration on the producer side. The previous approach (`check_rules_engine_no_public_api` Fastlane lane + `check-rules-engine-public-api` CircleCI job) built RulesEngine for five SDKs and asserted the public swiftinterface contained zero declarations — i.e. it required everything to be `@_spi(Internal)`. With the SPI markers gone (#6787), that lane fails by definition, and its premise no longer matches the design. Replace it with a SwiftLint custom rule, `no_plain_rules_engine_import`, that flags any plain `import RulesEngine` and points the contributor at the canonical extension-safe form. With this in place, RulesEngine's symbols can stay plain `public` (no per-declaration SPI noise) because the only allowed import forms strip the implicit `@_exported public` re-export anyway: #if compiler(>=6) internal import RulesEngine #else @_implementationOnly import RulesEngine #endif `private import RulesEngine`, `fileprivate import RulesEngine`, and `@testable import RulesEngine` (for the test target) are also accepted naturally — the regex anchors on `^[ \t]*import\s+RulesEngine\b`, so any qualifier or attribute prefix bypasses it. Changes: - `.swiftlint.yml`: new `no_plain_rules_engine_import` custom rule with an actionable message that tells the contributor exactly which form to use. - `fastlane/Fastfile`: drop the `check_rules_engine_no_public_api` lane. The shared `setup_swiftinterface_xcconfig` / `cleanup_swiftinterface_xcconfig` private lanes stay — they're still used by `generate_swiftinterface`. - `.circleci/default_config.yml`: drop the `check-rules-engine-public-api` job, its two workflow references (`run-all-tests`, `release-or-main`), and the two `requires` entries in the `all-tasks-passed` / `all-tests-succeeded` summary gates. - `fastlane/README.md`: regenerated to drop the deleted lane. Verified locally: - The custom rule fires on every plain form (`import RulesEngine`, indented variants, `import RulesEngine.Submodule`) and stays silent on `internal` / `private` / `fileprivate` / `@_implementationOnly` / `@_exported` / `@testable` imports, line/doc/block comments, and unrelated modules (`RulesEngineMath`, `OtherRulesEngine`). - `swiftlint` against the full repo (1315 files): 0 violations from the new rule. - `ruby -c fastlane/Fastfile` and Ruby-YAML-load of `.circleci/default_config.yml` both pass. Co-authored-by: Cursor <cursoragent@cursor.com> * Expand RulesEngine import lint to also reject @_exported / public / package The previous regex only caught plain `import RulesEngine`, which is the common case but not the only leaky form. There are three other ways to import a module that re-export its public API surface, and the rule needs to block all of them or the guarantee has a hole: - `@_exported import RulesEngine` — explicit re-export (strictly worse than plain `import` because it's intentional rather than implicit). - `public import RulesEngine` — Swift 6 explicit access modifier on imports (SE-0409); functionally identical to plain `import` once access-on-imports is enforced. - `package import RulesEngine` — also from SE-0409; re-exports across the package boundary, which (for our SwiftPM setup) means every other target in `purchases-ios` would see RulesEngine's public API. Update the regex to `^[ \t]*(@_exported\s+)?(public\s+|package\s+)?import\s+RulesEngine\b` and rename the rule to `no_leaking_rules_engine_import` to match the broader scope. The message now lists all four forms so the contributor sees exactly which one tripped them. Allowed forms are unchanged (`internal`, `private`, `fileprivate`, `@_implementationOnly`, `@testable`) — they each strip the implicit `@_exported` re-export and bound visibility appropriately. Verified locally: - Rule now fires on 9 disallowed lines: plain `import` (with various indentation), `import RulesEngine.Submodule`, `@_exported import` (incl. extra whitespace variants), `public import`, `package import`, and `@_exported public import`. - Rule stays silent on every allowed form, comments, and unrelated modules (`RulesEngineMath`, `OtherRulesEngine`, `publicRulesEngineHelper`, `@_exported import RulesEngineHelpers`). - Full-repo `swiftlint`: 0 violations across 1316 files. Co-authored-by: Cursor <cursoragent@cursor.com> * TEMP: deliberately break no_leaking_rules_engine_import to verify CI Adds two intentional violations to `Tests/RulesEngineTests/RulesEngineTests.swift` so CI exercises the SwiftLint rule end-to-end: - `import RulesEngine` — exercises the original plain-import case. - `@_exported import RulesEngine` — exercises the newly-expanded coverage from the previous commit. Both lines must trigger `no_leaking_rules_engine_import` with severity `error`, failing the lint job. Revert this commit before merging. Locally `swiftlint` reports exactly these two violations and nothing else. Co-authored-by: Cursor <cursoragent@cursor.com> * TEMP: keep only plain `import RulesEngine` for second CI run Drop the `@_exported import RulesEngine` line added in the previous commit so this push exercises only the plain-import case in isolation and confirms the canonical violation still fails CI on its own. Local lint reports exactly one `no_leaking_rules_engine_import` error (line 19) plus an incidental `duplicate_imports` warning. Revert before merging. Co-authored-by: Cursor <cursoragent@cursor.com> * Use real newlines in no_leaking_rules_engine_import message Replace `\\n` escape sequences with real newlines (`\n` in YAML double-quoted form). The previous escaped form produced literal `\n` text in CircleCI's Tests tab — clearly visible in the JUnit XML artifact (`failure message='... Use \`#if compiler(>=6)\\n internal import RulesEngine\\n...\`'`). Also reformat the suggestion so the canonical conditional-import block appears on its own (`Use the canonical form:` followed by the snippet) instead of trying to inline a code block, which makes the message easier to scan even if a viewer collapses the newlines to spaces. SwiftLint does not escape the newlines as ` ` when writing the JUnit XML; it emits raw newline characters inside the attribute value. That is technically valid XML but XML attribute-value normalization usually replaces newlines with spaces on parse. The push is therefore also a probe to see how CircleCI's Tests UI actually renders the attribute (raw newline pass-through vs. spec-compliant normalization). Locally the rule still fires exactly once on the plain `import RulesEngine` (line 19) plus the incidental `duplicate_imports` warning, and the JUnit XML now contains the multi-line snippet on separate physical lines instead of `\\n` placeholders. Co-authored-by: Cursor <cursoragent@cursor.com> * Revert "TEMP: keep only plain `import RulesEngine` for second CI run" This reverts commit 8cac453. * Revert "TEMP: deliberately break no_leaking_rules_engine_import to verify CI" This reverts commit 0756f6d. * Drop stale fastlane/README.md changes that belong to PR #6796 The current branch's fastlane/README.md diverged from `main` in two ways that don't belong in this PR: 1. A `### ios push_rules_engine_pod` documentation block — that lane is part of #6796 (RulesEngine CocoaPods distribution wiring) and does not exist in this branch's Fastfile (`grep push_rules_engine_pod fastlane/Fastfile` → 0 matches). 2. A description tweak removing the trailing "locally" from `### ios regenerate_swiftinterface`. Both were left over from the `Enforce RulesEngine has no public API outside @_spi(Internal)` work, which originally regenerated the README from a Fastfile state that mixed in #6796's lane. The follow-up commit that replaced that enforcement with a SwiftLint rule (`Replace RulesEngine swiftinterface check with SwiftLint import rule`) didn't re-regenerate the README, so the bleed-over survived. This PR's actual change set (a custom SwiftLint rule + temp CI verification commits, all in `.swiftlint.yml` and the `Tests/RulesEngineTests` directory) doesn't touch any Fastlane lanes, so the README should be byte-identical to `main`. Restoring via `git checkout origin/main -- fastlane/README.md`. Verified: `git diff origin/main -- fastlane/README.md` is now empty. Co-authored-by: Cursor <cursoragent@cursor.com> * Rename `RulesEngine` module to `RulesEngineInternal` Per @tonidero on #6787: developers can transitively `import RulesEngine` from the SDK product even though the module is meant to be an internal implementation detail of the SDK. Naming the module `RulesEngineInternal` makes that intent explicit at the import site so consumers don't reach for it accidentally and don't expect API stability from it. This is a producer-side rename only — the previously-renamed `Rules` namespace inside the module is unchanged, so call sites still write `Rules.something`. Consumers' canonical extension-safe import (gated by the SwiftLint rule in #6788) becomes: #if compiler(>=6) internal import RulesEngineInternal #else @_implementationOnly import RulesEngineInternal #endif Renamed (`git mv`): - `RulesEngine/RulesEngine.swift` → `RulesEngineInternal/RulesEngineInternal.swift` - `Tests/RulesEngineTests/RulesEngineTests.swift` → `Tests/RulesEngineInternalTests/RulesEngineInternalTests.swift` - `Projects/RulesEngine/` → `Projects/RulesEngineInternal/` (Tuist project dir) - `RevenueCat.xcodeproj/xcshareddata/xcschemes/RulesEngine.xcscheme` → `RulesEngineInternal.xcscheme` Updated text references in: - `Package.swift` / `Package@swift-5.8.swift` (target + test target names + paths) - `Workspace.swift` (Tuist project path + comment) - `Projects/RulesEngineInternal/Project.swift` (target / scheme / bundle IDs / source paths) - `RevenueCat.xcodeproj/project.pbxproj` (target names, file refs, group names, build phase entries, bundle IDs `com.revenuecat.RulesEngineInternal[Tests]`) - `RevenueCat.xcodeproj/.../xcschemes/RulesEngineInternal.xcscheme` (BlueprintName / BuildableName) - `Tests/TestPlans/CI-AllTests.xctestplan` (target name) - `.swiftlint.yml` (`xctestcase_superclass` exclude path) - File header comments inside the two Swift sources The substitutions ran as `\bRulesEngine\b → RulesEngineInternal` first (safe because `RulesEngineTests` has no word boundary between `e` and `T`), then `\bRulesEngineTests\b → RulesEngineInternalTests`. The `Rules` namespace token was untouched because it isn't `RulesEngine`. Verified locally: - `swift build --target RulesEngineInternal` ✔ - `swift test --filter RulesEngineInternalTests` ✔ (1 test passed) - `xcodebuild -project RevenueCat.xcodeproj -scheme RulesEngineInternal -destination 'generic/platform=iOS' -configuration Debug build` ✔ - `swiftlint` ✔ (0 violations across 1316 files) - `git ls-files | xargs perl -ne 'if (/\bRulesEngine\b/ && !/RulesEngineInternal/) { ... }'` returns no hits. Co-authored-by: Cursor <cursoragent@cursor.com> * Update SwiftLint custom rule for `RulesEngineInternal` rename Mirror the producer-side rename from #6787: - Rule id: `no_leaking_rules_engine_import` → `no_leaking_rules_engine_internal_import`. - Rule name: "No leaking `import RulesEngine`" → "No leaking `import RulesEngineInternal`". - Regex: `import\s+RulesEngine\b` → `import\s+RulesEngineInternal\b`. - Violation message: every reference to `RulesEngine` (in disallowed-form examples, the canonical extension-safe import block, and the `private` / `fileprivate` fallbacks) updated to `RulesEngineInternal`. Verified locally: - Positive cases (`/tmp` test file): the rule fires on `import RulesEngineInternal`, `@_exported import RulesEngineInternal`, `public import RulesEngineInternal`, `package import RulesEngineInternal`, and indented `import RulesEngineInternal`. - Negative cases: `internal` / `private` / `fileprivate` / `@_implementationOnly` / `@testable` imports, the `#if compiler(>=6) internal import ... #else @_implementationOnly ... #endif` block, commented-out imports, unrelated modules (`RulesEngineInternalMath`, `OtherRulesEngineInternal`), and a plain `import RulesEngine` (the pre-rename name) all stay silent. - `swiftlint --no-cache` against the full repo: 0 violations across 1316 files. Co-authored-by: Cursor <cursoragent@cursor.com> * Catch kind-qualified imports and ignore comments in RulesEngine lint Address two review comments on #6788 from @rickvdl: 1. The previous regex caught plain `import RulesEngineInternal` plus the `@_exported` / `public` / `package` variants, but missed per-declaration imports like `import struct RulesEngineInternal.X` (also `class` / `func` / `enum` / `protocol` / `typealias` / `var` / `let`). Those forms are implicitly `@_exported` by default and re-export the named symbol into the importing module's public API surface, so they're the same leak as a plain `import` — the rule needs to block them too. Extend the regex with an optional kind-prefix group right after `import`: ^[ \t]*(@_exported\s+)?(public\s+|package\s+)?import\s+ (struct\s+|class\s+|func\s+|enum\s+|protocol\s+|typealias\s+| var\s+|let\s+)?RulesEngineInternal\b The kind group is optional, so all existing leaky forms still match. Update the violation message to mention the kind-qualified form so the suggestion the contributor sees lists everything that's forbidden. 2. Add `match_kinds` so SwiftLint only fires the rule when the regex match actually overlaps source tokens (not comments / strings). This eliminates the small false-positive risk of the rule triggering on a commented-out `import RulesEngineInternal` line. The kinds list is `identifier` (module name), `keyword` (`import`, `struct`, `public`, `package`, etc.), and `attribute.builtin` (`@_exported`). The last one is critical: copying the `[identifier, keyword]` pair from `avoid_using_directory_apis_directly` silently breaks every `@_exported …` case, because SourceKit classifies `@_exported` as `attribute.builtin`, not `keyword`. Caught during local verification before pushing. Verified locally: - Positive (constructed temp file, 19 disallowed forms covering plain / indented / `.Submodule` / `@_exported` / `public` / `package` / `@_exported public` / 8 kind-qualified variants / `@_exported` + kind-qualified + `public` / `package` + kind-qualified): all 19 lines flagged. - Negative (`internal` / `private` / `fileprivate` / `@_implementationOnly` / `@testable` imports, the canonical `#if compiler(>=6) … #endif` block, kind-qualified `internal` / `private` / `@_implementationOnly` imports, line / doc / block comments containing every disallowed form, unrelated modules `RulesEngineInternalMath` / `OtherRulesEngineInternal` / `RulesEngineInternalHelpers` including `@_exported` and kind-qualified variants): 0 flagged. - `swiftlint --no-cache` against the full repo: 0 violations across 1316 files. Co-authored-by: Cursor <cursoragent@cursor.com> * Rename Rules namespace to RulesEngine and keep it internal The entry-point enum is internal for now; it will become public once wired into the SDK public surface. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
* Add RulesEngine skeleton module
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>
* Gate RulesEngine API behind `@_spi(Internal)`
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>
* RulesEngine skeleton: cleanups
- 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>
* Enforce RulesEngine has no public API outside @_spi(Internal)
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>
* TEST: add leaked public API to verify CI guardrail (do not merge)
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add JSON Logic predicate evaluator to RulesEngine
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>
* Revert "TEST: add leaked public API to verify CI guardrail (do not merge)"
This reverts commit a9018bf1f1047708063ef5b7a15704157a75bf2f.
* RulesEngine skeleton: PR feedback
- 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>
* RulesEngine skeleton: PR feedback
- 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>
* Move RulesEngine CocoaPods distribution wiring out of skeleton
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>
* Trim verbose RulesEngine swiftinterface comment
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>
* Drop RulesEngine SPM target explanatory comment
Co-authored-by: Cursor <cursoragent@cursor.com>
* Always include `./Projects/RulesEngine` in the Tuist workspace
`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>
* Wire `RulesEngineTests` into the Tuist `RulesEngine` project
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>
* Simplify `RulesEngineTests` testable target reference
`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>
* Set `SKIP_INSTALL = YES` for `RulesEngine` Release config
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>
* Build `RulesEngine` via Tuist workspace in `check_rules_engine_no_public_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>
* Drop unused RulesEngine entry from Tuist/Package.swift productTypes
`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>
* Document strict swiftinterface filter in check_rules_engine_no_public_api
A future Xcode bump could introduce new top-of-file constructs
(`@_exported import ...`, `#if compiler(...)`, etc.) that the strict
filter would flag as a public-API leak. Leave a note so the next
person debugging a spurious failure extends the rejection list rather
than hunting for a leaked symbol that isn't there.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Tighten test helper and broaden RulesEngine test coverage
- `Value.fromJSONObject` now throws `RuleError.parse` for unexpected
`JSONSerialization` outputs (e.g. `Date`, `NSValue`) instead of
silently coercing to `.null`. `fromJSONString` propagates the same
error type, so existing throw-assertions keep working.
- Remove unused `import XCTest` from the test helper.
- Pin the previously-untested 1-arg `{"if": [expr]}` and 2-arg
`{"if": [cond, then]}` forms of the `if` operator.
- Add NaN / Infinity edge-case tests for `isTruthy`, `looseEq`, and
`strictEq` so the IEEE 754 behavior we inherit through Swift `==`
doesn't silently regress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extend RulesEngine public-API check to 4 simulator SDKs + macOS
Iterate over the four simulator SDKs (iOS / watchOS / tvOS / visionOS)
plus macOS instead of building for `iphonesimulator` only. Skipping the
matching device SDKs is intentional: for a Swift-only module built with
`BUILD_LIBRARY_FOR_DISTRIBUTION=YES` and no `targetEnvironment(simulator)`
gates, the public swiftinterface is identical to the simulator
counterpart, and our "declaration count == 0" assertion is invariant to
that diff. macOS stays in the set because `#if os(macOS)` can expose
Mac-only public symbols invisible to any iOS-family simulator.
Expected CI cost: ~5x xcodebuild time but only one Tuist install/generate,
so total job duration grows from ~57s to ~110s — still a fraction of the
existing per-module `check-api-changes-*` jobs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop `indirect` from RulesEngine.Value
No `Value` case directly contains a `Value` payload — the recursive
cases (`array([Value])`, `object([String: Value])`) thread their
`Value` storage through `Array` / `Dictionary`, both heap-backed value
types. The compiler only requires `indirect` when a case stores a
`Self` payload inline, so `indirect` here was a redundant safety net
that added a heap allocation per `Value` for no payoff.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant `internal` keyword from RulesEngine declarations
`internal` is the default access level in Swift, so stating it
explicitly on every top-level type / function is noise. Removing it
across the module to match the rest of the codebase's style.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename RulesEngine namespace to Rules
The module is called `RulesEngine`, so an `enum RulesEngine` inside it
collides with the module name from the test target's perspective —
`@testable import RulesEngine` makes the bare identifier resolve to the
module, forcing callers to write `RulesEngine.RulesEngine.something` to
reach the namespace.
Renaming the namespace to `Rules` keeps the module name descriptive
while letting consumers write `Rules.something` cleanly. Doing it now
(before any real implementation lands) avoids a churny rename in the
follow-up PRs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Replace per-call logger parameter with module-level Rules.logger
Threading a `RulesEngineLogger` argument through every operator and the
top-level evaluator is mostly boilerplate — only `AccessorOperators`
actually emits warnings today, and there's no scenario where two
concurrent evaluations would want different loggers. Make the logger
module state on `Rules.logger`, with a `Rules.withLogger(_:_:)` helper
for scoped overrides in tests.
Access is `NSLock`-synchronized via a private `LoggerStorage` reference
type so a reader that races a `withLogger` swap can't observe a
half-assigned protocol existential.
Operator and evaluator signatures lose the `logger:` parameter; the
test target's `@testable import RulesEngine` becomes
`@_spi(Internal) @testable import RulesEngine` where it needs to reach
`Rules`. Test suites install a `CapturingLogger` in
`setUp`/`tearDown` (or use `Rules.withLogger` for one-shot overrides)
instead of constructing one per call.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move Rules.withLogger scoped helper to the test target
`withLogger` is only used by tests, and the get/set/restore pattern is
short enough that it doesn't need to live in production code. Drop it
from `Rules` and add an equivalent extension under
`Tests/RulesEngineTests/Helpers/Rules+WithLogger.swift` so the one test
that wants a scoped override (`EvaluatorTests.testMissingVariable…`)
keeps its current call syntax.
`Evaluator.swift`'s doc comment is updated to drop the `withLogger`
mention.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim Rules namespace doc comment
The collision-with-module-name rationale was useful while deciding on
the name but doesn't add long-term value at the call site. Reduce to
a one-line summary.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim Rules.logger doc comment
The previous block explained the property's role and the rationale for
making the logger module state; both points are also covered in the
module-level doc on `Evaluator.swift`. The property itself is short and
self-describing, so drop the comment.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant setUp/tearDown comment in AccessorOperatorsTests
The setUp/tearDown body itself makes the intent obvious — installing a
capturing logger and restoring the previous one — and XCTest's
sequential-per-class execution model is well-known. The comment doesn't
add anything beyond the code.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify AccessorOperatorsTests logger lifecycle
Tracking the previous module logger to restore it on tear-down was
defensive but unnecessary: every test in the suite installs its own
fresh `CapturingLogger` in `setUp`, and no other test class reads
`Rules.logger` directly. Drop the `previousLogger` ivar and the
restore step so the lifecycle is just "create fresh, release in
tear-down".
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant empty-args guards in LogicOperators
`opAnd`, `opOr`, and `opIf` each had an explicit `items.isEmpty` early
return that produced exactly the same value as the natural fall-through
(seeded `last` for AND/OR, terminal `return .null` for IF). Replace the
guards with a brief comment where the value isn't obvious from the
nearby initializer. The existing empty-args assertions in
`LogicOperatorsTests` keep the behavior pinned.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move CapturingLogger to the test target
`CapturingLogger` is only ever instantiated by tests
(`AccessorOperatorsTests`, `EvaluatorTests`, `LoggerTests`); having it
in the production module was a holdover from an earlier iteration where
production helpers needed to reference it. Move the type to
`Tests/RulesEngineTests/Helpers/CapturingLogger.swift` and drop the
stale "lives in the production module so non-test callers can reference
it" doc comment. The legacy `RevenueCat.xcodeproj` gets the file
registered in the same four pbxproj spots used for the existing
helpers; Tuist regenerates from globs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify PrintLogger and drop trivial LoggerTests
`PrintLogger` is only ever the default until the native SDK injects its
own adapter, so the stderr-via-`FileHandleOutputStream` ceremony was
unjustified. Reduce it to a plain `print()`.
`LoggerTests` only exercised that `CapturingLogger`'s `[String]` append
works and that `PrintLogger.warn` doesn't crash — neither tells us
anything meaningful, so delete the file and its pbxproj entries.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix `formatNumber` trap on out-of-Int64-range doubles
A finite, whole-number `Double` whose magnitude exceeds `Int64.max`
(e.g. `1e19`) passed both `isFinite` and `rounded() == value` and then
trapped at `Int64(value)`. Reachable from `var` / `missing` whenever a
`.float` is used as a path segment.
Switch to `Int64(exactly:)`, which returns `nil` for non-integer,
out-of-range, NaN, and ±Infinity inputs — letting the `String(value)`
fall-through cover every degenerate case in one place. Pin the
behavior with `testVarWithOversizedFloatPathDoesNotCrash`, which
crashes against the previous implementation.
Reported by Cursor Bugbot:
https://github.com/RevenueCat/purchases-ios/pull/6789#discussion_r3255272418
Co-authored-by: Cursor <cursoragent@cursor.com>
* Pin formatNumber's normal-float path-rendering behavior
Add two tests so the contract that `formatNumber` enforces — whole-number
doubles render as integer paths, fractional doubles render with their
decimal — is no longer implicit:
- `testVarWithIntegerValuedFloatPathLooksUpIntegerIndex` locks in
`{"var": 1.0}` → array index 1 (i.e. "1", not "1.0").
- `testVarWithFractionalFloatPathDoesNotMatchAdjacentIndices` guards
against an over-eager rounding fix that would collapse 1.5 to "1" or
"2"; the path stays "1.5", the lookup misses, and we warn.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore SKIP_INSTALL = YES on RulesEngine Release config
The skeleton PR (#6787) intentionally flipped this from `NO` to `YES`
in 42e362c35 to match every other framework target in the project
(`RevenueCat`, `RevenueCatUI`, `ReceiptParser` — all `YES` on Release,
`NO` on Debug). Leaving it `NO` on Release would copy
`RulesEngine.framework` into the archive's Products directory during
`xcodebuild archive` flows (e.g. XCFramework export) and risks
"Invalid Bundle" App Store submission failures for downstream archives
that embed the framework.
That fix was silently dropped during one of the pbxproj merge-conflict
resolutions while cascading the skeleton into #6789. Re-apply it.
Reported by Cursor Bugbot:
https://github.com/RevenueCat/purchases-ios/pull/6789#discussion_r3256900958
Co-authored-by: Cursor <cursoragent@cursor.com>
* Recursively evaluate `var`/`missing` arguments per JSON Logic spec
`json-logic-js` recursively evaluates the argument(s) of `var` and
`missing` before using them as paths, so dynamic constructs like
`{"var": {"var": "active_path_key"}}` or
`{"missing": [{"var": "key_to_check"}]}` are valid. Our previous
implementation treated those args as literals, which silently rejected
otherwise-valid predicates.
`opVar` now routes its argument through `Evaluator.evaluateValue` before
parsing: the array form evaluates each element in place (path and
default both become dynamic), and the singleton form evaluates the lone
argument and treats the result as the path. `opMissing` evaluates each
key the same way, and additionally unpacks the first evaluated arg when
it resolves to an array — mirrors `Array.isArray(arguments[0])` so
constructs like `{"missing": {"merge": [["a"], ["b"]]}}` work as the
spec describes.
The one deliberate deviation: when the singleton form of `var`
evaluates to a non-primitive (e.g. an array), we throw `typeMismatch`
instead of JS-stringifying it ("x,y") and looking that up. Pinned by
`testVarSingletonExpressionResolvingToArrayThrows`.
Doc comments updated to describe the spec-aligned behavior. New tests
cover dynamic singleton paths, dynamic array-form paths, dynamic
defaults, dynamic `missing` keys, and the first-arg-array unpack rule.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Return `.null` for empty `and`/`or` to match the JSON Logic spec
`json-logic-js` falls through to an uninitialized `current` when `and`
or `or` is called with no arguments, so the empty-args case returns
`undefined` (falsy). We were returning `.bool(true)` and `.bool(false)`
respectively, which happens to share truthiness with the spec but
diverges on identity — the visible failure mode is something like
`{"if": [{"and": []}, "yes", "no"]}`: the spec routes to "no", we were
returning "yes".
Both operators now seed `last` to `.null` (our closest mapping for
`undefined`). Doc comments and existing empty-args tests updated;
added an integration test that pins the `if`/`and` interaction so a
future revert can't slip through unnoticed.
Co-authored-by: Cursor <cursoragent@cursor.com>
* RulesEngine target: include Mac in TARGETED_DEVICE_FAMILY and set APPLICATION_EXTENSION_API_ONLY
The `RulesEngine` framework target in `RevenueCat.xcodeproj` was missing
two settings that the rest of the SDK relies on:
- `TARGETED_DEVICE_FAMILY = "1,2,3,4,7"` was missing `6` (Mac), even
though `SUPPORTED_PLATFORMS` includes `macosx`, `destinations` is
`.allRevenueCat` (which covers `.mac` / `.macWithiPadDesign` /
`.macCatalyst`), and the sibling `RulesEngineTests` target already
has `1,2,3,4,6,7`. Bring the framework in line with the test target
and with `ReceiptParser` (which is the closest analog and uses the
full `1,2,3,4,6,7`).
- `APPLICATION_EXTENSION_API_ONLY` wasn't set. Both `RevenueCat` and
`ReceiptParser` set it to `YES`. Once `RulesEngine` is wired in as a
dependency of `RevenueCat` (the planned follow-up), Xcode would have
rejected the build because an extension-only framework can't embed a
framework that doesn't also opt in. Set it now to keep the
extension-safe guarantee and avoid a surprise blocker later.
Mirror the `APPLICATION_EXTENSION_API_ONLY` change in
`Projects/RulesEngine/Project.swift` so the Tuist-generated project
matches the legacy `.xcodeproj`. The Tuist destinations already cover
all device families, so no `TARGETED_DEVICE_FAMILY` change is needed
on that side.
Reported by @rickvdl on #6787.
Verified:
- `xcodebuild -scheme RulesEngine` Debug + Release on iOS
- `xcodebuild -scheme RulesEngine` Debug on macOS
- `tuist generate RulesEngine` + `xcodebuild` against the generated
workspace
- `swift test --filter RulesEngineTests`
* Drop @_spi(Internal) markers from RulesEngine skeleton
Per @tonidero on #6787: requiring every declaration in `RulesEngine`
to be `@_spi(Internal)` is noisy and easy to forget. The module is an
internal implementation dependency of the SDK — consumers should not
be able to reach its public API surface from outside the SDK at all.
Drop `@_spi(Internal)` from:
- `RulesEngine/RulesEngine.swift` (the `Rules` namespace stays plain
`public`, since the consumer-side import form will guarantee that no
RulesEngine symbol leaks into the SDK's public API surface).
- `Tests/RulesEngineTests/RulesEngineTests.swift` (a plain
`@testable import RulesEngine` is enough now that there's no SPI
gate to traverse).
Enforcement of "no plain `import RulesEngine` from anywhere in the
SDK" moves to a SwiftLint custom rule landing in #6788, which replaces
the previous swiftinterface-based check there. The rule allows the
canonical extension-safe import block:
#if compiler(>=6)
internal import RulesEngine
#else
@_implementationOnly import RulesEngine
#endif
(plus `private import` / `fileprivate import`), and rejects everything
else. That guarantee is what previously needed `@_spi(Internal)` to
hold — without those markers, a plain `import RulesEngine` in any
SDK source would re-expose every `public` declaration. The lint rule
makes that impossible.
Verified: `xcodebuild -scheme RulesEngine` Debug + Release on iOS,
`xcodebuild -scheme RulesEngine` Debug on macOS, and
`swift test --filter RulesEngineTests` — all green.
* Replace RulesEngine swiftinterface check with SwiftLint import rule
Per @tonidero on #6787: requiring every declaration in RulesEngine to
be `@_spi(Internal)` is noisy and easy to forget, but we still need a
hard guarantee that no RulesEngine symbol leaks into the SDK's public
API surface. The cleanest way to achieve that is to constrain the
*import* form on the consumer side instead of every declaration on the
producer side.
The previous approach (`check_rules_engine_no_public_api` Fastlane lane
+ `check-rules-engine-public-api` CircleCI job) built RulesEngine for
five SDKs and asserted the public swiftinterface contained zero
declarations — i.e. it required everything to be `@_spi(Internal)`.
With the SPI markers gone (#6787), that lane fails by definition, and
its premise no longer matches the design.
Replace it with a SwiftLint custom rule, `no_plain_rules_engine_import`,
that flags any plain `import RulesEngine` and points the contributor at
the canonical extension-safe form. With this in place, RulesEngine's
symbols can stay plain `public` (no per-declaration SPI noise) because
the only allowed import forms strip the implicit `@_exported public`
re-export anyway:
#if compiler(>=6)
internal import RulesEngine
#else
@_implementationOnly import RulesEngine
#endif
`private import RulesEngine`, `fileprivate import RulesEngine`, and
`@testable import RulesEngine` (for the test target) are also accepted
naturally — the regex anchors on `^[ \t]*import\s+RulesEngine\b`, so any
qualifier or attribute prefix bypasses it.
Changes:
- `.swiftlint.yml`: new `no_plain_rules_engine_import` custom rule with
an actionable message that tells the contributor exactly which form
to use.
- `fastlane/Fastfile`: drop the `check_rules_engine_no_public_api`
lane. The shared `setup_swiftinterface_xcconfig` /
`cleanup_swiftinterface_xcconfig` private lanes stay — they're still
used by `generate_swiftinterface`.
- `.circleci/default_config.yml`: drop the
`check-rules-engine-public-api` job, its two workflow references
(`run-all-tests`, `release-or-main`), and the two `requires` entries
in the `all-tasks-passed` / `all-tests-succeeded` summary gates.
- `fastlane/README.md`: regenerated to drop the deleted lane.
Verified locally:
- The custom rule fires on every plain form (`import RulesEngine`,
indented variants, `import RulesEngine.Submodule`) and stays silent
on `internal` / `private` / `fileprivate` / `@_implementationOnly` /
`@_exported` / `@testable` imports, line/doc/block comments, and
unrelated modules (`RulesEngineMath`, `OtherRulesEngine`).
- `swiftlint` against the full repo (1315 files): 0 violations from
the new rule.
- `ruby -c fastlane/Fastfile` and Ruby-YAML-load of
`.circleci/default_config.yml` both pass.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Expand RulesEngine import lint to also reject @_exported / public / package
The previous regex only caught plain `import RulesEngine`, which is the
common case but not the only leaky form. There are three other ways to
import a module that re-export its public API surface, and the rule
needs to block all of them or the guarantee has a hole:
- `@_exported import RulesEngine` — explicit re-export (strictly worse
than plain `import` because it's intentional rather than implicit).
- `public import RulesEngine` — Swift 6 explicit access modifier on
imports (SE-0409); functionally identical to plain `import` once
access-on-imports is enforced.
- `package import RulesEngine` — also from SE-0409; re-exports across
the package boundary, which (for our SwiftPM setup) means every
other target in `purchases-ios` would see RulesEngine's public API.
Update the regex to `^[ \t]*(@_exported\s+)?(public\s+|package\s+)?import\s+RulesEngine\b`
and rename the rule to `no_leaking_rules_engine_import` to match the
broader scope. The message now lists all four forms so the contributor
sees exactly which one tripped them.
Allowed forms are unchanged (`internal`, `private`, `fileprivate`,
`@_implementationOnly`, `@testable`) — they each strip the implicit
`@_exported` re-export and bound visibility appropriately.
Verified locally:
- Rule now fires on 9 disallowed lines: plain `import` (with various
indentation), `import RulesEngine.Submodule`, `@_exported import` (incl.
extra whitespace variants), `public import`, `package import`, and
`@_exported public import`.
- Rule stays silent on every allowed form, comments, and unrelated
modules (`RulesEngineMath`, `OtherRulesEngine`, `publicRulesEngineHelper`,
`@_exported import RulesEngineHelpers`).
- Full-repo `swiftlint`: 0 violations across 1316 files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* TEMP: deliberately break no_leaking_rules_engine_import to verify CI
Adds two intentional violations to `Tests/RulesEngineTests/RulesEngineTests.swift`
so CI exercises the SwiftLint rule end-to-end:
- `import RulesEngine` — exercises the original plain-import case.
- `@_exported import RulesEngine` — exercises the newly-expanded
coverage from the previous commit.
Both lines must trigger `no_leaking_rules_engine_import` with severity
`error`, failing the lint job. Revert this commit before merging.
Locally `swiftlint` reports exactly these two violations and nothing
else.
Co-authored-by: Cursor <cursoragent@cursor.com>
* TEMP: keep only plain `import RulesEngine` for second CI run
Drop the `@_exported import RulesEngine` line added in the previous
commit so this push exercises only the plain-import case in isolation
and confirms the canonical violation still fails CI on its own.
Local lint reports exactly one `no_leaking_rules_engine_import` error
(line 19) plus an incidental `duplicate_imports` warning. Revert
before merging.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use real newlines in no_leaking_rules_engine_import message
Replace `\\n` escape sequences with real newlines (`\n` in YAML
double-quoted form). The previous escaped form produced literal `\n`
text in CircleCI's Tests tab — clearly visible in the JUnit XML
artifact (`failure message='... Use \`#if compiler(>=6)\\n internal
import RulesEngine\\n...\`'`).
Also reformat the suggestion so the canonical conditional-import block
appears on its own (`Use the canonical form:` followed by the snippet)
instead of trying to inline a code block, which makes the message
easier to scan even if a viewer collapses the newlines to spaces.
SwiftLint does not escape the newlines as ` ` when writing the
JUnit XML; it emits raw newline characters inside the attribute value.
That is technically valid XML but XML attribute-value normalization
usually replaces newlines with spaces on parse. The push is therefore
also a probe to see how CircleCI's Tests UI actually renders the
attribute (raw newline pass-through vs. spec-compliant normalization).
Locally the rule still fires exactly once on the plain `import
RulesEngine` (line 19) plus the incidental `duplicate_imports`
warning, and the JUnit XML now contains the multi-line snippet on
separate physical lines instead of `\\n` placeholders.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "TEMP: keep only plain `import RulesEngine` for second CI run"
This reverts commit 8cac453fae6f09210419daf31589fe97db246624.
* Revert "TEMP: deliberately break no_leaking_rules_engine_import to verify CI"
This reverts commit 0756f6d9638e058cb884f98517337b3f679c55dc.
* Drop stale fastlane/README.md changes that belong to PR #6796
The current branch's fastlane/README.md diverged from `main` in two
ways that don't belong in this PR:
1. A `### ios push_rules_engine_pod` documentation block — that
lane is part of #6796 (RulesEngine CocoaPods distribution wiring)
and does not exist in this branch's Fastfile (`grep
push_rules_engine_pod fastlane/Fastfile` → 0 matches).
2. A description tweak removing the trailing "locally" from
`### ios regenerate_swiftinterface`.
Both were left over from the `Enforce RulesEngine has no public API
outside @_spi(Internal)` work, which originally regenerated the
README from a Fastfile state that mixed in #6796's lane. The
follow-up commit that replaced that enforcement with a SwiftLint rule
(`Replace RulesEngine swiftinterface check with SwiftLint import
rule`) didn't re-regenerate the README, so the bleed-over survived.
This PR's actual change set (a custom SwiftLint rule + temp CI
verification commits, all in `.swiftlint.yml` and the
`Tests/RulesEngineTests` directory) doesn't touch any Fastlane lanes,
so the README should be byte-identical to `main`. Restoring via
`git checkout origin/main -- fastlane/README.md`.
Verified: `git diff origin/main -- fastlane/README.md` is now empty.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename `RulesEngine` module to `RulesEngineInternal`
Per @tonidero on #6787: developers can transitively `import RulesEngine`
from the SDK product even though the module is meant to be an internal
implementation detail of the SDK. Naming the module `RulesEngineInternal`
makes that intent explicit at the import site so consumers don't reach
for it accidentally and don't expect API stability from it.
This is a producer-side rename only — the previously-renamed `Rules`
namespace inside the module is unchanged, so call sites still write
`Rules.something`. Consumers' canonical extension-safe import (gated by
the SwiftLint rule in #6788) becomes:
#if compiler(>=6)
internal import RulesEngineInternal
#else
@_implementationOnly import RulesEngineInternal
#endif
Renamed (`git mv`):
- `RulesEngine/RulesEngine.swift` → `RulesEngineInternal/RulesEngineInternal.swift`
- `Tests/RulesEngineTests/RulesEngineTests.swift` → `Tests/RulesEngineInternalTests/RulesEngineInternalTests.swift`
- `Projects/RulesEngine/` → `Projects/RulesEngineInternal/` (Tuist project dir)
- `RevenueCat.xcodeproj/xcshareddata/xcschemes/RulesEngine.xcscheme` → `RulesEngineInternal.xcscheme`
Updated text references in:
- `Package.swift` / `Package@swift-5.8.swift` (target + test target names + paths)
- `Workspace.swift` (Tuist project path + comment)
- `Projects/RulesEngineInternal/Project.swift` (target / scheme / bundle IDs / source paths)
- `RevenueCat.xcodeproj/project.pbxproj` (target names, file refs, group names, build phase entries, bundle IDs `com.revenuecat.RulesEngineInternal[Tests]`)
- `RevenueCat.xcodeproj/.../xcschemes/RulesEngineInternal.xcscheme` (BlueprintName / BuildableName)
- `Tests/TestPlans/CI-AllTests.xctestplan` (target name)
- `.swiftlint.yml` (`xctestcase_superclass` exclude path)
- File header comments inside the two Swift sources
The substitutions ran as `\bRulesEngine\b → RulesEngineInternal` first
(safe because `RulesEngineTests` has no word boundary between `e` and
`T`), then `\bRulesEngineTests\b → RulesEngineInternalTests`. The
`Rules` namespace token was untouched because it isn't `RulesEngine`.
Verified locally:
- `swift build --target RulesEngineInternal` ✔
- `swift test --filter RulesEngineInternalTests` ✔ (1 test passed)
- `xcodebuild -project RevenueCat.xcodeproj -scheme RulesEngineInternal -destination 'generic/platform=iOS' -configuration Debug build` ✔
- `swiftlint` ✔ (0 violations across 1316 files)
- `git ls-files | xargs perl -ne 'if (/\bRulesEngine\b/ && !/RulesEngineInternal/) { ... }'` returns no hits.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Update SwiftLint custom rule for `RulesEngineInternal` rename
Mirror the producer-side rename from #6787:
- Rule id: `no_leaking_rules_engine_import` → `no_leaking_rules_engine_internal_import`.
- Rule name: "No leaking `import RulesEngine`" → "No leaking `import RulesEngineInternal`".
- Regex: `import\s+RulesEngine\b` → `import\s+RulesEngineInternal\b`.
- Violation message: every reference to `RulesEngine` (in disallowed-form
examples, the canonical extension-safe import block, and the
`private` / `fileprivate` fallbacks) updated to `RulesEngineInternal`.
Verified locally:
- Positive cases (`/tmp` test file): the rule fires on `import RulesEngineInternal`,
`@_exported import RulesEngineInternal`, `public import RulesEngineInternal`,
`package import RulesEngineInternal`, and indented `import RulesEngineInternal`.
- Negative cases: `internal` / `private` / `fileprivate` / `@_implementationOnly` /
`@testable` imports, the `#if compiler(>=6) internal import ... #else @_implementationOnly ... #endif`
block, commented-out imports, unrelated modules
(`RulesEngineInternalMath`, `OtherRulesEngineInternal`), and a plain
`import RulesEngine` (the pre-rename name) all stay silent.
- `swiftlint --no-cache` against the full repo: 0 violations across 1316 files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Treat null and empty-string leaves as missing per JSON Logic spec
`opMissing` only flagged keys whose path failed to resolve. The
json-logic-js reference routes through `var` and treats any key whose
lookup resolves to `null` (key absent OR leaf is `null`) or to the
empty string as missing — so `{"missing": ["country"]}` against
`{"country": null}` is supposed to return `["country"]`, but our impl
returned `[]`. Backend payloads regularly come down with explicitly
cleared fields as `null`, so the divergence was reachable from real
predicates.
`opMissing` now uses the same lookup `var` does (without the
missing-variable warning, since `missing` is a check, not a read) and
matches against the spec's `value === null || value === ""` test.
Falsy non-empty values (`0`, `false`, `[]`) are NOT missing — pinned
by a regression test so a future truthiness-based simplification can't
silently flip them.
Reported by Cursor Bugbot:
#6789 (comment)
Co-authored-by: Cursor <cursoragent@cursor.com>
* Coerce arrays/objects to JS strings in `==` to match the spec
`looseEq` only had explicit cases for primitives and same-compound
operands, so `[1] == "1"` and `[1, 2] == "1,2"` returned `false` —
divergent from json-logic-js, which inherits JS abstract equality:
when one side is a compound (Array/Object) and the other is a
primitive, the compound goes through ToPrimitive (string hint) and
the comparison is retried.
Add four cross-type arms to `looseEq` that mirror exactly that
coercion: arrays render via `Array.prototype.toString()` (recursive
comma-join, with `null` elements as the empty string); objects render
as `"[object Object]"`. The recursive call falls through to the
existing primitive arms — string-vs-string for `[1, 2] == "1,2"`,
the numeric fallback for `[] == 0` / `[1] == 1`. Same-compound
comparisons keep their structural-eq behavior (deliberate divergence
from JS reference identity, which would make rule patterns like
`{"==": [{"var": "tags"}, ["a", "b"]]}` always false).
Sharpen the `looseEq` doc comment to spell out the four behaviors
(same-type, cross-numeric, same-compound structural, compound-vs-
primitive ToPrimitive) and the JS reference identity divergence.
Pin the new behavior with `ValueTests` cases covering each spec
example (`[1] == "1"`, `[1, 2] == "1,2"`, `[null, 1] == ",1"`,
`[] == ""`, nested arrays, the numeric fallback path, JS-specific
float spellings, the object → "[object Object]" coercion, and the
array-vs-object cross-compound case). Two `EvaluatorTests` cases
pin the same coercion through the full `Evaluator.evaluate` path so
predicate authors get the spec-aligned behavior end-to-end.
Also annotate `testMultiKeyObjectIsLiteralDataValue` to call out the
json-logic-js `is_logic` parity (multi-key objects fall back to the
"return as-is" branch in `apply`), so a future reader doesn't pattern
-match it onto a "dispatch first key, ignore extras" misreading.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Catch kind-qualified imports and ignore comments in RulesEngine lint
Address two review comments on #6788 from @rickvdl:
1. The previous regex caught plain `import RulesEngineInternal` plus the
`@_exported` / `public` / `package` variants, but missed
per-declaration imports like `import struct RulesEngineInternal.X`
(also `class` / `func` / `enum` / `protocol` / `typealias` / `var`
/ `let`). Those forms are implicitly `@_exported` by default and
re-export the named symbol into the importing module's public API
surface, so they're the same leak as a plain `import` — the rule
needs to block them too.
Extend the regex with an optional kind-prefix group right after
`import`:
^[ \t]*(@_exported\s+)?(public\s+|package\s+)?import\s+
(struct\s+|class\s+|func\s+|enum\s+|protocol\s+|typealias\s+|
var\s+|let\s+)?RulesEngineInternal\b
The kind group is optional, so all existing leaky forms still match.
Update the violation message to mention the kind-qualified form so
the suggestion the contributor sees lists everything that's
forbidden.
2. Add `match_kinds` so SwiftLint only fires the rule when the regex
match actually overlaps source tokens (not comments / strings).
This eliminates the small false-positive risk of the rule
triggering on a commented-out `import RulesEngineInternal` line.
The kinds list is `identifier` (module name), `keyword` (`import`,
`struct`, `public`, `package`, etc.), and `attribute.builtin`
(`@_exported`). The last one is critical: copying the
`[identifier, keyword]` pair from `avoid_using_directory_apis_directly`
silently breaks every `@_exported …` case, because SourceKit
classifies `@_exported` as `attribute.builtin`, not `keyword`. Caught
during local verification before pushing.
Verified locally:
- Positive (constructed temp file, 19 disallowed forms covering plain /
indented / `.Submodule` / `@_exported` / `public` / `package` /
`@_exported public` / 8 kind-qualified variants / `@_exported` +
kind-qualified + `public` / `package` + kind-qualified): all 19 lines
flagged.
- Negative (`internal` / `private` / `fileprivate` /
`@_implementationOnly` / `@testable` imports, the canonical
`#if compiler(>=6) … #endif` block, kind-qualified `internal` /
`private` / `@_implementationOnly` imports, line / doc / block
comments containing every disallowed form, unrelated modules
`RulesEngineInternalMath` / `OtherRulesEngineInternal` /
`RulesEngineInternalHelpers` including `@_exported` and
kind-qualified variants): 0 flagged.
- `swiftlint --no-cache` against the full repo: 0 violations across
1316 files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* RulesEngine: pin single-key-object-as-operand parity with json-logic-js
Locks in the observed runtime behavior for `{"==": [{"a":1}, {"a":1}]}`:
both our engine and json-logic-js dispatch single-key objects as
operators via `is_logic`/`evaluateValue`, so this literal predicate
throws `RuleError.unsupportedOperator("a")` instead of doing a
structural compare. Pins the contrast with the existing multi-key
test (where the structural-vs-reference divergence still applies) so
a future operator-dispatch refactor can't quietly regress this.
Co-authored-by: Cursor <cursoragent@cursor.com>
* AccessorOperatorsTests: restore previous Rules.logger in tearDown
The class installs a `CapturingLogger` into the module-global
`Rules.logger` from `setUp`, but `tearDown` only released the local
reference, leaving the test's `CapturingLogger` installed for the
rest of the process. Mirror the save/restore pattern that
`Rules.withLogger(_:)` uses so the previous logger is reinstated when
the test class is done.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "AccessorOperatorsTests: restore previous Rules.logger in tearDown"
This reverts commit daaa11a82ac277e0c16744cacffa48ad6fec552b.
* Trim verbose hypothetical justifications from operator doc comments
Co-authored-by: Cursor <cursoragent@cursor.com>
* Stringify non-primitive var paths per json-logic-js spec
`{"var": <expr>}` now coerces any evaluated path value to a string
via `String(value).split(".")`, matching `json-logic-js`. Boolean
paths look up `"true"` / `"false"`, array paths comma-join (with
`null` elements rendering as empty), and object paths stringify to
`"[object Object]"` and silently miss. Removes the previous strict
typeMismatch throw on non-string/non-numeric path arguments.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Match binary operator arity with json-logic-js spec
`evalTwo` no longer throws on missing/extra operands. A missing
operand defaults to `.null` (standing in for JS `undefined`) and
extras are silently dropped — matches `json-logic-js`'s
`function(a, b)` signature used by `==`, `===`, `!=`, `!==`, `in`,
etc.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Hoist JS String() coercion into a shared `jsString` helper
`AccessorOperators` no longer duplicates the JS `String(value)`
stack: the private helpers in `Value.swift` (used by `looseEq`) are
now module-internal and exposed as a single `jsString(Value)`
function that `pathSegment` / `keyAsPath` reuse.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Align compound equality with JS reference identity
`looseEq` and `strictEq` now return `false` for any array-vs-array
or object-vs-object comparison, matching JS's `==` / `===`
reference semantics. Without reference identity in our value model,
the spec-aligned answer for two distinct compound operands is
always `false`; structural comparison can be reintroduced explicitly
later if needed.
Compound-vs-primitive coercion (Array#toString / "[object Object]")
is unaffected.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add empty-or-inside-if parity test from Android rules engine suite
Mirror the Android LogicOperatorsTest that pins empty `or` returning
null and routing an outer `if` to its else branch, keeping cross-platform
coverage aligned for PR #6789.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Broaden missing falsy-value test to match Android parity
Cover empty objects and the "0" string alongside 0, false, and [] so
both platforms pin the same negative missing-operator cases.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add json-logic-js spec edge-case tests for MVP operators
Cover var null-path/default semantics, unary empty-arg logic operators,
strict equality arity, and literal predicate truthiness rules.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop trivial RulesEngineInternal module smoke test
The namespace wiring is already exercised by every test that imports
RulesEngineInternal; the standalone reachability check added no spec value.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Disable file_length lint for AccessorOperatorsTests
The spec-parity test matrix exceeds the 400-line file cap; match other
long integration-style test files with a targeted swiftlint disable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore RulesEngineInternal extension-safe build settings
Re-add APPLICATION_EXTENSION_API_ONLY and Mac Catalyst (device family 6)
to RulesEngineInternal Debug/Release so the pbxproj matches Tuist and
ReceiptParser. Clarify that the `vars` parameter is the JSON Logic data scope.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert accidental Package.resolved changes
Restore the lockfile to match the base branch. The deletions were
introduced accidentally when running SPM resolve locally during doc
comment cleanup — unrelated to RulesEngineInternal.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Remove redundant Swift enum note from LoggerStorage docs
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop unnecessary throws from var path coercion helpers
pathSegment, parseVarArrayArgs, and their call sites no longer throw
after non-primitive paths were stringified per json-logic-js. resolveVarArgs
still throws only for nested Evaluator.evaluateValue calls.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add Hashable and Sendable conformance to Value
Pure value enum — synthesis covers all cases and sets up Set<Value>
for the upcoming in / membership operators.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename Rules namespace to RulesEngine and keep it internal
The entry-point enum is internal for now; it will become public once
wired into the SDK public surface.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Align accessor and operator helpers with Android review feedback
Route missing through varLookup semantics for empty paths, drop dead
evalTwo opName, pin jsNumberString platform divergence, and add regression
tests for missing [""] and out-of-Long-range float stringification.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert accidental Package.resolved changes
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fold opVar lookup into shared lookupVar and pin 1e18 boundary
Extract lookupVar for opVar and missing, add jsNumberString boundary
coverage at 1e18, and inline logger swap in the missing-variable test.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Pin json-logic-js dot-path splitting edge cases in var tests
Add regression coverage for empty path segments (a..b, .a, a., .)
to match json-logic-js String(path).split(".") semantics.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify rules engine logger documentation
Trim RulesEngineLogger and PrintLogger docs to high-level defaults
without future integration promises.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify Value documentation
Trim Value docs to high-level predicate and variable data semantics,
aligned with Android.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Document jsNumberString divergence against JS only
Drop cross-platform comparisons in docs and regression test comments.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Align opAnd documentation with Android
Use the same concise empty-input wording on both platforms.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Link Objective-C type encodings doc in Value JSON helper
Clarify objCType usage in the test-only JSONSerialization parser.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix SwiftLint line length for type encodings doc link
Put the Apple archive URL on its own comment line with a line_length
disable; there is no shorter official link for the encoding table.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Address PR review: logger API parity with Android.
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>
* Remove unused RuleError.typeMismatch.
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>
* Restore ExitOfferHelperTests and ButtonComponentViewTests in pbxproj.
Re-register test files accidentally dropped during RulesEngine pbxproj merge conflict resolutions so they match main and run in CI again.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add RulesEngine skeleton module
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>
* Gate RulesEngine API behind `@_spi(Internal)`
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>
* RulesEngine skeleton: cleanups
- 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>
* Enforce RulesEngine has no public API outside @_spi(Internal)
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>
* TEST: add leaked public API to verify CI guardrail (do not merge)
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add JSON Logic predicate evaluator to RulesEngine
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>
* Revert "TEST: add leaked public API to verify CI guardrail (do not merge)"
This reverts commit a9018bf1f1047708063ef5b7a15704157a75bf2f.
* RulesEngine skeleton: PR feedback
- 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>
* RulesEngine skeleton: PR feedback
- 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>
* Move RulesEngine CocoaPods distribution wiring out of skeleton
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>
* Trim verbose RulesEngine swiftinterface comment
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>
* Drop RulesEngine SPM target explanatory comment
Co-authored-by: Cursor <cursoragent@cursor.com>
* Always include `./Projects/RulesEngine` in the Tuist workspace
`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>
* Wire `RulesEngineTests` into the Tuist `RulesEngine` project
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>
* Simplify `RulesEngineTests` testable target reference
`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>
* Set `SKIP_INSTALL = YES` for `RulesEngine` Release config
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>
* Build `RulesEngine` via Tuist workspace in `check_rules_engine_no_public_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>
* Drop unused RulesEngine entry from Tuist/Package.swift productTypes
`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>
* Document strict swiftinterface filter in check_rules_engine_no_public_api
A future Xcode bump could introduce new top-of-file constructs
(`@_exported import ...`, `#if compiler(...)`, etc.) that the strict
filter would flag as a public-API leak. Leave a note so the next
person debugging a spurious failure extends the rejection list rather
than hunting for a leaked symbol that isn't there.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Tighten test helper and broaden RulesEngine test coverage
- `Value.fromJSONObject` now throws `RuleError.parse` for unexpected
`JSONSerialization` outputs (e.g. `Date`, `NSValue`) instead of
silently coercing to `.null`. `fromJSONString` propagates the same
error type, so existing throw-assertions keep working.
- Remove unused `import XCTest` from the test helper.
- Pin the previously-untested 1-arg `{"if": [expr]}` and 2-arg
`{"if": [cond, then]}` forms of the `if` operator.
- Add NaN / Infinity edge-case tests for `isTruthy`, `looseEq`, and
`strictEq` so the IEEE 754 behavior we inherit through Swift `==`
doesn't silently regress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extend RulesEngine public-API check to 4 simulator SDKs + macOS
Iterate over the four simulator SDKs (iOS / watchOS / tvOS / visionOS)
plus macOS instead of building for `iphonesimulator` only. Skipping the
matching device SDKs is intentional: for a Swift-only module built with
`BUILD_LIBRARY_FOR_DISTRIBUTION=YES` and no `targetEnvironment(simulator)`
gates, the public swiftinterface is identical to the simulator
counterpart, and our "declaration count == 0" assertion is invariant to
that diff. macOS stays in the set because `#if os(macOS)` can expose
Mac-only public symbols invisible to any iOS-family simulator.
Expected CI cost: ~5x xcodebuild time but only one Tuist install/generate,
so total job duration grows from ~57s to ~110s — still a fraction of the
existing per-module `check-api-changes-*` jobs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop `indirect` from RulesEngine.Value
No `Value` case directly contains a `Value` payload — the recursive
cases (`array([Value])`, `object([String: Value])`) thread their
`Value` storage through `Array` / `Dictionary`, both heap-backed value
types. The compiler only requires `indirect` when a case stores a
`Self` payload inline, so `indirect` here was a redundant safety net
that added a heap allocation per `Value` for no payoff.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant `internal` keyword from RulesEngine declarations
`internal` is the default access level in Swift, so stating it
explicitly on every top-level type / function is noise. Removing it
across the module to match the rest of the codebase's style.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename RulesEngine namespace to Rules
The module is called `RulesEngine`, so an `enum RulesEngine` inside it
collides with the module name from the test target's perspective —
`@testable import RulesEngine` makes the bare identifier resolve to the
module, forcing callers to write `RulesEngine.RulesEngine.something` to
reach the namespace.
Renaming the namespace to `Rules` keeps the module name descriptive
while letting consumers write `Rules.something` cleanly. Doing it now
(before any real implementation lands) avoids a churny rename in the
follow-up PRs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Replace per-call logger parameter with module-level Rules.logger
Threading a `RulesEngineLogger` argument through every operator and the
top-level evaluator is mostly boilerplate — only `AccessorOperators`
actually emits warnings today, and there's no scenario where two
concurrent evaluations would want different loggers. Make the logger
module state on `Rules.logger`, with a `Rules.withLogger(_:_:)` helper
for scoped overrides in tests.
Access is `NSLock`-synchronized via a private `LoggerStorage` reference
type so a reader that races a `withLogger` swap can't observe a
half-assigned protocol existential.
Operator and evaluator signatures lose the `logger:` parameter; the
test target's `@testable import RulesEngine` becomes
`@_spi(Internal) @testable import RulesEngine` where it needs to reach
`Rules`. Test suites install a `CapturingLogger` in
`setUp`/`tearDown` (or use `Rules.withLogger` for one-shot overrides)
instead of constructing one per call.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move Rules.withLogger scoped helper to the test target
`withLogger` is only used by tests, and the get/set/restore pattern is
short enough that it doesn't need to live in production code. Drop it
from `Rules` and add an equivalent extension under
`Tests/RulesEngineTests/Helpers/Rules+WithLogger.swift` so the one test
that wants a scoped override (`EvaluatorTests.testMissingVariable…`)
keeps its current call syntax.
`Evaluator.swift`'s doc comment is updated to drop the `withLogger`
mention.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim Rules namespace doc comment
The collision-with-module-name rationale was useful while deciding on
the name but doesn't add long-term value at the call site. Reduce to
a one-line summary.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim Rules.logger doc comment
The previous block explained the property's role and the rationale for
making the logger module state; both points are also covered in the
module-level doc on `Evaluator.swift`. The property itself is short and
self-describing, so drop the comment.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant setUp/tearDown comment in AccessorOperatorsTests
The setUp/tearDown body itself makes the intent obvious — installing a
capturing logger and restoring the previous one — and XCTest's
sequential-per-class execution model is well-known. The comment doesn't
add anything beyond the code.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify AccessorOperatorsTests logger lifecycle
Tracking the previous module logger to restore it on tear-down was
defensive but unnecessary: every test in the suite installs its own
fresh `CapturingLogger` in `setUp`, and no other test class reads
`Rules.logger` directly. Drop the `previousLogger` ivar and the
restore step so the lifecycle is just "create fresh, release in
tear-down".
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant empty-args guards in LogicOperators
`opAnd`, `opOr`, and `opIf` each had an explicit `items.isEmpty` early
return that produced exactly the same value as the natural fall-through
(seeded `last` for AND/OR, terminal `return .null` for IF). Replace the
guards with a brief comment where the value isn't obvious from the
nearby initializer. The existing empty-args assertions in
`LogicOperatorsTests` keep the behavior pinned.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move CapturingLogger to the test target
`CapturingLogger` is only ever instantiated by tests
(`AccessorOperatorsTests`, `EvaluatorTests`, `LoggerTests`); having it
in the production module was a holdover from an earlier iteration where
production helpers needed to reference it. Move the type to
`Tests/RulesEngineTests/Helpers/CapturingLogger.swift` and drop the
stale "lives in the production module so non-test callers can reference
it" doc comment. The legacy `RevenueCat.xcodeproj` gets the file
registered in the same four pbxproj spots used for the existing
helpers; Tuist regenerates from globs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify PrintLogger and drop trivial LoggerTests
`PrintLogger` is only ever the default until the native SDK injects its
own adapter, so the stderr-via-`FileHandleOutputStream` ceremony was
unjustified. Reduce it to a plain `print()`.
`LoggerTests` only exercised that `CapturingLogger`'s `[String]` append
works and that `PrintLogger.warn` doesn't crash — neither tells us
anything meaningful, so delete the file and its pbxproj entries.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix `formatNumber` trap on out-of-Int64-range doubles
A finite, whole-number `Double` whose magnitude exceeds `Int64.max`
(e.g. `1e19`) passed both `isFinite` and `rounded() == value` and then
trapped at `Int64(value)`. Reachable from `var` / `missing` whenever a
`.float` is used as a path segment.
Switch to `Int64(exactly:)`, which returns `nil` for non-integer,
out-of-range, NaN, and ±Infinity inputs — letting the `String(value)`
fall-through cover every degenerate case in one place. Pin the
behavior with `testVarWithOversizedFloatPathDoesNotCrash`, which
crashes against the previous implementation.
Reported by Cursor Bugbot:
https://github.com/RevenueCat/purchases-ios/pull/6789#discussion_r3255272418
Co-authored-by: Cursor <cursoragent@cursor.com>
* Pin formatNumber's normal-float path-rendering behavior
Add two tests so the contract that `formatNumber` enforces — whole-number
doubles render as integer paths, fractional doubles render with their
decimal — is no longer implicit:
- `testVarWithIntegerValuedFloatPathLooksUpIntegerIndex` locks in
`{"var": 1.0}` → array index 1 (i.e. "1", not "1.0").
- `testVarWithFractionalFloatPathDoesNotMatchAdjacentIndices` guards
against an over-eager rounding fix that would collapse 1.5 to "1" or
"2"; the path stays "1.5", the lookup misses, and we warn.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore SKIP_INSTALL = YES on RulesEngine Release config
The skeleton PR (#6787) intentionally flipped this from `NO` to `YES`
in 42e362c35 to match every other framework target in the project
(`RevenueCat`, `RevenueCatUI`, `ReceiptParser` — all `YES` on Release,
`NO` on Debug). Leaving it `NO` on Release would copy
`RulesEngine.framework` into the archive's Products directory during
`xcodebuild archive` flows (e.g. XCFramework export) and risks
"Invalid Bundle" App Store submission failures for downstream archives
that embed the framework.
That fix was silently dropped during one of the pbxproj merge-conflict
resolutions while cascading the skeleton into #6789. Re-apply it.
Reported by Cursor Bugbot:
https://github.com/RevenueCat/purchases-ios/pull/6789#discussion_r3256900958
Co-authored-by: Cursor <cursoragent@cursor.com>
* Recursively evaluate `var`/`missing` arguments per JSON Logic spec
`json-logic-js` recursively evaluates the argument(s) of `var` and
`missing` before using them as paths, so dynamic constructs like
`{"var": {"var": "active_path_key"}}` or
`{"missing": [{"var": "key_to_check"}]}` are valid. Our previous
implementation treated those args as literals, which silently rejected
otherwise-valid predicates.
`opVar` now routes its argument through `Evaluator.evaluateValue` before
parsing: the array form evaluates each element in place (path and
default both become dynamic), and the singleton form evaluates the lone
argument and treats the result as the path. `opMissing` evaluates each
key the same way, and additionally unpacks the first evaluated arg when
it resolves to an array — mirrors `Array.isArray(arguments[0])` so
constructs like `{"missing": {"merge": [["a"], ["b"]]}}` work as the
spec describes.
The one deliberate deviation: when the singleton form of `var`
evaluates to a non-primitive (e.g. an array), we throw `typeMismatch`
instead of JS-stringifying it ("x,y") and looking that up. Pinned by
`testVarSingletonExpressionResolvingToArrayThrows`.
Doc comments updated to describe the spec-aligned behavior. New tests
cover dynamic singleton paths, dynamic array-form paths, dynamic
defaults, dynamic `missing` keys, and the first-arg-array unpack rule.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Return `.null` for empty `and`/`or` to match the JSON Logic spec
`json-logic-js` falls through to an uninitialized `current` when `and`
or `or` is called with no arguments, so the empty-args case returns
`undefined` (falsy). We were returning `.bool(true)` and `.bool(false)`
respectively, which happens to share truthiness with the spec but
diverges on identity — the visible failure mode is something like
`{"if": [{"and": []}, "yes", "no"]}`: the spec routes to "no", we were
returning "yes".
Both operators now seed `last` to `.null` (our closest mapping for
`undefined`). Doc comments and existing empty-args tests updated;
added an integration test that pins the `if`/`and` interaction so a
future revert can't slip through unnoticed.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add JSON Logic arithmetic operators (+, -, *, /, %)
Extends the JSON Logic operator set with arithmetic so predicates can
express counter and sum conditions before the comparison and string
operators land.
What's new:
- `+`, `-`, `*`, `/`, `%` per the JSON Logic spec, including variadic
`+` and `*`, the 1-arg numeric-cast form of `+`, and unary negation
via `-`. All arithmetic returns `.float(Double)` for consistency
with the JS reference (which `parseFloat`s every operand).
`looseEq` and `strictEq` already bridge `.int(n) ↔ .float(n.0)`,
so existing comparisons keep working.
- Non-numeric operands (`.object`, `.array`, unparseable strings)
coerce to `Double.nan` and propagate through arithmetic — the
result is `.float(nan)`, falsy under `isTruthy`.
- Division and modulo by zero return `.null` (deliberate deviation
from JS's `Infinity` / `NaN` — friendlier for rule authors and
matches the engine's "missing value" convention). Documented in
the type's doc comment.
Tests:
- `ArithmeticOperatorsTests` covers each operator (basic, variadic,
coercion, NaN propagation, divide/mod by zero, arity errors).
- `EvaluatorTests` adds two integration tests through dispatch:
`var * 2 == 6` and the `divide-by-zero → null → falsy` flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
* RulesEngine target: include Mac in TARGETED_DEVICE_FAMILY and set APPLICATION_EXTENSION_API_ONLY
The `RulesEngine` framework target in `RevenueCat.xcodeproj` was missing
two settings that the rest of the SDK relies on:
- `TARGETED_DEVICE_FAMILY = "1,2,3,4,7"` was missing `6` (Mac), even
though `SUPPORTED_PLATFORMS` includes `macosx`, `destinations` is
`.allRevenueCat` (which covers `.mac` / `.macWithiPadDesign` /
`.macCatalyst`), and the sibling `RulesEngineTests` target already
has `1,2,3,4,6,7`. Bring the framework in line with the test target
and with `ReceiptParser` (which is the closest analog and uses the
full `1,2,3,4,6,7`).
- `APPLICATION_EXTENSION_API_ONLY` wasn't set. Both `RevenueCat` and
`ReceiptParser` set it to `YES`. Once `RulesEngine` is wired in as a
dependency of `RevenueCat` (the planned follow-up), Xcode would have
rejected the build because an extension-only framework can't embed a
framework that doesn't also opt in. Set it now to keep the
extension-safe guarantee and avoid a surprise blocker later.
Mirror the `APPLICATION_EXTENSION_API_ONLY` change in
`Projects/RulesEngine/Project.swift` so the Tuist-generated project
matches the legacy `.xcodeproj`. The Tuist destinations already cover
all device families, so no `TARGETED_DEVICE_FAMILY` change is needed
on that side.
Reported by @rickvdl on #6787.
Verified:
- `xcodebuild -scheme RulesEngine` Debug + Release on iOS
- `xcodebuild -scheme RulesEngine` Debug on macOS
- `tuist generate RulesEngine` + `xcodebuild` against the generated
workspace
- `swift test --filter RulesEngineTests`
* Drop @_spi(Internal) markers from RulesEngine skeleton
Per @tonidero on #6787: requiring every declaration in `RulesEngine`
to be `@_spi(Internal)` is noisy and easy to forget. The module is an
internal implementation dependency of the SDK — consumers should not
be able to reach its public API surface from outside the SDK at all.
Drop `@_spi(Internal)` from:
- `RulesEngine/RulesEngine.swift` (the `Rules` namespace stays plain
`public`, since the consumer-side import form will guarantee that no
RulesEngine symbol leaks into the SDK's public API surface).
- `Tests/RulesEngineTests/RulesEngineTests.swift` (a plain
`@testable import RulesEngine` is enough now that there's no SPI
gate to traverse).
Enforcement of "no plain `import RulesEngine` from anywhere in the
SDK" moves to a SwiftLint custom rule landing in #6788, which replaces
the previous swiftinterface-based check there. The rule allows the
canonical extension-safe import block:
#if compiler(>=6)
internal import RulesEngine
#else
@_implementationOnly import RulesEngine
#endif
(plus `private import` / `fileprivate import`), and rejects everything
else. That guarantee is what previously needed `@_spi(Internal)` to
hold — without those markers, a plain `import RulesEngine` in any
SDK source would re-expose every `public` declaration. The lint rule
makes that impossible.
Verified: `xcodebuild -scheme RulesEngine` Debug + Release on iOS,
`xcodebuild -scheme RulesEngine` Debug on macOS, and
`swift test --filter RulesEngineTests` — all green.
* Replace RulesEngine swiftinterface check with SwiftLint import rule
Per @tonidero on #6787: requiring every declaration in RulesEngine to
be `@_spi(Internal)` is noisy and easy to forget, but we still need a
hard guarantee that no RulesEngine symbol leaks into the SDK's public
API surface. The cleanest way to achieve that is to constrain the
*import* form on the consumer side instead of every declaration on the
producer side.
The previous approach (`check_rules_engine_no_public_api` Fastlane lane
+ `check-rules-engine-public-api` CircleCI job) built RulesEngine for
five SDKs and asserted the public swiftinterface contained zero
declarations — i.e. it required everything to be `@_spi(Internal)`.
With the SPI markers gone (#6787), that lane fails by definition, and
its premise no longer matches the design.
Replace it with a SwiftLint custom rule, `no_plain_rules_engine_import`,
that flags any plain `import RulesEngine` and points the contributor at
the canonical extension-safe form. With this in place, RulesEngine's
symbols can stay plain `public` (no per-declaration SPI noise) because
the only allowed import forms strip the implicit `@_exported public`
re-export anyway:
#if compiler(>=6)
internal import RulesEngine
#else
@_implementationOnly import RulesEngine
#endif
`private import RulesEngine`, `fileprivate import RulesEngine`, and
`@testable import RulesEngine` (for the test target) are also accepted
naturally — the regex anchors on `^[ \t]*import\s+RulesEngine\b`, so any
qualifier or attribute prefix bypasses it.
Changes:
- `.swiftlint.yml`: new `no_plain_rules_engine_import` custom rule with
an actionable message that tells the contributor exactly which form
to use.
- `fastlane/Fastfile`: drop the `check_rules_engine_no_public_api`
lane. The shared `setup_swiftinterface_xcconfig` /
`cleanup_swiftinterface_xcconfig` private lanes stay — they're still
used by `generate_swiftinterface`.
- `.circleci/default_config.yml`: drop the
`check-rules-engine-public-api` job, its two workflow references
(`run-all-tests`, `release-or-main`), and the two `requires` entries
in the `all-tasks-passed` / `all-tests-succeeded` summary gates.
- `fastlane/README.md`: regenerated to drop the deleted lane.
Verified locally:
- The custom rule fires on every plain form (`import RulesEngine`,
indented variants, `import RulesEngine.Submodule`) and stays silent
on `internal` / `private` / `fileprivate` / `@_implementationOnly` /
`@_exported` / `@testable` imports, line/doc/block comments, and
unrelated modules (`RulesEngineMath`, `OtherRulesEngine`).
- `swiftlint` against the full repo (1315 files): 0 violations from
the new rule.
- `ruby -c fastlane/Fastfile` and Ruby-YAML-load of
`.circleci/default_config.yml` both pass.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Expand RulesEngine import lint to also reject @_exported / public / package
The previous regex only caught plain `import RulesEngine`, which is the
common case but not the only leaky form. There are three other ways to
import a module that re-export its public API surface, and the rule
needs to block all of them or the guarantee has a hole:
- `@_exported import RulesEngine` — explicit re-export (strictly worse
than plain `import` because it's intentional rather than implicit).
- `public import RulesEngine` — Swift 6 explicit access modifier on
imports (SE-0409); functionally identical to plain `import` once
access-on-imports is enforced.
- `package import RulesEngine` — also from SE-0409; re-exports across
the package boundary, which (for our SwiftPM setup) means every
other target in `purchases-ios` would see RulesEngine's public API.
Update the regex to `^[ \t]*(@_exported\s+)?(public\s+|package\s+)?import\s+RulesEngine\b`
and rename the rule to `no_leaking_rules_engine_import` to match the
broader scope. The message now lists all four forms so the contributor
sees exactly which one tripped them.
Allowed forms are unchanged (`internal`, `private`, `fileprivate`,
`@_implementationOnly`, `@testable`) — they each strip the implicit
`@_exported` re-export and bound visibility appropriately.
Verified locally:
- Rule now fires on 9 disallowed lines: plain `import` (with various
indentation), `import RulesEngine.Submodule`, `@_exported import` (incl.
extra whitespace variants), `public import`, `package import`, and
`@_exported public import`.
- Rule stays silent on every allowed form, comments, and unrelated
modules (`RulesEngineMath`, `OtherRulesEngine`, `publicRulesEngineHelper`,
`@_exported import RulesEngineHelpers`).
- Full-repo `swiftlint`: 0 violations across 1316 files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* TEMP: deliberately break no_leaking_rules_engine_import to verify CI
Adds two intentional violations to `Tests/RulesEngineTests/RulesEngineTests.swift`
so CI exercises the SwiftLint rule end-to-end:
- `import RulesEngine` — exercises the original plain-import case.
- `@_exported import RulesEngine` — exercises the newly-expanded
coverage from the previous commit.
Both lines must trigger `no_leaking_rules_engine_import` with severity
`error`, failing the lint job. Revert this commit before merging.
Locally `swiftlint` reports exactly these two violations and nothing
else.
Co-authored-by: Cursor <cursoragent@cursor.com>
* TEMP: keep only plain `import RulesEngine` for second CI run
Drop the `@_exported import RulesEngine` line added in the previous
commit so this push exercises only the plain-import case in isolation
and confirms the canonical violation still fails CI on its own.
Local lint reports exactly one `no_leaking_rules_engine_import` error
(line 19) plus an incidental `duplicate_imports` warning. Revert
before merging.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use real newlines in no_leaking_rules_engine_import message
Replace `\\n` escape sequences with real newlines (`\n` in YAML
double-quoted form). The previous escaped form produced literal `\n`
text in CircleCI's Tests tab — clearly visible in the JUnit XML
artifact (`failure message='... Use \`#if compiler(>=6)\\n internal
import RulesEngine\\n...\`'`).
Also reformat the suggestion so the canonical conditional-import block
appears on its own (`Use the canonical form:` followed by the snippet)
instead of trying to inline a code block, which makes the message
easier to scan even if a viewer collapses the newlines to spaces.
SwiftLint does not escape the newlines as ` ` when writing the
JUnit XML; it emits raw newline characters inside the attribute value.
That is technically valid XML but XML attribute-value normalization
usually replaces newlines with spaces on parse. The push is therefore
also a probe to see how CircleCI's Tests UI actually renders the
attribute (raw newline pass-through vs. spec-compliant normalization).
Locally the rule still fires exactly once on the plain `import
RulesEngine` (line 19) plus the incidental `duplicate_imports`
warning, and the JUnit XML now contains the multi-line snippet on
separate physical lines instead of `\\n` placeholders.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "TEMP: keep only plain `import RulesEngine` for second CI run"
This reverts commit 8cac453fae6f09210419daf31589fe97db246624.
* Revert "TEMP: deliberately break no_leaking_rules_engine_import to verify CI"
This reverts commit 0756f6d9638e058cb884f98517337b3f679c55dc.
* Drop stale fastlane/README.md changes that belong to PR #6796
The current branch's fastlane/README.md diverged from `main` in two
ways that don't belong in this PR:
1. A `### ios push_rules_engine_pod` documentation block — that
lane is part of #6796 (RulesEngine CocoaPods distribution wiring)
and does not exist in this branch's Fastfile (`grep
push_rules_engine_pod fastlane/Fastfile` → 0 matches).
2. A description tweak removing the trailing "locally" from
`### ios regenerate_swiftinterface`.
Both were left over from the `Enforce RulesEngine has no public API
outside @_spi(Internal)` work, which originally regenerated the
README from a Fastfile state that mixed in #6796's lane. The
follow-up commit that replaced that enforcement with a SwiftLint rule
(`Replace RulesEngine swiftinterface check with SwiftLint import
rule`) didn't re-regenerate the README, so the bleed-over survived.
This PR's actual change set (a custom SwiftLint rule + temp CI
verification commits, all in `.swiftlint.yml` and the
`Tests/RulesEngineTests` directory) doesn't touch any Fastlane lanes,
so the README should be byte-identical to `main`. Restoring via
`git checkout origin/main -- fastlane/README.md`.
Verified: `git diff origin/main -- fastlane/README.md` is now empty.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename `RulesEngine` module to `RulesEngineInternal`
Per @tonidero on #6787: developers can transitively `import RulesEngine`
from the SDK product even though the module is meant to be an internal
implementation detail of the SDK. Naming the module `RulesEngineInternal`
makes that intent explicit at the import site so consumers don't reach
for it accidentally and don't expect API stability from it.
This is a producer-side rename only — the previously-renamed `Rules`
namespace inside the module is unchanged, so call sites still write
`Rules.something`. Consumers' canonical extension-safe import (gated by
the SwiftLint rule in #6788) becomes:
#if compiler(>=6)
internal import RulesEngineInternal
#else
@_implementationOnly import RulesEngineInternal
#endif
Renamed (`git mv`):
- `RulesEngine/RulesEngine.swift` → `RulesEngineInternal/RulesEngineInternal.swift`
- `Tests/RulesEngineTests/RulesEngineTests.swift` → `Tests/RulesEngineInternalTests/RulesEngineInternalTests.swift`
- `Projects/RulesEngine/` → `Projects/RulesEngineInternal/` (Tuist project dir)
- `RevenueCat.xcodeproj/xcshareddata/xcschemes/RulesEngine.xcscheme` → `RulesEngineInternal.xcscheme`
Updated text references in:
- `Package.swift` / `Package@swift-5.8.swift` (target + test target names + paths)
- `Workspace.swift` (Tuist project path + comment)
- `Projects/RulesEngineInternal/Project.swift` (target / scheme / bundle IDs / source paths)
- `RevenueCat.xcodeproj/project.pbxproj` (target names, file refs, group names, build phase entries, bundle IDs `com.revenuecat.RulesEngineInternal[Tests]`)
- `RevenueCat.xcodeproj/.../xcschemes/RulesEngineInternal.xcscheme` (BlueprintName / BuildableName)
- `Tests/TestPlans/CI-AllTests.xctestplan` (target name)
- `.swiftlint.yml` (`xctestcase_superclass` exclude path)
- File header comments inside the two Swift sources
The substitutions ran as `\bRulesEngine\b → RulesEngineInternal` first
(safe because `RulesEngineTests` has no word boundary between `e` and
`T`), then `\bRulesEngineTests\b → RulesEngineInternalTests`. The
`Rules` namespace token was untouched because it isn't `RulesEngine`.
Verified locally:
- `swift build --target RulesEngineInternal` ✔
- `swift test --filter RulesEngineInternalTests` ✔ (1 test passed)
- `xcodebuild -project RevenueCat.xcodeproj -scheme RulesEngineInternal -destination 'generic/platform=iOS' -configuration Debug build` ✔
- `swiftlint` ✔ (0 violations across 1316 files)
- `git ls-files | xargs perl -ne 'if (/\bRulesEngine\b/ && !/RulesEngineInternal/) { ... }'` returns no hits.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Update SwiftLint custom rule for `RulesEngineInternal` rename
Mirror the producer-side rename from #6787:
- Rule id: `no_leaking_rules_engine_import` → `no_leaking_rules_engine_internal_import`.
- Rule name: "No leaking `import RulesEngine`" → "No leaking `import RulesEngineInternal`".
- Regex: `import\s+RulesEngine\b` → `import\s+RulesEngineInternal\b`.
- Violation message: every reference to `RulesEngine` (in disallowed-form
examples, the canonical extension-safe import block, and the
`private` / `fileprivate` fallbacks) updated to `RulesEngineInternal`.
Verified locally:
- Positive cases (`/tmp` test file): the rule fires on `import RulesEngineInternal`,
`@_exported import RulesEngineInternal`, `public import RulesEngineInternal`,
`package import RulesEngineInternal`, and indented `import RulesEngineInternal`.
- Negative cases: `internal` / `private` / `fileprivate` / `@_implementationOnly` /
`@testable` imports, the `#if compiler(>=6) internal import ... #else @_implementationOnly ... #endif`
block, commented-out imports, unrelated modules
(`RulesEngineInternalMath`, `OtherRulesEngineInternal`), and a plain
`import RulesEngine` (the pre-rename name) all stay silent.
- `swiftlint --no-cache` against the full repo: 0 violations across 1316 files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Update arithmetic operator test imports for `RulesEngineInternal` rename
`Tests/RulesEngineInternalTests/ArithmeticOperatorsTests.swift` was
moved into the renamed `Tests/RulesEngineInternalTests/` directory by
git's directory-rename detection during the merge from #6789, but its
`@testable import RulesEngine` line was added in this branch (after
the directory rename was set up upstream) and still referenced the old
module name. Update it to `@testable import RulesEngineInternal` so
the file matches every other test in the target.
Verified: `swift test --filter RulesEngineInternalTests.ArithmeticOperatorsTests` ✔
(21 tests passed); `swiftlint --no-cache` ✔ (0 violations across 1325 files).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Treat null and empty-string leaves as missing per JSON Logic spec
`opMissing` only flagged keys whose path failed to resolve. The
json-logic-js reference routes through `var` and treats any key whose
lookup resolves to `null` (key absent OR leaf is `null`) or to the
empty string as missing — so `{"missing": ["country"]}` against
`{"country": null}` is supposed to return `["country"]`, but our impl
returned `[]`. Backend payloads regularly come down with explicitly
cleared fields as `null`, so the divergence was reachable from real
predicates.
`opMissing` now uses the same lookup `var` does (without the
missing-variable warning, since `missing` is a check, not a read) and
matches against the spec's `value === null || value === ""` test.
Falsy non-empty values (`0`, `false`, `[]`) are NOT missing — pinned
by a regression test so a future truthiness-based simplification can't
silently flip them.
Reported by Cursor Bugbot:
#6789 (comment)
Co-authored-by: Cursor <cursoragent@cursor.com>
* Coerce arrays/objects to JS strings in `==` to match the spec
`looseEq` only had explicit cases for primitives and same-compound
operands, so `[1] == "1"` and `[1, 2] == "1,2"` returned `false` —
divergent from json-logic-js, which inherits JS abstract equality:
when one side is a compound (Array/Object) and the other is a
primitive, the compound goes through ToPrimitive (string hint) and
the comparison is retried.
Add four cross-type arms to `looseEq` that mirror exactly that
coercion: arrays render via `Array.prototype.toString()` (recursive
comma-join, with `null` elements as the empty string); objects render
as `"[object Object]"`. The recursive call falls through to the
existing primitive arms — string-vs-string for `[1, 2] == "1,2"`,
the numeric fallback for `[] == 0` / `[1] == 1`. Same-compound
comparisons keep their structural-eq behavior (deliberate divergence
from JS reference identity, which would make rule patterns like
`{"==": [{"var": "tags"}, ["a", "b"]]}` always false).
Sharpen the `looseEq` doc comment to spell out the four behaviors
(same-type, cross-numeric, same-compound structural, compound-vs-
primitive ToPrimitive) and the JS reference identity divergence.
Pin the new behavior with `ValueTests` cases covering each spec
example (`[1] == "1"`, `[1, 2] == "1,2"`, `[null, 1] == ",1"`,
`[] == ""`, nested arrays, the numeric fallback path, JS-specific
float spellings, the object → "[object Object]" coercion, and the
array-vs-object cross-compound case). Two `EvaluatorTests` cases
pin the same coercion through the full `Evaluator.evaluate` path so
predicate authors get the spec-aligned behavior end-to-end.
Also annotate `testMultiKeyObjectIsLiteralDataValue` to call out the
json-logic-js `is_logic` parity (multi-key objects fall back to the
"return as-is" branch in `apply`), so a future reader doesn't pattern
-match it onto a "dispatch first key, ignore extras" misreading.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Catch kind-qualified imports and ignore comments in RulesEngine lint
Address two review comments on #6788 from @rickvdl:
1. The previous regex caught plain `import RulesEngineInternal` plus the
`@_exported` / `public` / `package` variants, but missed
per-declaration imports like `import struct RulesEngineInternal.X`
(also `class` / `func` / `enum` / `protocol` / `typealias` / `var`
/ `let`). Those forms are implicitly `@_exported` by default and
re-export the named symbol into the importing module's public API
surface, so they're the same leak as a plain `import` — the rule
needs to block them too.
Extend the regex with an optional kind-prefix group right after
`import`:
^[ \t]*(@_exported\s+)?(public\s+|package\s+)?import\s+
(struct\s+|class\s+|func\s+|enum\s+|protocol\s+|typealias\s+|
var\s+|let\s+)?RulesEngineInternal\b
The kind group is optional, so all existing leaky forms still match.
Update the violation message to mention the kind-qualified form so
the suggestion the contributor sees lists everything that's
forbidden.
2. Add `match_kinds` so SwiftLint only fires the rule when the regex
match actually overlaps source tokens (not comments / strings).
This eliminates the small false-positive risk of the rule
triggering on a commented-out `import RulesEngineInternal` line.
The kinds list is `identifier` (module name), `keyword` (`import`,
`struct`, `public`, `package`, etc.), and `attribute.builtin`
(`@_exported`). The last one is critical: copying the
`[identifier, keyword]` pair from `avoid_using_directory_apis_directly`
silently breaks every `@_exported …` case, because SourceKit
classifies `@_exported` as `attribute.builtin`, not `keyword`. Caught
during local verification before pushing.
Verified locally:
- Positive (constructed temp file, 19 disallowed forms covering plain /
indented / `.Submodule` / `@_exported` / `public` / `package` /
`@_exported public` / 8 kind-qualified variants / `@_exported` +
kind-qualified + `public` / `package` + kind-qualified): all 19 lines
flagged.
- Negative (`internal` / `private` / `fileprivate` /
`@_implementationOnly` / `@testable` imports, the canonical
`#if compiler(>=6) … #endif` block, kind-qualified `internal` /
`private` / `@_implementationOnly` imports, line / doc / block
comments containing every disallowed form, unrelated modules
`RulesEngineInternalMath` / `OtherRulesEngineInternal` /
`RulesEngineInternalHelpers` including `@_exported` and
kind-qualified variants): 0 flagged.
- `swiftlint --no-cache` against the full repo: 0 violations across
1316 files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* RulesEngine: pin single-key-object-as-operand parity with json-logic-js
Locks in the observed runtime behavior for `{"==": [{"a":1}, {"a":1}]}`:
both our engine and json-logic-js dispatch single-key objects as
operators via `is_logic`/`evaluateValue`, so this literal predicate
throws `RuleError.unsupportedOperator("a")` instead of doing a
structural compare. Pins the contrast with the existing multi-key
test (where the structural-vs-reference divergence still applies) so
a future operator-dispatch refactor can't quietly regress this.
Co-authored-by: Cursor <cursoragent@cursor.com>
* AccessorOperatorsTests: restore previous Rules.logger in tearDown
The class installs a `CapturingLogger` into the module-global
`Rules.logger` from `setUp`, but `tearDown` only released the local
reference, leaving the test's `CapturingLogger` installed for the
rest of the process. Mirror the save/restore pattern that
`Rules.withLogger(_:)` uses so the previous logger is reinstated when
the test class is done.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "AccessorOperatorsTests: restore previous Rules.logger in tearDown"
This reverts commit daaa11a82ac277e0c16744cacffa48ad6fec552b.
* Pin null-operand handling for arithmetic operators
Adds a single test asserting that `null` is treated as `0` across
`+`, `-`, `*`, `/`, `%`, including the unary `-` form and the
1-arg numeric-cast `+`. Comment block explains the deliberate
deviation from json-logic-js for `+` / `*` (JS uses `parseFloat`,
which makes `parseFloat(null) === NaN`); the other three already
match JS exactly.
Addresses review comment from @rickvdl on PR #6791.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Arithmetic: match json-logic-js coercion exactly
`json-logic-js` is asymmetric about which JS coercion arithmetic uses:
`+` and `*` go through `parseFloat(value)` (stringify, parse longest
numeric prefix); `-`, `/`, `%` go through native arithmetic which calls
`Number(value)` (a.k.a. `ToNumber`). The prior implementation routed
every operand through `Value.asNumber` (a partial `ToNumber`), which
diverged from the spec in two directions:
- For `+` / `*`: `null`, bools, the empty string, and `[1]` produced
numbers instead of `NaN`.
- For `-` / `/` / `%`: arrays produced `NaN` instead of being coerced
via `ToPrimitive("number")` → `toString` → recurse, so `[] - 1` and
`[1] - 1` were `NaN` instead of `-1` and `0`.
Add `jsString(value)` (top-level JS `String()`) and `jsParseFloat(value)`
to `Value.swift`, extend `Value.asNumber` to coerce arrays/objects via
`jsString`, and route `+` / `*` through `jsParseFloat` while leaving
`-` / `/` / `%` on `asNumber ?? .nan`. The existing
divide-/modulo-by-zero short-circuit (`.null` instead of `±Infinity` /
`NaN`) is the only remaining intentional deviation in this file and is
documented as such.
Tests:
- Replace `testNullOperandIsTreatedAsZero` with two coercion-path tests
that pin both branches against the spec, including the array cases
(`[1] + 1 = 2`, `[1,2] + 0 = 1`, `[] - 1 = -1`, `[1] - 1 = 0`,
`[1,2] - 0 = NaN`).
- Update `testAddOneArgActsAsNumericCast` and split
`testAddCoercesStringsAndBools` into `testAddCoercesNumericStrings`
to reflect that bools no longer bridge through `+` / `*`
(`parseFloat("true") === NaN`) but trailing junk in numeric strings
now parses (`parseFloat("3.14abc") === 3.14`).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Arithmetic: drop divide-/modulo-by-zero short-circuit
The previous implementation intercepted `n / 0` and `n % 0` and returned
`.null`, on the rationale that `null` is friendlier for rule authors and
matches the engine's "missing value" convention. That's the last
remaining intentional deviation from `json-logic-js` in this file —
remove it for full spec parity.
`json-logic-js` delegates `/` and `%` to native JS arithmetic, which
follows IEEE 754: `n / 0` is `±Infinity` (sign matches the dividend),
`0 / 0` is `NaN`, and any `n % 0` is `NaN`. Swift's `Double` operators
and `truncatingRemainder(dividingBy:)` already produce these IEEE
values, so the fix is just dropping the divisor==0 short-circuit and
wrapping the result in `.float`.
Tests:
- Replace `testDivByZeroReturnsNull` with `testDivByZeroFollowsIeee754`
pinning all three cases (`+Infinity`, `-Infinity`, `NaN`).
- Replace `testModByZeroReturnsNull` with `testModByZeroIsNan` covering
`7 % 0` and `0 % 0`.
- Update the `1 / null` / `1 % null` cases inside
`testSubDivAndModUseToNumberPerSpec` (divisor coerces to 0 →
`+Infinity` / `NaN` instead of `.null`).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim verbose hypothetical justifications from operator doc comments
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim verbose hypothetical justifications from arithmetic doc comments
Also pin the IEEE 754 div-by-zero behavior (introduced in e32933780)
in the evaluator-level test.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Align arithmetic operator arity behavior with json-logic-js spec
- {"+": []} returns 0 (matches `Array.prototype.reduce(fn, 0)`).
- {"*": [a]} returns the operand unchanged (single-arg reduce without
seed never invokes the reducer, so no `parseFloat` coercion).
- {"-": []}, {"/": []}, {"%": []} return NaN (missing operands act
as JS `undefined`); extra operands past the first two are ignored to
match `function(a, b)` argument truncation.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Stringify non-primitive var paths per json-logic-js spec
`{"var": <expr>}` now coerces any evaluated path value to a string
via `String(value).split(".")`, matching `json-logic-js`. Boolean
paths look up `"true"` / `"false"`, array paths comma-join (with
`null` elements rendering as empty), and object paths stringify to
`"[object Object]"` and silently miss. Removes the previous strict
typeMismatch throw on non-string/non-numeric path arguments.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Match binary operator arity with json-logic-js spec
`evalTwo` no longer throws on missing/extra operands. A missing
operand defaults to `.null` (standing in for JS `undefined`) and
extras are silently dropped — matches `json-logic-js`'s
`function(a, b)` signature used by `==`, `===`, `!=`, `!==`, `in`,
etc.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Hoist JS String() coercion into a shared `jsString` helper
`AccessorOperators` no longer duplicates the JS `String(value)`
stack: the private helpers in `Value.swift` (used by `looseEq`) are
now module-internal and exposed as a single `jsString(Value)`
function that `pathSegment` / `keyAsPath` reuse.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Align compound equality with JS reference identity
`looseEq` and `strictEq` now return `false` for any array-vs-array
or object-vs-object comparison, matching JS's `==` / `===`
reference semantics. Without reference identity in our value model,
the spec-aligned answer for two distinct compound operands is
always `false`; structural comparison can be reintroduced explicitly
later if needed.
Compound-vs-primitive coercion (Array#toString / "[object Object]")
is unaffected.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add empty-or-inside-if parity test from Android rules engine suite
Mirror the Android LogicOperatorsTest that pins empty `or` returning
null and routing an outer `if` to its else branch, keeping cross-platform
coverage aligned for PR #6789.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Broaden missing falsy-value test to match Android parity
Cover empty objects and the "0" string alongside 0, false, and [] so
both platforms pin the same negative missing-operator cases.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add json-logic-js spec edge-case tests for MVP operators
Cover var null-path/default semantics, unary empty-arg logic operators,
strict equality arity, and literal predicate truthiness rules.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop trivial RulesEngineInternal module smoke test
The namespace wiring is already exercised by every test that imports
RulesEngineInternal; the standalone reachability check added no spec value.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Disable file_length lint for AccessorOperatorsTests
The spec-parity test matrix exceeds the 400-line file cap; match other
long integration-style test files with a targeted swiftlint disable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore RulesEngineInternal extension-safe build settings
Re-add APPLICATION_EXTENSION_API_ONLY and Mac Catalyst (device family 6)
to RulesEngineInternal Debug/Release so the pbxproj matches Tuist and
ReceiptParser. Clarify that the `vars` parameter is the JSON Logic data scope.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert accidental Package.resolved changes
Restore the lockfile to match the base branch. The deletions were
introduced accidentally when running SPM resolve locally during doc
comment cleanup — unrelated to RulesEngineInternal.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Pin parseFloat vs Number coercion gaps in arithmetic tests.
Assert multi-arg * parses junk-suffix strings and - rejects them via Number(),
closing the last spec holes where a wrong coercion path could pass the suite.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Remove redundant Swift enum note from LoggerStorage docs
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop unnecessary throws from var path coercion helpers
pathSegment, parseVarArrayArgs, and their call sites no longer throw
after non-primitive paths were stringified per json-logic-js. resolveVarArgs
still throws only for nested Evaluator.evaluateValue calls.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add Hashable and Sendable conformance to Value
Pure value enum — synthesis covers all cases and sets up Set<Value>
for the upcoming in / membership operators.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename Rules namespace to RulesEngine and keep it internal
The entry-point enum is internal for now; it will become public once
wired into the SDK public surface.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add direct jsParseFloat tests including scientific notation.
Pin parseFloatPrefix in ValueTests separately from arithmetic operators so
coercion regressions can't hide behind compensating operator bugs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Align accessor and operator helpers with Android review feedback
Route missing through varLookup semantics for empty paths, drop dead
evalTwo opName, pin jsNumberString platform divergence, and add regression
tests for missing [""] and out-of-Long-range float stringification.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert accidental Package.resolved changes
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fold opVar lookup into shared lookupVar and pin 1e18 boundary
Extract lookupVar for opVar and missing, add jsNumberString boundary
coverage at 1e18, and inline logger swap in the missing-variable test.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Pin json-logic-js dot-path splitting edge cases in var tests
Add regression coverage for empty path segments (a..b, .a, a., .)
to match json-logic-js String(path).split(".") semantics.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify rules engine logger documentation
Trim RulesEngineLogger and PrintLogger docs to high-level defaults
without future integration promises.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify Value documentation
Trim Value docs to high-level predicate and variable data semantics,
aligned with Android.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Document jsNumberString divergence against JS only
Drop cross-platform comparisons in docs and regression test comments.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Align opAnd documentation with Android
Use the same concise empty-input wording on both platforms.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Link Objective-C type encodings doc in Value JSON helper
Clarify objCType usage in the test-only JSONSerialization parser.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix SwiftLint line length for type encodings doc link
Put the Apple archive URL on its own comment line with a line_length
disable; there is no shorter official link for the encoding table.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Pin negative mod and direct ToNumber coercion tests.
Add JS-sign modulo cases and test Value.asNumber independently of
arithmetic operators, matching the jsParseFloat helper coverage pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Clarify negative mod test doc without cross-platform refs.
Describe JS IEEE 754 remainder sign rule directly instead of
referencing Kotlin in the iOS test comment.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extract parseFloat numeric prefix pattern to a constant.
Mirror Android's NUMERIC_PREFIX_REGEX by hoisting the regex string
next to parseFloatPrefix for readability.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Address PR review: logger API parity with Android.
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>
* Remove unused RuleError.typeMismatch.
Nothing in the MVP evaluator throws it yet; add it back in the PR that introduces the first call site.
C…
* Add RulesEngine skeleton module
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>
* Gate RulesEngine API behind `@_spi(Internal)`
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>
* RulesEngine skeleton: cleanups
- 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>
* Enforce RulesEngine has no public API outside @_spi(Internal)
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>
* TEST: add leaked public API to verify CI guardrail (do not merge)
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add JSON Logic predicate evaluator to RulesEngine
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>
* Revert "TEST: add leaked public API to verify CI guardrail (do not merge)"
This reverts commit a9018bf1f1047708063ef5b7a15704157a75bf2f.
* RulesEngine skeleton: PR feedback
- 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>
* RulesEngine skeleton: PR feedback
- 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>
* Move RulesEngine CocoaPods distribution wiring out of skeleton
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>
* Trim verbose RulesEngine swiftinterface comment
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>
* Drop RulesEngine SPM target explanatory comment
Co-authored-by: Cursor <cursoragent@cursor.com>
* Always include `./Projects/RulesEngine` in the Tuist workspace
`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>
* Wire `RulesEngineTests` into the Tuist `RulesEngine` project
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>
* Simplify `RulesEngineTests` testable target reference
`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>
* Set `SKIP_INSTALL = YES` for `RulesEngine` Release config
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>
* Build `RulesEngine` via Tuist workspace in `check_rules_engine_no_public_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>
* Drop unused RulesEngine entry from Tuist/Package.swift productTypes
`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>
* Document strict swiftinterface filter in check_rules_engine_no_public_api
A future Xcode bump could introduce new top-of-file constructs
(`@_exported import ...`, `#if compiler(...)`, etc.) that the strict
filter would flag as a public-API leak. Leave a note so the next
person debugging a spurious failure extends the rejection list rather
than hunting for a leaked symbol that isn't there.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Tighten test helper and broaden RulesEngine test coverage
- `Value.fromJSONObject` now throws `RuleError.parse` for unexpected
`JSONSerialization` outputs (e.g. `Date`, `NSValue`) instead of
silently coercing to `.null`. `fromJSONString` propagates the same
error type, so existing throw-assertions keep working.
- Remove unused `import XCTest` from the test helper.
- Pin the previously-untested 1-arg `{"if": [expr]}` and 2-arg
`{"if": [cond, then]}` forms of the `if` operator.
- Add NaN / Infinity edge-case tests for `isTruthy`, `looseEq`, and
`strictEq` so the IEEE 754 behavior we inherit through Swift `==`
doesn't silently regress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extend RulesEngine public-API check to 4 simulator SDKs + macOS
Iterate over the four simulator SDKs (iOS / watchOS / tvOS / visionOS)
plus macOS instead of building for `iphonesimulator` only. Skipping the
matching device SDKs is intentional: for a Swift-only module built with
`BUILD_LIBRARY_FOR_DISTRIBUTION=YES` and no `targetEnvironment(simulator)`
gates, the public swiftinterface is identical to the simulator
counterpart, and our "declaration count == 0" assertion is invariant to
that diff. macOS stays in the set because `#if os(macOS)` can expose
Mac-only public symbols invisible to any iOS-family simulator.
Expected CI cost: ~5x xcodebuild time but only one Tuist install/generate,
so total job duration grows from ~57s to ~110s — still a fraction of the
existing per-module `check-api-changes-*` jobs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop `indirect` from RulesEngine.Value
No `Value` case directly contains a `Value` payload — the recursive
cases (`array([Value])`, `object([String: Value])`) thread their
`Value` storage through `Array` / `Dictionary`, both heap-backed value
types. The compiler only requires `indirect` when a case stores a
`Self` payload inline, so `indirect` here was a redundant safety net
that added a heap allocation per `Value` for no payoff.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant `internal` keyword from RulesEngine declarations
`internal` is the default access level in Swift, so stating it
explicitly on every top-level type / function is noise. Removing it
across the module to match the rest of the codebase's style.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename RulesEngine namespace to Rules
The module is called `RulesEngine`, so an `enum RulesEngine` inside it
collides with the module name from the test target's perspective —
`@testable import RulesEngine` makes the bare identifier resolve to the
module, forcing callers to write `RulesEngine.RulesEngine.something` to
reach the namespace.
Renaming the namespace to `Rules` keeps the module name descriptive
while letting consumers write `Rules.something` cleanly. Doing it now
(before any real implementation lands) avoids a churny rename in the
follow-up PRs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Replace per-call logger parameter with module-level Rules.logger
Threading a `RulesEngineLogger` argument through every operator and the
top-level evaluator is mostly boilerplate — only `AccessorOperators`
actually emits warnings today, and there's no scenario where two
concurrent evaluations would want different loggers. Make the logger
module state on `Rules.logger`, with a `Rules.withLogger(_:_:)` helper
for scoped overrides in tests.
Access is `NSLock`-synchronized via a private `LoggerStorage` reference
type so a reader that races a `withLogger` swap can't observe a
half-assigned protocol existential.
Operator and evaluator signatures lose the `logger:` parameter; the
test target's `@testable import RulesEngine` becomes
`@_spi(Internal) @testable import RulesEngine` where it needs to reach
`Rules`. Test suites install a `CapturingLogger` in
`setUp`/`tearDown` (or use `Rules.withLogger` for one-shot overrides)
instead of constructing one per call.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move Rules.withLogger scoped helper to the test target
`withLogger` is only used by tests, and the get/set/restore pattern is
short enough that it doesn't need to live in production code. Drop it
from `Rules` and add an equivalent extension under
`Tests/RulesEngineTests/Helpers/Rules+WithLogger.swift` so the one test
that wants a scoped override (`EvaluatorTests.testMissingVariable…`)
keeps its current call syntax.
`Evaluator.swift`'s doc comment is updated to drop the `withLogger`
mention.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim Rules namespace doc comment
The collision-with-module-name rationale was useful while deciding on
the name but doesn't add long-term value at the call site. Reduce to
a one-line summary.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim Rules.logger doc comment
The previous block explained the property's role and the rationale for
making the logger module state; both points are also covered in the
module-level doc on `Evaluator.swift`. The property itself is short and
self-describing, so drop the comment.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant setUp/tearDown comment in AccessorOperatorsTests
The setUp/tearDown body itself makes the intent obvious — installing a
capturing logger and restoring the previous one — and XCTest's
sequential-per-class execution model is well-known. The comment doesn't
add anything beyond the code.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify AccessorOperatorsTests logger lifecycle
Tracking the previous module logger to restore it on tear-down was
defensive but unnecessary: every test in the suite installs its own
fresh `CapturingLogger` in `setUp`, and no other test class reads
`Rules.logger` directly. Drop the `previousLogger` ivar and the
restore step so the lifecycle is just "create fresh, release in
tear-down".
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant empty-args guards in LogicOperators
`opAnd`, `opOr`, and `opIf` each had an explicit `items.isEmpty` early
return that produced exactly the same value as the natural fall-through
(seeded `last` for AND/OR, terminal `return .null` for IF). Replace the
guards with a brief comment where the value isn't obvious from the
nearby initializer. The existing empty-args assertions in
`LogicOperatorsTests` keep the behavior pinned.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move CapturingLogger to the test target
`CapturingLogger` is only ever instantiated by tests
(`AccessorOperatorsTests`, `EvaluatorTests`, `LoggerTests`); having it
in the production module was a holdover from an earlier iteration where
production helpers needed to reference it. Move the type to
`Tests/RulesEngineTests/Helpers/CapturingLogger.swift` and drop the
stale "lives in the production module so non-test callers can reference
it" doc comment. The legacy `RevenueCat.xcodeproj` gets the file
registered in the same four pbxproj spots used for the existing
helpers; Tuist regenerates from globs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify PrintLogger and drop trivial LoggerTests
`PrintLogger` is only ever the default until the native SDK injects its
own adapter, so the stderr-via-`FileHandleOutputStream` ceremony was
unjustified. Reduce it to a plain `print()`.
`LoggerTests` only exercised that `CapturingLogger`'s `[String]` append
works and that `PrintLogger.warn` doesn't crash — neither tells us
anything meaningful, so delete the file and its pbxproj entries.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix `formatNumber` trap on out-of-Int64-range doubles
A finite, whole-number `Double` whose magnitude exceeds `Int64.max`
(e.g. `1e19`) passed both `isFinite` and `rounded() == value` and then
trapped at `Int64(value)`. Reachable from `var` / `missing` whenever a
`.float` is used as a path segment.
Switch to `Int64(exactly:)`, which returns `nil` for non-integer,
out-of-range, NaN, and ±Infinity inputs — letting the `String(value)`
fall-through cover every degenerate case in one place. Pin the
behavior with `testVarWithOversizedFloatPathDoesNotCrash`, which
crashes against the previous implementation.
Reported by Cursor Bugbot:
https://github.com/RevenueCat/purchases-ios/pull/6789#discussion_r3255272418
Co-authored-by: Cursor <cursoragent@cursor.com>
* Pin formatNumber's normal-float path-rendering behavior
Add two tests so the contract that `formatNumber` enforces — whole-number
doubles render as integer paths, fractional doubles render with their
decimal — is no longer implicit:
- `testVarWithIntegerValuedFloatPathLooksUpIntegerIndex` locks in
`{"var": 1.0}` → array index 1 (i.e. "1", not "1.0").
- `testVarWithFractionalFloatPathDoesNotMatchAdjacentIndices` guards
against an over-eager rounding fix that would collapse 1.5 to "1" or
"2"; the path stays "1.5", the lookup misses, and we warn.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore SKIP_INSTALL = YES on RulesEngine Release config
The skeleton PR (#6787) intentionally flipped this from `NO` to `YES`
in 42e362c35 to match every other framework target in the project
(`RevenueCat`, `RevenueCatUI`, `ReceiptParser` — all `YES` on Release,
`NO` on Debug). Leaving it `NO` on Release would copy
`RulesEngine.framework` into the archive's Products directory during
`xcodebuild archive` flows (e.g. XCFramework export) and risks
"Invalid Bundle" App Store submission failures for downstream archives
that embed the framework.
That fix was silently dropped during one of the pbxproj merge-conflict
resolutions while cascading the skeleton into #6789. Re-apply it.
Reported by Cursor Bugbot:
https://github.com/RevenueCat/purchases-ios/pull/6789#discussion_r3256900958
Co-authored-by: Cursor <cursoragent@cursor.com>
* Recursively evaluate `var`/`missing` arguments per JSON Logic spec
`json-logic-js` recursively evaluates the argument(s) of `var` and
`missing` before using them as paths, so dynamic constructs like
`{"var": {"var": "active_path_key"}}` or
`{"missing": [{"var": "key_to_check"}]}` are valid. Our previous
implementation treated those args as literals, which silently rejected
otherwise-valid predicates.
`opVar` now routes its argument through `Evaluator.evaluateValue` before
parsing: the array form evaluates each element in place (path and
default both become dynamic), and the singleton form evaluates the lone
argument and treats the result as the path. `opMissing` evaluates each
key the same way, and additionally unpacks the first evaluated arg when
it resolves to an array — mirrors `Array.isArray(arguments[0])` so
constructs like `{"missing": {"merge": [["a"], ["b"]]}}` work as the
spec describes.
The one deliberate deviation: when the singleton form of `var`
evaluates to a non-primitive (e.g. an array), we throw `typeMismatch`
instead of JS-stringifying it ("x,y") and looking that up. Pinned by
`testVarSingletonExpressionResolvingToArrayThrows`.
Doc comments updated to describe the spec-aligned behavior. New tests
cover dynamic singleton paths, dynamic array-form paths, dynamic
defaults, dynamic `missing` keys, and the first-arg-array unpack rule.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Return `.null` for empty `and`/`or` to match the JSON Logic spec
`json-logic-js` falls through to an uninitialized `current` when `and`
or `or` is called with no arguments, so the empty-args case returns
`undefined` (falsy). We were returning `.bool(true)` and `.bool(false)`
respectively, which happens to share truthiness with the spec but
diverges on identity — the visible failure mode is something like
`{"if": [{"and": []}, "yes", "no"]}`: the spec routes to "no", we were
returning "yes".
Both operators now seed `last` to `.null` (our closest mapping for
`undefined`). Doc comments and existing empty-args tests updated;
added an integration test that pins the `if`/`and` interaction so a
future revert can't slip through unnoticed.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add JSON Logic arithmetic operators (+, -, *, /, %)
Extends the JSON Logic operator set with arithmetic so predicates can
express counter and sum conditions before the comparison and string
operators land.
What's new:
- `+`, `-`, `*`, `/`, `%` per the JSON Logic spec, including variadic
`+` and `*`, the 1-arg numeric-cast form of `+`, and unary negation
via `-`. All arithmetic returns `.float(Double)` for consistency
with the JS reference (which `parseFloat`s every operand).
`looseEq` and `strictEq` already bridge `.int(n) ↔ .float(n.0)`,
so existing comparisons keep working.
- Non-numeric operands (`.object`, `.array`, unparseable strings)
coerce to `Double.nan` and propagate through arithmetic — the
result is `.float(nan)`, falsy under `isTruthy`.
- Division and modulo by zero return `.null` (deliberate deviation
from JS's `Infinity` / `NaN` — friendlier for rule authors and
matches the engine's "missing value" convention). Documented in
the type's doc comment.
Tests:
- `ArithmeticOperatorsTests` covers each operator (basic, variadic,
coercion, NaN propagation, divide/mod by zero, arity errors).
- `EvaluatorTests` adds two integration tests through dispatch:
`var * 2 == 6` and the `divide-by-zero → null → falsy` flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add JSON Logic comparison operators (<, <=, >, >=)
Extends the JSON Logic operator set with the comparison operators rule
authors need to express numeric thresholds and ranges.
What's new:
- `<`, `<=`, `>`, `>=` per the JSON Logic spec.
- `<` and `<=` accept the 3-arg between form (`{"<=": [1, x, 10]}`
reads as `1 <= x <= 10`). `>` and `>=` are binary only, matching
the JS reference.
- All operators coerce operands through `Value.asNumber` and compare
as `Double`. Non-numeric operands (`.object`, `.array`,
unparseable strings) become `Double.nan`; per IEEE 754 every
comparison against NaN is `false`, so a malformed operand makes
the predicate fail closed.
String semantics deviate from the JS reference (which compares two
strings lexicographically, e.g. `"10" < "9"` is true). We always
coerce numerically — `"10" < "9"` is false. Documented in the type's
doc comment.
Tests:
- `ComparisonOperatorsTests` covers each operator (basic, between
form, coercion, NaN propagation, no-between for `>` / `>=`, arity
errors).
- `EvaluatorTests` adds two integration tests through dispatch:
`var >= 3` and the 3-arg `1 <= var <= 10` between form.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use lexicographic compare for two-string operands per spec
`json-logic-js` (and ECMAScript Abstract Relational Comparison)
compare two string operands lexicographically and only coerce to a
number when the types mix. We were always coercing numerically, which
disagrees with the spec for pure-string compares — `"10" < "9"` was
returning `false` instead of the spec-required `true`.
`compare(_:_:using:)` now branches on type: if both operands are
`.string` it lex-compares; otherwise it falls through to the existing
`asDouble` numeric path. A small `Comparator` enum drives both
branches so the same case (`.less`, `.lessOrEqual`, …) can dispatch
against either `String` or `Double`.
Doc comment rewritten to describe the spec-aligned split. The test
that previously pinned the numeric-only deviation is flipped to
assert lex order, and tests for `<=` and `>` exercise the lex path
through their respective operators. A separate test pins that mixed
types still coerce numerically so we don't drift the other way.
Co-authored-by: Cursor <cursoragent@cursor.com>
* RulesEngine target: include Mac in TARGETED_DEVICE_FAMILY and set APPLICATION_EXTENSION_API_ONLY
The `RulesEngine` framework target in `RevenueCat.xcodeproj` was missing
two settings that the rest of the SDK relies on:
- `TARGETED_DEVICE_FAMILY = "1,2,3,4,7"` was missing `6` (Mac), even
though `SUPPORTED_PLATFORMS` includes `macosx`, `destinations` is
`.allRevenueCat` (which covers `.mac` / `.macWithiPadDesign` /
`.macCatalyst`), and the sibling `RulesEngineTests` target already
has `1,2,3,4,6,7`. Bring the framework in line with the test target
and with `ReceiptParser` (which is the closest analog and uses the
full `1,2,3,4,6,7`).
- `APPLICATION_EXTENSION_API_ONLY` wasn't set. Both `RevenueCat` and
`ReceiptParser` set it to `YES`. Once `RulesEngine` is wired in as a
dependency of `RevenueCat` (the planned follow-up), Xcode would have
rejected the build because an extension-only framework can't embed a
framework that doesn't also opt in. Set it now to keep the
extension-safe guarantee and avoid a surprise blocker later.
Mirror the `APPLICATION_EXTENSION_API_ONLY` change in
`Projects/RulesEngine/Project.swift` so the Tuist-generated project
matches the legacy `.xcodeproj`. The Tuist destinations already cover
all device families, so no `TARGETED_DEVICE_FAMILY` change is needed
on that side.
Reported by @rickvdl on #6787.
Verified:
- `xcodebuild -scheme RulesEngine` Debug + Release on iOS
- `xcodebuild -scheme RulesEngine` Debug on macOS
- `tuist generate RulesEngine` + `xcodebuild` against the generated
workspace
- `swift test --filter RulesEngineTests`
* Drop @_spi(Internal) markers from RulesEngine skeleton
Per @tonidero on #6787: requiring every declaration in `RulesEngine`
to be `@_spi(Internal)` is noisy and easy to forget. The module is an
internal implementation dependency of the SDK — consumers should not
be able to reach its public API surface from outside the SDK at all.
Drop `@_spi(Internal)` from:
- `RulesEngine/RulesEngine.swift` (the `Rules` namespace stays plain
`public`, since the consumer-side import form will guarantee that no
RulesEngine symbol leaks into the SDK's public API surface).
- `Tests/RulesEngineTests/RulesEngineTests.swift` (a plain
`@testable import RulesEngine` is enough now that there's no SPI
gate to traverse).
Enforcement of "no plain `import RulesEngine` from anywhere in the
SDK" moves to a SwiftLint custom rule landing in #6788, which replaces
the previous swiftinterface-based check there. The rule allows the
canonical extension-safe import block:
#if compiler(>=6)
internal import RulesEngine
#else
@_implementationOnly import RulesEngine
#endif
(plus `private import` / `fileprivate import`), and rejects everything
else. That guarantee is what previously needed `@_spi(Internal)` to
hold — without those markers, a plain `import RulesEngine` in any
SDK source would re-expose every `public` declaration. The lint rule
makes that impossible.
Verified: `xcodebuild -scheme RulesEngine` Debug + Release on iOS,
`xcodebuild -scheme RulesEngine` Debug on macOS, and
`swift test --filter RulesEngineTests` — all green.
* Replace RulesEngine swiftinterface check with SwiftLint import rule
Per @tonidero on #6787: requiring every declaration in RulesEngine to
be `@_spi(Internal)` is noisy and easy to forget, but we still need a
hard guarantee that no RulesEngine symbol leaks into the SDK's public
API surface. The cleanest way to achieve that is to constrain the
*import* form on the consumer side instead of every declaration on the
producer side.
The previous approach (`check_rules_engine_no_public_api` Fastlane lane
+ `check-rules-engine-public-api` CircleCI job) built RulesEngine for
five SDKs and asserted the public swiftinterface contained zero
declarations — i.e. it required everything to be `@_spi(Internal)`.
With the SPI markers gone (#6787), that lane fails by definition, and
its premise no longer matches the design.
Replace it with a SwiftLint custom rule, `no_plain_rules_engine_import`,
that flags any plain `import RulesEngine` and points the contributor at
the canonical extension-safe form. With this in place, RulesEngine's
symbols can stay plain `public` (no per-declaration SPI noise) because
the only allowed import forms strip the implicit `@_exported public`
re-export anyway:
#if compiler(>=6)
internal import RulesEngine
#else
@_implementationOnly import RulesEngine
#endif
`private import RulesEngine`, `fileprivate import RulesEngine`, and
`@testable import RulesEngine` (for the test target) are also accepted
naturally — the regex anchors on `^[ \t]*import\s+RulesEngine\b`, so any
qualifier or attribute prefix bypasses it.
Changes:
- `.swiftlint.yml`: new `no_plain_rules_engine_import` custom rule with
an actionable message that tells the contributor exactly which form
to use.
- `fastlane/Fastfile`: drop the `check_rules_engine_no_public_api`
lane. The shared `setup_swiftinterface_xcconfig` /
`cleanup_swiftinterface_xcconfig` private lanes stay — they're still
used by `generate_swiftinterface`.
- `.circleci/default_config.yml`: drop the
`check-rules-engine-public-api` job, its two workflow references
(`run-all-tests`, `release-or-main`), and the two `requires` entries
in the `all-tasks-passed` / `all-tests-succeeded` summary gates.
- `fastlane/README.md`: regenerated to drop the deleted lane.
Verified locally:
- The custom rule fires on every plain form (`import RulesEngine`,
indented variants, `import RulesEngine.Submodule`) and stays silent
on `internal` / `private` / `fileprivate` / `@_implementationOnly` /
`@_exported` / `@testable` imports, line/doc/block comments, and
unrelated modules (`RulesEngineMath`, `OtherRulesEngine`).
- `swiftlint` against the full repo (1315 files): 0 violations from
the new rule.
- `ruby -c fastlane/Fastfile` and Ruby-YAML-load of
`.circleci/default_config.yml` both pass.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Expand RulesEngine import lint to also reject @_exported / public / package
The previous regex only caught plain `import RulesEngine`, which is the
common case but not the only leaky form. There are three other ways to
import a module that re-export its public API surface, and the rule
needs to block all of them or the guarantee has a hole:
- `@_exported import RulesEngine` — explicit re-export (strictly worse
than plain `import` because it's intentional rather than implicit).
- `public import RulesEngine` — Swift 6 explicit access modifier on
imports (SE-0409); functionally identical to plain `import` once
access-on-imports is enforced.
- `package import RulesEngine` — also from SE-0409; re-exports across
the package boundary, which (for our SwiftPM setup) means every
other target in `purchases-ios` would see RulesEngine's public API.
Update the regex to `^[ \t]*(@_exported\s+)?(public\s+|package\s+)?import\s+RulesEngine\b`
and rename the rule to `no_leaking_rules_engine_import` to match the
broader scope. The message now lists all four forms so the contributor
sees exactly which one tripped them.
Allowed forms are unchanged (`internal`, `private`, `fileprivate`,
`@_implementationOnly`, `@testable`) — they each strip the implicit
`@_exported` re-export and bound visibility appropriately.
Verified locally:
- Rule now fires on 9 disallowed lines: plain `import` (with various
indentation), `import RulesEngine.Submodule`, `@_exported import` (incl.
extra whitespace variants), `public import`, `package import`, and
`@_exported public import`.
- Rule stays silent on every allowed form, comments, and unrelated
modules (`RulesEngineMath`, `OtherRulesEngine`, `publicRulesEngineHelper`,
`@_exported import RulesEngineHelpers`).
- Full-repo `swiftlint`: 0 violations across 1316 files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* TEMP: deliberately break no_leaking_rules_engine_import to verify CI
Adds two intentional violations to `Tests/RulesEngineTests/RulesEngineTests.swift`
so CI exercises the SwiftLint rule end-to-end:
- `import RulesEngine` — exercises the original plain-import case.
- `@_exported import RulesEngine` — exercises the newly-expanded
coverage from the previous commit.
Both lines must trigger `no_leaking_rules_engine_import` with severity
`error`, failing the lint job. Revert this commit before merging.
Locally `swiftlint` reports exactly these two violations and nothing
else.
Co-authored-by: Cursor <cursoragent@cursor.com>
* TEMP: keep only plain `import RulesEngine` for second CI run
Drop the `@_exported import RulesEngine` line added in the previous
commit so this push exercises only the plain-import case in isolation
and confirms the canonical violation still fails CI on its own.
Local lint reports exactly one `no_leaking_rules_engine_import` error
(line 19) plus an incidental `duplicate_imports` warning. Revert
before merging.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use real newlines in no_leaking_rules_engine_import message
Replace `\\n` escape sequences with real newlines (`\n` in YAML
double-quoted form). The previous escaped form produced literal `\n`
text in CircleCI's Tests tab — clearly visible in the JUnit XML
artifact (`failure message='... Use \`#if compiler(>=6)\\n internal
import RulesEngine\\n...\`'`).
Also reformat the suggestion so the canonical conditional-import block
appears on its own (`Use the canonical form:` followed by the snippet)
instead of trying to inline a code block, which makes the message
easier to scan even if a viewer collapses the newlines to spaces.
SwiftLint does not escape the newlines as ` ` when writing the
JUnit XML; it emits raw newline characters inside the attribute value.
That is technically valid XML but XML attribute-value normalization
usually replaces newlines with spaces on parse. The push is therefore
also a probe to see how CircleCI's Tests UI actually renders the
attribute (raw newline pass-through vs. spec-compliant normalization).
Locally the rule still fires exactly once on the plain `import
RulesEngine` (line 19) plus the incidental `duplicate_imports`
warning, and the JUnit XML now contains the multi-line snippet on
separate physical lines instead of `\\n` placeholders.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "TEMP: keep only plain `import RulesEngine` for second CI run"
This reverts commit 8cac453fae6f09210419daf31589fe97db246624.
* Revert "TEMP: deliberately break no_leaking_rules_engine_import to verify CI"
This reverts commit 0756f6d9638e058cb884f98517337b3f679c55dc.
* Drop stale fastlane/README.md changes that belong to PR #6796
The current branch's fastlane/README.md diverged from `main` in two
ways that don't belong in this PR:
1. A `### ios push_rules_engine_pod` documentation block — that
lane is part of #6796 (RulesEngine CocoaPods distribution wiring)
and does not exist in this branch's Fastfile (`grep
push_rules_engine_pod fastlane/Fastfile` → 0 matches).
2. A description tweak removing the trailing "locally" from
`### ios regenerate_swiftinterface`.
Both were left over from the `Enforce RulesEngine has no public API
outside @_spi(Internal)` work, which originally regenerated the
README from a Fastfile state that mixed in #6796's lane. The
follow-up commit that replaced that enforcement with a SwiftLint rule
(`Replace RulesEngine swiftinterface check with SwiftLint import
rule`) didn't re-regenerate the README, so the bleed-over survived.
This PR's actual change set (a custom SwiftLint rule + temp CI
verification commits, all in `.swiftlint.yml` and the
`Tests/RulesEngineTests` directory) doesn't touch any Fastlane lanes,
so the README should be byte-identical to `main`. Restoring via
`git checkout origin/main -- fastlane/README.md`.
Verified: `git diff origin/main -- fastlane/README.md` is now empty.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename `RulesEngine` module to `RulesEngineInternal`
Per @tonidero on #6787: developers can transitively `import RulesEngine`
from the SDK product even though the module is meant to be an internal
implementation detail of the SDK. Naming the module `RulesEngineInternal`
makes that intent explicit at the import site so consumers don't reach
for it accidentally and don't expect API stability from it.
This is a producer-side rename only — the previously-renamed `Rules`
namespace inside the module is unchanged, so call sites still write
`Rules.something`. Consumers' canonical extension-safe import (gated by
the SwiftLint rule in #6788) becomes:
#if compiler(>=6)
internal import RulesEngineInternal
#else
@_implementationOnly import RulesEngineInternal
#endif
Renamed (`git mv`):
- `RulesEngine/RulesEngine.swift` → `RulesEngineInternal/RulesEngineInternal.swift`
- `Tests/RulesEngineTests/RulesEngineTests.swift` → `Tests/RulesEngineInternalTests/RulesEngineInternalTests.swift`
- `Projects/RulesEngine/` → `Projects/RulesEngineInternal/` (Tuist project dir)
- `RevenueCat.xcodeproj/xcshareddata/xcschemes/RulesEngine.xcscheme` → `RulesEngineInternal.xcscheme`
Updated text references in:
- `Package.swift` / `Package@swift-5.8.swift` (target + test target names + paths)
- `Workspace.swift` (Tuist project path + comment)
- `Projects/RulesEngineInternal/Project.swift` (target / scheme / bundle IDs / source paths)
- `RevenueCat.xcodeproj/project.pbxproj` (target names, file refs, group names, build phase entries, bundle IDs `com.revenuecat.RulesEngineInternal[Tests]`)
- `RevenueCat.xcodeproj/.../xcschemes/RulesEngineInternal.xcscheme` (BlueprintName / BuildableName)
- `Tests/TestPlans/CI-AllTests.xctestplan` (target name)
- `.swiftlint.yml` (`xctestcase_superclass` exclude path)
- File header comments inside the two Swift sources
The substitutions ran as `\bRulesEngine\b → RulesEngineInternal` first
(safe because `RulesEngineTests` has no word boundary between `e` and
`T`), then `\bRulesEngineTests\b → RulesEngineInternalTests`. The
`Rules` namespace token was untouched because it isn't `RulesEngine`.
Verified locally:
- `swift build --target RulesEngineInternal` ✔
- `swift test --filter RulesEngineInternalTests` ✔ (1 test passed)
- `xcodebuild -project RevenueCat.xcodeproj -scheme RulesEngineInternal -destination 'generic/platform=iOS' -configuration Debug build` ✔
- `swiftlint` ✔ (0 violations across 1316 files)
- `git ls-files | xargs perl -ne 'if (/\bRulesEngine\b/ && !/RulesEngineInternal/) { ... }'` returns no hits.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Update SwiftLint custom rule for `RulesEngineInternal` rename
Mirror the producer-side rename from #6787:
- Rule id: `no_leaking_rules_engine_import` → `no_leaking_rules_engine_internal_import`.
- Rule name: "No leaking `import RulesEngine`" → "No leaking `import RulesEngineInternal`".
- Regex: `import\s+RulesEngine\b` → `import\s+RulesEngineInternal\b`.
- Violation message: every reference to `RulesEngine` (in disallowed-form
examples, the canonical extension-safe import block, and the
`private` / `fileprivate` fallbacks) updated to `RulesEngineInternal`.
Verified locally:
- Positive cases (`/tmp` test file): the rule fires on `import RulesEngineInternal`,
`@_exported import RulesEngineInternal`, `public import RulesEngineInternal`,
`package import RulesEngineInternal`, and indented `import RulesEngineInternal`.
- Negative cases: `internal` / `private` / `fileprivate` / `@_implementationOnly` /
`@testable` imports, the `#if compiler(>=6) internal import ... #else @_implementationOnly ... #endif`
block, commented-out imports, unrelated modules
(`RulesEngineInternalMath`, `OtherRulesEngineInternal`), and a plain
`import RulesEngine` (the pre-rename name) all stay silent.
- `swiftlint --no-cache` against the full repo: 0 violations across 1316 files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Update arithmetic operator test imports for `RulesEngineInternal` rename
`Tests/RulesEngineInternalTests/ArithmeticOperatorsTests.swift` was
moved into the renamed `Tests/RulesEngineInternalTests/` directory by
git's directory-rename detection during the merge from #6789, but its
`@testable import RulesEngine` line was added in this branch (after
the directory rename was set up upstream) and still referenced the old
module name. Update it to `@testable import RulesEngineInternal` so
the file matches every other test in the target.
Verified: `swift test --filter RulesEngineInternalTests.ArithmeticOperatorsTests` ✔
(21 tests passed); `swiftlint --no-cache` ✔ (0 violations across 1325 files).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Update comparison operator test imports for `RulesEngineInternal` rename
`Tests/RulesEngineInternalTests/ComparisonOperatorsTests.swift` was
moved into the renamed `Tests/RulesEngineInternalTests/` directory by
git's directory-rename detection during the merge from #6791, but its
`@testable import RulesEngine` line was added in this branch and still
referenced the old module name. Update it to
`@testable import RulesEngineInternal`.
Verified: `swift test --filter RulesEngineInternalTests.ComparisonOperatorsTests` ✔;
`swiftlint --no-cache` ✔ (0 violations across 1327 files).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Treat null and empty-string leaves as missing per JSON Logic spec
`opMissing` only flagged keys whose path failed to resolve. The
json-logic-js reference routes through `var` and treats any key whose
lookup resolves to `null` (key absent OR leaf is `null`) or to the
empty string as missing — so `{"missing": ["country"]}` against
`{"country": null}` is supposed to return `["country"]`, but our impl
returned `[]`. Backend payloads regularly come down with explicitly
cleared fields as `null`, so the divergence was reachable from real
predicates.
`opMissing` now uses the same lookup `var` does (without the
missing-variable warning, since `missing` is a check, not a read) and
matches against the spec's `value === null || value === ""` test.
Falsy non-empty values (`0`, `false`, `[]`) are NOT missing — pinned
by a regression test so a future truthiness-based simplification can't
silently flip them.
Reported by Cursor Bugbot:
#6789 (comment)
Co-authored-by: Cursor <cursoragent@cursor.com>
* Coerce arrays/objects to JS strings in `==` to match the spec
`looseEq` only had explicit cases for primitives and same-compound
operands, so `[1] == "1"` and `[1, 2] == "1,2"` returned `false` —
divergent from json-logic-js, which inherits JS abstract equality:
when one side is a compound (Array/Object) and the other is a
primitive, the compound goes through ToPrimitive (string hint) and
the comparison is retried.
Add four cross-type arms to `looseEq` that mirror exactly that
coercion: arrays render via `Array.prototype.toString()` (recursive
comma-join, with `null` elements as the empty string); objects render
as `"[object Object]"`. The recursive call falls through to the
existing primitive arms — string-vs-string for `[1, 2] == "1,2"`,
the numeric fallback for `[] == 0` / `[1] == 1`. Same-compound
comparisons keep their structural-eq behavior (deliberate divergence
from JS reference identity, which would make rule patterns like
`{"==": [{"var": "tags"}, ["a", "b"]]}` always false).
Sharpen the `looseEq` doc comment to spell out the four behaviors
(same-type, cross-numeric, same-compound structural, compound-vs-
primitive ToPrimitive) and the JS reference identity divergence.
Pin the new behavior with `ValueTests` cases covering each spec
example (`[1] == "1"`, `[1, 2] == "1,2"`, `[null, 1] == ",1"`,
`[] == ""`, nested arrays, the numeric fallback path, JS-specific
float spellings, the object → "[object Object]" coercion, and the
array-vs-object cross-compound case). Two `EvaluatorTests` cases
pin the same coercion through the full `Evaluator.evaluate` path so
predicate authors get the spec-aligned behavior end-to-end.
Also annotate `testMultiKeyObjectIsLiteralDataValue` to call out the
json-logic-js `is_logic` parity (multi-key objects fall back to the
"return as-is" branch in `apply`), so a future reader doesn't pattern
-match it onto a "dispatch first key, ignore extras" misreading.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Catch kind-qualified imports and ignore comments in RulesEngine lint
Address two review comments on #6788 from @rickvdl:
1. The previous regex caught plain `import RulesEngineInternal` plus the
`@_exported` / `public` / `package` variants, but missed
per-declaration imports like `import struct RulesEngineInternal.X`
(also `class` / `func` / `enum` / `protocol` / `typealias` / `var`
/ `let`). Those forms are implicitly `@_exported` by default and
re-export the named symbol into the importing module's public API
surface, so they're the same leak as a plain `import` — the rule
needs to block them too.
Extend the regex with an optional kind-prefix group right after
`import`:
^[ \t]*(@_exported\s+)?(public\s+|package\s+)?import\s+
(struct\s+|class\s+|func\s+|enum\s+|protocol\s+|typealias\s+|
var\s+|let\s+)?RulesEngineInternal\b
The kind group is optional, so all existing leaky forms still match.
Update the violation message to mention the kind-qualified form so
the suggestion the contributor sees lists everything that's
forbidden.
2. Add `match_kinds` so SwiftLint only fires the rule when the regex
match actually overlaps source tokens (not comments / strings).
This eliminates the small false-positive risk of the rule
triggering on a commented-out `import RulesEngineInternal` line.
The kinds list is `identifier` (module name), `keyword` (`import`,
`struct`, `public`, `package`, etc.), and `attribute.builtin`
(`@_exported`). The last one is critical: copying the
`[identifier, keyword]` pair from `avoid_using_directory_apis_directly`
silently breaks every `@_exported …` case, because SourceKit
classifies `@_exported` as `attribute.builtin`, not `keyword`. Caught
during local verification before pushing.
Verified locally:
- Positive (constructed temp file, 19 disallowed forms covering plain /
indented / `.Submodule` / `@_exported` / `public` / `package` /
`@_exported public` / 8 kind-qualified variants / `@_exported` +
kind-qualified + `public` / `package` + kind-qualified): all 19 lines
flagged.
- Negative (`internal` / `private` / `fileprivate` /
`@_implementationOnly` / `@testable` imports, the canonical
`#if compiler(>=6) … #endif` block, kind-qualified `internal` /
`private` / `@_implementationOnly` imports, line / doc / block
comments containing every disallowed form, unrelated modules
`RulesEngineInternalMath` / `OtherRulesEngineInternal` /
`RulesEngineInternalHelpers` including `@_exported` and
kind-qualified variants): 0 flagged.
- `swiftlint --no-cache` against the full repo: 0 violations across
1316 files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* RulesEngine: pin single-key-object-as-operand parity with json-logic-js
Locks in the observed runtime behavior for `{"==": [{"a":1}, {"a":1}]}`:
both our engine and json-logic-js dispatch single-key objects as
operators via `is_logic`/`evaluateValue`, so this literal predicate
throws `RuleError.unsupportedOperator("a")` instead of doing a
structural compare. Pins the contrast with the existing multi-key
test (where the structural-vs-reference divergence still applies) so
a future operator-dispatch refactor can't quietly regress this.
Co-authored-by: Cursor <cursoragent@cursor.com>
* AccessorOperatorsTests: restore previous Rules.logger in tearDown
The class installs a `CapturingLogger` into the module-global
`Rules.logger` from `setUp`, but `tearDown` only released the local
reference, leaving the test's `CapturingLogger` installed for the
rest of the process. Mirror the save/restore pattern that
`Rules.withLogger(_:)` uses so the previous logger is reinstated when
the test class is done.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "AccessorOperatorsTests: restore previous Rules.logger in tearDown"
This reverts commit daaa11a82ac277e0c16744cacffa48ad6fec552b.
* Pin null-operand handling for arithmetic operators
Adds a single test asserting that `null` is treated as `0` across
`+`, `-`, `*`, `/`, `%`, including the unary `-` form and the
1-arg numeric-cast `+`. Comment block explains the deliberate
deviation from json-logic-js for `+` / `*` (JS uses `parseFloat`,
which makes `parseFloat(null) === NaN`); the other three already
match JS exactly.
Addresses review comment from @rickvdl on PR #6791.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Arithmetic: match json-logic-js coercion exactly
`json-logic-js` is asymmetric about which JS coercion arithmetic uses:
`+` and `*` go through `parseFloat(value)` (stringify, parse longest
numeric prefix); `-`, `/`, `%` go through native arithmetic which calls
`Number(value)` (a.k.a. `ToNumber`). The prior implementation routed
every operand through `Value.asNumber` (a partial `ToNumber`), which
diverged from the spec in two directions:
- For `+` / `*`: `null`, bools, the empty string, and `[1]` produced
numbers instead of `NaN`.
- For `-` / `/` / `%`: arrays produced `NaN` instead of being coerced
via `ToPrimitive("number")` → `toString` → recurse, so `[] - 1` and
`[1] - 1` were `NaN` instead of `-1` and `0`.
Add `jsString(value)` (top-level JS `String()`) and `jsParseFloat(value)`
to `Value.swift`, extend `Value.asNumber` to coerce arrays/objects via
`jsString`, and route `+` / `*` through `jsParseFloat` while leaving
`-` / `/` / `%` on `asNumber ?? .nan`. The existing
divide-/modulo-by-zero short-circuit (`.null` instead of `±Infinity` /
`NaN`) is the only remaining intentional deviation in this file and is
documented as such.
Tests:
- Replace `testNullOperandIsTreatedAsZero` with two coercion-path tests
that pin both branches against the spec, including the array cases
(`[1] + 1 = 2`, `[1,2] + 0 = 1`, `[] - 1 = -1`, `[1] - 1 = 0`,
`[1,2] - 0 = NaN`).
- Update `testAddOneArgActsAsNumericCast` and split
`testAddCoercesStringsAndBools` into `testAddCoercesNumericStrings`
to reflect that bools no longer bridge through `+` / `*`
(`parseFloat("true") === NaN`) but trailing junk in numeric strings
now parses (`parseFloat("3.14abc") === 3.14`).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Arithmetic: drop divide-/modulo-by-zero short-circuit
The previous implementation intercepted `n / 0` and `n % 0` and returned
`.null`, on the rationale that `null` is friendlier for rule authors and
matches the engine's "missing value" convention. That's the last
remaining intentional deviation from `json-logic-js` in this file —
remove it for full spec parity.
`json-logic-js` delegates `/` and `%` to native JS arithmetic, which
follows IEEE 754: `n / 0` is `±Infinity` (sign matches the dividend),
`0 / 0` is `NaN`, and any `n % 0` is `NaN`. Swift's `Double` operators
and `truncatingRemainder(dividingBy:)` already produce these IEEE
values, so the fix is just dropping the divisor==0 short-circuit and
wrapping the result in `.float`.
Tests:
- Replace `testDivByZeroReturnsNull` with `testDivByZeroFollowsIeee754`
pinning all three cases (`+Infinity`, `-Infinity`, `NaN`).
- Replace `testModByZeroReturnsNull` with `testModByZeroIsNan` covering
`7 % 0` and `0 % 0`.
- Update the `1 / null` / `1 % null` cases inside
`testSubDivAndModUseToNumberPerSpec` (divisor coerces to 0 →
`+Infinity` / `NaN` instead of `.null`).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim verbose hypothetical justifications from operator doc comments
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim verbose hypothetical justifications from arithmetic doc comments
Also pin the IEEE 754 div-by-zero behavior (introduced in e32933780)
in the evaluator-level test.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim verbose hypothetical justifications from comparison doc comments
Co-authored-by: Cursor <cursoragent@cursor.com>
* Align arithmetic operator arity behavior with json-logic-js spec
- {"+": []} returns 0 (matches `Array.prototype.reduce(fn, 0)`).
- {"*": [a]} returns the operand unchanged (single-arg reduce without
seed never invokes the reducer, so no `parseFloat` coercion).
- {"-": []}, {"/": []}, {"%": []} return NaN (missing operands act
as JS `undefined`); extra operands past the first two are ignored to
match `function(a, b)` argument truncation.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Match comparison-operator arity with json-logic-js
`<`, `<=`, `>`, `>=` no longer throw on missing or extra operands.
Missing operands stand in for JS `undefined` (coerced to `NaN`, so
any comparison is `false`); arguments past the named parameters
(`(a, b)` for `>` / `>=`, `(a, b, c)` for `<` / `<=`) are silently
dropped, matching the JS reference's `function(a, b[, c])` signatures.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Stringify non-primitive var paths per json-logic-js spec
`{"var": <expr>}` now coerces any evaluated path value to a string
via `String(value).split(".")`, matching `json-logic-js`. Boolean
paths look up `"true"` / `"false"`, array paths comma-join (with
`null` elements rendering as empty), and object paths stringify to
`"[object Object]"` and silently miss. Removes the previous strict
typeMismatch throw on non-string/non-numeric path arguments.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Match binary operator arity with json-logic-js spec
`evalTwo` no longer throws on missing/extra operands. A missing
operand defaults to `.null` (standing in for JS `undefined`) and
extras are silently dropped — matches `json-logic-js`'s
`function(a, b)` signature used by `==`, `===`, `!=`, `!==`, `in`,
etc.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Hoist JS String() coercion into a shared `jsString` helper
`AccessorOperators` no longer duplicates the JS `String(value)`
stack: the private helpers in `Value.swift` (used by `looseEq`) are
now module-internal and exposed as a single `jsString(Value)`
function that `pathSegment` / `keyAsPath` reuse.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Align compound equality with JS reference identity
`looseEq` and `strictEq` now return `false` for any array-vs-array
or object-vs-object comparison, matching JS's `==` / `===`
reference semantics. Without reference identity in our value model,
the spec-aligned answer for two distinct compound operands is
always `false`; structural comparison can be reintroduced explicitly
later if needed.
Compound-vs-primitive coercion (Array#toString / "[object Object]")
is unaffected.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add empty-or-inside-if parity test from Android rules engine suite
Mirror the Android LogicOperatorsTest that pins empty `or` returning
null and routing an outer `if` to its else branch, keeping cross-platform
coverage aligned for PR #6789.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Broaden missing falsy-value test to match Android parity
Cover empty objects and the "0" string alongside 0, false, and [] so
both platforms pin the same negative missing-operator cases.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add json-logic-js spec edge-case tests for MVP operators
Cover var null-path/default semantics, unary empty-arg logic operators,
strict equality arity, and literal predicate truthiness rules.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop trivial RulesEngineInternal module smoke test
The namespace wiring is already exercised by every test that imports
RulesEngineInternal; the standalone reachability check added no spec value.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Disable file_length lint for AccessorOperatorsTests
The spec-parity test matrix exceeds the 400-line file cap; match other
long integration-style test files with a targeted swiftlint disable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore RulesEngineInternal extension-safe build settings
Re-add APPLICATION_EXTENSION_API_ONLY and Mac Catalyst (device family 6)
to RulesEngineInternal Debug/Release so the pbxproj matches Tuist and
ReceiptParser. Clarify that the `vars` parameter is the JSON Logic data scope.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert accidental Package.resolved changes
Restore the lockfile to match the base branch. The deletions were
introduced accidentally when running SPM resolve locally during doc
comment cleanup — unrelated to RulesEngineInternal.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Pin parseFloat vs Number coercion gaps in arithmetic tests.
Assert multi-arg * parses junk-suffix strings and - rejects them via Number(),
closing the last spec holes where a wrong coercion path could pass the suite.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Remove redundant Swift enum note from LoggerStorage docs
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop unnecessary throws from var path coercion helpers
pathSegment, parseVarArrayArgs, and their call sites no longer throw
after non-primitive paths were stringified per json-logic-js. resolveVarArgs
still throws only for nested Evaluator.evaluateValue calls.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add Hashable and Sendable conformance to Value
Pure value enum — synthesis covers all cases and sets up Set<Value>
for the upcoming in / membership operators.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename Rules namespace to RulesEngine and keep it internal
The entry-point enum is…
* Add RulesEngine skeleton module
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>
* Gate RulesEngine API behind `@_spi(Internal)`
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>
* RulesEngine skeleton: cleanups
- 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>
* Enforce RulesEngine has no public API outside @_spi(Internal)
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>
* TEST: add leaked public API to verify CI guardrail (do not merge)
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add JSON Logic predicate evaluator to RulesEngine
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>
* Revert "TEST: add leaked public API to verify CI guardrail (do not merge)"
This reverts commit a9018bf1f1047708063ef5b7a15704157a75bf2f.
* RulesEngine skeleton: PR feedback
- 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>
* RulesEngine skeleton: PR feedback
- 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>
* Move RulesEngine CocoaPods distribution wiring out of skeleton
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>
* Trim verbose RulesEngine swiftinterface comment
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>
* Drop RulesEngine SPM target explanatory comment
Co-authored-by: Cursor <cursoragent@cursor.com>
* Always include `./Projects/RulesEngine` in the Tuist workspace
`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>
* Wire `RulesEngineTests` into the Tuist `RulesEngine` project
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>
* Simplify `RulesEngineTests` testable target reference
`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>
* Set `SKIP_INSTALL = YES` for `RulesEngine` Release config
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>
* Build `RulesEngine` via Tuist workspace in `check_rules_engine_no_public_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>
* Drop unused RulesEngine entry from Tuist/Package.swift productTypes
`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>
* Document strict swiftinterface filter in check_rules_engine_no_public_api
A future Xcode bump could introduce new top-of-file constructs
(`@_exported import ...`, `#if compiler(...)`, etc.) that the strict
filter would flag as a public-API leak. Leave a note so the next
person debugging a spurious failure extends the rejection list rather
than hunting for a leaked symbol that isn't there.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Tighten test helper and broaden RulesEngine test coverage
- `Value.fromJSONObject` now throws `RuleError.parse` for unexpected
`JSONSerialization` outputs (e.g. `Date`, `NSValue`) instead of
silently coercing to `.null`. `fromJSONString` propagates the same
error type, so existing throw-assertions keep working.
- Remove unused `import XCTest` from the test helper.
- Pin the previously-untested 1-arg `{"if": [expr]}` and 2-arg
`{"if": [cond, then]}` forms of the `if` operator.
- Add NaN / Infinity edge-case tests for `isTruthy`, `looseEq`, and
`strictEq` so the IEEE 754 behavior we inherit through Swift `==`
doesn't silently regress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extend RulesEngine public-API check to 4 simulator SDKs + macOS
Iterate over the four simulator SDKs (iOS / watchOS / tvOS / visionOS)
plus macOS instead of building for `iphonesimulator` only. Skipping the
matching device SDKs is intentional: for a Swift-only module built with
`BUILD_LIBRARY_FOR_DISTRIBUTION=YES` and no `targetEnvironment(simulator)`
gates, the public swiftinterface is identical to the simulator
counterpart, and our "declaration count == 0" assertion is invariant to
that diff. macOS stays in the set because `#if os(macOS)` can expose
Mac-only public symbols invisible to any iOS-family simulator.
Expected CI cost: ~5x xcodebuild time but only one Tuist install/generate,
so total job duration grows from ~57s to ~110s — still a fraction of the
existing per-module `check-api-changes-*` jobs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop `indirect` from RulesEngine.Value
No `Value` case directly contains a `Value` payload — the recursive
cases (`array([Value])`, `object([String: Value])`) thread their
`Value` storage through `Array` / `Dictionary`, both heap-backed value
types. The compiler only requires `indirect` when a case stores a
`Self` payload inline, so `indirect` here was a redundant safety net
that added a heap allocation per `Value` for no payoff.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant `internal` keyword from RulesEngine declarations
`internal` is the default access level in Swift, so stating it
explicitly on every top-level type / function is noise. Removing it
across the module to match the rest of the codebase's style.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename RulesEngine namespace to Rules
The module is called `RulesEngine`, so an `enum RulesEngine` inside it
collides with the module name from the test target's perspective —
`@testable import RulesEngine` makes the bare identifier resolve to the
module, forcing callers to write `RulesEngine.RulesEngine.something` to
reach the namespace.
Renaming the namespace to `Rules` keeps the module name descriptive
while letting consumers write `Rules.something` cleanly. Doing it now
(before any real implementation lands) avoids a churny rename in the
follow-up PRs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Replace per-call logger parameter with module-level Rules.logger
Threading a `RulesEngineLogger` argument through every operator and the
top-level evaluator is mostly boilerplate — only `AccessorOperators`
actually emits warnings today, and there's no scenario where two
concurrent evaluations would want different loggers. Make the logger
module state on `Rules.logger`, with a `Rules.withLogger(_:_:)` helper
for scoped overrides in tests.
Access is `NSLock`-synchronized via a private `LoggerStorage` reference
type so a reader that races a `withLogger` swap can't observe a
half-assigned protocol existential.
Operator and evaluator signatures lose the `logger:` parameter; the
test target's `@testable import RulesEngine` becomes
`@_spi(Internal) @testable import RulesEngine` where it needs to reach
`Rules`. Test suites install a `CapturingLogger` in
`setUp`/`tearDown` (or use `Rules.withLogger` for one-shot overrides)
instead of constructing one per call.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move Rules.withLogger scoped helper to the test target
`withLogger` is only used by tests, and the get/set/restore pattern is
short enough that it doesn't need to live in production code. Drop it
from `Rules` and add an equivalent extension under
`Tests/RulesEngineTests/Helpers/Rules+WithLogger.swift` so the one test
that wants a scoped override (`EvaluatorTests.testMissingVariable…`)
keeps its current call syntax.
`Evaluator.swift`'s doc comment is updated to drop the `withLogger`
mention.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim Rules namespace doc comment
The collision-with-module-name rationale was useful while deciding on
the name but doesn't add long-term value at the call site. Reduce to
a one-line summary.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim Rules.logger doc comment
The previous block explained the property's role and the rationale for
making the logger module state; both points are also covered in the
module-level doc on `Evaluator.swift`. The property itself is short and
self-describing, so drop the comment.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant setUp/tearDown comment in AccessorOperatorsTests
The setUp/tearDown body itself makes the intent obvious — installing a
capturing logger and restoring the previous one — and XCTest's
sequential-per-class execution model is well-known. The comment doesn't
add anything beyond the code.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify AccessorOperatorsTests logger lifecycle
Tracking the previous module logger to restore it on tear-down was
defensive but unnecessary: every test in the suite installs its own
fresh `CapturingLogger` in `setUp`, and no other test class reads
`Rules.logger` directly. Drop the `previousLogger` ivar and the
restore step so the lifecycle is just "create fresh, release in
tear-down".
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant empty-args guards in LogicOperators
`opAnd`, `opOr`, and `opIf` each had an explicit `items.isEmpty` early
return that produced exactly the same value as the natural fall-through
(seeded `last` for AND/OR, terminal `return .null` for IF). Replace the
guards with a brief comment where the value isn't obvious from the
nearby initializer. The existing empty-args assertions in
`LogicOperatorsTests` keep the behavior pinned.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move CapturingLogger to the test target
`CapturingLogger` is only ever instantiated by tests
(`AccessorOperatorsTests`, `EvaluatorTests`, `LoggerTests`); having it
in the production module was a holdover from an earlier iteration where
production helpers needed to reference it. Move the type to
`Tests/RulesEngineTests/Helpers/CapturingLogger.swift` and drop the
stale "lives in the production module so non-test callers can reference
it" doc comment. The legacy `RevenueCat.xcodeproj` gets the file
registered in the same four pbxproj spots used for the existing
helpers; Tuist regenerates from globs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify PrintLogger and drop trivial LoggerTests
`PrintLogger` is only ever the default until the native SDK injects its
own adapter, so the stderr-via-`FileHandleOutputStream` ceremony was
unjustified. Reduce it to a plain `print()`.
`LoggerTests` only exercised that `CapturingLogger`'s `[String]` append
works and that `PrintLogger.warn` doesn't crash — neither tells us
anything meaningful, so delete the file and its pbxproj entries.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix `formatNumber` trap on out-of-Int64-range doubles
A finite, whole-number `Double` whose magnitude exceeds `Int64.max`
(e.g. `1e19`) passed both `isFinite` and `rounded() == value` and then
trapped at `Int64(value)`. Reachable from `var` / `missing` whenever a
`.float` is used as a path segment.
Switch to `Int64(exactly:)`, which returns `nil` for non-integer,
out-of-range, NaN, and ±Infinity inputs — letting the `String(value)`
fall-through cover every degenerate case in one place. Pin the
behavior with `testVarWithOversizedFloatPathDoesNotCrash`, which
crashes against the previous implementation.
Reported by Cursor Bugbot:
https://github.com/RevenueCat/purchases-ios/pull/6789#discussion_r3255272418
Co-authored-by: Cursor <cursoragent@cursor.com>
* Pin formatNumber's normal-float path-rendering behavior
Add two tests so the contract that `formatNumber` enforces — whole-number
doubles render as integer paths, fractional doubles render with their
decimal — is no longer implicit:
- `testVarWithIntegerValuedFloatPathLooksUpIntegerIndex` locks in
`{"var": 1.0}` → array index 1 (i.e. "1", not "1.0").
- `testVarWithFractionalFloatPathDoesNotMatchAdjacentIndices` guards
against an over-eager rounding fix that would collapse 1.5 to "1" or
"2"; the path stays "1.5", the lookup misses, and we warn.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore SKIP_INSTALL = YES on RulesEngine Release config
The skeleton PR (#6787) intentionally flipped this from `NO` to `YES`
in 42e362c35 to match every other framework target in the project
(`RevenueCat`, `RevenueCatUI`, `ReceiptParser` — all `YES` on Release,
`NO` on Debug). Leaving it `NO` on Release would copy
`RulesEngine.framework` into the archive's Products directory during
`xcodebuild archive` flows (e.g. XCFramework export) and risks
"Invalid Bundle" App Store submission failures for downstream archives
that embed the framework.
That fix was silently dropped during one of the pbxproj merge-conflict
resolutions while cascading the skeleton into #6789. Re-apply it.
Reported by Cursor Bugbot:
https://github.com/RevenueCat/purchases-ios/pull/6789#discussion_r3256900958
Co-authored-by: Cursor <cursoragent@cursor.com>
* Recursively evaluate `var`/`missing` arguments per JSON Logic spec
`json-logic-js` recursively evaluates the argument(s) of `var` and
`missing` before using them as paths, so dynamic constructs like
`{"var": {"var": "active_path_key"}}` or
`{"missing": [{"var": "key_to_check"}]}` are valid. Our previous
implementation treated those args as literals, which silently rejected
otherwise-valid predicates.
`opVar` now routes its argument through `Evaluator.evaluateValue` before
parsing: the array form evaluates each element in place (path and
default both become dynamic), and the singleton form evaluates the lone
argument and treats the result as the path. `opMissing` evaluates each
key the same way, and additionally unpacks the first evaluated arg when
it resolves to an array — mirrors `Array.isArray(arguments[0])` so
constructs like `{"missing": {"merge": [["a"], ["b"]]}}` work as the
spec describes.
The one deliberate deviation: when the singleton form of `var`
evaluates to a non-primitive (e.g. an array), we throw `typeMismatch`
instead of JS-stringifying it ("x,y") and looking that up. Pinned by
`testVarSingletonExpressionResolvingToArrayThrows`.
Doc comments updated to describe the spec-aligned behavior. New tests
cover dynamic singleton paths, dynamic array-form paths, dynamic
defaults, dynamic `missing` keys, and the first-arg-array unpack rule.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Return `.null` for empty `and`/`or` to match the JSON Logic spec
`json-logic-js` falls through to an uninitialized `current` when `and`
or `or` is called with no arguments, so the empty-args case returns
`undefined` (falsy). We were returning `.bool(true)` and `.bool(false)`
respectively, which happens to share truthiness with the spec but
diverges on identity — the visible failure mode is something like
`{"if": [{"and": []}, "yes", "no"]}`: the spec routes to "no", we were
returning "yes".
Both operators now seed `last` to `.null` (our closest mapping for
`undefined`). Doc comments and existing empty-args tests updated;
added an integration test that pins the `if`/`and` interaction so a
future revert can't slip through unnoticed.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add JSON Logic arithmetic operators (+, -, *, /, %)
Extends the JSON Logic operator set with arithmetic so predicates can
express counter and sum conditions before the comparison and string
operators land.
What's new:
- `+`, `-`, `*`, `/`, `%` per the JSON Logic spec, including variadic
`+` and `*`, the 1-arg numeric-cast form of `+`, and unary negation
via `-`. All arithmetic returns `.float(Double)` for consistency
with the JS reference (which `parseFloat`s every operand).
`looseEq` and `strictEq` already bridge `.int(n) ↔ .float(n.0)`,
so existing comparisons keep working.
- Non-numeric operands (`.object`, `.array`, unparseable strings)
coerce to `Double.nan` and propagate through arithmetic — the
result is `.float(nan)`, falsy under `isTruthy`.
- Division and modulo by zero return `.null` (deliberate deviation
from JS's `Infinity` / `NaN` — friendlier for rule authors and
matches the engine's "missing value" convention). Documented in
the type's doc comment.
Tests:
- `ArithmeticOperatorsTests` covers each operator (basic, variadic,
coercion, NaN propagation, divide/mod by zero, arity errors).
- `EvaluatorTests` adds two integration tests through dispatch:
`var * 2 == 6` and the `divide-by-zero → null → falsy` flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add JSON Logic comparison operators (<, <=, >, >=)
Extends the JSON Logic operator set with the comparison operators rule
authors need to express numeric thresholds and ranges.
What's new:
- `<`, `<=`, `>`, `>=` per the JSON Logic spec.
- `<` and `<=` accept the 3-arg between form (`{"<=": [1, x, 10]}`
reads as `1 <= x <= 10`). `>` and `>=` are binary only, matching
the JS reference.
- All operators coerce operands through `Value.asNumber` and compare
as `Double`. Non-numeric operands (`.object`, `.array`,
unparseable strings) become `Double.nan`; per IEEE 754 every
comparison against NaN is `false`, so a malformed operand makes
the predicate fail closed.
String semantics deviate from the JS reference (which compares two
strings lexicographically, e.g. `"10" < "9"` is true). We always
coerce numerically — `"10" < "9"` is false. Documented in the type's
doc comment.
Tests:
- `ComparisonOperatorsTests` covers each operator (basic, between
form, coercion, NaN propagation, no-between for `>` / `>=`, arity
errors).
- `EvaluatorTests` adds two integration tests through dispatch:
`var >= 3` and the 3-arg `1 <= var <= 10` between form.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use lexicographic compare for two-string operands per spec
`json-logic-js` (and ECMAScript Abstract Relational Comparison)
compare two string operands lexicographically and only coerce to a
number when the types mix. We were always coercing numerically, which
disagrees with the spec for pure-string compares — `"10" < "9"` was
returning `false` instead of the spec-required `true`.
`compare(_:_:using:)` now branches on type: if both operands are
`.string` it lex-compares; otherwise it falls through to the existing
`asDouble` numeric path. A small `Comparator` enum drives both
branches so the same case (`.less`, `.lessOrEqual`, …) can dispatch
against either `String` or `Double`.
Doc comment rewritten to describe the spec-aligned split. The test
that previously pinned the numeric-only deviation is flipped to
assert lex order, and tests for `<=` and `>` exercise the lex path
through their respective operators. A separate test pins that mixed
types still coerce numerically so we don't drift the other way.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add JSON Logic string + array operators
Implements `in`, `cat`, `substr`, `merge`, and `missing_some` so v1
targeting predicates can express string-content, array-membership, and
"any N of these fields populated" checks.
What's new:
- `StringArrayOperators` covers `in`, `cat`, `substr`, and `merge`.
- `opMissingSome` lives next to `opMissing` in `AccessorOperators` —
it reuses the same dot-path lookup, so co-locating beats spawning a
parallel module.
- `Operators` dispatch table extended with all five operators.
Behavior notes (deviations from the JSON Logic JS reference, both
documented in type-level docs and unit-tested):
- **`in` array membership uses `looseEq` instead of strict `===`.**
Rule authors regularly write integer literals against
backend-supplied string lists (`{"in": [{"var": "tier_id"},
["1", "2", "3"]]}` etc.); strict equality would silently fail
those, loose equality matches the rest of our equality story.
- **`substr` slices by Unicode code points**, not UTF-16 code units.
Matches Swift's default `String.Character` semantics and gives the
intuitive answer for multibyte strings. Differs from JS only for
surrogate-pair characters, which are vanishingly rare in real
rule data.
A few smaller decisions that follow JS:
- `cat` stringifies via a JS-style `String(value)` helper (`null` →
`"null"`, arrays → comma-joined, objects → `"[object Object]"`).
- `substr` with negative `length` mirrors the JS reference's
two-step impl (drop from the right of the substring-from-start).
- `missing_some` falls back to `0` for non-numeric `min_required`,
matching our other operators' lenient numeric coercion.
Tests:
- `StringArrayOperatorsTests` covers each of the four operators.
- `AccessorOperatorsTests` extended with `missing_some` cases
(threshold met, below threshold, zero required, dot-paths, arity
errors).
- `EvaluatorTests` adds two integration tests: a `country in [...]`
membership check, and a `missing_some` gate inside an `if`.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Guard `Double → Int` coercion in `substr` / `missing_some`
`Int(start.asNumber ?? 0)` (and the matching `lenN` / `need` sites) used
the trapping `Int` initializer, which crashes on NaN, ±Infinity, and
out-of-range finite values. A malformed predicate can easily produce
any of those:
- `.float(.nan)` literal or `{"+": ["abc"]}` (NaN propagation)
- `.string("nan")` / `.string("inf")` (both parse via `Double(_:)`)
- `.float(1.0e30)` (out of `Int64` range, also out of `Int.max`)
The existing `?? 0` fallback only covered `nil` (arrays / objects), not
non-finite or oversized values. Introduce a shared
`Operators.clampedInt(_:)` helper that maps `NaN → 0` (mirroring JS
`ToInteger`) and saturates `±Infinity` / out-of-range finites to
`Int.max` / `Int.min` so downstream `min` / `max` clamping handles
them naturally. Apply it to `opSubstr` (both `start` and `length`) and
`opMissingSome`'s `need_count`.
Add tests pinning the new safe behavior for every code path that the
trapping initializer would have crashed on (NaN, ±Infinity, oversized
finite).
Co-authored-by: Cursor <cursoragent@cursor.com>
* RulesEngine target: include Mac in TARGETED_DEVICE_FAMILY and set APPLICATION_EXTENSION_API_ONLY
The `RulesEngine` framework target in `RevenueCat.xcodeproj` was missing
two settings that the rest of the SDK relies on:
- `TARGETED_DEVICE_FAMILY = "1,2,3,4,7"` was missing `6` (Mac), even
though `SUPPORTED_PLATFORMS` includes `macosx`, `destinations` is
`.allRevenueCat` (which covers `.mac` / `.macWithiPadDesign` /
`.macCatalyst`), and the sibling `RulesEngineTests` target already
has `1,2,3,4,6,7`. Bring the framework in line with the test target
and with `ReceiptParser` (which is the closest analog and uses the
full `1,2,3,4,6,7`).
- `APPLICATION_EXTENSION_API_ONLY` wasn't set. Both `RevenueCat` and
`ReceiptParser` set it to `YES`. Once `RulesEngine` is wired in as a
dependency of `RevenueCat` (the planned follow-up), Xcode would have
rejected the build because an extension-only framework can't embed a
framework that doesn't also opt in. Set it now to keep the
extension-safe guarantee and avoid a surprise blocker later.
Mirror the `APPLICATION_EXTENSION_API_ONLY` change in
`Projects/RulesEngine/Project.swift` so the Tuist-generated project
matches the legacy `.xcodeproj`. The Tuist destinations already cover
all device families, so no `TARGETED_DEVICE_FAMILY` change is needed
on that side.
Reported by @rickvdl on #6787.
Verified:
- `xcodebuild -scheme RulesEngine` Debug + Release on iOS
- `xcodebuild -scheme RulesEngine` Debug on macOS
- `tuist generate RulesEngine` + `xcodebuild` against the generated
workspace
- `swift test --filter RulesEngineTests`
* Drop @_spi(Internal) markers from RulesEngine skeleton
Per @tonidero on #6787: requiring every declaration in `RulesEngine`
to be `@_spi(Internal)` is noisy and easy to forget. The module is an
internal implementation dependency of the SDK — consumers should not
be able to reach its public API surface from outside the SDK at all.
Drop `@_spi(Internal)` from:
- `RulesEngine/RulesEngine.swift` (the `Rules` namespace stays plain
`public`, since the consumer-side import form will guarantee that no
RulesEngine symbol leaks into the SDK's public API surface).
- `Tests/RulesEngineTests/RulesEngineTests.swift` (a plain
`@testable import RulesEngine` is enough now that there's no SPI
gate to traverse).
Enforcement of "no plain `import RulesEngine` from anywhere in the
SDK" moves to a SwiftLint custom rule landing in #6788, which replaces
the previous swiftinterface-based check there. The rule allows the
canonical extension-safe import block:
#if compiler(>=6)
internal import RulesEngine
#else
@_implementationOnly import RulesEngine
#endif
(plus `private import` / `fileprivate import`), and rejects everything
else. That guarantee is what previously needed `@_spi(Internal)` to
hold — without those markers, a plain `import RulesEngine` in any
SDK source would re-expose every `public` declaration. The lint rule
makes that impossible.
Verified: `xcodebuild -scheme RulesEngine` Debug + Release on iOS,
`xcodebuild -scheme RulesEngine` Debug on macOS, and
`swift test --filter RulesEngineTests` — all green.
* Replace RulesEngine swiftinterface check with SwiftLint import rule
Per @tonidero on #6787: requiring every declaration in RulesEngine to
be `@_spi(Internal)` is noisy and easy to forget, but we still need a
hard guarantee that no RulesEngine symbol leaks into the SDK's public
API surface. The cleanest way to achieve that is to constrain the
*import* form on the consumer side instead of every declaration on the
producer side.
The previous approach (`check_rules_engine_no_public_api` Fastlane lane
+ `check-rules-engine-public-api` CircleCI job) built RulesEngine for
five SDKs and asserted the public swiftinterface contained zero
declarations — i.e. it required everything to be `@_spi(Internal)`.
With the SPI markers gone (#6787), that lane fails by definition, and
its premise no longer matches the design.
Replace it with a SwiftLint custom rule, `no_plain_rules_engine_import`,
that flags any plain `import RulesEngine` and points the contributor at
the canonical extension-safe form. With this in place, RulesEngine's
symbols can stay plain `public` (no per-declaration SPI noise) because
the only allowed import forms strip the implicit `@_exported public`
re-export anyway:
#if compiler(>=6)
internal import RulesEngine
#else
@_implementationOnly import RulesEngine
#endif
`private import RulesEngine`, `fileprivate import RulesEngine`, and
`@testable import RulesEngine` (for the test target) are also accepted
naturally — the regex anchors on `^[ \t]*import\s+RulesEngine\b`, so any
qualifier or attribute prefix bypasses it.
Changes:
- `.swiftlint.yml`: new `no_plain_rules_engine_import` custom rule with
an actionable message that tells the contributor exactly which form
to use.
- `fastlane/Fastfile`: drop the `check_rules_engine_no_public_api`
lane. The shared `setup_swiftinterface_xcconfig` /
`cleanup_swiftinterface_xcconfig` private lanes stay — they're still
used by `generate_swiftinterface`.
- `.circleci/default_config.yml`: drop the
`check-rules-engine-public-api` job, its two workflow references
(`run-all-tests`, `release-or-main`), and the two `requires` entries
in the `all-tasks-passed` / `all-tests-succeeded` summary gates.
- `fastlane/README.md`: regenerated to drop the deleted lane.
Verified locally:
- The custom rule fires on every plain form (`import RulesEngine`,
indented variants, `import RulesEngine.Submodule`) and stays silent
on `internal` / `private` / `fileprivate` / `@_implementationOnly` /
`@_exported` / `@testable` imports, line/doc/block comments, and
unrelated modules (`RulesEngineMath`, `OtherRulesEngine`).
- `swiftlint` against the full repo (1315 files): 0 violations from
the new rule.
- `ruby -c fastlane/Fastfile` and Ruby-YAML-load of
`.circleci/default_config.yml` both pass.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Expand RulesEngine import lint to also reject @_exported / public / package
The previous regex only caught plain `import RulesEngine`, which is the
common case but not the only leaky form. There are three other ways to
import a module that re-export its public API surface, and the rule
needs to block all of them or the guarantee has a hole:
- `@_exported import RulesEngine` — explicit re-export (strictly worse
than plain `import` because it's intentional rather than implicit).
- `public import RulesEngine` — Swift 6 explicit access modifier on
imports (SE-0409); functionally identical to plain `import` once
access-on-imports is enforced.
- `package import RulesEngine` — also from SE-0409; re-exports across
the package boundary, which (for our SwiftPM setup) means every
other target in `purchases-ios` would see RulesEngine's public API.
Update the regex to `^[ \t]*(@_exported\s+)?(public\s+|package\s+)?import\s+RulesEngine\b`
and rename the rule to `no_leaking_rules_engine_import` to match the
broader scope. The message now lists all four forms so the contributor
sees exactly which one tripped them.
Allowed forms are unchanged (`internal`, `private`, `fileprivate`,
`@_implementationOnly`, `@testable`) — they each strip the implicit
`@_exported` re-export and bound visibility appropriately.
Verified locally:
- Rule now fires on 9 disallowed lines: plain `import` (with various
indentation), `import RulesEngine.Submodule`, `@_exported import` (incl.
extra whitespace variants), `public import`, `package import`, and
`@_exported public import`.
- Rule stays silent on every allowed form, comments, and unrelated
modules (`RulesEngineMath`, `OtherRulesEngine`, `publicRulesEngineHelper`,
`@_exported import RulesEngineHelpers`).
- Full-repo `swiftlint`: 0 violations across 1316 files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* TEMP: deliberately break no_leaking_rules_engine_import to verify CI
Adds two intentional violations to `Tests/RulesEngineTests/RulesEngineTests.swift`
so CI exercises the SwiftLint rule end-to-end:
- `import RulesEngine` — exercises the original plain-import case.
- `@_exported import RulesEngine` — exercises the newly-expanded
coverage from the previous commit.
Both lines must trigger `no_leaking_rules_engine_import` with severity
`error`, failing the lint job. Revert this commit before merging.
Locally `swiftlint` reports exactly these two violations and nothing
else.
Co-authored-by: Cursor <cursoragent@cursor.com>
* TEMP: keep only plain `import RulesEngine` for second CI run
Drop the `@_exported import RulesEngine` line added in the previous
commit so this push exercises only the plain-import case in isolation
and confirms the canonical violation still fails CI on its own.
Local lint reports exactly one `no_leaking_rules_engine_import` error
(line 19) plus an incidental `duplicate_imports` warning. Revert
before merging.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use real newlines in no_leaking_rules_engine_import message
Replace `\\n` escape sequences with real newlines (`\n` in YAML
double-quoted form). The previous escaped form produced literal `\n`
text in CircleCI's Tests tab — clearly visible in the JUnit XML
artifact (`failure message='... Use \`#if compiler(>=6)\\n internal
import RulesEngine\\n...\`'`).
Also reformat the suggestion so the canonical conditional-import block
appears on its own (`Use the canonical form:` followed by the snippet)
instead of trying to inline a code block, which makes the message
easier to scan even if a viewer collapses the newlines to spaces.
SwiftLint does not escape the newlines as ` ` when writing the
JUnit XML; it emits raw newline characters inside the attribute value.
That is technically valid XML but XML attribute-value normalization
usually replaces newlines with spaces on parse. The push is therefore
also a probe to see how CircleCI's Tests UI actually renders the
attribute (raw newline pass-through vs. spec-compliant normalization).
Locally the rule still fires exactly once on the plain `import
RulesEngine` (line 19) plus the incidental `duplicate_imports`
warning, and the JUnit XML now contains the multi-line snippet on
separate physical lines instead of `\\n` placeholders.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "TEMP: keep only plain `import RulesEngine` for second CI run"
This reverts commit 8cac453fae6f09210419daf31589fe97db246624.
* Revert "TEMP: deliberately break no_leaking_rules_engine_import to verify CI"
This reverts commit 0756f6d9638e058cb884f98517337b3f679c55dc.
* Drop stale fastlane/README.md changes that belong to PR #6796
The current branch's fastlane/README.md diverged from `main` in two
ways that don't belong in this PR:
1. A `### ios push_rules_engine_pod` documentation block — that
lane is part of #6796 (RulesEngine CocoaPods distribution wiring)
and does not exist in this branch's Fastfile (`grep
push_rules_engine_pod fastlane/Fastfile` → 0 matches).
2. A description tweak removing the trailing "locally" from
`### ios regenerate_swiftinterface`.
Both were left over from the `Enforce RulesEngine has no public API
outside @_spi(Internal)` work, which originally regenerated the
README from a Fastfile state that mixed in #6796's lane. The
follow-up commit that replaced that enforcement with a SwiftLint rule
(`Replace RulesEngine swiftinterface check with SwiftLint import
rule`) didn't re-regenerate the README, so the bleed-over survived.
This PR's actual change set (a custom SwiftLint rule + temp CI
verification commits, all in `.swiftlint.yml` and the
`Tests/RulesEngineTests` directory) doesn't touch any Fastlane lanes,
so the README should be byte-identical to `main`. Restoring via
`git checkout origin/main -- fastlane/README.md`.
Verified: `git diff origin/main -- fastlane/README.md` is now empty.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename `RulesEngine` module to `RulesEngineInternal`
Per @tonidero on #6787: developers can transitively `import RulesEngine`
from the SDK product even though the module is meant to be an internal
implementation detail of the SDK. Naming the module `RulesEngineInternal`
makes that intent explicit at the import site so consumers don't reach
for it accidentally and don't expect API stability from it.
This is a producer-side rename only — the previously-renamed `Rules`
namespace inside the module is unchanged, so call sites still write
`Rules.something`. Consumers' canonical extension-safe import (gated by
the SwiftLint rule in #6788) becomes:
#if compiler(>=6)
internal import RulesEngineInternal
#else
@_implementationOnly import RulesEngineInternal
#endif
Renamed (`git mv`):
- `RulesEngine/RulesEngine.swift` → `RulesEngineInternal/RulesEngineInternal.swift`
- `Tests/RulesEngineTests/RulesEngineTests.swift` → `Tests/RulesEngineInternalTests/RulesEngineInternalTests.swift`
- `Projects/RulesEngine/` → `Projects/RulesEngineInternal/` (Tuist project dir)
- `RevenueCat.xcodeproj/xcshareddata/xcschemes/RulesEngine.xcscheme` → `RulesEngineInternal.xcscheme`
Updated text references in:
- `Package.swift` / `Package@swift-5.8.swift` (target + test target names + paths)
- `Workspace.swift` (Tuist project path + comment)
- `Projects/RulesEngineInternal/Project.swift` (target / scheme / bundle IDs / source paths)
- `RevenueCat.xcodeproj/project.pbxproj` (target names, file refs, group names, build phase entries, bundle IDs `com.revenuecat.RulesEngineInternal[Tests]`)
- `RevenueCat.xcodeproj/.../xcschemes/RulesEngineInternal.xcscheme` (BlueprintName / BuildableName)
- `Tests/TestPlans/CI-AllTests.xctestplan` (target name)
- `.swiftlint.yml` (`xctestcase_superclass` exclude path)
- File header comments inside the two Swift sources
The substitutions ran as `\bRulesEngine\b → RulesEngineInternal` first
(safe because `RulesEngineTests` has no word boundary between `e` and
`T`), then `\bRulesEngineTests\b → RulesEngineInternalTests`. The
`Rules` namespace token was untouched because it isn't `RulesEngine`.
Verified locally:
- `swift build --target RulesEngineInternal` ✔
- `swift test --filter RulesEngineInternalTests` ✔ (1 test passed)
- `xcodebuild -project RevenueCat.xcodeproj -scheme RulesEngineInternal -destination 'generic/platform=iOS' -configuration Debug build` ✔
- `swiftlint` ✔ (0 violations across 1316 files)
- `git ls-files | xargs perl -ne 'if (/\bRulesEngine\b/ && !/RulesEngineInternal/) { ... }'` returns no hits.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Update SwiftLint custom rule for `RulesEngineInternal` rename
Mirror the producer-side rename from #6787:
- Rule id: `no_leaking_rules_engine_import` → `no_leaking_rules_engine_internal_import`.
- Rule name: "No leaking `import RulesEngine`" → "No leaking `import RulesEngineInternal`".
- Regex: `import\s+RulesEngine\b` → `import\s+RulesEngineInternal\b`.
- Violation message: every reference to `RulesEngine` (in disallowed-form
examples, the canonical extension-safe import block, and the
`private` / `fileprivate` fallbacks) updated to `RulesEngineInternal`.
Verified locally:
- Positive cases (`/tmp` test file): the rule fires on `import RulesEngineInternal`,
`@_exported import RulesEngineInternal`, `public import RulesEngineInternal`,
`package import RulesEngineInternal`, and indented `import RulesEngineInternal`.
- Negative cases: `internal` / `private` / `fileprivate` / `@_implementationOnly` /
`@testable` imports, the `#if compiler(>=6) internal import ... #else @_implementationOnly ... #endif`
block, commented-out imports, unrelated modules
(`RulesEngineInternalMath`, `OtherRulesEngineInternal`), and a plain
`import RulesEngine` (the pre-rename name) all stay silent.
- `swiftlint --no-cache` against the full repo: 0 violations across 1316 files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Update arithmetic operator test imports for `RulesEngineInternal` rename
`Tests/RulesEngineInternalTests/ArithmeticOperatorsTests.swift` was
moved into the renamed `Tests/RulesEngineInternalTests/` directory by
git's directory-rename detection during the merge from #6789, but its
`@testable import RulesEngine` line was added in this branch (after
the directory rename was set up upstream) and still referenced the old
module name. Update it to `@testable import RulesEngineInternal` so
the file matches every other test in the target.
Verified: `swift test --filter RulesEngineInternalTests.ArithmeticOperatorsTests` ✔
(21 tests passed); `swiftlint --no-cache` ✔ (0 violations across 1325 files).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Update comparison operator test imports for `RulesEngineInternal` rename
`Tests/RulesEngineInternalTests/ComparisonOperatorsTests.swift` was
moved into the renamed `Tests/RulesEngineInternalTests/` directory by
git's directory-rename detection during the merge from #6791, but its
`@testable import RulesEngine` line was added in this branch and still
referenced the old module name. Update it to
`@testable import RulesEngineInternal`.
Verified: `swift test --filter RulesEngineInternalTests.ComparisonOperatorsTests` ✔;
`swiftlint --no-cache` ✔ (0 violations across 1327 files).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Update string + array operator test imports for `RulesEngineInternal` rename
`Tests/RulesEngineInternalTests/StringArrayOperatorsTests.swift` was
moved into the renamed test directory by git's directory-rename
detection during the merge from #6792, but its `@testable import
RulesEngine` line was added in this branch and still referenced the
old module name. Update it to `@testable import RulesEngineInternal`.
Verified: `swift test --filter RulesEngineInternalTests.StringArrayOperatorsTests` ✔;
`swiftlint --no-cache` ✔.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Treat null and empty-string leaves as missing per JSON Logic spec
`opMissing` only flagged keys whose path failed to resolve. The
json-logic-js reference routes through `var` and treats any key whose
lookup resolves to `null` (key absent OR leaf is `null`) or to the
empty string as missing — so `{"missing": ["country"]}` against
`{"country": null}` is supposed to return `["country"]`, but our impl
returned `[]`. Backend payloads regularly come down with explicitly
cleared fields as `null`, so the divergence was reachable from real
predicates.
`opMissing` now uses the same lookup `var` does (without the
missing-variable warning, since `missing` is a check, not a read) and
matches against the spec's `value === null || value === ""` test.
Falsy non-empty values (`0`, `false`, `[]`) are NOT missing — pinned
by a regression test so a future truthiness-based simplification can't
silently flip them.
Reported by Cursor Bugbot:
#6789 (comment)
Co-authored-by: Cursor <cursoragent@cursor.com>
* Coerce arrays/objects to JS strings in `==` to match the spec
`looseEq` only had explicit cases for primitives and same-compound
operands, so `[1] == "1"` and `[1, 2] == "1,2"` returned `false` —
divergent from json-logic-js, which inherits JS abstract equality:
when one side is a compound (Array/Object) and the other is a
primitive, the compound goes through ToPrimitive (string hint) and
the comparison is retried.
Add four cross-type arms to `looseEq` that mirror exactly that
coercion: arrays render via `Array.prototype.toString()` (recursive
comma-join, with `null` elements as the empty string); objects render
as `"[object Object]"`. The recursive call falls through to the
existing primitive arms — string-vs-string for `[1, 2] == "1,2"`,
the numeric fallback for `[] == 0` / `[1] == 1`. Same-compound
comparisons keep their structural-eq behavior (deliberate divergence
from JS reference identity, which would make rule patterns like
`{"==": [{"var": "tags"}, ["a", "b"]]}` always false).
Sharpen the `looseEq` doc comment to spell out the four behaviors
(same-type, cross-numeric, same-compound structural, compound-vs-
primitive ToPrimitive) and the JS reference identity divergence.
Pin the new behavior with `ValueTests` cases covering each spec
example (`[1] == "1"`, `[1, 2] == "1,2"`, `[null, 1] == ",1"`,
`[] == ""`, nested arrays, the numeric fallback path, JS-specific
float spellings, the object → "[object Object]" coercion, and the
array-vs-object cross-compound case). Two `EvaluatorTests` cases
pin the same coercion through the full `Evaluator.evaluate` path so
predicate authors get the spec-aligned behavior end-to-end.
Also annotate `testMultiKeyObjectIsLiteralDataValue` to call out the
json-logic-js `is_logic` parity (multi-key objects fall back to the
"return as-is" branch in `apply`), so a future reader doesn't pattern
-match it onto a "dispatch first key, ignore extras" misreading.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Catch kind-qualified imports and ignore comments in RulesEngine lint
Address two review comments on #6788 from @rickvdl:
1. The previous regex caught plain `import RulesEngineInternal` plus the
`@_exported` / `public` / `package` variants, but missed
per-declaration imports like `import struct RulesEngineInternal.X`
(also `class` / `func` / `enum` / `protocol` / `typealias` / `var`
/ `let`). Those forms are implicitly `@_exported` by default and
re-export the named symbol into the importing module's public API
surface, so they're the same leak as a plain `import` — the rule
needs to block them too.
Extend the regex with an optional kind-prefix group right after
`import`:
^[ \t]*(@_exported\s+)?(public\s+|package\s+)?import\s+
(struct\s+|class\s+|func\s+|enum\s+|protocol\s+|typealias\s+|
var\s+|let\s+)?RulesEngineInternal\b
The kind group is optional, so all existing leaky forms still match.
Update the violation message to mention the kind-qualified form so
the suggestion the contributor sees lists everything that's
forbidden.
2. Add `match_kinds` so SwiftLint only fires the rule when the regex
match actually overlaps source tokens (not comments / strings).
This eliminates the small false-positive risk of the rule
triggering on a commented-out `import RulesEngineInternal` line.
The kinds list is `identifier` (module name), `keyword` (`import`,
`struct`, `public`, `package`, etc.), and `attribute.builtin`
(`@_exported`). The last one is critical: copying the
`[identifier, keyword]` pair from `avoid_using_directory_apis_directly`
silently breaks every `@_exported …` case, because SourceKit
classifies `@_exported` as `attribute.builtin`, not `keyword`. Caught
during local verification before pushing.
Verified locally:
- Positive (constructed temp file, 19 disallowed forms covering plain /
indented / `.Submodule` / `@_exported` / `public` / `package` /
`@_exported public` / 8 kind-qualified variants / `@_exported` +
kind-qualified + `public` / `package` + kind-qualified): all 19 lines
flagged.
- Negative (`internal` / `private` / `fileprivate` /
`@_implementationOnly` / `@testable` imports, the canonical
`#if compiler(>=6) … #endif` block, kind-qualified `internal` /
`private` / `@_implementationOnly` imports, line / doc / block
comments containing every disallowed form, unrelated modules
`RulesEngineInternalMath` / `OtherRulesEngineInternal` /
`RulesEngineInternalHelpers` including `@_exported` and
kind-qualified variants): 0 flagged.
- `swiftlint --no-cache` against the full repo: 0 violations across
1316 files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* RulesEngine: pin single-key-object-as-operand parity with json-logic-js
Locks in the observed runtime behavior for `{"==": [{"a":1}, {"a":1}]}`:
both our engine and json-logic-js dispatch single-key objects as
operators via `is_logic`/`evaluateValue`, so this literal predicate
throws `RuleError.unsupportedOperator("a")` instead of doing a
structural compare. Pins the contrast with the existing multi-key
test (where the structural-vs-reference divergence still applies) so
a future operator-dispatch refactor can't quietly regress this.
Co-authored-by: Cursor <cursoragent@cursor.com>
* AccessorOperatorsTests: restore previous Rules.logger in tearDown
The class installs a `CapturingLogger` into the module-global
`Rules.logger` from `setUp`, but `tearDown` only released the local
reference, leaving the test's `CapturingLogger` installed for the
rest of the process. Mirror the save/restore pattern that
`Rules.withLogger(_:)` uses so the previous logger is reinstated when
the test class is done.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "AccessorOperatorsTests: restore previous Rules.logger in tearDown"
This reverts commit daaa11a82ac277e0c16744cacffa48ad6fec552b.
* Pin null-operand handling for arithmetic operators
Adds a single test asserting that `null` is treated as `0` across
`+`, `-`, `*`, `/`, `%`, including the unary `-` form and the
1-arg numeric-cast `+`. Comment block explains the deliberate
deviation from json-logic-js for `+` / `*` (JS uses `parseFloat`,
which makes `parseFloat(null) === NaN`); the other three already
match JS exactly.
Addresses review comment from @rickvdl on PR #6791.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Arithmetic: match json-logic-js coercion exactly
`json-logic-js` is asymmetric about which JS coercion arithmetic uses:
`+` and `*` go through `parseFloat(value)` (stringify, parse longest
numeric prefix); `-`, `/`, `%` go through native arithmetic which calls
`Number(value)` (a.k.a. `ToNumber`). The prior implementation routed
every operand through `Value.asNumber` (a partial `ToNumber`), which
diverged from the spec in two directions:
- For `+` / `*`: `null`, bools, the empty string, and `[1]` produced
numbers instead of `NaN`.
- For `-` / `/` / `%`: arrays produced `NaN` instead of being coerced
via `ToPrimitive("number")` → `toString` → recurse, so `[] - 1` and
`[1] - 1` were `NaN` instead of `-1` and `0`.
Add `jsString(value)` (top-level JS `String()`) and `jsParseFloat(value)`
to `Value.swift`, extend `Value.asNumber` to coerce arrays/objects via
`jsString`, and route `+` / `*` through `jsParseFloat` while leaving
`-` / `/` / `%` on `asNumber ?? .nan`. The existing
divide-/modulo-by-zero short-circuit (`.null` instead of `±Infinity` /
`NaN`) is the only remaining intentional deviation in this file and is
documented as such.
Tests:
- Replace `testNullOperandIsTreatedAsZero` with two coercion-path tests
that pin both branches against the spec, including the array cases
(`[1] + 1 = 2`, `[1,2] + 0 = 1`, `[] - 1 = -1`, `[1] - 1 = 0`,
`[1,2] - 0 = NaN`).
- Update `testAddOneArgActsAsNumericCast` and split
`testAddCoercesStringsAndBools` into `testAddCoercesNumericStrings`
to reflect that bools no longer bridge through `+` / `*`
(`parseFloat("true") === NaN`) but trailing junk in numeric strings
now parses (`parseFloat("3.14abc") === 3.14`).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Arithmetic: drop divide-/modulo-by-zero short-circuit
The previous implementation intercepted `n / 0` and `n % 0` and returned
`.null`, on the rationale that `null` is friendlier for rule authors and
matches the engine's "missing value" convention. That's the last
remaining intentional deviation from `json-logic-js` in this file —
remove it for full spec parity.
`json-logic-js` delegates `/` and `%` to native JS arithmetic, which
follows IEEE 754: `n / 0` is `±Infinity` (sign matches the dividend),
`0 / 0` is `NaN`, and any `n % 0` is `NaN`. Swift's `Double` operators
and `truncatingRemainder(dividingBy:)` already produce these IEEE
values, so the fix is just dropping the divisor==0 short-circuit and
wrapping the result in `.float`.
Tests:
- Replace `testDivByZeroReturnsNull` with `testDivByZeroFollowsIeee754`
pinning all three cases (`+Infinity`, `-Infinity`, `NaN`).
- Replace `testModByZeroReturnsNull` with `testModByZeroIsNan` covering
`7 % 0` and `0 % 0`.
- Update the `1 / null` / `1 % null` cases inside
`testSubDivAndModUseToNumberPerSpec` (divisor coerces to 0 →
`+Infinity` / `NaN` instead of `.null`).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim verbose hypothetical justifications from operator doc comments
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim verbose hypothetical justifications from arithmetic doc comments
Also pin the IEEE 754 div-by-zero behavior (introduced in e32933780)
in the evaluator-level test.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim verbose hypothetical justifications from comparison doc comments
Co-authored-by: Cursor <cursoragent@cursor.com>
* Trim verbose hypothetical justifications from string/array doc comments
Co-authored-by: Cursor <cursoragent@cursor.com>
* Align arithmetic operator arity behavior with json-logic-js spec
- {"+": []} returns 0 (matches `Array.prototype.reduce(fn, 0)`).
- {"*": [a]} returns the operand unchanged (single-arg reduce without
seed never invokes the reducer, so no `parseFloat` coercion).
- {"-": []}, {"/": []}, {"%": []} return NaN (missing operands act
as JS `undefined`); extra operands past the first two are ignored to
match `function(a, b)` argument truncation.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Render null array elements as empty in cat stringification
Mirrors `Array.prototype.join` (used by `String([...])`), which
substitutes `null` and `undefined` elements with the empty string,
so `cat` of `[1, null, 2]` is now `"1,,2"` instead of `"1,null,2"`.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Match comparison-operator arity with json-logic-js
`<`, `<=`, `>`, `>=` no longer throw on missing or extra operands.
Missing operands stand in for JS `undefined` (coerced to `NaN`, so
any comparison is `false`); arguments past the named parameters
(`(a, b)` for `>` / `>=`, `(a, b, c)` for `<` / `<=`) are silently
dropped, matching the JS reference's `function(a, b[, c])` signatures.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Align in operator with json-logic-js spec
Array membership now uses strict equality (mirrors JS
`Array.prototype.indexOf`), substring lookup stringifies a
non-string needle (mirrors JS `String.prototype.indexOf`), and the
operator no longer throws on arity mismatch — missing/extra operands
return `false` per the JS reference's `function(a, b)` signature.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Match substr arity with j…
### Checklist - [x] If applicable, unit tests ### Motivation Resolves SDK-4336 Adds a `:rules-engine-internal` module so the upcoming JSON Logic engine can ship as a separate library. Sibling iOS PR: RevenueCat/purchases-ios#6787. ### Description - Skeleton module with an internal `RulesEngine` namespace, smoke test, and Maven publishing deferred until the engine has a consumer ([RevenueCat#3488](RevenueCat#3488)). - Module and artifact named `rules-engine-internal` / `purchases-rules-engine-internal` to make it clear that the API is unstable and not meant to be reached for directly, mirroring iOS' `RulesEngineInternal`. - Metalava and Dokka are also skipped for this module since it has no public-facing API for third-party developers. - New `test-rules-engine-internal` CircleCI job runs `:rules-engine-internal:testDefaultsDebugUnitTest`. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Scaffolding-only module and build/CI wiring with no runtime behavior or public API surface changes to the main SDK. > > **Overview** > Adds a new **`:rules-engine-internal`** Android library (artifact `purchases-rules-engine-internal`) as a placeholder for the upcoming JSON Logic rules engine, aligned with iOS’ internal rules module naming. > > The module only exposes an **`internal` `RulesEngine` namespace** plus a smoke unit test. **Maven publishing, Metalava, and Dokka are explicitly skipped** for this path until there is a real consumer; Dokka application is refactored behind **`configureDokka()`** on the public-library convention plugin. > > **CircleCI** gains **`test-rules-engine-internal`** (`:rules-engine-internal:testDefaultsDebugUnitTest`), wired into the main workflow and the release approval gate. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2d7bf9d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>

Resolves SDK-4335
Checklist
purchases-androidand hybridsMotivation
Adds a
RulesEngineInternalframework so the upcoming JSON Logic engine can ship as a separate library. Sibling Android PR: RevenueCat/purchases-android#3478.Description
RulesEngineInternalframework + smoke test wired across SPM (target only, no.libraryproduct), the legacy.xcodeproj, Tuist (Projects/RulesEngineInternalwith framework + tests, scheme runs the tests), and theCI-AllTeststest plan. No rules logic yet.RulesEnginenamespace is internal for now; it will become public once wired into the SDK by a future PR.RulesEngineInternalto make it clear that the API is unstable and not meant to be reached for directly.Note
Low Risk
Scaffolding-only changes (new internal module, build graph, and a smoke test) with no runtime behavior or public SDK API impact.
Overview
Introduces a new
RulesEngineInternalframework as build-only scaffolding for an upcoming JSON Logic rules engine, without exposing it as an SPM library product or wiring it intoRevenueCat/RevenueCatUIyet.The module currently contains only a
RulesEnginenamespace enum plus a smoke unit test. It is registered across SPM (Package.swiftandPackage@swift-5.8.swift),RevenueCat.xcodeproj(framework + test targets, shared scheme), Tuist (Projects/RulesEngineInternal), andCI-AllTests.Workspace.swiftalways includes the Tuist project so the scheme can be built without duplicate-framework conflicts with SPM products. SwiftLint exemptsTests/RulesEngineInternalTestsfrom theTestCasesuperclass rule.Reviewed by Cursor Bugbot for commit 79ba8cf. Bugbot is set up for automated code reviews on this repo. Configure here.