From 61250999d3e6b1a775dc0dedea5f1f3898b33378 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 21:33:31 +0000 Subject: [PATCH 1/7] feat(matrix): support (array), (string) and (bool) casts via engine handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ObjectCastInterface::__cast() dispatching on the cast type: pretty-printed rows for (string), true for (bool) — a valid matrix is never empty by construction — and toArray() for IS_ARRAY. Unsupported cast types fall through to $hook->proceed(); when the default engine handler reports failure (numeric casts write no value), the handler substitutes the engine's own documented fallback of 1/1.0 because the z-engine trampoline always reports success to the engine. The handler runs across the FFI boundary and therefore never throws. Two PHP 8.4 realities shape the implementation: - PHP 8.1 inserted IS_NEVER = 17 into the engine type table, shifting _IS_BOOL to 18 and _IS_NUMBER to 19; z-engine dev-master still declares the pre-8.1 values, so boolean casts are matched against a local ENGINE_IS_BOOL = 18 constant instead of the stale ReflectionValue::_IS_BOOL. - (array) casts do not reach cast_object at all: the engine routes them through get_properties_for with the ARRAY_CAST purpose. Matrix now also implements ObjectGetPropertiesForInterface::__getFields(), returning the rows for array casts while reproducing the default property table for debugging, serialization, var_export and JSON so their output stays byte-identical. get_object_vars() exposes only publicly visible entries because the engine skips visibility filtering once a custom handler is installed. CastObjectHook::getResult() is deliberately never called after a failed proceed(): the retval slot is uninitialized scratch memory in that case and reading it corrupts the calling VM frame. Closes #9 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JsbdqisRfGuujN3QD9n8En --- src/Matrix.php | 143 +++++++++++++++++- .../Functional/testCanCastMatrixToArray.phpt | 38 +++++ tests/Functional/testCanCastMatrixToBool.phpt | 23 +++ ...CastMatrixToNumberUsingEngineFallback.phpt | 21 +++ .../Functional/testCanCastMatrixToString.phpt | 20 +++ .../testKeepsDefaultDebugOutput.phpt | 26 ++++ ...epsPropertyVisibilityForGetObjectVars.phpt | 20 +++ 7 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 tests/Functional/testCanCastMatrixToArray.phpt create mode 100644 tests/Functional/testCanCastMatrixToBool.phpt create mode 100644 tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt create mode 100644 tests/Functional/testCanCastMatrixToString.phpt create mode 100644 tests/Functional/testKeepsDefaultDebugOutput.phpt create mode 100644 tests/Functional/testKeepsPropertyVisibilityForGetObjectVars.phpt diff --git a/src/Matrix.php b/src/Matrix.php index 8146084..53df9a0 100644 --- a/src/Matrix.php +++ b/src/Matrix.php @@ -13,9 +13,12 @@ namespace Lisachenko\NativePhpMatrix; use function array_column; +use function array_filter; use function array_is_list; use function array_keys; use function count; +use function get_mangled_object_vars; +use function implode; use InvalidArgumentException; @@ -28,13 +31,20 @@ use LogicException; use function sprintf; +use function str_starts_with; +use ZEngine\ClassExtension\Hook\CastObjectHook; use ZEngine\ClassExtension\Hook\CompareValuesHook; use ZEngine\ClassExtension\Hook\DoOperationHook; +use ZEngine\ClassExtension\Hook\GetPropertiesForHook; +use ZEngine\ClassExtension\ObjectCastInterface; use ZEngine\ClassExtension\ObjectCompareValuesInterface; use ZEngine\ClassExtension\ObjectCreateInterface; use ZEngine\ClassExtension\ObjectCreateTrait; use ZEngine\ClassExtension\ObjectDoOperationInterface; +use ZEngine\ClassExtension\ObjectGetPropertiesForInterface; +use ZEngine\Core; +use ZEngine\Reflection\ReflectionValue; use ZEngine\System\OpCode; /** @@ -46,10 +56,44 @@ * * @template-covariant T of int|float */ -final class Matrix implements ObjectCreateInterface, ObjectDoOperationInterface, ObjectCompareValuesInterface +final class Matrix implements + ObjectCastInterface, + ObjectCompareValuesInterface, + ObjectCreateInterface, + ObjectDoOperationInterface, + ObjectGetPropertiesForInterface { use ObjectCreateTrait; + /** + * Cast type the engine passes for boolean casts (`_IS_BOOL` in Zend/zend_types.h) + * + * PHP 8.1 inserted IS_NEVER = 17 into the engine type table, shifting _IS_BOOL to 18 and + * _IS_NUMBER to 19. z-engine dev-master still declares the pre-8.1 values + * (ReflectionValue::_IS_BOOL = 17, ReflectionValue::_IS_NUMBER = 18), so dispatching on those + * constants would misroute every boolean cast on PHP 8.4. + */ + private const int ENGINE_IS_BOOL = 18; + + /** + * Cast type the engine passes for numeric coercion (`_IS_NUMBER` in Zend/zend_types.h) + * + * @see self::ENGINE_IS_BOOL for why ReflectionValue::_IS_NUMBER cannot be used here + */ + private const int ENGINE_IS_NUMBER = 19; + + /** + * Purpose the engine passes to the get_properties_for handler on `(array)` casts + * (ZEND_PROP_PURPOSE_ARRAY_CAST in Zend/zend_object_handlers.h) + */ + private const int PROP_PURPOSE_ARRAY_CAST = 1; + + /** + * Purpose the engine passes to the get_properties_for handler for get_object_vars() calls + * (ZEND_PROP_PURPOSE_GET_OBJECT_VARS in Zend/zend_object_handlers.h) + */ + private const int PROP_PURPOSE_GET_OBJECT_VARS = 5; + /** * Matrix cells, stored as a list of rows * @@ -385,6 +429,103 @@ public static function __compare(CompareValuesHook $hook): int return -2; } + /** + * Performs casting of this object to another type, requested by the engine + * + * Unlike the operation and comparison hooks this handler never throws: it runs inside an FFI + * callback, and PHP 8.4 escalates any exception crossing that boundary into an engine-level + * fatal error. Cast types that are not implemented here defer to the default engine behaviour + * via {@see CastObjectHook::proceed()} instead. + * + * @param CastObjectHook $hook Instance of current hook + * + * @return mixed Casted value + */ + public static function __cast(CastObjectHook $hook): mixed + { + $object = $hook->getObject(); + $castType = $hook->getCastType(); + + if ($object instanceof self) { + switch ($castType) { + case ReflectionValue::IS_ARRAY: + // PHP 8.4 routes `(array)` casts through the get_properties_for handler (see + // __getFields), the branch stays for engine paths that pass IS_ARRAY directly + return $object->toArray(); + case ReflectionValue::IS_STRING: + return $object->toString(); + case self::ENGINE_IS_BOOL: + // A valid matrix holds at least one cell by construction, so it is never "empty" + return true; + } + } + + $status = $hook->proceed(); + if ($status === Core::SUCCESS) { + return $hook->getResult(); + } + + // The default handler produced no value: for numeric casts the engine caller would emit + // a warning and substitute 1, but the z-engine trampoline always reports success to the + // engine, so that substitute value has to be supplied here + return match ($castType) { + ReflectionValue::IS_LONG, self::ENGINE_IS_NUMBER => 1, + ReflectionValue::IS_DOUBLE => 1.0, + default => null, + }; + } + + /** + * Returns an array representation of this object for the purpose requested by the engine + * + * PHP 8.4 does not route `(array)` casts through the cast_object handler: they arrive here, + * at the get_properties_for handler, with the ARRAY_CAST purpose. Every other purpose + * (debugging, serialization, var_export, JSON encoding, get_object_vars) keeps the default + * property table, reproduced with get_mangled_object_vars() because the raw hashtable + * returned by the original engine handler cannot cross the hook boundary as a PHP array. + * This handler runs in non-throwing engine contexts and therefore never throws. + * + * @param GetPropertiesForHook $hook Instance of current hook + * + * @return array Key-value pairs for the requested purpose + */ + public static function __getFields(GetPropertiesForHook $hook): array + { + $object = $hook->getObject(); + $purpose = $hook->getPurpose(); + + if ($object instanceof self && $purpose === self::PROP_PURPOSE_ARRAY_CAST) { + return $object->toArray(); + } + + if ($purpose === self::PROP_PURPOSE_GET_OBJECT_VARS) { + // The engine hands the returned table to get_object_vars() callers without applying + // any visibility filtering once a custom handler is installed, so only the publicly + // visible entries may be exposed here: every property of Matrix is private, exactly + // like the default handlers would show to an outside caller + return array_filter( + get_mangled_object_vars($object), + static fn(int|string $key): bool => !is_string($key) || !str_starts_with($key, "\0"), + ARRAY_FILTER_USE_KEY, + ); + } + + return get_mangled_object_vars($object); + } + + /** + * Returns a human-readable representation of this matrix, one row per line + */ + private function toString(): string + { + $rows = []; + foreach ($this->matrix as $row) { + $rows[] = '[' . implode(', ', $row) . ']'; + } + + return implode("\n", $rows); + } + /** * Casts an operand received from the engine into a native scalar value, if possible * diff --git a/tests/Functional/testCanCastMatrixToArray.phpt b/tests/Functional/testCanCastMatrixToArray.phpt new file mode 100644 index 0000000..5f7ee05 --- /dev/null +++ b/tests/Functional/testCanCastMatrixToArray.phpt @@ -0,0 +1,38 @@ +--TEST-- +Matrix can be cast to array with "(array)" operator +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + +--EXPECT-- +array(2) { + [0]=> + array(3) { + [0]=> + int(1) + [1]=> + int(2) + [2]=> + int(3) + } + [1]=> + array(3) { + [0]=> + int(4) + [1]=> + int(5) + [2]=> + int(6) + } +} diff --git a/tests/Functional/testCanCastMatrixToBool.phpt b/tests/Functional/testCanCastMatrixToBool.phpt new file mode 100644 index 0000000..b0a931b --- /dev/null +++ b/tests/Functional/testCanCastMatrixToBool.phpt @@ -0,0 +1,23 @@ +--TEST-- +Matrix can be cast to bool with "(bool)" operator and is always truthy +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + +--EXPECT-- +bool(true) +truthy diff --git a/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt b/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt new file mode 100644 index 0000000..0b2e678 --- /dev/null +++ b/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt @@ -0,0 +1,21 @@ +--TEST-- +Numeric casts of a Matrix fall back to the default engine value +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + +--EXPECT-- +int(1) +float(1) diff --git a/tests/Functional/testCanCastMatrixToString.phpt b/tests/Functional/testCanCastMatrixToString.phpt new file mode 100644 index 0000000..ce0a8d0 --- /dev/null +++ b/tests/Functional/testCanCastMatrixToString.phpt @@ -0,0 +1,20 @@ +--TEST-- +Matrix can be cast to string with "(string)" operator +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + +--EXPECT-- +[1, 2, 3] +[4, 5.5, 6] diff --git a/tests/Functional/testKeepsDefaultDebugOutput.phpt b/tests/Functional/testKeepsDefaultDebugOutput.phpt new file mode 100644 index 0000000..77ec5ef --- /dev/null +++ b/tests/Functional/testKeepsDefaultDebugOutput.phpt @@ -0,0 +1,26 @@ +--TEST-- +Debugging a Matrix keeps the default engine property table with visibility markers +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +bool(true) diff --git a/tests/Functional/testKeepsPropertyVisibilityForGetObjectVars.phpt b/tests/Functional/testKeepsPropertyVisibilityForGetObjectVars.phpt new file mode 100644 index 0000000..ee0751d --- /dev/null +++ b/tests/Functional/testKeepsPropertyVisibilityForGetObjectVars.phpt @@ -0,0 +1,20 @@ +--TEST-- +get_object_vars() on a Matrix keeps default property visibility for outside callers +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + +--EXPECT-- +array(0) { +} From c75bf942a291aac20224a9184eba8e255262ed46 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 21:33:31 +0000 Subject: [PATCH 2/7] docs: document casting behaviour and retire the scalar-casts roadmap item Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JsbdqisRfGuujN3QD9n8En --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3aa7450..359638f 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,25 @@ var_dump($a != $c); // bool(true) Equality is element-wise and strict. Ordering operators (`<`, `>`) are deliberately not meaningful for matrices — the `compare` hook reports "unordered" rather than inventing a total order. +### Casting + +Casts are dispatched by the engine too — the `cast_object` and `get_properties_for` handlers: + +```php +$m = new Matrix([[1, 2], [3, 4]]); + +var_dump((array) $m); // [[1, 2], [3, 4]] — the rows, not the object's internals + +echo (string) $m; // [1, 2] + // [3, 4] + +var_dump((bool) $m); // bool(true) — a valid matrix is never empty by construction +``` + +Numeric casts keep the engine's default behaviour (`(int) $m` gives `1`), and everything that is +not a cast — `var_dump()`, `serialize()`, `var_export()`, `json_encode()`, `get_object_vars()` — +still sees the object exactly as before. + ### Beyond operators The class is a normal PHP object too: @@ -154,7 +173,6 @@ Tracked as [GitHub issues](https://github.com/lisachenko/native-php-matrix/issue - **Array-style row access** — `$matrix[0]` and `$matrix[0][1]` via the `read_dimension` handler - **`count($matrix)`** — row count through `Countable`, installed at the engine level - **`foreach` iteration** — row-by-row traversal via the `get_iterator` handler -- **Scalar casts** — `(string)` and `(float)` behaviour through `cast_object` - **Friendly `var_dump()`** — a `get_debug_info` handler that prints the matrix instead of its internals - **FFI BLAS backend** — hand multiplication off to a real BLAS library for performance, keeping the pure-PHP path as the fallback From 52ed90a85b8aeb60f77225f4fcd65ffb22ca463f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 21:38:58 +0000 Subject: [PATCH 3/7] docs(matrix): spell out deliberate deviations in the cast and properties hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: the silently-absent engine warning on numeric cast fallback, the scope-insensitive public view served to get_object_vars() callers, and the getResult()-only-after-success rule are all deliberate; say so where a future change would otherwise "fix" them. Also corrects the stale-constants note to reference the z-engine 8.4 line instead of dev-master — composer.json already pins the stable 8.4.0 tag — and imports ARRAY_FILTER_USE_KEY for consistency with the function imports. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JsbdqisRfGuujN3QD9n8En --- src/Matrix.php | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/Matrix.php b/src/Matrix.php index 53df9a0..72fd686 100644 --- a/src/Matrix.php +++ b/src/Matrix.php @@ -14,6 +14,9 @@ use function array_column; use function array_filter; + +use const ARRAY_FILTER_USE_KEY; + use function array_is_list; use function array_keys; use function count; @@ -69,7 +72,7 @@ final class Matrix implements * Cast type the engine passes for boolean casts (`_IS_BOOL` in Zend/zend_types.h) * * PHP 8.1 inserted IS_NEVER = 17 into the engine type table, shifting _IS_BOOL to 18 and - * _IS_NUMBER to 19. z-engine dev-master still declares the pre-8.1 values + * _IS_NUMBER to 19. The z-engine 8.4 line (8.4.0) still declares the pre-8.1 values * (ReflectionValue::_IS_BOOL = 17, ReflectionValue::_IS_NUMBER = 18), so dispatching on those * constants would misroute every boolean cast on PHP 8.4. */ @@ -435,7 +438,10 @@ public static function __compare(CompareValuesHook $hook): int * Unlike the operation and comparison hooks this handler never throws: it runs inside an FFI * callback, and PHP 8.4 escalates any exception crossing that boundary into an engine-level * fatal error. Cast types that are not implemented here defer to the default engine behaviour - * via {@see CastObjectHook::proceed()} instead. + * via {@see CastObjectHook::proceed()} instead. One deliberate deviation: the engine caller + * normally emits "Object of class ... could not be converted to int/float" when the default + * handler fails, but the z-engine trampoline reports success unconditionally, so numeric + * casts yield the substitute value silently — the warning cannot be restored from here. * * @param CastObjectHook $hook Instance of current hook * @@ -460,6 +466,8 @@ public static function __cast(CastObjectHook $hook): mixed } } + // getResult() may only be consulted after a successful proceed(): on failure the retval + // slot is uninitialized scratch memory and reading it corrupts the calling VM frame $status = $hook->proceed(); if ($status === Core::SUCCESS) { return $hook->getResult(); @@ -485,6 +493,10 @@ public static function __cast(CastObjectHook $hook): mixed * returned by the original engine handler cannot cross the hook boundary as a PHP array. * This handler runs in non-throwing engine contexts and therefore never throws. * + * One deliberate deviation: get_object_vars() is scope-sensitive by default (a closure bound + * to Matrix would see the private properties), but the calling scope is not recoverable from + * inside this FFI callback, so every caller receives the public view — an empty array. + * * @param GetPropertiesForHook $hook Instance of current hook * * @return array Key-value pairs for the requested purpose @@ -502,7 +514,8 @@ public static function __getFields(GetPropertiesForHook $hook): array // The engine hands the returned table to get_object_vars() callers without applying // any visibility filtering once a custom handler is installed, so only the publicly // visible entries may be exposed here: every property of Matrix is private, exactly - // like the default handlers would show to an outside caller + // like the default handlers would show to an outside caller. Class-scoped callers + // lose their privileged view — see the deviation note in the method docblock return array_filter( get_mangled_object_vars($object), static fn(int|string $key): bool => !is_string($key) || !str_starts_with($key, "\0"), From bd44c44a1d1956364407904dbb83f726691c9a6d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 21:38:58 +0000 Subject: [PATCH 4/7] test(tests): pin the public-only view for class-scoped get_object_vars The scope-insensitivity of the get_properties_for hook is a documented deviation from default engine behaviour; lock it in a test so a change there is a conscious decision, and name the intentionally absent engine warning in the numeric-fallback test title. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JsbdqisRfGuujN3QD9n8En --- ...CastMatrixToNumberUsingEngineFallback.phpt | 2 +- ...VisibilityForClassScopedGetObjectVars.phpt | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 tests/Functional/testKeepsPropertyVisibilityForClassScopedGetObjectVars.phpt diff --git a/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt b/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt index 0b2e678..32d38da 100644 --- a/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt +++ b/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt @@ -1,5 +1,5 @@ --TEST-- -Numeric casts of a Matrix fall back to the default engine value +Numeric casts of a Matrix fall back to the default engine value (the engine warning is intentionally absent) --INI-- ffi.enable=1 opcache.jit=off diff --git a/tests/Functional/testKeepsPropertyVisibilityForClassScopedGetObjectVars.phpt b/tests/Functional/testKeepsPropertyVisibilityForClassScopedGetObjectVars.phpt new file mode 100644 index 0000000..fce0e82 --- /dev/null +++ b/tests/Functional/testKeepsPropertyVisibilityForClassScopedGetObjectVars.phpt @@ -0,0 +1,24 @@ +--TEST-- +get_object_vars() on a Matrix returns the public view even for class-scoped callers (known deviation) +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + get_object_vars($m), null, Matrix::class); +var_dump($scoped($matrix)); +?> +--EXPECT-- +array(0) { +} From 8896331392bc5b575bb2ade31d8784999a151bb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 22:09:47 +0000 Subject: [PATCH 5/7] refactor(matrix): adopt z-engine cast-type enums and safe engine fall-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit z-engine 8.4.1 (PRs lisachenko/z-engine#155 and #156) ships the API the local workarounds stood in for: CastType/PropertyPurpose enums exposed via getCastTypeEnum()/getPurposeEnum(), guarded upstream against the generated engine ground truth, and a CastObjectHook whose fall-through behaves exactly like an uninstalled handler. Drop the local ENGINE_IS_BOOL/ENGINE_IS_NUMBER/PROP_PURPOSE_* constants — values that can silently drift between PHP minors — and dispatch on the named cases instead. The numeric-cast fallback is gone entirely: __cast now defers to proceed()/getResult(), and the failed cast propagates to the engine caller, which emits its own "could not be converted to int/float" warning and substitutes 1 — the previously undeliverable default diagnostic is restored, and the fallback test asserts it. Raises the z-engine floor to ~8.4.1 accordingly, in lockstep with the PHP 8.4 pin. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JsbdqisRfGuujN3QD9n8En --- src/Matrix.php | 75 +++++-------------- ...CastMatrixToNumberUsingEngineFallback.phpt | 11 ++- 2 files changed, 24 insertions(+), 62 deletions(-) diff --git a/src/Matrix.php b/src/Matrix.php index 72fd686..cd53c3e 100644 --- a/src/Matrix.php +++ b/src/Matrix.php @@ -37,17 +37,17 @@ use function str_starts_with; use ZEngine\ClassExtension\Hook\CastObjectHook; +use ZEngine\ClassExtension\Hook\CastType; use ZEngine\ClassExtension\Hook\CompareValuesHook; use ZEngine\ClassExtension\Hook\DoOperationHook; use ZEngine\ClassExtension\Hook\GetPropertiesForHook; +use ZEngine\ClassExtension\Hook\PropertyPurpose; use ZEngine\ClassExtension\ObjectCastInterface; use ZEngine\ClassExtension\ObjectCompareValuesInterface; use ZEngine\ClassExtension\ObjectCreateInterface; use ZEngine\ClassExtension\ObjectCreateTrait; use ZEngine\ClassExtension\ObjectDoOperationInterface; use ZEngine\ClassExtension\ObjectGetPropertiesForInterface; -use ZEngine\Core; -use ZEngine\Reflection\ReflectionValue; use ZEngine\System\OpCode; /** @@ -68,35 +68,6 @@ final class Matrix implements { use ObjectCreateTrait; - /** - * Cast type the engine passes for boolean casts (`_IS_BOOL` in Zend/zend_types.h) - * - * PHP 8.1 inserted IS_NEVER = 17 into the engine type table, shifting _IS_BOOL to 18 and - * _IS_NUMBER to 19. The z-engine 8.4 line (8.4.0) still declares the pre-8.1 values - * (ReflectionValue::_IS_BOOL = 17, ReflectionValue::_IS_NUMBER = 18), so dispatching on those - * constants would misroute every boolean cast on PHP 8.4. - */ - private const int ENGINE_IS_BOOL = 18; - - /** - * Cast type the engine passes for numeric coercion (`_IS_NUMBER` in Zend/zend_types.h) - * - * @see self::ENGINE_IS_BOOL for why ReflectionValue::_IS_NUMBER cannot be used here - */ - private const int ENGINE_IS_NUMBER = 19; - - /** - * Purpose the engine passes to the get_properties_for handler on `(array)` casts - * (ZEND_PROP_PURPOSE_ARRAY_CAST in Zend/zend_object_handlers.h) - */ - private const int PROP_PURPOSE_ARRAY_CAST = 1; - - /** - * Purpose the engine passes to the get_properties_for handler for get_object_vars() calls - * (ZEND_PROP_PURPOSE_GET_OBJECT_VARS in Zend/zend_object_handlers.h) - */ - private const int PROP_PURPOSE_GET_OBJECT_VARS = 5; - /** * Matrix cells, stored as a list of rows * @@ -438,10 +409,9 @@ public static function __compare(CompareValuesHook $hook): int * Unlike the operation and comparison hooks this handler never throws: it runs inside an FFI * callback, and PHP 8.4 escalates any exception crossing that boundary into an engine-level * fatal error. Cast types that are not implemented here defer to the default engine behaviour - * via {@see CastObjectHook::proceed()} instead. One deliberate deviation: the engine caller - * normally emits "Object of class ... could not be converted to int/float" when the default - * handler fails, but the z-engine trampoline reports success unconditionally, so numeric - * casts yield the substitute value silently — the warning cannot be restored from here. + * via {@see CastObjectHook::proceed()} — with z-engine >= 8.4.1 that fall-through behaves + * exactly like an uninstalled handler, so failed numeric casts propagate to the engine + * caller, which emits its own warning and substitutes the value 1. * * @param CastObjectHook $hook Instance of current hook * @@ -449,38 +419,27 @@ public static function __compare(CompareValuesHook $hook): int */ public static function __cast(CastObjectHook $hook): mixed { - $object = $hook->getObject(); - $castType = $hook->getCastType(); + $object = $hook->getObject(); if ($object instanceof self) { - switch ($castType) { - case ReflectionValue::IS_ARRAY: + switch ($hook->getCastTypeEnum()) { + case CastType::Array: // PHP 8.4 routes `(array)` casts through the get_properties_for handler (see // __getFields), the branch stays for engine paths that pass IS_ARRAY directly return $object->toArray(); - case ReflectionValue::IS_STRING: + case CastType::String: return $object->toString(); - case self::ENGINE_IS_BOOL: + case CastType::Bool: // A valid matrix holds at least one cell by construction, so it is never "empty" return true; + default: + break; } } - // getResult() may only be consulted after a successful proceed(): on failure the retval - // slot is uninitialized scratch memory and reading it corrupts the calling VM frame - $status = $hook->proceed(); - if ($status === Core::SUCCESS) { - return $hook->getResult(); - } + $hook->proceed(); - // The default handler produced no value: for numeric casts the engine caller would emit - // a warning and substitute 1, but the z-engine trampoline always reports success to the - // engine, so that substitute value has to be supplied here - return match ($castType) { - ReflectionValue::IS_LONG, self::ENGINE_IS_NUMBER => 1, - ReflectionValue::IS_DOUBLE => 1.0, - default => null, - }; + return $hook->getResult(); } /** @@ -504,13 +463,13 @@ public static function __cast(CastObjectHook $hook): mixed public static function __getFields(GetPropertiesForHook $hook): array { $object = $hook->getObject(); - $purpose = $hook->getPurpose(); + $purpose = $hook->getPurposeEnum(); - if ($object instanceof self && $purpose === self::PROP_PURPOSE_ARRAY_CAST) { + if ($object instanceof self && $purpose === PropertyPurpose::ArrayCast) { return $object->toArray(); } - if ($purpose === self::PROP_PURPOSE_GET_OBJECT_VARS) { + if ($purpose === PropertyPurpose::GetObjectVars) { // The engine hands the returned table to get_object_vars() callers without applying // any visibility filtering once a custom handler is installed, so only the publicly // visible entries may be exposed here: every property of Matrix is private, exactly diff --git a/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt b/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt index 32d38da..6289062 100644 --- a/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt +++ b/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt @@ -1,5 +1,5 @@ --TEST-- -Numeric casts of a Matrix fall back to the default engine value (the engine warning is intentionally absent) +Numeric casts of a Matrix fall back to the default engine behaviour (warning and substitute value) --INI-- ffi.enable=1 opcache.jit=off @@ -16,6 +16,9 @@ $matrix = new Matrix([[1, 2], [3, 4]]); var_dump((int) $matrix); var_dump((float) $matrix); ?> ---EXPECT-- -int(1) -float(1) +--EXPECTREGEX-- +Warning: Object of class Lisachenko\\NativePhpMatrix\\Matrix could not be converted to int in .+ on line \d+ +int\(1\) + +Warning: Object of class Lisachenko\\NativePhpMatrix\\Matrix could not be converted to float in .+ on line \d+ +float\(1\) From 91bf21b5890d5b395497f872313a19c31f5290b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 22:36:15 +0000 Subject: [PATCH 6/7] ci: fail the suite when tests are skipped or incomplete With the suite running on PHP 8.4 and 8.5 in parallel, a version-gated skip could silently shrink coverage on one leg while the job stays green. failOnSkipped/failOnIncomplete in phpunit.xml.dist turn any skipped or incomplete test into a failure for both CI legs and local runs alike. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JsbdqisRfGuujN3QD9n8En --- phpunit.xml.dist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index bdde63c..a208f97 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -13,6 +13,8 @@ colors="true" bootstrap="./vendor/autoload.php" failOnWarning="true" + failOnSkipped="true" + failOnIncomplete="true" displayDetailsOnTestsThatTriggerWarnings="true" displayDetailsOnTestsThatTriggerNotices="true" displayDetailsOnTestsThatTriggerDeprecations="true"> From 9b292ceb29c04f4a6f4b38a72db0526679ee7903 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 22:36:15 +0000 Subject: [PATCH 7/7] docs(matrix): refer to the tracked z-engine dev lines instead of 8.4.1 Review follow-up: the cast fall-through docblock still promised the behaviour "with z-engine >= 8.4.1", but the package consumes the 8.4.x-dev/8.5.x-dev branches now - no tagged version to point at. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JsbdqisRfGuujN3QD9n8En --- src/Matrix.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Matrix.php b/src/Matrix.php index cd53c3e..a83779f 100644 --- a/src/Matrix.php +++ b/src/Matrix.php @@ -407,11 +407,11 @@ public static function __compare(CompareValuesHook $hook): int * Performs casting of this object to another type, requested by the engine * * Unlike the operation and comparison hooks this handler never throws: it runs inside an FFI - * callback, and PHP 8.4 escalates any exception crossing that boundary into an engine-level + * callback, and PHP escalates any exception crossing that boundary into an engine-level * fatal error. Cast types that are not implemented here defer to the default engine behaviour - * via {@see CastObjectHook::proceed()} — with z-engine >= 8.4.1 that fall-through behaves - * exactly like an uninstalled handler, so failed numeric casts propagate to the engine - * caller, which emits its own warning and substitutes the value 1. + * via {@see CastObjectHook::proceed()} — on the z-engine dev lines this package tracks that + * fall-through behaves exactly like an uninstalled handler, so failed numeric casts propagate + * to the engine caller, which emits its own warning and substitutes the value 1. * * @param CastObjectHook $hook Instance of current hook *