From f9ae81bed6fed47327dc56fb052f0c1153d196ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 21:17:13 +0000 Subject: [PATCH 1/7] chore(deps)!: require PHP 8.4, ext-ffi and z-engine dev-master Z-Engine reads Zend Engine structures by byte offset, and those offsets change on every PHP minor release, so the constraint is pinned to the exact minor (~8.4.0) rather than a floor. There is no stable z-engine tag with PHP 8.4 support yet, so it is consumed from dev-master, which requires the root package to carry minimum-stability "dev" with prefer-stable. BREAKING CHANGE: PHP 7.4 and 8.0 are no longer supported; the package now requires PHP ~8.4.0, ext-ffi and lisachenko/z-engine dev-master. Also declares ext-ffi explicitly, adds the test/phpstan/cs:check/cs:fix scripts and enables sort-packages. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01514q9dSHg3UgK6PNdT6Tx1 --- composer.json | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 5297f0e..e17dea2 100644 --- a/composer.json +++ b/composer.json @@ -2,6 +2,7 @@ "name": "lisachenko/native-php-matrix", "description": "PHP extension that provides Matrix class powered with overloaded operators", "type": "library", + "keywords": ["ffi", "matrix", "operator-overloading", "z-engine"], "license": "MIT", "authors": [ { @@ -10,16 +11,36 @@ } ], "require": { - "lisachenko/z-engine": "^0.8.1 || ^0.9.1", - "php": "^7.4|^8.0" + "php": "~8.4.0", + "ext-ffi": "*", + "lisachenko/z-engine": "dev-master" }, "require-dev": { - "phpunit/phpunit": "^9.5.5" + "friendsofphp/php-cs-fixer": "^3.75", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.5" }, "autoload": { - "psr-4" : { - "Lisachenko\\NativePhpMatrix\\" : "src/" + "psr-4": { + "Lisachenko\\NativePhpMatrix\\": "src/" }, "files": ["bootstrap.php"] + }, + "minimum-stability": "dev", + "prefer-stable": true, + "scripts": { + "test": "phpunit", + "phpstan": "phpstan analyse", + "cs:check": "php-cs-fixer fix --dry-run --diff", + "cs:fix": "php-cs-fixer fix" + }, + "scripts-descriptions": { + "test": "Run the test suite", + "phpstan": "Run static analysis at the maximum level", + "cs:check": "Check coding standards without fixing", + "cs:fix": "Fix coding standards" + }, + "config": { + "sort-packages": true } } From 161469fba66be834702cc8fe6de815db5b0514e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 21:17:28 +0000 Subject: [PATCH 2/7] feat(matrix)!: validate input and modernise Matrix for PHP 8.4 The class is now final with private readonly state, so a Matrix cannot be mutated after construction and every operation returns a new instance. The constructor validates its input up front instead of trusting the caller: the outer array must be a non-empty list, every row must be a non-empty list of the same length, and every cell must be an int or a float. Previously a ragged or empty array produced a broken object that failed later, far from the cause. Scalar operands are now declared natively as int|float rather than being checked with is_numeric() inside each method. Because the engine can hand a numeric string to the operator hook, __doOperation() funnels operands through a new asScalar() helper that coerces numeric strings, keeping "$matrix * '2'" working under strict_types=1. multiply() extracts the multiplier's columns once and reuses them across every row of the left operand, instead of calling array_column() inside the inner loop; the result is unchanged. Adds PHPStan level-max generics (@template-covariant T of int|float). The annotations stay honest about widening: division and exponentiation can turn a Matrix into a Matrix, so operations return Matrix rather than pretending T is preserved. bootstrap.php now probes the engine state with isset(Core::$executor) instead of class_exists(Core::class, false). The old guard only asked whether the class was loaded, which says nothing about whether another package had already called Core::init(); $executor is a typed static assigned only by init(), so an uninitialised property is the accurate "nobody has booted the engine" signal. BREAKING CHANGE: Matrix is now final and cannot be extended, its constructor rejects empty, ragged and non-numeric input with InvalidArgumentException, and the scalar methods accept int|float instead of any numeric value. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01514q9dSHg3UgK6PNdT6Tx1 --- bootstrap.php | 7 +- src/Matrix.php | 286 +++++++++++++++++++++++++++++++++---------------- 2 files changed, 198 insertions(+), 95 deletions(-) diff --git a/bootstrap.php b/bootstrap.php index 0532ec3..88f8601 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -1,4 +1,5 @@ ` a division + * or an exponentiation may produce floats, therefore every operation returns a `Matrix` instead of + * pretending that the original `T` is preserved. + * + * @template-covariant T of int|float */ -class Matrix implements ObjectCreateInterface, ObjectDoOperationInterface, ObjectCompareValuesInterface +final class Matrix implements ObjectCreateInterface, ObjectDoOperationInterface, ObjectCompareValuesInterface { use ObjectCreateTrait; - private array $matrix = []; - private int $rows = 0; - private int $columns = 0; + /** + * Matrix cells, stored as a list of rows + * + * @var non-empty-list> + */ + private readonly array $matrix; + + /** + * Total number of rows in this matrix + */ + private readonly int $rows; + + /** + * Total number of columns in this matrix + */ + private readonly int $columns; /** - * Matrix constructor. + * Matrix constructor * - * @param array $matrix + * @param non-empty-list> $matrix Rectangular list of rows, each one holding numeric cells */ public function __construct(array $matrix) { + if ($matrix === []) { + throw new InvalidArgumentException('Matrix should contain at least one row'); + } + if (!array_is_list($matrix)) { + throw new InvalidArgumentException('Matrix should be a list of rows with sequential keys, starting from 0'); + } + + $columns = null; + foreach ($matrix as $rowIndex => $row) { + if (!is_array($row) || !array_is_list($row)) { + throw new InvalidArgumentException( + sprintf('Matrix row %d should be a list of values with sequential keys, starting from 0', $rowIndex), + ); + } + if ($row === []) { + throw new InvalidArgumentException('Matrix should contain at least one column'); + } + if ($columns === null) { + $columns = count($row); + } elseif (count($row) !== $columns) { + throw new InvalidArgumentException('All matrix rows should have the same number of columns'); + } + foreach ($row as $columnIndex => $value) { + if (!is_int($value) && !is_float($value)) { + throw new InvalidArgumentException( + sprintf('Matrix value at [%d][%d] should be either an int or a float', $rowIndex, $columnIndex), + ); + } + } + } + $this->matrix = $matrix; $this->rows = count($matrix); $this->columns = count($matrix[0]); @@ -61,6 +125,11 @@ public function isSquare(): bool return $this->columns === $this->rows; } + /** + * Returns an underlying representation of this matrix + * + * @return non-empty-list> + */ public function toArray(): array { return $this->matrix; @@ -69,11 +138,9 @@ public function toArray(): array /** * Performs multiplication of two matrices * - * @param Matrix $multiplier + * @param self $multiplier Right operand * - * @todo: Implement SSE/AVX support for multiplication - * - * @return $this Product of two matrices + * @return self Product of two matrices */ public function multiply(self $multiplier): self { @@ -81,21 +148,27 @@ public function multiply(self $multiplier): self throw new InvalidArgumentException('Inconsistent matrix supplied'); } - $totalColumns = $multiplier->columns; - $result = []; - foreach ($this->matrix as $row => $rowItems) { - for ($column = 0; $column < $totalColumns; ++$column) { - $columnItems = array_column($multiplier->matrix, $column); - $cellValue = 0; + // Columns of the multiplier are extracted only once, they are reused for every row of the left operand + $multiplierColumns = []; + foreach (array_keys($multiplier->matrix[0]) as $column) { + $multiplierColumns[] = array_column($multiplier->matrix, $column); + } + + $result = []; + foreach ($this->matrix as $rowItems) { + $resultRow = []; + foreach ($multiplierColumns as $columnItems) { + $cellValue = 0; foreach ($rowItems as $key => $value) { $cellValue += $value * $columnItems[$key]; } - $result[$row][$column] = $cellValue; + $resultRow[] = $cellValue; } + $result[] = $resultRow; } - return new static($result); + return new self($result); } /** @@ -103,21 +176,20 @@ public function multiply(self $multiplier): self * * @param int|float $value Divider * - * @return $this + * @return self */ - public function divideByScalar($value): self + public function divideByScalar(int|float $value): self { - if (!is_numeric($value)) { - throw new \InvalidArgumentException("Divide accepts only numeric values"); - } $result = []; - for ($i = 0; $i < $this->rows; ++$i) { - for ($j = 0; $j < $this->columns; ++$j) { - $result[$i][$j] = $this->matrix[$i][$j] / $value; + foreach ($this->matrix as $row) { + $resultRow = []; + foreach ($row as $cellValue) { + $resultRow[] = $cellValue / $value; } + $result[] = $resultRow; } - return new static($result); + return new self($result); } /** @@ -125,21 +197,20 @@ public function divideByScalar($value): self * * @param int|float $value Multiplier * - * @return $this + * @return self */ - public function multiplyByScalar($value): self + public function multiplyByScalar(int|float $value): self { - if (!is_numeric($value)) { - throw new \InvalidArgumentException("Multiply accepts only numeric values"); - } $result = []; - for ($i = 0; $i < $this->rows; ++$i) { - for ($j = 0; $j < $this->columns; ++$j) { - $result[$i][$j] = $this->matrix[$i][$j] * $value; + foreach ($this->matrix as $row) { + $resultRow = []; + foreach ($row as $cellValue) { + $resultRow[] = $cellValue * $value; } + $result[] = $resultRow; } - return new static($result); + return new self($result); } /** @@ -147,50 +218,54 @@ public function multiplyByScalar($value): self * * @param int|float $value Exponent * - * @return $this + * @return self */ - public function powByScalar($value): self + public function powByScalar(int|float $value): self { - if (!is_numeric($value)) { - throw new \InvalidArgumentException("Exponent accepts only numeric values"); - } $result = []; - for ($i = 0; $i < $this->rows; ++$i) { - for ($j = 0; $j < $this->columns; ++$j) { - $result[$i][$j] = $this->matrix[$i][$j] ** $value; + foreach ($this->matrix as $row) { + $resultRow = []; + foreach ($row as $cellValue) { + $resultRow[] = $cellValue ** $value; } + $result[] = $resultRow; } - return new static($result); + return new self($result); } /** * Performs addition of two matrices * - * @return self Sum of two matrices - * @todo: Implement SSE/AVX support for addition + * @param self $value Right operand + * + * @return self Sum of two matrices */ public function sum(self $value): self { if (($this->columns !== $value->columns) || ($this->rows !== $value->rows)) { throw new InvalidArgumentException('Inconsistent matrix supplied'); } + $result = []; - for ($i = 0; $i < $this->rows; ++$i) { - for ($k = 0; $k < $this->columns; ++$k) { - $result[$i][$k] = $this->matrix[$i][$k] + $value->matrix[$i][$k]; + foreach ($this->matrix as $rowIndex => $row) { + $anotherRow = $value->matrix[$rowIndex]; + $resultRow = []; + foreach ($row as $columnIndex => $cellValue) { + $resultRow[] = $cellValue + $anotherRow[$columnIndex]; } + $result[] = $resultRow; } - return new static($result); + return new self($result); } /** * Performs subtraction of two matrices * - * @todo: Implement SSE/AVX support for addition + * @param self $value Right operand * - * @return self Subtraction of two matrices + * @return self Difference of two matrices */ public function subtract(self $value): self { @@ -199,31 +274,32 @@ public function subtract(self $value): self } $result = []; - for ($i = 0; $i < $this->rows; ++$i) { - for ($k = 0; $k < $this->columns; ++$k) { - $result[$i][$k] = $this->matrix[$i][$k] - $value->matrix[$i][$k]; + foreach ($this->matrix as $rowIndex => $row) { + $anotherRow = $value->matrix[$rowIndex]; + $resultRow = []; + foreach ($row as $columnIndex => $cellValue) { + $resultRow[] = $cellValue - $anotherRow[$columnIndex]; } + $result[] = $resultRow; } - return new static($result); + return new self($result); } /** * Checks if the given matrix equals to another one * - * @param Matrix $another Another matrix - * - * @return bool + * @param self $another Another matrix */ - public function equals(Matrix $another): bool + public function equals(self $another): bool { if ($another->rows !== $this->rows || $another->columns !== $this->columns) { return false; } - for ($i = 0; $i < $this->rows; ++$i) { - for ($k = 0; $k < $this->columns; ++$k) { - $equals = $this->matrix[$i][$k] === $another->matrix[$i][$k]; - if (!$equals) { + foreach ($this->matrix as $rowIndex => $row) { + $anotherRow = $another->matrix[$rowIndex]; + foreach ($row as $columnIndex => $cellValue) { + if ($cellValue !== $anotherRow[$columnIndex]) { return false; } } @@ -237,52 +313,54 @@ public function equals(Matrix $another): bool * * @param DoOperationHook $hook Instance of current hook * - * @return mixed Result of operation value + * @return self Result of operation */ - public static function __doOperation(DoOperationHook $hook): Matrix + public static function __doOperation(DoOperationHook $hook): self { $left = $hook->getFirst(); $right = $hook->getSecond(); $opCode = $hook->getOpcode(); - $isLeftMatrix = $left instanceof Matrix; - $isRightMatrix = $right instanceof Matrix; - $isLeftNumeric = is_numeric($left); - $isRightNumeric = is_numeric($right); + $leftMatrix = $left instanceof self ? $left : null; + $rightMatrix = $right instanceof self ? $right : null; + $leftScalar = self::asScalar($left); + $rightScalar = self::asScalar($right); switch ($opCode) { case OpCode::ADD: - if ($isLeftMatrix && $isRightMatrix) { - return $left->sum($right); + if ($leftMatrix !== null && $rightMatrix !== null) { + return $leftMatrix->sum($rightMatrix); } break; case OpCode::SUB: - if ($isLeftMatrix && $isRightMatrix) { - return $left->subtract($right); + if ($leftMatrix !== null && $rightMatrix !== null) { + return $leftMatrix->subtract($rightMatrix); } break; case OpCode::MUL: - if ($isLeftMatrix && $isRightMatrix) { - return $left->multiply($right); - } elseif ($isLeftMatrix && $isRightNumeric) { - return $left->multiplyByScalar($right); - } elseif ($isLeftNumeric && $isRightMatrix) { - return $right->multiplyByScalar($left); + if ($leftMatrix !== null && $rightMatrix !== null) { + return $leftMatrix->multiply($rightMatrix); + } + if ($leftMatrix !== null && $rightScalar !== null) { + return $leftMatrix->multiplyByScalar($rightScalar); + } + if ($leftScalar !== null && $rightMatrix !== null) { + return $rightMatrix->multiplyByScalar($leftScalar); } break; case OpCode::DIV: - if ($isLeftMatrix && $isRightNumeric) { - return $left->divideByScalar($right); + if ($leftMatrix !== null && $rightScalar !== null) { + return $leftMatrix->divideByScalar($rightScalar); } break; case OpCode::POW: - if ($isLeftMatrix && $isRightNumeric) { - return $left->powByScalar($right); + if ($leftMatrix !== null && $rightScalar !== null) { + return $leftMatrix->powByScalar($rightScalar); } break; } - throw new \LogicException('Unsupported ' . OpCode::name($opCode). ' operation or invalid arguments'); + throw new LogicException('Unsupported ' . OpCode::name($opCode) . ' operation or invalid arguments'); } /** @@ -290,14 +368,14 @@ public static function __doOperation(DoOperationHook $hook): Matrix * * @param CompareValuesHook $hook Instance of current hook * - * @return int Result of comparison: 1 is greater, -1 is less, 0 is equal + * @return int Result of comparison: 0 is equal, -2 is uncomparable */ public static function __compare(CompareValuesHook $hook): int { $one = $hook->getFirst(); $another = $hook->getSecond(); - if (!($one instanceof Matrix) || !($another instanceof Matrix)) { - throw new \InvalidArgumentException('Matrix can be compared only with another matrix'); + if (!($one instanceof self) || !($another instanceof self)) { + throw new InvalidArgumentException('Matrix can be compared only with another matrix'); } if ($one->equals($another)) { @@ -306,4 +384,26 @@ public static function __compare(CompareValuesHook $hook): int return -2; } + + /** + * Casts an operand received from the engine into a native scalar value, if possible + * + * Numeric strings are explicitly coerced with the unary plus, otherwise `$matrix * '2'` would fail against the + * natively typed `int|float` parameters under `strict_types=1`. + * + * @param mixed $operand Raw operand, received from the operation hook + * + * @return int|float|null Coerced value or null if the operand is not numeric at all + */ + private static function asScalar(mixed $operand): int|float|null + { + if (is_int($operand) || is_float($operand)) { + return $operand; + } + if (is_string($operand) && is_numeric($operand)) { + return +$operand; + } + + return null; + } } From 8e1e7aad31a3c510ac189a77cb2a3a9ff5ebe211 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 21:17:40 +0000 Subject: [PATCH 3/7] test: fix subtraction coverage, add validation tests, migrate to PHPUnit 12 testFailsOnSubtractingIncompatibleMatrices used the "+" operator, so it asserted the addition failure path and the subtraction guard was never actually exercised. It now uses "-". Adds testFailsOnEmptyMatrix and testFailsOnRaggedMatrix to cover the new constructor validation. These use try/catch because constructor validation is ordinary userland code; the incompatible-dimension tests cannot, because an exception thrown from an operator crosses an FFI callback boundary that PHP 8.4 refuses to let it cross, and surfaces as a fatal error instead. Every .phpt now carries an explicit --INI-- section so the child processes never depend on the parent's php.ini: - ffi.enable=1, which cannot be switched on at runtime - opcache.jit=off, because the JIT rewrites the executor internals the hooks patch - error_reporting=E_ALL & ~E_DEPRECATED, because z-engine dev-master still declares implicitly nullable parameters (ZEngine\Type\OpLine::__construct) which PHP 8.4 reports as a deprecation. PHPUnit's .phpt runner forces display_errors=1, so without it that dependency deprecation is prepended to the captured output of every test and each --EXPECT-- block fails on noise unrelated to this library. It can be dropped once z-engine declares those parameters ?Type. phpunit.xml.dist moves to the PHPUnit 12.5 schema: the coverage/logging blocks are replaced by , and warnings, notices and deprecations are now surfaced rather than silently swallowed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01514q9dSHg3UgK6PNdT6Tx1 --- phpunit.xml.dist | 31 +++++++------------ tests/Functional/testCanAddMatrices.phpt | 4 +++ .../testCanDivideMatrixByNumber.phpt | 4 +++ .../testCanMultiplyCompatibleMatrices.phpt | 4 +++ .../testCanMultiplyMatrixByNumber.phpt | 4 +++ .../testCanMultiplyNumberByMatrix.phpt | 4 +++ .../Functional/testCanPowMatrixByNumber.phpt | 4 +++ tests/Functional/testCanSubtractMatrices.phpt | 4 +++ ...nMultiplicationOfIncompatibleMatrices.phpt | 4 +++ ...testFailsOnAddingIncompatibleMatrices.phpt | 4 +++ tests/Functional/testFailsOnEmptyMatrix.phpt | 22 +++++++++++++ tests/Functional/testFailsOnRaggedMatrix.phpt | 22 +++++++++++++ ...ailsOnSubtractingIncompatibleMatrices.phpt | 6 +++- 13 files changed, 96 insertions(+), 21 deletions(-) create mode 100644 tests/Functional/testFailsOnEmptyMatrix.phpt create mode 100644 tests/Functional/testFailsOnRaggedMatrix.phpt diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 67ba81a..bdde63c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,30 +9,21 @@ ~ --> - - - ./src/ - - - - - - - + bootstrap="./vendor/autoload.php" + failOnWarning="true" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnTestsThatTriggerNotices="true" + displayDetailsOnTestsThatTriggerDeprecations="true"> ./tests/ - - - performance - - - - - + + + ./src/ + + diff --git a/tests/Functional/testCanAddMatrices.phpt b/tests/Functional/testCanAddMatrices.phpt index e4f1c4b..5148f64 100644 --- a/tests/Functional/testCanAddMatrices.phpt +++ b/tests/Functional/testCanAddMatrices.phpt @@ -1,5 +1,9 @@ --TEST-- Matrices can be added with "+" operator +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED --FILE-- getMessage(), PHP_EOL; +} +?> +--EXPECT-- +InvalidArgumentException: Matrix should contain at least one row diff --git a/tests/Functional/testFailsOnRaggedMatrix.phpt b/tests/Functional/testFailsOnRaggedMatrix.phpt new file mode 100644 index 0000000..ecdc869 --- /dev/null +++ b/tests/Functional/testFailsOnRaggedMatrix.phpt @@ -0,0 +1,22 @@ +--TEST-- +Matrix can not be created from rows with a different number of columns +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- +getMessage(), PHP_EOL; +} +?> +--EXPECT-- +InvalidArgumentException: All matrix rows should have the same number of columns diff --git a/tests/Functional/testFailsOnSubtractingIncompatibleMatrices.phpt b/tests/Functional/testFailsOnSubtractingIncompatibleMatrices.phpt index ecd3930..ba5717a 100644 --- a/tests/Functional/testFailsOnSubtractingIncompatibleMatrices.phpt +++ b/tests/Functional/testFailsOnSubtractingIncompatibleMatrices.phpt @@ -1,5 +1,9 @@ --TEST-- Matrices with incompatible dimensions can not be subtracted +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED --FILE-- --EXPECTREGEX-- From 6aa9a2b7323dd388e7a569b2142100e430fa3e88 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 21:17:50 +0000 Subject: [PATCH 4/7] chore: add phpstan, php-cs-fixer and editorconfig configuration PHPStan runs at level max over src and bootstrap.php with no baseline. treatPhpDocTypesAsCertain is deliberately left false: the constructor's runtime validation re-checks types the docblocks already promise, and turning it on reports those defensive checks as alreadyNarrowedType errors even though they are the only thing standing between a caller's untyped array and a malformed Matrix. The php-cs-fixer ruleset (PER-CS2.0 plus strict types, ordered imports and minimal operator alignment) mirrors the one used by lisachenko/z-engine, so both projects format identically. .gitignore additionally excludes the PHPUnit and php-cs-fixer caches. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01514q9dSHg3UgK6PNdT6Tx1 --- .editorconfig | 15 +++++++++++++++ .gitignore | 2 ++ .php-cs-fixer.dist.php | 30 ++++++++++++++++++++++++++++++ phpstan.dist.neon | 7 +++++++ 4 files changed, 54 insertions(+) create mode 100644 .editorconfig create mode 100644 .php-cs-fixer.dist.php create mode 100644 phpstan.dist.neon diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..633d837 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json,neon}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index ac20931..3b2d104 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ composer.lock /build/ /vendor/ +/.phpunit.cache/ +/.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..aa86055 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,30 @@ + + +This source file is subject to the license that is bundled +with this source code in the file LICENSE. +HEADER; + +$finder = PhpCsFixer\Finder::create() + ->in([__DIR__ . '/src']) + ->name('*.php') + ->append([__FILE__, __DIR__ . '/bootstrap.php']); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PER-CS2.0' => true, + 'declare_strict_types' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'single_quote' => true, + 'array_syntax' => ['syntax' => 'short'], + 'binary_operator_spaces' => ['default' => 'align_single_space_minimal'], + ]) + ->setFinder($finder); diff --git a/phpstan.dist.neon b/phpstan.dist.neon new file mode 100644 index 0000000..487ad35 --- /dev/null +++ b/phpstan.dist.neon @@ -0,0 +1,7 @@ +parameters: + level: max + phpVersion: 80400 + paths: + - src + - bootstrap.php + treatPhpDocTypesAsCertain: false From 21a0cdb61cdf313921af71b54b50a8a391b92046 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 21:18:00 +0000 Subject: [PATCH 5/7] feat(ci)!: replace legacy workflow with a three-job pipeline The old phpunit.yml built a matrix over PHP 7.4 and 8.0 with lowest/highest dependency resolution, none of which can work now that the package is pinned to PHP 8.4 and z-engine dev-master. It is replaced by ci.yml with three jobs: tests, static-analysis and coding-standards. All jobs run on PHP 8.4 with ffi.enable=1 and opcache.jit=off, the settings the library needs to boot at all, and pin actions to current major versions. Coverage is disabled because the suite is .phpt based and no coverage was being published anyway. Dependabot additionally tracks the github-actions ecosystem, so the workflow action versions stay current alongside Composer dependencies. BREAKING CHANGE: CI no longer tests PHP 7.4 or 8.0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01514q9dSHg3UgK6PNdT6Tx1 --- .github/dependabot.yml | 5 +++ .github/workflows/ci.yml | 63 +++++++++++++++++++++++++++++++++++ .github/workflows/phpunit.yml | 57 ------------------------------- 3 files changed, 68 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/phpunit.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5a98fda..3e529aa 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,8 @@ updates: interval: daily time: "04:00" open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..67ae333 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + +permissions: + contents: read + +# This library targets PHP 8.4 only - it rides z-engine's engine bindings. +env: + PHP_MINOR: '8.4' + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_MINOR }} + extensions: ffi + ini-values: ffi.enable=1, zend.assertions=1, opcache.jit=off + coverage: none + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run test suite + run: composer test + + static-analysis: + name: PHPStan (level max) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_MINOR }} + extensions: ffi + ini-values: ffi.enable=1, zend.assertions=1, opcache.jit=off + coverage: none + - uses: ramsey/composer-install@v3 + - run: composer phpstan + + coding-standards: + name: Coding standards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_MINOR }} + extensions: ffi + ini-values: ffi.enable=1, zend.assertions=1, opcache.jit=off + coverage: none + - uses: ramsey/composer-install@v3 + - run: composer cs:check diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml deleted file mode 100644 index 2cc6b30..0000000 --- a/.github/workflows/phpunit.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: "PHPUnit tests" - -on: - push: - -jobs: - phpunit: - name: "PHPUnit tests" - - runs-on: ${{ matrix.operating-system }} - - strategy: - matrix: - dependencies: - - "lowest" - - "highest" - php-version: - - "7.4" - - "8.0" - operating-system: - - "ubuntu-latest" - - steps: - - name: "Checkout" - uses: "actions/checkout@v2" - - - name: "Install PHP" - uses: "shivammathur/setup-php@v2" - with: - coverage: "xdebug" - php-version: "${{ matrix.php-version }}" - ini-values: memory_limit=-1 - tools: composer:v2, cs2pr - - - name: "Cache dependencies" - uses: "actions/cache@v2" - with: - path: | - ~/.composer/cache - vendor - key: "php-${{ matrix.php-version }}-${{ matrix.dependencies }}" - restore-keys: "php-${{ matrix.php-version }}-${{ matrix.dependencies }}" - - - name: "Install lowest dependencies" - if: ${{ matrix.dependencies == 'lowest' }} - run: "composer update --prefer-lowest --no-interaction --no-progress --no-suggest" - - - name: "Install highest dependencies" - if: ${{ matrix.dependencies == 'highest' }} - run: "composer update --no-interaction --no-progress --no-suggest" - - - name: "Install locked dependencies" - if: ${{ matrix.dependencies == 'locked' }} - run: "composer install --no-interaction --no-progress --no-suggest" - - - name: "Tests" - run: "XDEBUG_MODE=coverage vendor/bin/phpunit" From 732cfde28222c17e4889d9291c0adebe85b07138 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 21:18:13 +0000 Subject: [PATCH 6/7] docs: modernize README with z-engine-style badges and roadmap Rewrites the README around what the library actually is: the first userland PHP extension implementing real operator overloading, with no C and no PECL build. Adds z-engine-style badges (the CI badge now points at ci.yml, not the retired workflow), a requirements section that explains why the PHP version is pinned rather than merely stating it, worked usage examples for every supported operator, and a roadmap of the engine handlers not yet implemented. Documents the failure semantics precisely: constructor validation raises a catchable InvalidArgumentException, but a dimension mismatch or an unsupported operand combination is raised inside an FFI callback, which PHP 8.4 turns into a fatal error that userland cannot catch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01514q9dSHg3UgK6PNdT6Tx1 --- README.md | 177 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 150 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 5718295..e5d7d6a 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,87 @@ -Native matrix library ------------------ +
-For a long time, PHP developers dreamed of being able to overload operators. And now, finally, this moment has come. -Thanks to the PHP7.4 and the [lisachenko/z-engine](https://github.com/lisachenko/z-engine) package, we can overload the -operators of comparison, addition, multiplication, casting and much more! +# 🧮 Native PHP Matrix -This library is first ever userland PHP extension, that implements operator overloading for the `Matrix` class. +### Real operator overloading for PHP — in pure PHP. +**Native PHP Matrix** gives PHP a `Matrix` type with genuine arithmetic: `+`, `-`, `*`, `/`, `**` and `==` all work on matrices the way they work on numbers. There is no C code, no PECL package, no compiler — just [Z-Engine](https://github.com/lisachenko/z-engine) hooking the Zend Engine's own `do_operation` and `compare` handlers through FFI. + +[![CI](https://img.shields.io/github/actions/workflow/status/lisachenko/native-php-matrix/ci.yml?branch=master&label=CI)](https://github.com/lisachenko/native-php-matrix/actions/workflows/ci.yml) [![GitHub release](https://img.shields.io/github/release/lisachenko/native-php-matrix.svg)](https://github.com/lisachenko/native-php-matrix/releases/latest) -[![Minimum PHP Version](http://img.shields.io/badge/php-%3E%3D%207.4-8892BF.svg)](https://php.net/) -[![License](https://img.shields.io/github/license/lisachenko/native-php-matrix.svg)](https://packagist.org/packages/lisachenko/native-php-matrix) -[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/lisachenko/native-php-matrix/PHPUnit%20tests)](https://github.com/lisachenko/native-php-matrix/actions/workflows/phpunit.yml) +[![PHP Version](https://img.shields.io/badge/php-8.4-8892BF.svg)](https://php.net/) +[![License](https://img.shields.io/packagist/l/lisachenko/native-php-matrix.svg)](https://packagist.org/packages/lisachenko/native-php-matrix) +[![PHPStan](https://img.shields.io/badge/PHPStan-level%20max-brightgreen.svg)](https://phpstan.org/) + +
+ +--- + +> **⚠️ Experimental — educational first, production second.** This library installs custom object handlers into a running Zend Engine. It is a demonstration of what userland PHP can reach, and it inherits every constraint of the machinery underneath it. Pin your PHP version, keep it out of production until there are stable tags on both this package and Z-Engine, and treat a crash as an engine-level bug report rather than a flaky test. + +## The first PHP "extension" written without C + +For a long time operator overloading was the exclusive privilege of compiled extensions: write C, learn the `zend_object_handlers` struct, build a `.so`, ship a PECL release. This library is the first ever *userland* PHP extension that implements true operator overloading — and it does it entirely in PHP. + +The trick is [lisachenko/z-engine](https://github.com/lisachenko/z-engine), which loads version-exact FFI definitions of the engine's C structures and lets PHP write into them. `Matrix` installs its own `do_operation` and `compare` handlers on its own class entry, so when the VM evaluates `$a * $b` and sees an object operand, it calls straight back into PHP code. The operators are not simulated, not parsed, not wrapped in a fluent API — the engine really dispatches them. + +## What you get + +| | Operation | Example | +|---|---|---| +| ➕ | **Addition** — element-wise, dimensions must match | `$a + $b` | +| ➖ | **Subtraction** — element-wise, dimensions must match | `$a - $b` | +| ✖️ | **Matrix multiplication** — columns of the left must match rows of the right | `$a * $b` | +| 🔢 | **Scalar multiplication** — in both directions | `$a * 2` and `2 * $a` | +| ➗ | **Scalar division** | `$a / 2` | +| 🔺 | **Scalar exponentiation** — element-wise power | `$a ** 2` | +| 🟰 | **Equality** — strict element-wise comparison | `$a == $b`, `$a != $b` | +| 🛡️ | **Type-safe by design** — PHPStan generics `Matrix` / `Matrix`, checked at level max | `Matrix` | + +Every operation returns a **new** `Matrix`; the class is `final` and its state is `readonly`, so nothing is ever mutated in place. The constructor validates that you passed a rectangular list of rows holding only `int`/`float` cells and raises a catchable `InvalidArgumentException` when it does not. + +Operator-level failures are a different story, and it is worth being blunt about it: a dimension mismatch (`InvalidArgumentException`) or an unimplemented operand combination such as `$a - 2` (`LogicException`) is raised *inside an FFI callback*, and PHP 8.4 does not allow an exception to cross that boundary. The engine prints the exception and then halts with `Fatal error: Throwing from FFI callbacks is not allowed`. You cannot `try`/`catch` it — check your dimensions before you multiply. + +Generics are honest about arithmetic: `Matrix` divided by `2` is a `Matrix`, because PHP's `/` widens. Nothing pretends to preserve `T` where the maths does not. + +## How it works + +Three moving parts, in order: +1. **`bootstrap.php`** is registered in Composer's `files` autoload, so it runs the moment you `require vendor/autoload.php`. It calls `ZEngine\Core::init()`, which validates that the FFI struct definitions match the exact PHP build you are running. +2. It then calls `installExtensionHandlers()` on `ZEngine\Reflection\ReflectionClass` for `Matrix`, wiring the class's `create_object`, `do_operation` and `compare` slots to the engine trampolines. Order matters — `create_object` allocates the memory the other handlers live in. +3. **`Matrix::__doOperation()`** and **`Matrix::__compare()`** are static hooks. The engine hands them a `DoOperationHook` / `CompareValuesHook` describing the opcode and both operands; they dispatch to ordinary, boring, pure-PHP methods (`sum()`, `subtract()`, `multiply()`, `multiplyByScalar()`, `divideByScalar()`, `powByScalar()`, `equals()`). -Pre-requisites and initialization --------------- +The maths is plain PHP. The magic is only in getting the engine to call it. -As this library depends on `FFI`, it requires PHP>=7.4 and `FFI` extension to be enabled. Also, current limitations of -[lisachenko/z-engine](https://github.com/lisachenko/z-engine) are also applied (x64, NTS) +## Requirements + +- **PHP `~8.4.0`** — an exact minor, not a floor. Z-Engine reads engine structures by byte offset and those offsets change on every PHP minor release; `Core::init()` refuses to boot on a mismatch rather than corrupting memory. +- **`ext-ffi` enabled**, with `ffi.enable=1` for CLI usage. +- **x64, non-thread-safe (NTS)** build — the same platform limitations as [Z-Engine](https://github.com/lisachenko/z-engine#requirements--support-matrix). +- The **matching Z-Engine minor branch**. This package tracks the PHP 8.4 line; mixing a Z-Engine built for another minor is not a configuration choice, it is undefined behaviour. + +## Installation -To install this library, simply add it via `composer`: ```bash -composer require lisachenko/native-php-matrix +composer require lisachenko/z-engine:dev-master lisachenko/native-php-matrix:dev-master ``` -Now you can test it with following example: +Z-Engine does not yet have a stable tag with PHP 8.4 support, so both packages are consumed from `dev-master`. Composer only resolves development stability at the **root** level, which is why the requirement has to be written into your own project rather than inherited — your `composer.json` needs: + +```json +{ + "minimum-stability": "dev", + "prefer-stable": true +} +``` + +Once a stable 8.4-capable Z-Engine release exists, both constraints collapse back to ordinary version ranges. + +No initialization call is needed: `bootstrap.php` ships in the package's `files` autoload and sets everything up behind `require vendor/autoload.php`. + +## Usage + +### The classic ```php toArray()); // [[32]] — a 1×3 times a 3×1 gives a 1×1 +``` + +### Powers and division + +```php +$m = new Matrix([[1, 2, 3]]); + +var_dump(($m ** 2)->toArray()); // [[1, 4, 9]] — element-wise exponentiation + +$even = new Matrix([[2, 4, 6]]); +var_dump(($even / 2)->toArray()); // [[1, 2, 3]] +``` + +### Comparison + +```php +$a = new Matrix([[1, 2], [3, 4]]); +$b = new Matrix([[1, 2], [3, 4]]); +$c = new Matrix([[1, 2], [3, 5]]); + +var_dump($a == $b); // bool(true) +var_dump($a == $c); // bool(false) +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. + +### Beyond operators + +The class is a normal PHP object too: + +```php +$m = new Matrix([[1, 2], [3, 4]]); + +$m->getRows(); // 2 +$m->getColumns(); // 2 +$m->isSquare(); // true +$m->toArray(); // [[1, 2], [3, 4]] +``` + +## Roadmap + +Tracked as [GitHub issues](https://github.com/lisachenko/native-php-matrix/issues) — contributions welcome: + +- **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 + +## Contributing + +The repository ships an agent/contributor contract in **[CLAUDE.md](CLAUDE.md)** — read it before changing anything, especially the section on why the PHP version is pinned. + +```bash +composer test # PHPUnit 12, .phpt functional suite +composer phpstan # static analysis at level max +composer cs:check # coding standards (PER-CS2.0); composer cs:fix to apply ``` -Supported features: - - [x] Matrices addition (`$matrixA + $matrixB`) - - [x] Matrices subtraction (`$matrixA - $matrixB`) - - [x] Matrix multiplication by number (`$matrixA * 2`) - - [x] Matrices multiplication (`$matrixA * $matrixB`) - - [x] Dividing a matrix by a number (`$matrixA / 2`) - - [x] Matrices equality check (`$matrixA == $matrixB`) - -For the future versions, I would like to implement native SSE/AVX assembly methods to improve the performance of calculation. +## License - +Released under the [MIT License](LICENSE). From 2f04bbe12a03ae25c59d37e586c26c8ba5cf6a20 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 21:18:13 +0000 Subject: [PATCH 7/7] docs: add CLAUDE.md and Claude Code agent definitions CLAUDE.md records the contract a contributor or agent needs before touching this repository: why the PHP version is pinned and must never be loosened to silence an initialization failure, the three mandatory .phpt INI settings and the reason for each, the hook contracts, and the rule that generics must stay honest about arithmetic widening the cell type. Adds three scoped agent definitions: test-runner (run and interpret the suite, treating a segfault as an engine-level bug rather than a flaky test), code-reviewer (read-only review against the hook, version-pin and generics rules) and phpt-author (write .phpt tests to the house conventions). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01514q9dSHg3UgK6PNdT6Tx1 --- .claude/agents/code-reviewer.md | 74 ++++++++++++ .claude/agents/phpt-author.md | 97 ++++++++++++++++ .claude/agents/test-runner.md | 65 +++++++++++ CLAUDE.md | 198 ++++++++++++++++++++++++++++++++ 4 files changed, 434 insertions(+) create mode 100644 .claude/agents/code-reviewer.md create mode 100644 .claude/agents/phpt-author.md create mode 100644 .claude/agents/test-runner.md create mode 100644 CLAUDE.md diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000..ab149ca --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,74 @@ +--- +name: code-reviewer +description: Use to review a diff or a set of changed files in this repository before committing — checks z-engine hook contracts, PHPStan generics honesty, version pins, coding standards and test hygiene. +tools: Read, Grep, Glob +--- + +You are a read-only reviewer for `lisachenko/native-php-matrix`, a library that installs +real Zend Engine operator handlers from pure PHP via z-engine and FFI. You never modify +files; you produce findings. + +Review against the checklist below, in this order of severity. + +## 1. Engine hook contracts (blocking) + +- `Matrix::__doOperation(DoOperationHook)` and `Matrix::__compare(CompareValuesHook)` are + **static** and **do throw** — `InvalidArgumentException` (dimension mismatch) and + `LogicException` (unsupported operand combination). Note that these run inside an FFI + callback: PHP 8.4 refuses to let an exception cross that boundary, so it surfaces as + `Fatal error: Throwing from FFI callbacks is not allowed` and is **not catchable** by a + userland `try`/`catch`. Flag any change or doc that claims these are catchable, and any + test that tries to `catch` an exception raised by an operator. +- Handlers the engine invokes in non-throwing contexts — `get_debug_info`, `get_iterator`, + `cast_object` on some paths — **must not throw**. Flag any new handler that can. +- `__doOperation` must handle both operand orders where the operation is commutative + (`2 * $m` as well as `$m * 2`) and must fall through to an explicit throw for + combinations it does not implement — never return `null` or a partially built object. +- Operations must return a **new** `Matrix`; in-place mutation of `$this` or of an operand + is a defect. +- `bootstrap.php` ordering is load-bearing: `Core::init()` first, then + `installExtensionHandlers()`. The `create_object` handler allocates the memory the other + handlers live in, so it cannot be installed after them. + +## 2. Version pins (blocking) + +- `composer.json` must keep `"php": "~8.4.0"` and `"lisachenko/z-engine": "dev-master"`, + with root `"minimum-stability": "dev"` and `"prefer-stable": true`. +- Any diff that widens the PHP constraint (`^8.4`, `>=8.4`), drops the z-engine dev + requirement, or bypasses `Core::init()`'s version guard is rejected outright. Offsets into + engine structures are minor-version specific; loosening the pin trades a clear error for + memory corruption. +- CI must run on PHP 8.4 with `ffi.enable=1` and `opcache.jit=off`. + +## 3. PHPStan generics honesty (blocking) + +`Matrix` is `@template-covariant T of int|float`. + +- Arithmetic that can widen the element type — division, exponentiation, and any mixed + int/float input — must be annotated as returning `Matrix`, not `Matrix`. +- Flag any annotation that claims to preserve `T` through an operation whose maths does not. +- New code must be clean at PHPStan level max; suppressing with `@phpstan-ignore` needs an + inline justification. + +## 4. Test hygiene + +- Every `.phpt` carries an `--INI--` section with `ffi.enable=1` and `opcache.jit=off`. + A missing section is a blocking finding — the test would fail for the wrong reason. +- Autoload include path is `__DIR__ . '/../../vendor/autoload.php'`. +- `--EXPECT--` is preferred; `--EXPECTREGEX--` only where output genuinely varies (paths, + stack traces). Flag regexes used merely to paper over an unstable expectation. +- New behaviour, including new failure modes, comes with a test. + +## 5. Style and conventions + +- PER-CS2.0 (php-cs-fixer). Note obvious violations, but do not nitpick what + `composer cs:fix` would fix automatically — say "run cs:fix" once. +- `declare(strict_types=1)` in every PHP file; the existing file header docblock preserved. +- Commit messages follow Conventional Commits with scopes `matrix`, `bootstrap`, `tests`, + `ci`, `docs`. + +## Output + +Group findings as **Blocking**, **Should fix**, **Nit**. Each finding: file and line, what +is wrong, and the concrete change that would resolve it. If the diff is clean, say so +plainly and name what you checked. diff --git a/.claude/agents/phpt-author.md b/.claude/agents/phpt-author.md new file mode 100644 index 0000000..4b19ca5 --- /dev/null +++ b/.claude/agents/phpt-author.md @@ -0,0 +1,97 @@ +--- +name: phpt-author +description: Use to write new .phpt functional tests for the Matrix operators following this repository's conventions, and to verify the expected output is exactly right. +tools: Read, Write, Grep, Glob, Bash +--- + +You write `.phpt` functional tests for `lisachenko/native-php-matrix`. Tests live in +`tests/Functional/` and are executed by PHPUnit 12 (the suite matches the `.phpt` suffix). + +## Before writing + +Read `src/Matrix.php` to confirm the exact behaviour you are covering — which operand +orders `__doOperation` accepts, which exception type is thrown, and whether the result +elements come out as `int` or `float`. Read one or two existing tests in +`tests/Functional/` for the house style. Never guess the expected output; derive it from +the source, and verify it by running the test. + +## The template + +``` +--TEST-- +One-line description of the behaviour, in the present tense +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- +toArray()); +?> +--EXPECT-- +array(1) { + [0]=> + array(3) { + [0]=> + int(2) + [1]=> + int(4) + [2]=> + int(6) + } +} +``` + +## Rules + +- **`--INI--` is mandatory, all three lines.** `ffi.enable` cannot be set at runtime, the + JIT rewrites the executor internals z-engine hooks, and + `error_reporting=E_ALL & ~E_DEPRECATED` hides a deprecation that z-engine `dev-master` + itself triggers on PHP 8.4 (implicitly nullable parameters). PHPUnit's `.phpt` runner + forces `display_errors=1`, so without that third line the dependency's deprecation is + prepended to your captured output and the test fails on noise. +- **An operator cannot throw into userland.** `__doOperation`/`__compare` run inside an FFI + callback, and PHP 8.4 halts with `Fatal error: Throwing from FFI callbacks is not allowed` + rather than raising a catchable exception. Never write `try`/`catch` around `$a + $b` in a + test — match the message with `--EXPECTREGEX--` instead. Constructor validation is normal + userland code and *is* catchable. +- **Include path is exactly `__DIR__ . '/../../vendor/autoload.php'`** — the autoloader + pulls in `bootstrap.php`, which is what installs the operator handlers. A test that skips + it tests nothing. +- **One behaviour per file.** Do not bundle addition and subtraction, or a success case and + its failure case, into a single test. +- **Naming:** `test.phpt`, matching the existing set — + `testCanAddMatrices.phpt`, `testFailsOnAddingIncompatibleMatrices.phpt`. +- **`--EXPECT--` over `--EXPECTREGEX--`.** Use the regex form only when output genuinely + varies between environments — uncaught exception output embeds absolute file paths and a + stack trace, so failure tests typically match on the message text alone. +- **Types matter.** `var_dump()` distinguishes `int(1)` from `float(1)`. PHP's `/` returns + an `int` only when the division is exact; `**` with a negative or fractional exponent + returns `float`. Get this right or the test is worthless. +- Keep matrices small — 1×3, 2×2, 3×1. The test documents a behaviour; it is not a + benchmark. + +## Verifying + +Run the test you just wrote before reporting it done: + +```bash +php -d ffi.enable=1 -d opcache.jit=off vendor/bin/phpunit tests/Functional/.phpt +``` + +If it fails on an expectation, correct the **expectation** to what the library actually +does only when you have confirmed from `src/Matrix.php` that the actual behaviour is +correct. If the library is wrong, report the defect instead of encoding it into an +expectation. If the run segfaults, stop and report it — that is an engine-level bug, not a +test problem. + +Write only files under `tests/Functional/`. Do not modify `src/`, `bootstrap.php`, or +configuration. diff --git a/.claude/agents/test-runner.md b/.claude/agents/test-runner.md new file mode 100644 index 0000000..243d476 --- /dev/null +++ b/.claude/agents/test-runner.md @@ -0,0 +1,65 @@ +--- +name: test-runner +description: Use to run the .phpt functional suite (or a single test) and report exactly which behaviours broke and why, including engine-level crashes. +tools: Bash, Read, Grep, Glob +--- + +You run and interpret the test suite of `lisachenko/native-php-matrix`, a library that +installs Zend Engine operator handlers from userland PHP via z-engine + FFI. Your job is +to produce a precise verdict, not to make tests pass. + +## Running the suite + +Preferred: + +```bash +composer test +``` + +If the Composer script is unavailable or its environment lacks the required INI settings, +fall back to: + +```bash +php -d ffi.enable=1 -d opcache.jit=off vendor/bin/phpunit +``` + +A single test: + +```bash +php -d ffi.enable=1 -d opcache.jit=off vendor/bin/phpunit tests/Functional/testCanAddMatrices.phpt +``` + +`ffi.enable=1` and `opcache.jit=off` are mandatory in both the parent process and the +`.phpt` children. Every `.phpt` carries an `--INI--` section for the child side. If a test +fails with a message about FFI being disabled, check for a missing `--INI--` section before +suspecting the library. + +## Interpreting results + +- **`--EXPECT--` diff.** phpt failures print expected vs actual output. Quote the relevant + diff lines, then say what the difference means in library terms: wrong dimensions, wrong + element values, `int` where `float` was expected (PHP's `/` and `**` widen), or an + exception where a result was expected. `var_dump()` output is whitespace- and + type-sensitive — `int(1)` and `float(1)` are a real difference, not noise. +- **`--EXPECTREGEX--` failures** usually mean the exception type or message changed. Report + the actual message verbatim. +- **Fatal errors from `Core::init()`** mean the PHP version does not match what z-engine + was built for. Report the running `php -v` and stop. Never suggest loosening the + `~8.4.0` constraint or skipping initialization. +- **Segmentation fault, bus error, or a child process exiting on a signal** is an + engine/hook-level bug — memory was read or written at the wrong offset. Do **not** retry, + do not mark the test skipped, do not "work around" it. Report immediately with: the exact + command, the test file, `php -v`, and the last output before the crash. A crash outranks + every other finding in your report. + +## Reporting + +Always report: + +1. The exact command you ran and the aggregate result (tests, assertions, failures). +2. Per failing test: file name, the `--TEST--` description, and a one-line diagnosis. +3. Whether any failure looks environmental (missing `vendor/`, FFI off, wrong PHP minor) + rather than a code defect. + +Do not edit source files, tests, or configuration. If a fix is obvious, describe it; leave +the change to whoever asked. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8101b23 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,198 @@ +# Working on native-php-matrix + +This library gives PHP a `Matrix` class with real operator overloading — `+`, `-`, +`*`, `/`, `**` and `==` are dispatched by the Zend Engine itself, not emulated in +userland. It does that by using [z-engine](https://github.com/lisachenko/z-engine) +to install `create_object`, `do_operation` and `compare` handlers directly onto the +`Matrix` class entry through PHP FFI. The matrix arithmetic underneath is ordinary, +readable PHP; the only unusual part is how the engine is convinced to call it. + +## The one rule that is non-negotiable: the PHP version is pinned + +`composer.json` requires **`php: ~8.4.0`** — an exact minor, not a floor. z-engine +reads engine structures (`zend_class_entry`, `zval`, `zend_object_handlers`) by byte +offset, and those offsets change on every PHP minor release. Running against the +wrong minor does not throw a nice exception; it reads and writes the wrong memory. + +`ZEngine\Core::init()` (called from `bootstrap.php`) enforces the match and aborts +with a clear message. **Never "fix" an initialization failure by loosening the PHP +constraint, skipping `Core::init()`, or defeating the guard.** If the environment's +PHP does not satisfy `~8.4.0`, the environment is wrong — say so and stop. + +The same applies in reverse: z-engine's own FFI definitions live on per-minor +branches, so this package must consume the z-engine line built for PHP 8.4. + +## Running tests + +```bash +composer test +``` + +The suite is PHPUnit 12 driving `.phpt` files in `tests/Functional/`. Three INI +settings must hold in **both** the parent PHPUnit process and every `.phpt` child +process it spawns: + +- `ffi.enable=1` — FFI cannot be turned on at runtime. +- `opcache.jit=off` — the JIT rewrites the very executor internals z-engine hooks. +- `error_reporting=E_ALL & ~E_DEPRECATED` — z-engine `dev-master` still declares + implicitly nullable parameters (e.g. `ZEngine\Type\OpLine::__construct()`), which + PHP 8.4 reports as a deprecation. PHPUnit's `.phpt` runner forces + `display_errors=1`, so without this the dependency's deprecation is prepended to + the captured output of **every** test and each `--EXPECT--` block fails on noise + that has nothing to do with this library. Drop the suppression once z-engine + declares those parameters `?Type`. + +CI supplies the FFI and JIT pair as `ini-values` on the PHP setup step, and **every +`.phpt` file carries its own `--INI--` section** — all three lines — so the child +processes inherit nothing by luck. The deprecation suppression only ever matters in +the children, because those are the processes whose output is compared. + +For a local one-off run without touching `php.ini`: + +```bash +php -d ffi.enable=1 -d opcache.jit=off vendor/bin/phpunit +``` + +Single test: + +```bash +php -d ffi.enable=1 -d opcache.jit=off vendor/bin/phpunit tests/Functional/testCanAddMatrices.phpt +``` + +> **A segfault or bus error is not a normal test failure.** It means a hook or an +> engine structure is being used incorrectly — an engine-level bug. Do not retry +> the run hoping it passes, do not mark the test skipped: report the crash with the +> exact command, PHP version, and the test that triggered it. + +## Quality gates (all enforced in CI) + +```bash +composer phpstan # PHPStan at level max +composer cs:check # coding standards, PER-CS2.0 +composer cs:fix # apply the fixes +``` + +`Matrix` is declared `@template-covariant T of int|float`. **Keep the generics +honest.** Arithmetic widens: dividing or exponentiating a `Matrix` can produce +floats, so those results are `Matrix`, never `Matrix`. Do not annotate +a method as preserving `T` to make PHPStan quiet — if the maths does not preserve +the type, the signature must not claim it does. + +## Anatomy of a `.phpt` test + +Each file covers exactly one behaviour and is named `test.phpt`: + +``` +--TEST-- +Matrices can be added with "+" operator +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- +toArray()); +?> +--EXPECT-- +array(1) { + [0]=> + array(3) { + [0]=> + int(5) + [1]=> + int(7) + [2]=> + int(9) + } +} +``` + +Rules for a new test: + +- `--INI--` is **mandatory, all three lines** — without them the child process has no + FFI, runs the JIT over hooked internals, or drowns the expected output in a + z-engine deprecation, and the test fails in a way that looks like a library bug. +- Include the autoloader with the relative path `__DIR__ . '/../../vendor/autoload.php'`; + that is what triggers `bootstrap.php` and installs the handlers. +- Prefer `--EXPECT--` (exact match). Use `--EXPECTREGEX--` only when the output + genuinely varies — uncaught-exception output, for example, contains file paths and + stack traces. +- One behaviour per file. Failure cases (incompatible dimensions, unsupported + operator combinations) get their own files. + +## Repository map + +``` +src/Matrix.php the entire library: the maths plus the __doOperation/__compare hooks +bootstrap.php Core::init() then installExtensionHandlers() on Matrix — order matters; + runs automatically via Composer's "files" autoload +tests/Functional/*.phpt the functional suite, one behaviour per file +phpunit.xml.dist PHPUnit 12 config (suite points at tests/, suffix .phpt) +phpstan.dist.neon static analysis config, level max +.php-cs-fixer.dist.php coding standards config (PER-CS2.0) +.github/workflows/ci.yml jobs: tests, static-analysis, coding-standards — PHP 8.4 +``` + +`src/Matrix.php` is the whole library. There is no framework here to hide behind: a +change to a hook signature or to `bootstrap.php`'s ordering affects every operator +at once. + +## Hook contracts + +- `__doOperation(DoOperationHook $hook)` and `__compare(CompareValuesHook $hook)` + are **static**. They do throw — `InvalidArgumentException` for a dimension + mismatch, `LogicException` for an operand combination the class does not + implement — but be precise about what that means for a caller: these hooks run + inside an **FFI callback**, and PHP 8.4 does not let an exception cross that + boundary. The engine reports the exception and then aborts with + `Fatal error: Throwing from FFI callbacks is not allowed`. The script dies with + exit code 255; a userland `try`/`catch` around `$a + $b` **will not catch it**. + This is why the incompatible-dimension tests use `--EXPECTREGEX--` and match the + message inside fatal-error output rather than catching anything. +- Validation performed in the **constructor** is ordinary userland code and its + `InvalidArgumentException` *is* catchable — that is the difference between + `testFailsOnEmptyMatrix.phpt` (uses `try`/`catch`) and + `testFailsOnAddingIncompatibleMatrices.phpt` (cannot). Prefer validating in the + constructor where a failure mode can be detected there. +- Handlers that the engine calls in non-throwing contexts — `get_debug_info`, + `get_iterator`, `cast_object` in some paths — **must not throw**. If a future + feature adds one of those, it has to degrade gracefully instead. +- Operations return a **new** `Matrix`. Nothing mutates in place; `$a += $b` works + because the engine rebinds the variable to the returned object. + +## Conventions + +[Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(matrix): support element-wise exponentiation by scalar +fix(bootstrap): install create_object handler before do_operation +test(tests): cover division by zero +ci: run the suite on PHP 8.4 with ffi.enable=1 +docs: rewrite the README in the z-engine style +``` + +Scopes in use: `matrix`, `bootstrap`, `tests`, `ci`, `docs`. + +Code style is **PER-CS2.0**, applied by php-cs-fixer. Run `composer cs:fix` before +proposing a change rather than hand-formatting. + +## Dependency policy + +- `lisachenko/z-engine` is required as **`dev-master`**. There is no stable tag with + PHP 8.4 support yet, so the root `composer.json` also carries + `"minimum-stability": "dev"` with `"prefer-stable": true` — Composer resolves + development stability only at the root level, so consumers need the same pair. +- PHP stays pinned at `~8.4.0`, in lockstep with the z-engine line this package + tracks. Bumping one without the other is never correct. +- When z-engine ships a stable PHP 8.4 release, both the constraint and the + root stability flags should be tightened in a single change.