Skip to content

fix: all tests passing (317/317 unit, 180/183 cross-SDK)#7

Open
joalves wants to merge 9 commits intomainfrom
fix/all-tests-passing
Open

fix: all tests passing (317/317 unit, 180/183 cross-SDK)#7
joalves wants to merge 9 commits intomainfrom
fix/all-tests-passing

Conversation

@joalves
Copy link

@joalves joalves commented Feb 21, 2026

Summary

  • Fix cross-SDK test compatibility issues (PHP 8.4 deprecation, test expectations)
  • Fix variable exposure queuing when variables exist regardless of key
  • Fix traffic seed property names for eligibility check
  • Add comprehensive test coverage (169 -> 317 tests) for canonical test parity
  • Correct HTTPClient class name in docs
  • Fix InOperator argument order (needle, haystack) and loose type comparison
  • Fix MatchOperator regex delimiter conflict with forward slashes
  • Fix post-finalize error message to match cross-SDK expectations

Test Results

  • Unit Tests: 317 passed / 317 total, exit code 0
  • Cross-SDK Tests (138 scenarios): 138 passed / 138 total, exit code 0
  • Cross-SDK Tests (183 scenarios): 180 passed / 183 total, exit code 0
    • 3 failures are Async Context DNS resolution (standalone container, not in Docker network) - not real bugs

Changes

  • src/Assignment.php - Default property initialization for PHP 8.4
  • src/Context/Context.php - Fix exposure queuing, traffic seed, publish flush, finalize error message
  • src/Context/ContextEventLoggerEvent.php - Add missing event type
  • src/Experiment.php - Fix experiment data parsing
  • src/Http/HTTPClient.php - Fix class naming
  • src/JsonExpression/Operator/InOperator.php - Fix argument order (lhs=needle, rhs=haystack), loose comparison
  • src/JsonExpression/Operator/MatchOperator.php - Use ~ as regex delimiter instead of /
  • tests/Context/ContextTest.php - Major test expansion (169 -> 317 tests)
  • tests/JsonExpression/Operator/InOperatorTest.php - Updated for correct argument order
  • tests/AudienceMatcherTest.php - New tests
  • tests/Client/ClientConfigTest.php - New tests
  • tests/MD5Test.php - New tests
  • tests/Murmur3Test.php - New tests
  • tests/VariantAssignerTest.php - Updated tests
  • tests/Fixtures/json/ - New test fixture data

Test plan

  • All 317 unit tests pass with exit code 0
  • All 138 cross-SDK tests pass with exit code 0
  • 180/183 expanded cross-SDK tests pass (3 async DNS failures in standalone mode)
  • PHP 8.4 compatibility verified

Summary by CodeRabbit

  • New Features

    • Custom fields support with a public accessor for experiment custom values
    • Async ReactPHP HTTP client and platform-specific examples (Laravel, Symfony)
    • Public context accessors: pending(), getUnit(), getUnits()
    • Finalize added as a lifecycle event
  • API Updates

    • Main SDK class renamed to ABsmartly (deprecated SDK alias kept)
    • Context methods standardised to camelCase (track(), close())
    • Public configuration and event-logger surfaces clarified
  • Tests

    • Expanded and new test suites and fixtures for hashing, matching and assignment logic

- Add customFieldValue() method to Context for custom field access
- Add Finalize event constant to ContextEventLoggerEvent
- Add customFieldValues property to Experiment class
- Fix Assignment class: make variables nullable with null default
- Fix Assignment class: set exposed default to false
- Fix getAssignment() to use array_key_exists() for override check (fixes variant 0)
- Fix audienceStrict check from isset() to !empty() (scenario 44 fix)
- Fix getVariableValue() to check variables before queueing exposure
- Fix implicit nullable parameter deprecation in HTTPClient::setupRequest
- Update test expectations for Finalize event (was incorrectly expecting Close)
- Fix type coercion in tests - SDK correctly preserves numeric attribute types
Match JavaScript SDK behavior: queue exposure when assignment.variables is
not null, regardless of whether the specific variable key exists. Only
return the variable value if the key exists AND the user is assigned or
has an override.

This fixes exposure tracking for cases where the user is assigned a variant
that doesn't contain the specific variable being requested.
Change seedHi/seedLo to trafficSeedHi/trafficSeedLo to match the
experiment data structure for traffic split assignment.
- Add failed state testing
- Add attribute management tests
- Add timeout/retry configuration tests
- Add error handling tests
- Add event handler scenario tests
- Add integration scenario tests

Total: 30 new tests added, all 142 tests pass
Create MD5Test (14), Murmur3Test (36), AudienceMatcherTest (3)
with parameterized data providers. Refactor VariantAssignerTest
to 42 individual cases. Add ~76 context tests covering event
logger, exposure queuing, variable values, publish lifecycle,
refresh cache invalidation, custom fields, and audience matching.
@coderabbitai
Copy link

coderabbitai bot commented Feb 21, 2026

Walkthrough

This pull request renames the public SDK entry to ABsmartly (keeps deprecated SDK alias); updates docs and examples to use ABsmartly::createWithDefaults and camelCase context methods (track(), close()); adds attrsSeq attribute/versioning to Assignment and Context, plus pending(), customFieldValue(), getUnit(), getUnits() accessors; changes assignment caching and audience re-evaluation logic; replaces Close event with Finalize (added constant); allows nullable assignment variables and HTTP request data; adds ReactHttpClient examples; and expands test coverage and fixtures (audience matcher, MD5, Murmur3, refreshed fixtures, many Context tests).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hopped through code, from SDK to ABsmartly bright,
I nudged attrsSeq, made events end with Finalize light,
I planted tests, fixtures, and hashes in a row,
Now contexts count and audiences can grow—
A tiny rabbit cheers the commit tonight!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately reflects the primary objective: ensuring all unit and cross-SDK tests pass. The title is specific, concise, and directly summarizes the main change.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/all-tests-passing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (9)
src/Http/HTTPClient.php (2)

123-133: Consider migrating to CURLOPT_PROTOCOLS_STR / CURLOPT_REDIR_PROTOCOLS_STR.

CURLOPT_PROTOCOLS has been deprecated since libcurl 7.85; PHP does not emit deprecation notices, but on PHP builds with libcurl 7.85 or later, CURLOPT_PROTOCOLS_STR (available since PHP 8.3) is the recommended replacement. The code comment on line 123 even links to the php.watch article that documents exactly this migration. Since the PR already targets PHP 8.4, the replacement constants are unconditionally available.

Similarly, CURLOPT_REDIR_PROTOCOLS is also deprecated since libcurl 7.85, and on PHP 8.3+, CURLOPT_REDIR_PROTOCOLS_STR is the replacement.

♻️ Proposed migration to string-based protocol constants
-            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, // Always use HTTPS, never HTTP
-            CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
+            CURLOPT_PROTOCOLS_STR => 'HTTPS', // Always use HTTPS, never HTTP
+            CURLOPT_REDIR_PROTOCOLS_STR => 'HTTPS',

The corresponding use const imports at the top of the file should also be updated accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/HTTPClient.php` around lines 123 - 133, The code uses deprecated
numeric protocol options CURLOPT_PROTOCOLS and CURLOPT_REDIR_PROTOCOLS in
curl_setopt_array; replace them with the string-based constants
CURLOPT_PROTOCOLS_STR and CURLOPT_REDIR_PROTOCOLS_STR (available in PHP 8.3+)
and set their values to 'https' (or the equivalent string protocol list you
need), while keeping the other curl options intact in the curl_setopt_array call
(referencing the curl_setopt_array call and these constant names). Also update
the corresponding use const imports at the top of the file to import
CURLOPT_PROTOCOLS_STR and CURLOPT_REDIR_PROTOCOLS_STR instead of the deprecated
constants.

27-27: Dead use const import — CURLOPT_FAILONERROR is never referenced.

The constant is imported but not passed to any curl_setopt* call; error detection is handled manually in throwOnError(). The import can be removed to keep the namespace tidy.

♻️ Proposed cleanup
-use const CURLOPT_FAILONERROR;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/HTTPClient.php` at line 27, Remove the dead import of the CURL
constant: delete the `use const CURLOPT_FAILONERROR;` line from HTTPClient.php
(the class HTTPClient) since `CURLOPT_FAILONERROR` is never used; confirm there
are no `curl_setopt*` calls referencing that constant and that error handling
remains in `throwOnError()` so behavior is unchanged.
README.md (2)

573-575: Timeout callback does not cancel the in-flight context fetch.

The addTimer callback only prints a message; the underlying HTTP request for context data continues regardless. Users following this example should understand that the timer provides a UI/flow timeout, not a network-level cancellation. A brief inline comment would prevent misuse.

♻️ Suggested clarification
 $timeout = Loop::addTimer(1.5, function() use ($context) {
-    echo "Context creation timed out\n";
+    // Note: this cancels the *wait*, not the in-flight HTTP request.
+    // The request will still complete in the background.
+    echo "Context creation timed out\n";
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 573 - 575, Add an inline clarification next to the
Loop::addTimer call (and mention the $timeout variable/closure) that this timer
only provides a UI/flow timeout and does not cancel any in-flight HTTP request
used to fetch $context; indicate that to actually cancel a network request the
example must use a cancellable HTTP client or abortable request handle (e.g.,
cancel token/abort controller or closing the request handle) rather than relying
on the timer alone.

213-214: Callback parameter $context shadows the outer variable.

The $context closure parameter reuses the same name as the enclosing scope's $context, which can confuse readers.

♻️ Suggested rename
-    function($context) {
+    function($ctx) {
         // Context is ready
-        $treatment = $context->getTreatment('exp_test_experiment');
+        $treatment = $ctx->getTreatment('exp_test_experiment');
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 213 - 214, The closure passed to
$context->ready()->then currently declares a parameter named $context which
shadows the outer $context; rename the closure parameter (e.g., to $ctx or
$readyContext) in the function signature and update all references inside that
closure to the new name so the outer $context variable is not shadowed (ensure
consistent rename of the parameter in the anonymous function passed to
ready()->then and any uses within that function body).
tests/AudienceMatcherTest.php (1)

21-27: Redundant assertion in testShouldReturnNullIfFilterNotObjectOrArray.

The second assertion (line 25–26, new stdClass() with no filter property) is identical to the body of testShouldReturnNullOnEmptyAudience. The stated purpose of this test is a null/non-object filter value; removing the duplicate assertion keeps each test focused on a single invariant.

♻️ Proposed cleanup
 public function testShouldReturnNullIfFilterNotObjectOrArray(): void {
     $audience = (object) ['filter' => null];
     self::assertNull($this->matcher->evaluate($audience, []));
-
-    $audience2 = new stdClass();
-    self::assertNull($this->matcher->evaluate($audience2, []));
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/AudienceMatcherTest.php` around lines 21 - 27, The test method
testShouldReturnNullIfFilterNotObjectOrArray contains a redundant assertion:
remove the second assertion that creates $audience2 = new stdClass() and asserts
null on $this->matcher->evaluate($audience2, []), because that case is already
covered by testShouldReturnNullOnEmptyAudience; keep only the first assertion
using (object) ['filter' => null] so the test focuses on a null/non-object
filter value.
src/Context/Context.php (3)

77-79: Duplicate public accessors: pending() and getPendingCount() both return $this->pendingCount.

If pending() is added for cross-SDK API parity, consider deprecating getPendingCount() (or vice versa) so the public surface doesn't offer two identical methods indefinitely.

Also applies to: 597-599

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Context/Context.php` around lines 77 - 79, The class exposes two
identical public accessors pending() and getPendingCount() that both return
$this->pendingCount; choose one canonical API (e.g., keep pending() for
cross-SDK parity) and mark the other as deprecated: update the non-canonical
method (getPendingCount) with a `@deprecated` PHPDoc pointing callers to
pending(), have it simply return $this->pendingCount (or call pending()) to
preserve behavior, and apply the same change to the duplicate pair mentioned
(the other copy around the getPendingCount/pending definitions). Ensure both
methods remain consistent and add a TODO or ticket reference if you want to
remove the deprecated method later.

323-327: Indentation is inconsistent on the $eligible assignment.

Line 323 has an extra level of indentation compared to its surroundings.

Fix indentation
-				$eligible = $assigner->assign(
+				$eligible = $assigner->assign(
 					$experiment->data->trafficSplit,
 					$experiment->data->trafficSeedHi,
 					$experiment->data->trafficSeedLo
 				);

Note: the diff above is hard to show purely in text — the fix is to remove the leading extra tab on line 323 so it aligns with the surrounding code at the same nesting level.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Context/Context.php` around lines 323 - 327, Fix the inconsistent
indentation on the $eligible assignment: remove the extra leading tab/spaces
before " $eligible = $assigner->assign(...)" so that the $eligible line aligns
with the surrounding statements at the same nesting level; locate the block
where $assigner->assign($experiment->data->trafficSplit,
$experiment->data->trafficSeedHi, $experiment->data->trafficSeedLo) is called
and adjust indentation to match its neighboring lines.

202-240: customFieldValue lacks a return type declaration and silently swallows malformed JSON.

Two observations:

  1. Missing return type — the method can return string, int, float, bool, array, or null. A mixed return type would make the contract explicit.
  2. Line 225json_decode($value, true) returns null on invalid JSON with no indication of failure. Consider checking json_last_error() or using JSON_THROW_ON_ERROR (as done in Experiment::__construct) so callers can distinguish "field not found" (null) from "unparseable JSON".
Proposed improvement
-	public function customFieldValue(string $experimentName, string $fieldName) {
+	public function customFieldValue(string $experimentName, string $fieldName): mixed {
 		$experiment = $this->getExperiment($experimentName);
 		...
 		if ($type === 'json' && is_string($value)) {
-			return json_decode($value, true);
+			return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Context/Context.php` around lines 202 - 240, Add an explicit return type
to customFieldValue (use ": mixed") and make JSON decoding strict: when decoding
$experiment->data->customFieldValues and when decoding a field value of type
'json' in customFieldValue, call json_decode with the JSON_THROW_ON_ERROR flag
(or use the equivalent options) inside a try/catch for \JsonException so
malformed JSON doesn't silently return null; allow the exception to propagate or
rethrow it so callers can distinguish "not found" (null) from "unparseable
JSON". Ensure you reference the customFieldValue method and use \JsonException
(or import JsonException) when updating the json_decode calls.
tests/Context/ContextTest.php (1)

1625-1632: Minor: $context is assigned but never used.

The return value of createReadyContext() is not referenced after assignment. You can call the method without capturing the result to silence the static analysis warning.

Proposed fix
 	public function testCallsEventLoggerOnSuccess(): void {
 		$eventLogger = new MockContextEventLoggerProxy();
-		$context = $this->createReadyContext('context.json', true, $eventLogger);
+		$this->createReadyContext('context.json', true, $eventLogger);

 		self::assertSame(1, $eventLogger->called);
 		self::assertSame(ContextEventLoggerEvent::Ready, $eventLogger->events[0]->getEvent());
 		self::assertInstanceOf(\ABSmartly\SDK\Context\ContextData::class, $eventLogger->events[0]->getData());
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/Context/ContextTest.php` around lines 1625 - 1632, In
testCallsEventLoggerOnSuccess the variable $context is assigned but never used
causing a static analysis warning; remove the unused assignment by calling
createReadyContext('context.json', true, $eventLogger) without capturing its
return value (i.e., drop "$context =") inside the testCallsEventLoggerOnSuccess
method so the call still triggers side effects on MockContextEventLoggerProxy
while eliminating the unused variable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@README.md`:
- Around line 384-401: In handle(), ensure the Context created by
$this->sdk->createContext($contextConfig) is always closed by wrapping the call
to $next($request) in a try/finally: call $response = $next($request) inside try
and invoke $context->close() in finally so buffered exposures/goals are flushed
even if $next throws, then return or rethrow as appropriate; update references
around handle, createContext and $context->close() to guarantee deterministic
cleanup.
- Line 270: The sentence about overriding a unit type is missing a comma before
the coordinating conjunction; update the README sentence mentioning
Context->setUnit() and Context->setUnits() to read "...as that would be a change
of identity, and would throw an exception." so the comma is inserted before
"and" to fix the grammar.
- Around line 176-184: The README event table is missing the newly added
Finalize event from ContextEventLoggerEvent; add a new table row for the
`Finalize` event showing when it fires (e.g., when `Context->finalize()` method
succeeds) and what data it carries (e.g., `null` or the relevant finalization
payload), so the table documents the `Finalize` constant alongside the existing
`Close` row.
- Around line 199-223: The async README example uses the nonexistent
Context::ready() promise method and must be replaced or removed; update the
snippet that calls $context->ready()->then(...) to use the synchronous API:
after $context = $sdk->createContext($contextConfig) poll or check
$context->isReady() and then call $context->getTreatment('exp_test_experiment')
once isReady() returns true (or show a loop/conditional example), and rename the
inner callback parameter (e.g., from $context to $readyContext) to avoid
shadowing the outer $context variable; alternatively remove the entire async
example and keep only synchronous usage demonstrating createContext, isReady(),
and getTreatment.

In `@src/Context/ContextEventLoggerEvent.php`:
- Around line 15-16: The Close constant in ContextEventLoggerEvent is now unused
and should either be removed or marked deprecated; update the Close constant by
adding a PHP docblock with `@deprecated` pointing consumers to use Finalize
instead (e.g., above the Close constant add "@deprecated Use
ContextEventLoggerEvent::Finalize"), or if you accept breaking changes, remove
the public const Close declaration entirely and ensure no references remain;
keep the Finalize constant unchanged.

In `@src/Http/HTTPClient.php`:
- Line 46: The class currently exposes public int $timeout = 3000 but only
applies it to CURLOPT_CONNECTTIMEOUT_MS (TCP handshake) — add a total-transfer
timeout by setting CURLOPT_TIMEOUT_MS using the same (or a new) property;
specifically, in the code path where CURLOPT_CONNECTTIMEOUT_MS is configured
(and in the other block referenced around lines 124-134) add CURLOPT_TIMEOUT_MS
=> $this->timeout (or introduce separate $connectTimeout and $transferTimeout
properties and set CURLOPT_CONNECTTIMEOUT_MS => $this->connectTimeout and
CURLOPT_TIMEOUT_MS => $this->transferTimeout), and update the property
name/comments to clearly state the unit is milliseconds and that it bounds the
full request (DNS+connect+send+receive).
- Line 45: The public property $retries on class HTTPClient is dead API
surface—remove it (delete the property declaration "public int $retries = 5;")
and any related references or docblocks in the HTTPClient class so callers don't
get a misleading configurable field; alternatively if you prefer to keep
retries, implement retry logic in the request/sending path (e.g., in sendRequest
or request methods) that respects a configured retry count and backoff, but do
not leave the public $retries field declared without behavior.

In `@tests/Context/ContextTest.php`:
- Around line 1545-1564: The test testAttributeUpdatesInTemplates currently
assigns $treatmentBefore and $treatmentAfter from getTreatment but never uses
them; either remove those unused local variables or (if the intent is to verify
the template behavior when age changes) add an assertion comparing them—e.g.,
after calling setAttribute('age', 15) and later setAttribute('age', 25) and
calling getTreatment('exp_test_ab'), assertNotEquals($treatmentBefore,
$treatmentAfter) (or assertEquals if you expect no change) so the variables are
meaningfully asserted; update the test accordingly around the
getTreatment/setAttribute/publish calls.

In `@tests/Murmur3Test.php`:
- Around line 7-10: Add a PHPUnit class-level annotation to skip this test on
PHP < 8.1: update the Murmur3Test test class (class Murmur3Test) so that the
PHPUnit docblock includes "@requires PHP 8.1" above the class declaration; this
ensures the murmur3Hash(string $input, int $seed = 0) helper (which calls
hash('murmur3a', …) with the ['seed' => $seed] option) does not run on pre-8.1
environments and avoids the ValueError.

---

Nitpick comments:
In `@README.md`:
- Around line 573-575: Add an inline clarification next to the Loop::addTimer
call (and mention the $timeout variable/closure) that this timer only provides a
UI/flow timeout and does not cancel any in-flight HTTP request used to fetch
$context; indicate that to actually cancel a network request the example must
use a cancellable HTTP client or abortable request handle (e.g., cancel
token/abort controller or closing the request handle) rather than relying on the
timer alone.
- Around line 213-214: The closure passed to $context->ready()->then currently
declares a parameter named $context which shadows the outer $context; rename the
closure parameter (e.g., to $ctx or $readyContext) in the function signature and
update all references inside that closure to the new name so the outer $context
variable is not shadowed (ensure consistent rename of the parameter in the
anonymous function passed to ready()->then and any uses within that function
body).

In `@src/Context/Context.php`:
- Around line 77-79: The class exposes two identical public accessors pending()
and getPendingCount() that both return $this->pendingCount; choose one canonical
API (e.g., keep pending() for cross-SDK parity) and mark the other as
deprecated: update the non-canonical method (getPendingCount) with a `@deprecated`
PHPDoc pointing callers to pending(), have it simply return $this->pendingCount
(or call pending()) to preserve behavior, and apply the same change to the
duplicate pair mentioned (the other copy around the getPendingCount/pending
definitions). Ensure both methods remain consistent and add a TODO or ticket
reference if you want to remove the deprecated method later.
- Around line 323-327: Fix the inconsistent indentation on the $eligible
assignment: remove the extra leading tab/spaces before " $eligible =
$assigner->assign(...)" so that the $eligible line aligns with the surrounding
statements at the same nesting level; locate the block where
$assigner->assign($experiment->data->trafficSplit,
$experiment->data->trafficSeedHi, $experiment->data->trafficSeedLo) is called
and adjust indentation to match its neighboring lines.
- Around line 202-240: Add an explicit return type to customFieldValue (use ":
mixed") and make JSON decoding strict: when decoding
$experiment->data->customFieldValues and when decoding a field value of type
'json' in customFieldValue, call json_decode with the JSON_THROW_ON_ERROR flag
(or use the equivalent options) inside a try/catch for \JsonException so
malformed JSON doesn't silently return null; allow the exception to propagate or
rethrow it so callers can distinguish "not found" (null) from "unparseable
JSON". Ensure you reference the customFieldValue method and use \JsonException
(or import JsonException) when updating the json_decode calls.

In `@src/Http/HTTPClient.php`:
- Around line 123-133: The code uses deprecated numeric protocol options
CURLOPT_PROTOCOLS and CURLOPT_REDIR_PROTOCOLS in curl_setopt_array; replace them
with the string-based constants CURLOPT_PROTOCOLS_STR and
CURLOPT_REDIR_PROTOCOLS_STR (available in PHP 8.3+) and set their values to
'https' (or the equivalent string protocol list you need), while keeping the
other curl options intact in the curl_setopt_array call (referencing the
curl_setopt_array call and these constant names). Also update the corresponding
use const imports at the top of the file to import CURLOPT_PROTOCOLS_STR and
CURLOPT_REDIR_PROTOCOLS_STR instead of the deprecated constants.
- Line 27: Remove the dead import of the CURL constant: delete the `use const
CURLOPT_FAILONERROR;` line from HTTPClient.php (the class HTTPClient) since
`CURLOPT_FAILONERROR` is never used; confirm there are no `curl_setopt*` calls
referencing that constant and that error handling remains in `throwOnError()` so
behavior is unchanged.

In `@tests/AudienceMatcherTest.php`:
- Around line 21-27: The test method
testShouldReturnNullIfFilterNotObjectOrArray contains a redundant assertion:
remove the second assertion that creates $audience2 = new stdClass() and asserts
null on $this->matcher->evaluate($audience2, []), because that case is already
covered by testShouldReturnNullOnEmptyAudience; keep only the first assertion
using (object) ['filter' => null] so the test focuses on a null/non-object
filter value.

In `@tests/Context/ContextTest.php`:
- Around line 1625-1632: In testCallsEventLoggerOnSuccess the variable $context
is assigned but never used causing a static analysis warning; remove the unused
assignment by calling createReadyContext('context.json', true, $eventLogger)
without capturing its return value (i.e., drop "$context =") inside the
testCallsEventLoggerOnSuccess method so the call still triggers side effects on
MockContextEventLoggerProxy while eliminating the unused variable.

Comment on lines +176 to +184
| Event | When | Data |
| ---------- | ----------------------------------------------------------- | ----------------------------------------------------------- |
| `Error` | `Context` receives an error | `Exception` object thrown |
| `Ready` | `Context` turns ready | `ContextData` object used to initialize the context |
| `Refresh` | `Context->refresh()` method succeeds | `ContextData` used to refresh the context |
| `Publish` | `Context->publish()` method succeeds | `PublishEvent` data sent to the A/B Smartly event collector|
| `Exposure` | `Context->getTreatment()` method succeeds on first exposure | `Exposure` data enqueued for publishing |
| `Goal` | `Context->track()` method succeeds | `GoalAchievement` goal data enqueued for publishing |
| `Close` | `Context->close()` method succeeds the first time | `null` |
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Finalize event type is absent from the event table.

The PR objective and AI summary both state that a Finalize event type was added to ContextEventLoggerEvent. The event table only documents Close and does not include Finalize, leaving the new constant undocumented.

Add the missing row:

📝 Proposed addition to the event table
 | `Close`    | `Context->close()` method succeeds the first time           | `null`                                                      |
+| `Finalize` | `Context->close()` seals the context after publishing       | `null`                                                      |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| Event | When | Data |
| ---------- | ----------------------------------------------------------- | ----------------------------------------------------------- |
| `Error` | `Context` receives an error | `Exception` object thrown |
| `Ready` | `Context` turns ready | `ContextData` object used to initialize the context |
| `Refresh` | `Context->refresh()` method succeeds | `ContextData` used to refresh the context |
| `Publish` | `Context->publish()` method succeeds | `PublishEvent` data sent to the A/B Smartly event collector|
| `Exposure` | `Context->getTreatment()` method succeeds on first exposure | `Exposure` data enqueued for publishing |
| `Goal` | `Context->track()` method succeeds | `GoalAchievement` goal data enqueued for publishing |
| `Close` | `Context->close()` method succeeds the first time | `null` |
| Event | When | Data |
| ---------- | ----------------------------------------------------------- | ----------------------------------------------------------- |
| `Error` | `Context` receives an error | `Exception` object thrown |
| `Ready` | `Context` turns ready | `ContextData` object used to initialize the context |
| `Refresh` | `Context->refresh()` method succeeds | `ContextData` used to refresh the context |
| `Publish` | `Context->publish()` method succeeds | `PublishEvent` data sent to the A/B Smartly event collector|
| `Exposure` | `Context->getTreatment()` method succeeds on first exposure | `Exposure` data enqueued for publishing |
| `Goal` | `Context->track()` method succeeds | `GoalAchievement` goal data enqueued for publishing |
| `Close` | `Context->close()` method succeeds the first time | `null` |
| `Finalize` | `Context->close()` seals the context after publishing | `null` |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 176 - 184, The README event table is missing the
newly added Finalize event from ContextEventLoggerEvent; add a new table row for
the `Finalize` event showing when it fires (e.g., when `Context->finalize()`
method succeeds) and what data it carries (e.g., `null` or the relevant
finalization payload), so the table documents the `Finalize` constant alongside
the existing `Close` row.

Comment on lines +199 to +223
### Asynchronously (with ReactPHP)

**With Prefetched Data**
When using the async HTTP client, context creation is non-blocking:

```php
$contextConfig = new ContextConfig();
$contextConfig->setUnit('session_id', 'session_id5ebf06d8cb5d8137290c4abb64155584fbdb64d8'); // a unique id identifying the user
use ABSmartly\SDK\Context\ContextConfig;
use React\Promise\PromiseInterface;

$contextConfig = new ContextConfig();
$contextConfig->setUnit('session_id', '5ebf06d8cb5d8137290c4abb64155584fbdb64d8');

$context = $sdk->createContext($contextConfig);

// Use promises for async operations
$context->ready()->then(
function($context) {
// Context is ready
$treatment = $context->getTreatment('exp_test_experiment');
},
function($error) {
// Handle error
error_log('Context failed: ' . $error->getMessage());
}
);
```
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Search for the ready() method definition in the Context class
ast-grep --pattern 'public function ready($$$)'

Repository: absmartly/php-sdk

Length of output: 43


🏁 Script executed:

# Also search for any ready() method, not just public
rg 'function ready\(' --type php -A 5

Repository: absmartly/php-sdk

Length of output: 43


🏁 Script executed:

# Find the Context class file to examine its structure
fd -e php -t f | xargs rg -l 'class Context'

Repository: absmartly/php-sdk

Length of output: 443


🏁 Script executed:

# Read the Context.php file to examine available methods
cat -n src/Context/Context.php | head -150

Repository: absmartly/php-sdk

Length of output: 4894


🏁 Script executed:

# Search for Promise or async-related code in Context
rg 'Promise|async|ready' src/Context/Context.php -i

Repository: absmartly/php-sdk

Length of output: 516


🏁 Script executed:

# Check the entire Context class for public methods
ast-grep --pattern 'class Context { $$$ }'

Repository: absmartly/php-sdk

Length of output: 36142


Remove the async example or replace with synchronous API.

The ready() method does not exist on the Context class. The code at lines 213 and 577 calling $context->ready()->then(...) will fail at runtime. The Context class only provides isReady(): bool, which returns a boolean instead of a Promise. Either remove the async example entirely, or rewrite it using the synchronous API (isReady() checks) without promise-based callbacks.

As a secondary note, the callback parameter $context at line 213 shadows the outer $context variable; rename the parameter for clarity (e.g., $readyContext).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 199 - 223, The async README example uses the
nonexistent Context::ready() promise method and must be replaced or removed;
update the snippet that calls $context->ready()->then(...) to use the
synchronous API: after $context = $sdk->createContext($contextConfig) poll or
check $context->isReady() and then call
$context->getTreatment('exp_test_experiment') once isReady() returns true (or
show a loop/conditional example), and rename the inner callback parameter (e.g.,
from $context to $readyContext) to avoid shadowing the outer $context variable;
alternatively remove the entire async example and keep only synchronous usage
demonstrating createContext, isReady(), and getTreatment.

]);
```

> **Note:** You cannot override an already set unit type as that would be a change of identity and would throw an exception. In this case, you must create a new context instead. The `Context->setUnit()` and `Context->setUnits()` methods can be called before the context is ready.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing comma before the conjunction.

✏️ Proposed fix
-> **Note:** You cannot override an already set unit type as that would be a change of identity and would throw an exception.
+> **Note:** You cannot override an already set unit type as that would be a change of identity, and would throw an exception.
🧰 Tools
🪛 LanguageTool

[uncategorized] ~270-~270: Possible missing comma found.
Context: ...You cannot override an already set unit type as that would be a change of identity a...

(AI_HYDRA_LEO_MISSING_COMMA)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 270, The sentence about overriding a unit type is missing
a comma before the coordinating conjunction; update the README sentence
mentioning Context->setUnit() and Context->setUnits() to read "...as that would
be a change of identity, and would throw an exception." so the comma is inserted
before "and" to fix the grammar.

Comment on lines +384 to +401
public function handle($request, Closure $next)
{
$contextConfig = new ContextConfig();
$contextConfig->setUnit('session_id', $request->session()->getId());

if (auth()->check()) {
$contextConfig->setUnit('user_id', auth()->id());
}

$context = $this->sdk->createContext($contextConfig);
$request->attributes->set('absmartly_context', $context);

$response = $next($request);

$context->close();

return $response;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

$context->close() is not guarded with try/finally in the Laravel middleware.

If $next($request) throws, the context is never closed and any buffered exposures or goal events will be silently lost.

🛡️ Proposed fix
-        $response = $next($request);
-
-        $context->close();
-
-        return $response;
+        try {
+            $response = $next($request);
+        } finally {
+            $context->close();
+        }
+
+        return $response;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function handle($request, Closure $next)
{
$contextConfig = new ContextConfig();
$contextConfig->setUnit('session_id', $request->session()->getId());
if (auth()->check()) {
$contextConfig->setUnit('user_id', auth()->id());
}
$context = $this->sdk->createContext($contextConfig);
$request->attributes->set('absmartly_context', $context);
$response = $next($request);
$context->close();
return $response;
}
public function handle($request, Closure $next)
{
$contextConfig = new ContextConfig();
$contextConfig->setUnit('session_id', $request->session()->getId());
if (auth()->check()) {
$contextConfig->setUnit('user_id', auth()->id());
}
$context = $this->sdk->createContext($contextConfig);
$request->attributes->set('absmartly_context', $context);
try {
$response = $next($request);
} finally {
$context->close();
}
return $response;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 384 - 401, In handle(), ensure the Context created by
$this->sdk->createContext($contextConfig) is always closed by wrapping the call
to $next($request) in a try/finally: call $response = $next($request) inside try
and invoke $context->close() in finally so buffered exposures/goals are flushed
even if $next throws, then return or rethrow as appropriate; update references
around handle, createContext and $context->close() to guarantee deterministic
cleanup.

Comment on lines 15 to +16
public const Close = 'Close';
public const Finalize = 'Finalize';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n --type=php 'ContextEventLoggerEvent::Close\b' -A2 -B2

Repository: absmartly/php-sdk

Length of output: 43


🏁 Script executed:

cat -n src/Context/ContextEventLoggerEvent.php

Repository: absmartly/php-sdk

Length of output: 902


🏁 Script executed:

rg -n --type=php 'ContextEventLoggerEvent::Finalize\b' -A2 -B2

Repository: absmartly/php-sdk

Length of output: 3030


Add deprecation docblock to Close constant or remove it.

The Close constant is not used anywhere in the codebase; Context.php now emits Finalize instead. To avoid confusing SDK consumers, add a @deprecated docblock directing them to Finalize, or remove the constant entirely if backwards compatibility is not a concern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Context/ContextEventLoggerEvent.php` around lines 15 - 16, The Close
constant in ContextEventLoggerEvent is now unused and should either be removed
or marked deprecated; update the Close constant by adding a PHP docblock with
`@deprecated` pointing consumers to use Finalize instead (e.g., above the Close
constant add "@deprecated Use ContextEventLoggerEvent::Finalize"), or if you
accept breaking changes, remove the public const Close declaration entirely and
ensure no references remain; keep the Finalize constant unchanged.

@@ -45,7 +45,7 @@ class HTTPClient {
public int $retries = 5;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

$retries is public but has no effect — either implement or remove it.

No retry logic exists anywhere in the class; the property is dead API surface. Callers who configure it will silently get no retries, contrary to expectation.

♻️ Option A — remove the property until retry logic is implemented
-    public int $retries = 5;
     public int $timeout = 3000;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public int $retries = 5;
public int $timeout = 3000;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/HTTPClient.php` at line 45, The public property $retries on class
HTTPClient is dead API surface—remove it (delete the property declaration
"public int $retries = 5;") and any related references or docblocks in the
HTTPClient class so callers don't get a misleading configurable field;
alternatively if you prefer to keep retries, implement retry logic in the
request/sending path (e.g., in sendRequest or request methods) that respects a
configured retry count and backoff, but do not leave the public $retries field
declared without behavior.

@@ -45,7 +45,7 @@ class HTTPClient {
public int $retries = 5;
public int $timeout = 3000;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only connection timeout is set — total transfer timeout is missing.

CURLOPT_CONNECTTIMEOUT_MS caps only the TCP handshake phase. There is no CURLOPT_TIMEOUT_MS to bound the full transfer (DNS + connect + send + receive), so a server that accepts the connection but sends a slow or never-ending response will stall the calling process indefinitely. The public $timeout property implies full-request coverage, which is misleading.

🛡️ Proposed fix — add total transfer timeout
 use const CURLOPT_CONNECTTIMEOUT_MS;
+use const CURLOPT_TIMEOUT_MS;
             CURLOPT_CONNECTTIMEOUT_MS => $this->timeout,
+            CURLOPT_TIMEOUT_MS => $this->timeout,

Also applies to: 124-134

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/HTTPClient.php` at line 46, The class currently exposes public int
$timeout = 3000 but only applies it to CURLOPT_CONNECTTIMEOUT_MS (TCP handshake)
— add a total-transfer timeout by setting CURLOPT_TIMEOUT_MS using the same (or
a new) property; specifically, in the code path where CURLOPT_CONNECTTIMEOUT_MS
is configured (and in the other block referenced around lines 124-134) add
CURLOPT_TIMEOUT_MS => $this->timeout (or introduce separate $connectTimeout and
$transferTimeout properties and set CURLOPT_CONNECTTIMEOUT_MS =>
$this->connectTimeout and CURLOPT_TIMEOUT_MS => $this->transferTimeout), and
update the property name/comments to clearly state the unit is milliseconds and
that it bounds the full request (DNS+connect+send+receive).

Comment on lines +1545 to +1564
public function testAttributeUpdatesInTemplates(): void {
$context = $this->createReadyContext('audience_context.json');

$context->setAttribute('age', 15);
$treatmentBefore = $context->getTreatment('exp_test_ab');

$context->publish();
$this->eventHandler->submitted = [];

$context->setAttribute('age', 25);
$treatmentAfter = $context->getTreatment('exp_test_ab');

$context->publish();

$events = $this->eventHandler->submitted;
self::assertCount(1, $events);

$attributeNames = array_map(fn($attr) => $attr->name, $events[0]->attributes);
self::assertContains('age', $attributeNames);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unused local variables $treatmentBefore and $treatmentAfter.

Both variables are assigned but never asserted or used. If the intent is to verify that treatments differ (or remain the same) after an attribute change, add assertions. Otherwise, remove them to keep the test's intent clear.

Proposed fix — either assert or remove
 	public function testAttributeUpdatesInTemplates(): void {
 		$context = $this->createReadyContext('audience_context.json');

 		$context->setAttribute('age', 15);
-		$treatmentBefore = $context->getTreatment('exp_test_ab');
+		$context->getTreatment('exp_test_ab');

 		$context->publish();
 		$this->eventHandler->submitted = [];

 		$context->setAttribute('age', 25);
-		$treatmentAfter = $context->getTreatment('exp_test_ab');
+		$context->getTreatment('exp_test_ab');

 		$context->publish();
🧰 Tools
🪛 PHPMD (2.15.0)

[warning] 1549-1549: Avoid unused local variables such as '$treatmentBefore'. (undefined)

(UnusedLocalVariable)


[warning] 1555-1555: Avoid unused local variables such as '$treatmentAfter'. (undefined)

(UnusedLocalVariable)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/Context/ContextTest.php` around lines 1545 - 1564, The test
testAttributeUpdatesInTemplates currently assigns $treatmentBefore and
$treatmentAfter from getTreatment but never uses them; either remove those
unused local variables or (if the intent is to verify the template behavior when
age changes) add an assertion comparing them—e.g., after calling
setAttribute('age', 15) and later setAttribute('age', 25) and calling
getTreatment('exp_test_ab'), assertNotEquals($treatmentBefore, $treatmentAfter)
(or assertEquals if you expect no change) so the variables are meaningfully
asserted; update the test accordingly around the
getTreatment/setAttribute/publish calls.

Comment on lines +7 to +10
class Murmur3Test extends TestCase {
private function murmur3Hash(string $input, int $seed = 0): int {
return hexdec(hash('murmur3a', $input, false, ['seed' => $seed]));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

PHP hash murmur3a algorithm minimum version requirement

💡 Result:

murmur3a is available in PHP’s built-in hash() / hash_algos() starting with PHP 8.1.0 (MurmurHash3 support was added in 8.1). [1] [2]


🏁 Script executed:

# Check the test file for annotations and find the production code
find . -name "Murmur3Test.php" -o -name "*VariantAssigner*" | head -20

Repository: absmartly/php-sdk

Length of output: 142


🏁 Script executed:

# Search for VariantAssigner class and digest method
rg "class VariantAssigner|function digest" -A 5 -B 2

Repository: absmartly/php-sdk

Length of output: 1309


🏁 Script executed:

# Get full content of VariantAssigner.php digest() method and surrounding context
cat -n src/VariantAssigner.php | head -50

Repository: absmartly/php-sdk

Length of output: 1449


🏁 Script executed:

# Get full content of Murmur3Test.php to see annotations and code
cat -n tests/Murmur3Test.php

Repository: absmartly/php-sdk

Length of output: 2840


🌐 Web query:

PHP hash() function $options parameter minimum version requirement

💡 Result:

The $options parameter for PHP’s hash() function was added in PHP 8.1.0 (so the minimum required version is PHP 8.1.0). [1]

(It was introduced alongside the same new $options parameter for hash_file() and hash_init().) [2]

Sources: [1], [2]


🏁 Script executed:

# Get the complete digest() method in VariantAssigner.php
sed -n '50,60p' src/VariantAssigner.php

Repository: absmartly/php-sdk

Length of output: 405


Add @requires PHP 8.1 class-level annotation to guard against pre-8.1 environments.

The test's murmur3Hash() helper uses hash('murmur3a', …) and its ['seed' => $seed] option, both introduced in PHP 8.1. The production code in VariantAssigner::digest() guards this with PHP_VERSION_ID >= 80100 and provides a fallback; without a matching PHPUnit @requires annotation, the test class will throw a ValueError (unknown algorithm) rather than be skipped on PHP 8.0 or earlier runners.

Proposed fix
+/**
+ * `@requires` PHP 8.1
+ */
 class Murmur3Test extends TestCase {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class Murmur3Test extends TestCase {
private function murmur3Hash(string $input, int $seed = 0): int {
return hexdec(hash('murmur3a', $input, false, ['seed' => $seed]));
}
/**
* `@requires` PHP 8.1
*/
class Murmur3Test extends TestCase {
private function murmur3Hash(string $input, int $seed = 0): int {
return hexdec(hash('murmur3a', $input, false, ['seed' => $seed]));
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/Murmur3Test.php` around lines 7 - 10, Add a PHPUnit class-level
annotation to skip this test on PHP < 8.1: update the Murmur3Test test class
(class Murmur3Test) so that the PHPUnit docblock includes "@requires PHP 8.1"
above the class declaration; this ensures the murmur3Hash(string $input, int
$seed = 0) helper (which calls hash('murmur3a', …) with the ['seed' => $seed]
option) does not run on pre-8.1 environments and avoids the ValueError.

- InOperator: fix argument order (lhs=needle, rhs=haystack) matching
  cross-SDK spec, use loose comparison for mixed types
- MatchOperator: use ~ as regex delimiter instead of / to avoid
  conflicts with patterns containing forward slashes
- Context: change error message from "closed" to "finalized" to match
  cross-SDK error_matches expectations
- Update InOperatorTest to use correct [needle, haystack] argument order
@joalves joalves changed the title fix: all tests passing (336/336 unit, 138/138 cross-SDK) fix: all tests passing (317/317 unit, 180/183 cross-SDK) Feb 22, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (4)
src/JsonExpression/Operator/InOperator.php (2)

23-24: Document the intentional loose comparison in in_array.

in_array($lhs, $rhs, false) uses non-strict comparison, which means in_array(false, [0], false) returns true due to PHP's bool/int coercion. This is intentional per the PR's cross-SDK compatibility requirement, but the absence of a comment makes it a maintenance trap.

📝 Suggested inline comment
		if (is_array($rhs)) {
-			return in_array($lhs, $rhs, false);
+			// Loose comparison is intentional for cross-SDK mixed-type compatibility.
+			return in_array($lhs, $rhs, false);
		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/JsonExpression/Operator/InOperator.php` around lines 23 - 24, Add an
inline comment next to the non-strict in_array call in InOperator::evaluate (the
line using in_array($lhs, $rhs, false)) explaining that the third parameter is
intentionally false to allow loose comparisons for cross-SDK compatibility and
noting the known coercion edge (e.g., false == 0) so future maintainers won't
change it inadvertently; place the comment immediately above or beside the
in_array(...) call in src/JsonExpression/Operator/InOperator.php and reference
the cross-SDK compatibility requirement and the specific coercion example.

18-18: Consider suppressing the PHPMD UnusedFormalParameter warning for $evaluator.

The parameter is required by BinaryOperator::binary() but is not used by this specific implementation. Whilst the warning is a false positive in context, leaving it unaddressed will clutter static-analysis reports over time.

🔧 Suggested suppression
-	public function binary(Evaluator $evaluator, $lhs, $rhs): ?bool {
+	/** `@SuppressWarnings`(PHPMD.UnusedFormalParameter) */
+	public function binary(Evaluator $evaluator, $lhs, $rhs): ?bool {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/JsonExpression/Operator/InOperator.php` at line 18, Add a PHPMD
suppression for the unused `$evaluator` parameter on InOperator::binary:
annotate the method (public function binary(Evaluator $evaluator, $lhs, $rhs):
?bool) with a PHPMD suppression such as /**
`@SuppressWarnings`(PHPMD.UnusedFormalParameter) */ so the implementation (which
must keep the signature required by BinaryOperator::binary) does not trigger the
UnusedFormalParameter warning.
src/Context/Context.php (2)

532-534: getUnit is missing a ?string return-type declaration.

ContextConfig::getUnit (the equivalent method in the config class) declares ?string. The corresponding method here omits the return type entirely, which weakens static analysis.

♻️ Proposed fix
-public function getUnit(string $unitType) {
+public function getUnit(string $unitType): ?string {
     return $this->units[$unitType] ?? null;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Context/Context.php` around lines 532 - 534, The getUnit method on the
Context class is missing a nullable string return-type; update the method
signature for public function getUnit(string $unitType) to declare a return type
of ?string so it matches ContextConfig::getUnit and improves static analysis
(ensure the method still returns $this->units[$unitType] ?? null).

77-79: pending() duplicates the existing getPendingCount() at line 597.

Both methods return $this->pendingCount unchanged. If pending() is required for cross-SDK API parity, getPendingCount() should be annotated @deprecated and proxied through pending() to avoid two sources of truth.

♻️ Proposed consolidation
 public function pending(): int {
-    return $this->pendingCount;
+    return $this->pendingCount;   // canonical implementation stays here
 }
 
 // …
 
+/** `@deprecated` Use pending() instead */
 public function getPendingCount(): int {
-    return $this->pendingCount;
+    return $this->pending();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Context/Context.php` around lines 77 - 79, Duplicate accessors: make
pending() the single source of truth and convert getPendingCount() into a
deprecated proxy. Add a `@deprecated` docblock to getPendingCount(), change its
body to simply return $this->pending() (or return $this->pendingCount via
pending()) so all callers funnel through pending(), and ensure pending() remains
the authoritative method returning $this->pendingCount.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Context/Context.php`:
- Around line 250-265: audienceMatches currently treats a null from
AudienceMatcher::evaluate() as a mismatch due to using !$result and also mutates
$assignment->attrsSeq inside what appears to be a boolean check; add a brief
inline comment in audienceMatches by the evaluate() call clarifying that
evaluate() returns ?bool and null is intentionally treated as a mismatch here
(i.e. "null => no filter => treat as mismatch for cache invalidation"), and
remove the side-effect by moving the assignment of $assignment->attrsSeq =
$this->attrsSeq out of audienceMatches into the getAssignment call site (call
audienceMatches to decide match, then update $assignment->attrsSeq in
getAssignment when appropriate) so audienceMatches remains a pure predicate
while preserving cache-coherence via getAssignment.
- Around line 209-211: The json_decode call that converts $customFieldValues
should use JSON_THROW_ON_ERROR (and appropriate flags) so malformed JSON doesn't
silently become null; update the decode in the Context class (the
$customFieldValues conversion) to call json_decode(..., true, 512,
JSON_THROW_ON_ERROR) and wrap it in a try/catch to surface or rethrow the
JsonException (matching the pattern used in Experiment::__construct); apply the
same change to the other json_decode occurrence around the later check (the
similar call near line 225) so both spots consistently throw on parse errors.
- Around line 202-207: The customFieldValue method currently skips lifecycle
checks; add a call to checkReady() at the start of customFieldValue(string
$experimentName, string $fieldName) so it enforces the same not-ready/not-closed
guard as other public data-accessors (checkReady() itself calls
checkNotClosed()). Place the checkReady() invocation before getExperiment(...)
to ensure the method throws when the Context is not ready or already finalised,
matching the class's lifecycle contract.

In `@src/JsonExpression/Operator/MatchOperator.php`:
- Line 28: The current trim($pattern, '/') call can leave trailing PCRE flags
attached to the body (e.g. '/foo/i' -> 'foo/i'), so update the handling in
MatchOperator to detect slash-delimited patterns and split the pattern body and
flags before re-wrapping: if $pattern starts with '/' find the last '/'
position, take the substring between first and last slash as the body and the
substring after the last slash as flags, escape any new delimiter characters in
the body, then build the final regex using the chosen delimiter (e.g. '~') and
append the flags; apply this logic where $pattern is processed in the
MatchOperator class to ensure flags are preserved correctly.
- Around line 28-30: The current MatchOperator implementation trims slashes then
builds a regex using '~' as the delimiter and calls
preg_match('@preg_match(\'~'. $pattern . '~\', $text)@'), which breaks when the
user pattern contains '~'; change this to escape occurrences of the chosen
delimiter inside $pattern (or call preg_quote($pattern, '~')) before
concatenation so embedded '~' do not produce malformed delimiters — update the
code that sets $pattern (and the preg_match invocation in MatchOperator.php) to
use the escaped pattern (or a preg_quote-based variant) when wrapping with the
'~' delimiters.

In `@tests/JsonExpression/Operator/InOperatorTest.php`:
- Around line 64-67: Rename the misleading test method testArrayDiffNull to
reflect it asserts false (e.g., testArrayDiffFalse or testArrayNotInArray) and
update any references to that method; the assertions call
$this->operator->evaluate($this->evaluator, [[2, 3], [1, 2, 3]]) and [[5, 6],
[1, 2, 3]] and expect false, so only the test method name needs to change
(ensure any test annotations or usages are updated to the new name).
- Around line 29-36: In testReturnFalseOnEmptyArray replace the duplicated
assertion that calls $this->operator->evaluate($this->evaluator, [false, []])
with an assertion that checks the integer 0 case (e.g. invoke
$this->operator->evaluate($this->evaluator, [0, []])) so the test covers the
edge-case of loose-comparison/in_array behavior; keep the other assertions
(false, "1", true, null) and ensure you still assertFalse for the 0 invocation
to match the test intent.

---

Nitpick comments:
In `@src/Context/Context.php`:
- Around line 532-534: The getUnit method on the Context class is missing a
nullable string return-type; update the method signature for public function
getUnit(string $unitType) to declare a return type of ?string so it matches
ContextConfig::getUnit and improves static analysis (ensure the method still
returns $this->units[$unitType] ?? null).
- Around line 77-79: Duplicate accessors: make pending() the single source of
truth and convert getPendingCount() into a deprecated proxy. Add a `@deprecated`
docblock to getPendingCount(), change its body to simply return $this->pending()
(or return $this->pendingCount via pending()) so all callers funnel through
pending(), and ensure pending() remains the authoritative method returning
$this->pendingCount.

In `@src/JsonExpression/Operator/InOperator.php`:
- Around line 23-24: Add an inline comment next to the non-strict in_array call
in InOperator::evaluate (the line using in_array($lhs, $rhs, false)) explaining
that the third parameter is intentionally false to allow loose comparisons for
cross-SDK compatibility and noting the known coercion edge (e.g., false == 0) so
future maintainers won't change it inadvertently; place the comment immediately
above or beside the in_array(...) call in
src/JsonExpression/Operator/InOperator.php and reference the cross-SDK
compatibility requirement and the specific coercion example.
- Line 18: Add a PHPMD suppression for the unused `$evaluator` parameter on
InOperator::binary: annotate the method (public function binary(Evaluator
$evaluator, $lhs, $rhs): ?bool) with a PHPMD suppression such as /**
`@SuppressWarnings`(PHPMD.UnusedFormalParameter) */ so the implementation (which
must keep the signature required by BinaryOperator::binary) does not trigger the
UnusedFormalParameter warning.

Comment on lines +202 to +207
public function customFieldValue(string $experimentName, string $fieldName) {
$experiment = $this->getExperiment($experimentName);
if ($experiment === null || !isset($experiment->data->customFieldValues)) {
return null;
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

customFieldValue must guard against a not-ready or finalised context.

Every other public data-access method calls checkReady() (which in turn calls checkNotClosed()). Without this guard, customFieldValue silently succeeds on a finalised context, diverging from the lifecycle contract enforced everywhere else.

🛡️ Proposed fix
 public function customFieldValue(string $experimentName, string $fieldName) {
+    $this->checkReady();
     $experiment = $this->getExperiment($experimentName);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Context/Context.php` around lines 202 - 207, The customFieldValue method
currently skips lifecycle checks; add a call to checkReady() at the start of
customFieldValue(string $experimentName, string $fieldName) so it enforces the
same not-ready/not-closed guard as other public data-accessors (checkReady()
itself calls checkNotClosed()). Place the checkReady() invocation before
getExperiment(...) to ensure the method throws when the Context is not ready or
already finalised, matching the class's lifecycle contract.

Comment on lines +209 to +211
if (is_string($customFieldValues)) {
$customFieldValues = json_decode($customFieldValues, true);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

json_decode without JSON_THROW_ON_ERROR silently discards malformed JSON.

If customFieldValues is an invalid JSON string, json_decode returns null; is_object(null) is false, isset(null[$fieldName]) is false, and the method returns null — masking the parse error entirely. Experiment::__construct already uses JSON_THROW_ON_ERROR for consistency.

🐛 Proposed fix (apply same pattern on line 225 too)
-    $customFieldValues = json_decode($customFieldValues, true);
+    $customFieldValues = json_decode($customFieldValues, true, 512, JSON_THROW_ON_ERROR);
-    return json_decode($value, true);
+    return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Context/Context.php` around lines 209 - 211, The json_decode call that
converts $customFieldValues should use JSON_THROW_ON_ERROR (and appropriate
flags) so malformed JSON doesn't silently become null; update the decode in the
Context class (the $customFieldValues conversion) to call json_decode(..., true,
512, JSON_THROW_ON_ERROR) and wrap it in a try/catch to surface or rethrow the
JsonException (matching the pattern used in Experiment::__construct); apply the
same change to the other json_decode occurrence around the later check (the
similar call near line 225) so both spots consistently throw on parse errors.

Comment on lines +250 to +265
private function audienceMatches(Experiment $experiment, Assignment $assignment): bool {
if (!empty($experiment->audience) && !empty((array) $experiment->audience)) {
if ($this->attrsSeq > ($assignment->attrsSeq ?? 0)) {
$attrs = $this->getAttributes();
$result = $this->audienceMatcher->evaluate($experiment->audience, $attrs);
$newAudienceMismatch = !$result;

if ($newAudienceMismatch !== $assignment->audienceMismatch) {
return false;
}

$assignment->attrsSeq = $this->attrsSeq;
}
}
return true;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

audienceMatches silently treats a null result from evaluate() as a mismatch.

AudienceMatcher::evaluate() returns ?boolnull signals "no filter defined". Because !null === true in PHP, $newAudienceMismatch is set to true when there is no filter. This is consistent with the initial-assignment path at line 313, so the cache-invalidation comparison remains coherent. However, adding a brief inline comment here would make the contract explicit for future readers.

Additionally, line 261 mutates $assignment->attrsSeq as a side effect of what looks like a pure boolean query. The mutation is intentional for cache coherence but is a mild code smell — consider extracting it into the call site in getAssignment to keep this method free of side effects.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Context/Context.php` around lines 250 - 265, audienceMatches currently
treats a null from AudienceMatcher::evaluate() as a mismatch due to using
!$result and also mutates $assignment->attrsSeq inside what appears to be a
boolean check; add a brief inline comment in audienceMatches by the evaluate()
call clarifying that evaluate() returns ?bool and null is intentionally treated
as a mismatch here (i.e. "null => no filter => treat as mismatch for cache
invalidation"), and remove the side-effect by moving the assignment of
$assignment->attrsSeq = $this->attrsSeq out of audienceMatches into the
getAssignment call site (call audienceMatches to decide match, then update
$assignment->attrsSeq in getAssignment when appropriate) so audienceMatches
remains a pure predicate while preserving cache-coherence via getAssignment.

* at source.
*/
$matches = preg_match('/'. trim($pattern, '/') . '/', $text);
$pattern = trim($pattern, '/');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

trim($pattern, '/') discards PCRE flags from slash-delimited patterns.

trim('/foo/i', '/') yields foo/i (only the leading / is stripped because the last character is i, not /). The resulting regex ~foo/i~ matches the four-character literal foo/i rather than applying the i (case-insensitive) flag. If callers can supply patterns in the conventional PHP/PCRE form /pattern/flags, the flags must be split off before re-wrapping with the new delimiter.

🛡️ Proposed fix — separate pattern body from flags before re-wrapping
 private function runRegexBounded(string $text, string $pattern): ?bool {
-    $pattern = trim($pattern, '/');
-
-    $matches = `@preg_match`('~'. $pattern . '~', $text);
+    // Strip a single leading/trailing slash delimiter and capture any trailing flags.
+    $flags = '';
+    if (preg_match('/^\\/(.+)\\/([imsuxADSUXJ]*)$/', $pattern, $parts) === 1) {
+        $pattern = $parts[1];
+        $flags   = $parts[2];
+    } else {
+        $pattern = trim($pattern, '/');
+    }
+
+    $safePattern = str_replace('~', '\~', $pattern);
+    $matches = `@preg_match`('~' . $safePattern . '~' . $flags, $text);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/JsonExpression/Operator/MatchOperator.php` at line 28, The current
trim($pattern, '/') call can leave trailing PCRE flags attached to the body
(e.g. '/foo/i' -> 'foo/i'), so update the handling in MatchOperator to detect
slash-delimited patterns and split the pattern body and flags before
re-wrapping: if $pattern starts with '/' find the last '/' position, take the
substring between first and last slash as the body and the substring after the
last slash as flags, escape any new delimiter characters in the body, then build
the final regex using the chosen delimiter (e.g. '~') and append the flags;
apply this logic where $pattern is processed in the MatchOperator class to
ensure flags are preserved correctly.

Comment on lines +28 to +30
$pattern = trim($pattern, '/');

$matches = @preg_match('~'. $pattern . '~', $text);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Patterns containing ~ will silently fail to evaluate correctly.

Using ~ as the PCRE delimiter while concatenating the raw user-supplied pattern string means any pattern that itself contains ~ (e.g. foo~bar) produces a malformed regex string like ~foo~bar~, which PCRE interprets as delimiter ~, pattern foo, flags bar~ — an invalid flag sequence. The @-suppression + preg_last_error() guard correctly returns null rather than crashing, but valid patterns with ~ are permanently broken. Consider escaping ~ in the pattern or choosing a delimiter that is less likely to collide (e.g. using preg_quote with the chosen delimiter, or selecting a non-printable delimiter).

🛡️ Proposed fix — escape the chosen delimiter before wrapping
 private function runRegexBounded(string $text, string $pattern): ?bool {
-    $pattern = trim($pattern, '/');
-
-    $matches = `@preg_match`('~'. $pattern . '~', $text);
+    $pattern = trim($pattern, '/');
+    // Escape the tilde delimiter so user-supplied patterns containing `~`
+    // do not break the PCRE boundary.
+    $safePattern = str_replace('~', '\~', $pattern);
+
+    $matches = `@preg_match`('~' . $safePattern . '~', $text);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/JsonExpression/Operator/MatchOperator.php` around lines 28 - 30, The
current MatchOperator implementation trims slashes then builds a regex using '~'
as the delimiter and calls preg_match('@preg_match(\'~'. $pattern . '~\',
$text)@'), which breaks when the user pattern contains '~'; change this to
escape occurrences of the chosen delimiter inside $pattern (or call
preg_quote($pattern, '~')) before concatenation so embedded '~' do not produce
malformed delimiters — update the code that sets $pattern (and the preg_match
invocation in MatchOperator.php) to use the escaped pattern (or a
preg_quote-based variant) when wrapping with the '~' delimiters.

Comment on lines 29 to 36
public function testReturnFalseOnEmptyArray(): void {
self::assertFalse($this->operator->evaluate($this->evaluator, [[], false]));
self::assertFalse($this->operator->evaluate($this->evaluator, [[], "1"]));
self::assertFalse($this->operator->evaluate($this->evaluator, [[], true]));
self::assertFalse($this->operator->evaluate($this->evaluator, [[], false]));
self::assertFalse($this->operator->evaluate($this->evaluator, [false, []]));
self::assertFalse($this->operator->evaluate($this->evaluator, ["1", []]));
self::assertFalse($this->operator->evaluate($this->evaluator, [true, []]));
self::assertFalse($this->operator->evaluate($this->evaluator, [false, []]));

self::assertNull($this->operator->evaluate($this->evaluator, [[], null]));
self::assertNull($this->operator->evaluate($this->evaluator, [null, []]));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Duplicate assertion on line 33 — likely a copy/paste error.

Line 33 is identical to line 30 ([false, []]). This was probably meant to cover the integer 0 case, which is particularly worth testing given the loose-comparison behaviour of in_array.

🐛 Proposed fix
-		self::assertFalse($this->operator->evaluate($this->evaluator, [false, []]));
+		self::assertFalse($this->operator->evaluate($this->evaluator, [0, []]));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function testReturnFalseOnEmptyArray(): void {
self::assertFalse($this->operator->evaluate($this->evaluator, [[], false]));
self::assertFalse($this->operator->evaluate($this->evaluator, [[], "1"]));
self::assertFalse($this->operator->evaluate($this->evaluator, [[], true]));
self::assertFalse($this->operator->evaluate($this->evaluator, [[], false]));
self::assertFalse($this->operator->evaluate($this->evaluator, [false, []]));
self::assertFalse($this->operator->evaluate($this->evaluator, ["1", []]));
self::assertFalse($this->operator->evaluate($this->evaluator, [true, []]));
self::assertFalse($this->operator->evaluate($this->evaluator, [false, []]));
self::assertNull($this->operator->evaluate($this->evaluator, [[], null]));
self::assertNull($this->operator->evaluate($this->evaluator, [null, []]));
}
public function testReturnFalseOnEmptyArray(): void {
self::assertFalse($this->operator->evaluate($this->evaluator, [false, []]));
self::assertFalse($this->operator->evaluate($this->evaluator, ["1", []]));
self::assertFalse($this->operator->evaluate($this->evaluator, [true, []]));
self::assertFalse($this->operator->evaluate($this->evaluator, [0, []]));
self::assertNull($this->operator->evaluate($this->evaluator, [null, []]));
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/JsonExpression/Operator/InOperatorTest.php` around lines 29 - 36, In
testReturnFalseOnEmptyArray replace the duplicated assertion that calls
$this->operator->evaluate($this->evaluator, [false, []]) with an assertion that
checks the integer 0 case (e.g. invoke
$this->operator->evaluate($this->evaluator, [0, []])) so the test covers the
edge-case of loose-comparison/in_array behavior; keep the other assertions
(false, "1", true, null) and ensure you still assertFalse for the 0 invocation
to match the test intent.

Comment on lines 64 to 67
public function testArrayDiffNull(): void {
self::assertFalse($this->operator->evaluate($this->evaluator, [[1, 2, 3], [2, 3]]));
self::assertFalse($this->operator->evaluate($this->evaluator, [[1, 2, 3], [5, 6]]));
self::assertFalse($this->operator->evaluate($this->evaluator, [[2, 3], [1, 2, 3]]));
self::assertFalse($this->operator->evaluate($this->evaluator, [[5, 6], [1, 2, 3]]));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test name testArrayDiffNull is misleading — assertions use assertFalse, not assertNull.

The name implies the operator returns null for array-in-array mismatches, but the current implementation returns false via in_array. Renaming it avoids confusion for future contributors.

📝 Suggested rename
-	public function testArrayDiffNull(): void {
+	public function testArrayNotContainedInArray(): void {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function testArrayDiffNull(): void {
self::assertFalse($this->operator->evaluate($this->evaluator, [[1, 2, 3], [2, 3]]));
self::assertFalse($this->operator->evaluate($this->evaluator, [[1, 2, 3], [5, 6]]));
self::assertFalse($this->operator->evaluate($this->evaluator, [[2, 3], [1, 2, 3]]));
self::assertFalse($this->operator->evaluate($this->evaluator, [[5, 6], [1, 2, 3]]));
}
public function testArrayNotContainedInArray(): void {
self::assertFalse($this->operator->evaluate($this->evaluator, [[2, 3], [1, 2, 3]]));
self::assertFalse($this->operator->evaluate($this->evaluator, [[5, 6], [1, 2, 3]]));
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/JsonExpression/Operator/InOperatorTest.php` around lines 64 - 67,
Rename the misleading test method testArrayDiffNull to reflect it asserts false
(e.g., testArrayDiffFalse or testArrayNotInArray) and update any references to
that method; the assertions call $this->operator->evaluate($this->evaluator,
[[2, 3], [1, 2, 3]]) and [[5, 6], [1, 2, 3]] and expect false, so only the test
method name needs to change (ensure any test annotations or usages are updated
to the new name).

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