Skip to content

[FINAL] Add handling for deferring a promotional payment#10

Merged
jeiting merged 5 commits into
masterfrom
feature/deferment-block
Jan 22, 2018
Merged

[FINAL] Add handling for deferring a promotional payment#10
jeiting merged 5 commits into
masterfrom
feature/deferment-block

Conversation

@jeiting

@jeiting jeiting commented Jan 22, 2018

Copy link
Copy Markdown
Contributor

Apple is very specific about using the same payment object for deferring a promotional purchase:
screen shot 2018-01-22 at 9 54 27 am

Since RCPurchases only has an API for adding products, and not payments, I'm passing a defermentBlock to the delegate that clients can save and call when they are ready.

@jeiting jeiting merged commit b3d684e into master Jan 22, 2018
@jeiting jeiting deleted the feature/deferment-block branch January 22, 2018 18:07
ajpallares added a commit that referenced this pull request May 21, 2026
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>
ajpallares added a commit that referenced this pull request May 29, 2026
…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 `&#10;` 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>
ajpallares added a commit that referenced this pull request May 29, 2026
* 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 `&#10;` 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>
ajpallares added a commit that referenced this pull request May 29, 2026
* 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 `&#10;` 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…
ajpallares added a commit that referenced this pull request May 29, 2026
* 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 `&#10;` 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…
ajpallares added a commit that referenced this pull request Jun 2, 2026
* 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 `&#10;` 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…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant