From 0cb3e75290530f76c85ce76e840ed398fe38df6d Mon Sep 17 00:00:00 2001 From: yl0711-coder Date: Fri, 12 Jun 2026 15:41:47 +0800 Subject: [PATCH 1/6] Fix CI advisory blocking on EOL Laravel legs Co-Authored-By: Claude Opus 4.8 --- .github/workflows/tests.yml | 8 ++++++++ CHANGELOG.md | 1 + 2 files changed, 9 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9f2bdc7..90f2b70 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,6 +41,14 @@ jobs: coverage: none tools: composer:v2 + # Laravel 10/11 are EOL: their advisories will never get patched releases, so + # Composer's advisory blocking would make these compatibility legs unresolvable. + - name: Allow advisory-affected versions on EOL Laravel legs + if: matrix.laravel-version == '10.x' || matrix.laravel-version == '11.x' + run: | + jq '.config.policy.advisories.block = false' composer.json > composer.tmp.json + mv composer.tmp.json composer.json + - name: Install dependencies run: | composer require --dev orchestra/testbench:${{ matrix.testbench-version }} --no-update diff --git a/CHANGELOG.md b/CHANGELOG.md index d0cfba6..ddc27ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to RuleFlow PHP will be documented in this file. - Exposed matched rule metadata through `metadata()`, `toArray()`, and `explain()` results. - Documented production guidance for rule ownership metadata. - Added PHP 8.4 and 8.5 (Laravel 12) to the CI test matrix. +- Fixed CI for EOL Laravel 10/11 legs by relaxing Composer advisory blocking there only. - Refreshed dev dependencies (Testbench 10 / Laravel 12 locally, PHPStan 2.2). ## v0.3.3 - 2026-05-03 From 7423ceec1667c99637cfddb992edf77a3b822337 Mon Sep 17 00:00:00 2001 From: yl0711-coder Date: Fri, 12 Jun 2026 15:44:43 +0800 Subject: [PATCH 2/6] Update GitHub Actions to Node 24 compatible versions Co-Authored-By: Claude Opus 4.8 --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 90f2b70..72ccc8f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,7 +32,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -75,7 +75,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -94,7 +94,7 @@ jobs: run: composer test-coverage - name: Upload coverage artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: clover-coverage path: build/logs/clover.xml From 21fe76790aafed6d0a5c5867180ca46241be238a Mon Sep 17 00:00:00 2001 From: yl0711-coder Date: Fri, 12 Jun 2026 15:53:42 +0800 Subject: [PATCH 3/6] Add Laravel 13 support and test it on PHP 8.4/8.5 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/tests.yml | 8 ++++---- CHANGELOG.md | 3 ++- composer.json | 4 ++-- docs/laravel-compatibility.md | 1 + docs/laravel-compatibility.zh-CN.md | 1 + docs/laravel-installation.md | 3 ++- docs/laravel-installation.zh-CN.md | 3 ++- 7 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 72ccc8f..6628396 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,11 +24,11 @@ jobs: testbench-version: '^10.0' laravel-version: '12.x' - php-version: '8.4' - testbench-version: '^10.0' - laravel-version: '12.x' + testbench-version: '^11.0' + laravel-version: '13.x' - php-version: '8.5' - testbench-version: '^10.0' - laravel-version: '12.x' + testbench-version: '^11.0' + laravel-version: '13.x' steps: - name: Checkout diff --git a/CHANGELOG.md b/CHANGELOG.md index ddc27ce..115029d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ All notable changes to RuleFlow PHP will be documented in this file. - Added optional rule `metadata` for ownership, version, ticket, and rollout context. - Exposed matched rule metadata through `metadata()`, `toArray()`, and `explain()` results. - Documented production guidance for rule ownership metadata. -- Added PHP 8.4 and 8.5 (Laravel 12) to the CI test matrix. +- Added Laravel 13 support (Testbench 11, PHPUnit 13 allowed) with compatibility docs. +- Added PHP 8.4 and 8.5 to the CI test matrix; PHP 8.4/8.5 legs run against Laravel 13. - Fixed CI for EOL Laravel 10/11 legs by relaxing Composer advisory blocking there only. - Refreshed dev dependencies (Testbench 10 / Laravel 12 locally, PHPStan 2.2). diff --git a/composer.json b/composer.json index e0bd316..af196e5 100644 --- a/composer.json +++ b/composer.json @@ -20,8 +20,8 @@ "php": "^8.1" }, "require-dev": { - "orchestra/testbench": "^8.0|^9.0|^10.0", - "phpunit/phpunit": "^10.5|^11.5|^12.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^10.5|^11.5|^12.0|^13.0", "squizlabs/php_codesniffer": "^3.9", "phpstan/phpstan": "^2.1" }, diff --git a/docs/laravel-compatibility.md b/docs/laravel-compatibility.md index b25f54d..98f3177 100644 --- a/docs/laravel-compatibility.md +++ b/docs/laravel-compatibility.md @@ -14,6 +14,7 @@ The package is tested against: | 10.x | 8.x | 8.1 | | 11.x | 9.x | 8.2 | | 12.x | 10.x | 8.3 | +| 13.x | 11.x | 8.3+ (tested on 8.4 and 8.5) | This matrix is reflected in GitHub Actions. diff --git a/docs/laravel-compatibility.zh-CN.md b/docs/laravel-compatibility.zh-CN.md index 7dc1525..5a06a0f 100644 --- a/docs/laravel-compatibility.zh-CN.md +++ b/docs/laravel-compatibility.zh-CN.md @@ -14,6 +14,7 @@ RuleFlow 分为两层: | 10.x | 8.x | 8.1 | | 11.x | 9.x | 8.2 | | 12.x | 10.x | 8.3 | +| 13.x | 11.x | 8.3+(在 8.4 和 8.5 上测试) | 这个矩阵会在 GitHub Actions 中显式验证。 diff --git a/docs/laravel-installation.md b/docs/laravel-installation.md index d21187c..ce4e7ca 100644 --- a/docs/laravel-installation.md +++ b/docs/laravel-installation.md @@ -27,6 +27,7 @@ RuleFlow is tested with: | 10.x | 8.1+ | | 11.x | 8.2+ | | 12.x | 8.3+ | +| 13.x | 8.3+ | The core package only requires PHP 8.1+. Laravel is optional and loaded through package auto-discovery when the package is installed in a Laravel application. @@ -142,7 +143,7 @@ Redis. For local smoke tests, Laravel's default cache store is enough. Before publishing a RuleFlow release, verify: -- GitHub Actions passes for Laravel 10, 11, and 12. +- GitHub Actions passes for Laravel 10, 11, 12, and 13. - A clean Laravel project can install the package. - `vendor:publish --tag=ruleflow-config` works. - `php artisan ruleflow:validate` works. diff --git a/docs/laravel-installation.zh-CN.md b/docs/laravel-installation.zh-CN.md index d702168..84e15b7 100644 --- a/docs/laravel-installation.zh-CN.md +++ b/docs/laravel-installation.zh-CN.md @@ -24,6 +24,7 @@ RuleFlow 当前测试: | 10.x | 8.1+ | | 11.x | 8.2+ | | 12.x | 8.3+ | +| 13.x | 8.3+ | 核心包只要求 PHP 8.1+。Laravel 是可选集成,在 Laravel 项目里通过 package auto-discovery 自动加载。 @@ -138,7 +139,7 @@ php artisan tinker 发布 RuleFlow 版本前,确认: -- GitHub Actions 通过 Laravel 10、11、12 测试矩阵。 +- GitHub Actions 通过 Laravel 10、11、12、13 测试矩阵。 - 干净 Laravel 项目可以安装这个包。 - `vendor:publish --tag=ruleflow-config` 正常。 - `php artisan ruleflow:validate` 正常。 From 571ab379f7b922d7b7a39c5353b52b2eb3e3e1d1 Mon Sep 17 00:00:00 2001 From: yl0711-coder Date: Fri, 12 Jun 2026 16:18:23 +0800 Subject: [PATCH 4/6] Fix object context resolution in FieldAccessor Non-public properties no longer raise an uncaught Error; they count as missing. ArrayAccess offsets and magic __isset/__get accessors are now supported, so Eloquent models work directly as evaluation context. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 ++ README.zh-CN.md | 3 ++ docs/semantics.md | 13 ++++- src/FieldAccessor.php | 79 ++++++++++++++++++++++++++--- tests/FieldAccessorTest.php | 85 ++++++++++++++++++++++++++++++++ tests/LaravelIntegrationTest.php | 13 +++++ 6 files changed, 187 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7797e2d..14d32d6 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,10 @@ Evaluate rules: $result = app(\RuleFlow\RuleFlow::class)->evaluate($context); ``` +The context accepts nested arrays, public object properties, `ArrayAccess` +offsets, and magic `__isset`/`__get` accessors — so Eloquent models can be +passed directly, for example `['order' => $order]`. + Validate configured rules: ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index b499e9e..356137d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -476,6 +476,9 @@ php artisan vendor:publish --tag=ruleflow-config $result = app(\RuleFlow\RuleFlow::class)->evaluate($context); ``` +context 支持嵌套数组、对象公开属性、`ArrayAccess` 以及魔术 `__isset`/`__get` +访问器——因此可以直接传 Eloquent 模型,例如 `['order' => $order]`。 + 校验配置中的规则: ```bash diff --git a/docs/semantics.md b/docs/semantics.md index d876086..06c646d 100644 --- a/docs/semantics.md +++ b/docs/semantics.md @@ -113,8 +113,17 @@ Built-in failure reason codes include: Fields are resolved from the input context using dot notation, for example `user.risk_score`. -The context may be an array or an object. Nested arrays and public object -properties are supported. +The context may be an array or an object. Each path segment is resolved in +this order: + +1. array keys +2. public object properties +3. `ArrayAccess` offsets (this covers Eloquent models) +4. magic `__isset`/`__get` accessor pairs + +Private and protected properties are treated as missing instead of producing +an error. For magic accessors, `isset()` semantics apply: an attribute whose +value is `null` counts as missing. When a field does not exist: diff --git a/src/FieldAccessor.php b/src/FieldAccessor.php index 457fdd6..84e0c9a 100644 --- a/src/FieldAccessor.php +++ b/src/FieldAccessor.php @@ -4,11 +4,18 @@ namespace RuleFlow; +use ArrayAccess; + final class FieldAccessor { /** * Reads nested values with dot notation, for example user.risk_score. * + * Each path segment is resolved against arrays, public object properties, + * ArrayAccess offsets (which covers Eloquent models), and magic + * __isset/__get pairs, in that order. Non-public properties are treated + * as missing instead of being read. + * * @param array|object $context */ public function get(array|object $context, string $path, mixed $default = null): mixed @@ -35,14 +42,35 @@ private function resolve(array|object $context, string $path): array $current = $context; foreach (explode('.', $path) as $segment) { - if (is_array($current) && array_key_exists($segment, $current)) { - $current = $current[$segment]; - continue; + $step = $this->step($current, $segment); + + if (!$step['exists']) { + return [ + 'exists' => false, + 'value' => null, + ]; } - if (is_object($current) && property_exists($current, $segment)) { - $current = $current->{$segment}; - continue; + $current = $step['value']; + } + + return [ + 'exists' => true, + 'value' => $current, + ]; + } + + /** + * @return array{exists:bool,value:mixed} + */ + private function step(mixed $current, string $segment): array + { + if (is_array($current)) { + if (array_key_exists($segment, $current)) { + return [ + 'exists' => true, + 'value' => $current[$segment], + ]; } return [ @@ -51,9 +79,44 @@ private function resolve(array|object $context, string $path): array ]; } + if (!is_object($current)) { + return [ + 'exists' => false, + 'value' => null, + ]; + } + + // get_object_vars() from this external scope only exposes public + // properties, so private and protected properties stay unreadable + // instead of triggering property access errors. + $publicProperties = get_object_vars($current); + + if (array_key_exists($segment, $publicProperties)) { + return [ + 'exists' => true, + 'value' => $publicProperties[$segment], + ]; + } + + if ($current instanceof ArrayAccess && $current->offsetExists($segment)) { + return [ + 'exists' => true, + 'value' => $current->offsetGet($segment), + ]; + } + + // isset() on inaccessible properties delegates to __isset(), which + // means null values reported by magic accessors count as missing. + if (method_exists($current, '__isset') && method_exists($current, '__get') && isset($current->{$segment})) { + return [ + 'exists' => true, + 'value' => $current->{$segment}, + ]; + } + return [ - 'exists' => true, - 'value' => $current, + 'exists' => false, + 'value' => null, ]; } } diff --git a/tests/FieldAccessorTest.php b/tests/FieldAccessorTest.php index b862054..d9d9518 100644 --- a/tests/FieldAccessorTest.php +++ b/tests/FieldAccessorTest.php @@ -48,4 +48,89 @@ public function testItReadsTopLevelObjects(): void self::assertSame(45, $accessor->get($context, 'user.risk_score')); self::assertTrue($accessor->exists($context, 'user.risk_score')); } + + public function testItTreatsNonPublicPropertiesAsMissingInsteadOfFailing(): void + { + $accessor = new FieldAccessor(); + $context = [ + 'order' => new class { + public function __construct( + private readonly float $amount = 200.0, + protected string $currency = 'USD' + ) { + } + + public function describe(): string + { + return "{$this->amount} {$this->currency}"; + } + }, + ]; + + self::assertFalse($accessor->exists($context, 'order.amount')); + self::assertFalse($accessor->exists($context, 'order.currency')); + self::assertSame('missing', $accessor->get($context, 'order.amount', 'missing')); + } + + public function testItReadsMagicGetObjects(): void + { + $accessor = new FieldAccessor(); + $context = [ + 'order' => new class { + /** @var array */ + private array $attributes = ['amount' => 200, 'note' => null]; + + public function __get(string $key): mixed + { + return $this->attributes[$key] ?? null; + } + + public function __isset(string $key): bool + { + return isset($this->attributes[$key]); + } + }, + ]; + + self::assertSame(200, $accessor->get($context, 'order.amount')); + self::assertTrue($accessor->exists($context, 'order.amount')); + + // isset() semantics apply to magic accessors: null values are missing. + self::assertFalse($accessor->exists($context, 'order.note')); + self::assertFalse($accessor->exists($context, 'order.unknown')); + } + + public function testItReadsArrayAccessObjects(): void + { + $accessor = new FieldAccessor(); + $context = [ + 'order' => new \ArrayObject(['amount' => 200]), + ]; + + self::assertSame(200, $accessor->get($context, 'order.amount')); + self::assertTrue($accessor->exists($context, 'order.amount')); + self::assertFalse($accessor->exists($context, 'order.unknown')); + } + + public function testItPrefersPublicPropertiesOverMagicAccessors(): void + { + $accessor = new FieldAccessor(); + $context = [ + 'order' => new class { + public int $amount = 100; + + public function __get(string $key): mixed + { + return 999; + } + + public function __isset(string $key): bool + { + return true; + } + }, + ]; + + self::assertSame(100, $accessor->get($context, 'order.amount')); + } } diff --git a/tests/LaravelIntegrationTest.php b/tests/LaravelIntegrationTest.php index 73490db..1ca3f8e 100644 --- a/tests/LaravelIntegrationTest.php +++ b/tests/LaravelIntegrationTest.php @@ -77,6 +77,19 @@ public function testItEvaluatesRulesThroughLaravelFacade(): void self::assertSame('manual_review', $result->action()); } + public function testItEvaluatesEloquentModelAttributesAsContext(): void + { + $order = new class extends \Illuminate\Database\Eloquent\Model { + protected $guarded = []; + }; + $order->forceFill(['amount' => 1299]); + + $result = RuleFlowFacade::evaluate(['order' => $order]); + + self::assertTrue($result->matched()); + self::assertSame('manual_review', $result->action()); + } + public function testItCanEvaluateAllRulesThroughLaravelContainer(): void { $this->app['config']->set('ruleflow.rules', [ From 4687c3c5ccf50185abbcdeff385d93444251d669 Mon Sep 17 00:00:00 2001 From: yl0711-coder Date: Fri, 12 Jun 2026 16:18:23 +0800 Subject: [PATCH 5/6] Add operator value validation for rule definitions New optional ValidatesValueInterface lets operators validate definition value shapes. regex, between, in, and not_in now report invalid patterns, malformed ranges, and non-array lists at validation time, and invalid regex patterns degrade to a non-match without PHP warnings at runtime. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 + docs/custom-operators.md | 38 ++++++ docs/validation.md | 5 + src/Operators/BetweenOperator.php | 21 +++- src/Operators/InOperator.php | 11 +- src/Operators/NotInOperator.php | 11 +- src/Operators/RegexOperator.php | 26 ++++- src/Operators/ValidatesValueInterface.php | 26 +++++ src/Validation/RuleValidator.php | 27 +++++ tests/OperatorTest.php | 2 + tests/RuleValidatorTest.php | 134 ++++++++++++++++++++++ 11 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 src/Operators/ValidatesValueInterface.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 115029d..0652cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ All notable changes to RuleFlow PHP will be documented in this file. - Added optional rule `metadata` for ownership, version, ticket, and rollout context. - Exposed matched rule metadata through `metadata()`, `toArray()`, and `explain()` results. - Documented production guidance for rule ownership metadata. +- Fixed field resolution crashing with an uncaught `Error` when context objects expose non-public properties; they now count as missing. +- Added context support for `ArrayAccess` offsets and magic `__isset`/`__get` accessors, so Eloquent models work as evaluation context. +- Added `ValidatesValueInterface` so operators can validate rule `value` shapes; `regex`, `between`, `in`, and `not_in` now report unusable values (invalid patterns, malformed ranges, non-array lists) at validation time. +- Stopped invalid regex patterns from emitting PHP warnings during evaluation; they degrade to a non-match. - Added Laravel 13 support (Testbench 11, PHPUnit 13 allowed) with compatibility docs. - Added PHP 8.4 and 8.5 to the CI test matrix; PHP 8.4/8.5 legs run against Laravel 13. - Fixed CI for EOL Laravel 10/11 legs by relaxing Composer advisory blocking there only. diff --git a/docs/custom-operators.md b/docs/custom-operators.md index c5b285b..33d5ba2 100644 --- a/docs/custom-operators.md +++ b/docs/custom-operators.md @@ -58,3 +58,41 @@ $result = Engine::makeWithOperators( 'action' => 'allow', ] ``` + +## Optional: Validate Definition Values + +Operators can also implement `ValidatesValueInterface` so that +`RuleValidator` (and `php artisan ruleflow:validate`) reports unusable +`value` shapes at validation time instead of failing silently at evaluation +time: + +```php +use RuleFlow\Operators\OperatorInterface; +use RuleFlow\Operators\ValidatesValueInterface; + +final class IpRangeOperator implements OperatorInterface, ValidatesValueInterface +{ + public function name(): string + { + return 'ip_in_range'; + } + + public function evaluate(mixed $actual, mixed $expected): bool + { + // ... + } + + public function validateValue(mixed $value): ?string + { + if (!is_string($value) || !str_contains($value, '/')) { + return 'must be a CIDR string such as 10.0.0.0/8.'; + } + + return null; + } +} +``` + +Returning `null` accepts the value; returning a string reports it as a +validation error. The built-in `regex`, `between`, `in`, and `not_in` +operators implement this interface. diff --git a/docs/validation.md b/docs/validation.md index 4f2b9de..d2442be 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -53,3 +53,8 @@ $validation = (new RuleValidator($operators))->validate($rules); - required condition keys: `field`, `operator`, `value` - non-empty field paths - registered operators +- operator value shapes: `regex` patterns must compile, `between` requires + exactly two numeric bounds in order, `in`/`not_in` require a non-empty array + +Custom operators can opt into value-shape validation by implementing +`RuleFlow\Operators\ValidatesValueInterface`. diff --git a/src/Operators/BetweenOperator.php b/src/Operators/BetweenOperator.php index 7581d21..380d586 100644 --- a/src/Operators/BetweenOperator.php +++ b/src/Operators/BetweenOperator.php @@ -4,7 +4,7 @@ namespace RuleFlow\Operators; -final class BetweenOperator implements OperatorInterface +final class BetweenOperator implements OperatorInterface, ValidatesValueInterface { public function name(): string { @@ -21,4 +21,23 @@ public function evaluate(mixed $actual, mixed $expected): bool return is_numeric($min) && is_numeric($max) && $actual >= $min && $actual <= $max; } + + public function validateValue(mixed $value): ?string + { + if (!is_array($value) || count($value) !== 2) { + return 'must be an array of exactly two numeric values.'; + } + + [$min, $max] = array_values($value); + + if (!is_numeric($min) || !is_numeric($max)) { + return 'must be an array of exactly two numeric values.'; + } + + if ($min > $max) { + return 'minimum must not be greater than maximum.'; + } + + return null; + } } diff --git a/src/Operators/InOperator.php b/src/Operators/InOperator.php index c4cffc3..c71fcf2 100644 --- a/src/Operators/InOperator.php +++ b/src/Operators/InOperator.php @@ -4,7 +4,7 @@ namespace RuleFlow\Operators; -final class InOperator implements OperatorInterface +final class InOperator implements OperatorInterface, ValidatesValueInterface { public function name(): string { @@ -15,4 +15,13 @@ public function evaluate(mixed $actual, mixed $expected): bool { return is_array($expected) && in_array($actual, $expected, true); } + + public function validateValue(mixed $value): ?string + { + if (!is_array($value) || $value === []) { + return 'must be a non-empty array.'; + } + + return null; + } } diff --git a/src/Operators/NotInOperator.php b/src/Operators/NotInOperator.php index 56ff852..516fc59 100644 --- a/src/Operators/NotInOperator.php +++ b/src/Operators/NotInOperator.php @@ -4,7 +4,7 @@ namespace RuleFlow\Operators; -final class NotInOperator implements OperatorInterface +final class NotInOperator implements OperatorInterface, ValidatesValueInterface { public function name(): string { @@ -15,4 +15,13 @@ public function evaluate(mixed $actual, mixed $expected): bool { return is_array($expected) && !in_array($actual, $expected, true); } + + public function validateValue(mixed $value): ?string + { + if (!is_array($value) || $value === []) { + return 'must be a non-empty array.'; + } + + return null; + } } diff --git a/src/Operators/RegexOperator.php b/src/Operators/RegexOperator.php index ddaf1eb..9ace69f 100644 --- a/src/Operators/RegexOperator.php +++ b/src/Operators/RegexOperator.php @@ -4,7 +4,7 @@ namespace RuleFlow\Operators; -final class RegexOperator implements OperatorInterface +final class RegexOperator implements OperatorInterface, ValidatesValueInterface { public function name(): string { @@ -13,8 +13,26 @@ public function name(): string public function evaluate(mixed $actual, mixed $expected): bool { - return is_string($actual) - && is_string($expected) - && preg_match($expected, $actual) === 1; + if (!is_string($actual) || !is_string($expected)) { + return false; + } + + // Invalid patterns are reported through validateValue(); at + // evaluation time they must degrade to a non-match without + // emitting per-evaluation PHP warnings. + return @preg_match($expected, $actual) === 1; + } + + public function validateValue(mixed $value): ?string + { + if (!is_string($value) || $value === '') { + return 'must be a non-empty string regex pattern.'; + } + + if (@preg_match($value, '') === false) { + return 'must be a valid regex pattern.'; + } + + return null; } } diff --git a/src/Operators/ValidatesValueInterface.php b/src/Operators/ValidatesValueInterface.php new file mode 100644 index 0000000..c99c040 --- /dev/null +++ b/src/Operators/ValidatesValueInterface.php @@ -0,0 +1,26 @@ +operators->names(), true)) { $errors[] = "{$path}.operator [{$condition['operator']}] is not registered."; + return; + } + + $this->validateConditionValue($condition, $path, $errors); + } + + /** + * @param array $condition + * @param list $errors + */ + private function validateConditionValue(array $condition, string $path, array &$errors): void + { + if (!array_key_exists('value', $condition)) { + return; + } + + $operator = $this->operators->get($condition['operator']); + + if (!$operator instanceof ValidatesValueInterface) { + return; + } + + $valueError = $operator->validateValue($condition['value']); + + if ($valueError !== null) { + $errors[] = "{$path}.value {$valueError}"; } } diff --git a/tests/OperatorTest.php b/tests/OperatorTest.php index 758628f..3b43652 100644 --- a/tests/OperatorTest.php +++ b/tests/OperatorTest.php @@ -100,6 +100,8 @@ public static function operatorCases(): iterable yield 'regex passes' => [RegexOperator::class, 'ORD-1001', '/^ORD-[0-9]+$/', true]; yield 'regex fails' => [RegexOperator::class, 'PAY-1001', '/^ORD-[0-9]+$/', false]; yield 'regex rejects non string expected' => [RegexOperator::class, 'ORD-1001', 1001, false]; + yield 'regex degrades to non match on invalid pattern' => + [RegexOperator::class, 'ORD-1001', '/[unclosed', false]; } public function testOperatorRegistryAcceptsCustomOperators(): void diff --git a/tests/RuleValidatorTest.php b/tests/RuleValidatorTest.php index 17e3581..52747c9 100644 --- a/tests/RuleValidatorTest.php +++ b/tests/RuleValidatorTest.php @@ -216,4 +216,138 @@ public function testItReportsInvalidNestedConditionGroups(): void $result->errors() ); } + + public function testItReportsInvalidRegexPatterns(): void + { + $result = RuleValidator::defaults()->validate([ + [ + 'name' => 'broken_regex', + 'conditions' => [ + ['field' => 'email', 'operator' => 'regex', 'value' => '/[unclosed'], + ], + 'action' => 'flag', + ], + [ + 'name' => 'non_string_regex', + 'conditions' => [ + ['field' => 'email', 'operator' => 'regex', 'value' => 123], + ], + 'action' => 'flag', + ], + ]); + + self::assertFalse($result->valid()); + self::assertContains( + 'rules[0].conditions[0].value must be a valid regex pattern.', + $result->errors() + ); + self::assertContains( + 'rules[1].conditions[0].value must be a non-empty string regex pattern.', + $result->errors() + ); + } + + public function testItAcceptsValidRegexPatterns(): void + { + $result = RuleValidator::defaults()->validate([ + [ + 'name' => 'valid_regex', + 'conditions' => [ + ['field' => 'email', 'operator' => 'regex', 'value' => '/@example\\.com$/'], + ], + 'action' => 'flag', + ], + ]); + + self::assertTrue($result->valid()); + } + + public function testItReportsMalformedBetweenValues(): void + { + $result = RuleValidator::defaults()->validate([ + [ + 'name' => 'single_bound', + 'conditions' => [ + ['field' => 'age', 'operator' => 'between', 'value' => [18]], + ], + 'action' => 'allow', + ], + [ + 'name' => 'non_numeric_bounds', + 'conditions' => [ + ['field' => 'age', 'operator' => 'between', 'value' => ['low', 'high']], + ], + 'action' => 'allow', + ], + [ + 'name' => 'inverted_bounds', + 'conditions' => [ + ['field' => 'age', 'operator' => 'between', 'value' => [65, 18]], + ], + 'action' => 'allow', + ], + ]); + + self::assertFalse($result->valid()); + self::assertContains( + 'rules[0].conditions[0].value must be an array of exactly two numeric values.', + $result->errors() + ); + self::assertContains( + 'rules[1].conditions[0].value must be an array of exactly two numeric values.', + $result->errors() + ); + self::assertContains( + 'rules[2].conditions[0].value minimum must not be greater than maximum.', + $result->errors() + ); + } + + public function testItReportsNonArrayInValues(): void + { + $result = RuleValidator::defaults()->validate([ + [ + 'name' => 'in_scalar', + 'conditions' => [ + ['field' => 'status', 'operator' => 'in', 'value' => 'active'], + ], + 'action' => 'allow', + ], + [ + 'name' => 'not_in_empty', + 'conditions' => [ + ['field' => 'status', 'operator' => 'not_in', 'value' => []], + ], + 'action' => 'allow', + ], + ]); + + self::assertFalse($result->valid()); + self::assertContains('rules[0].conditions[0].value must be a non-empty array.', $result->errors()); + self::assertContains('rules[1].conditions[0].value must be a non-empty array.', $result->errors()); + } + + public function testItValidatesOperatorValuesInsideNestedGroups(): void + { + $result = RuleValidator::defaults()->validate([ + [ + 'name' => 'nested_value_check', + 'conditions' => [ + [ + 'match' => 'any', + 'conditions' => [ + ['field' => 'email', 'operator' => 'regex', 'value' => '/[unclosed'], + ], + ], + ], + 'action' => 'flag', + ], + ]); + + self::assertFalse($result->valid()); + self::assertContains( + 'rules[0].conditions[0].conditions[0].value must be a valid regex pattern.', + $result->errors() + ); + } } From 266e40ef4d2c23278854ee591152edb1c17a395e Mon Sep 17 00:00:00 2001 From: yl0711-coder Date: Fri, 12 Jun 2026 16:21:42 +0800 Subject: [PATCH 6/6] Add missing Chinese docs to documentation index Co-Authored-By: Claude Opus 4.8 --- docs/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/README.md b/docs/README.md index 881a264..ddbfb00 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ RuleFlow documentation is organized by usage stage. - [quickstart.md](quickstart.md): the smallest useful setup - [decision-list-model.md](decision-list-model.md): the evaluation model, trade-offs, and why RuleFlow is not a RETE engine +- [decision-list-model.zh-CN.md](decision-list-model.zh-CN.md): Chinese version of the decision list model overview - [rule-format.md](rule-format.md): rule structure and JSON format - [semantics.md](semantics.md): evaluation behavior and trace contract @@ -20,7 +21,9 @@ RuleFlow documentation is organized by usage stage. - [laravel.md](laravel.md): package integration basics - [laravel-compatibility.md](laravel-compatibility.md): supported Laravel versions and integration boundaries +- [laravel-compatibility.zh-CN.md](laravel-compatibility.zh-CN.md): Chinese version of the Laravel compatibility guide - [laravel-installation.md](laravel-installation.md): real Laravel project installation and smoke test checklist +- [laravel-installation.zh-CN.md](laravel-installation.zh-CN.md): Chinese version of the Laravel installation checklist - [laravel-example.md](laravel-example.md): production-style Laravel order risk example - [laravel-example.zh-CN.md](laravel-example.zh-CN.md): Chinese version of the Laravel order risk example