From cd1d706b9c53a0c5012735f44df14ca5747ebaf3 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 18 Oct 2026 11:26:10 +0200 Subject: [PATCH 1/4] Fix disabling the noRecordThatWhenApplying rule The extension was tagged as restricted method usage extension in the service definition as well, so the conditional tag never had an effect and the rule stayed active with noRecordThatWhenApplying: false. --- extension.neon | 2 -- 1 file changed, 2 deletions(-) diff --git a/extension.neon b/extension.neon index b7a4b9f..1992403 100644 --- a/extension.neon +++ b/extension.neon @@ -41,8 +41,6 @@ services: class: Patchlevel\EventSourcingPHPStanExtension\DontRecordWhenApplyingExtension arguments: parser: @defaultAnalysisParser - tags: - - phpstan.restrictedMethodUsageExtension - class: Patchlevel\EventSourcingPHPStanExtension\DontWriteStateWhenNotApplyingRule From 2a213f2d9976233728ed2b069dc7f7f1e593dd72 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 18 Oct 2026 11:27:02 +0200 Subject: [PATCH 2/4] Extract shared helpers from the rules The rules each had their own copy of detecting aggregates, apply methods and property writes. This moves that into two internal helpers, without changing what the rules report, and covers them with unit tests. For that PHPUnit is added, and src is now part of the PHPStan analysis as well. --- .github/workflows/phpunit.yml | 46 + .gitignore | 1 + Makefile | 10 + composer.json | 1 + composer.lock | 2673 +++++++++++++++++---- phpstan.neon.dist | 1 + phpunit.xml.dist | 20 + src/AggregateRootExtension.php | 12 +- src/DontRecordWhenApplyingExtension.php | 25 +- src/DontWriteStateWhenNotApplyingRule.php | 99 +- src/Helper/EventSourcing.php | 62 + src/Helper/PropertyWrites.php | 130 + src/UnusedPropertyRule.php | 118 +- src/WriteOnlyPropertyRule.php | 141 +- tests/Unit/Helper/EventSourcingTest.php | 103 + tests/Unit/Helper/PropertyWritesTest.php | 158 ++ 16 files changed, 2768 insertions(+), 832 deletions(-) create mode 100644 .github/workflows/phpunit.yml create mode 100644 phpunit.xml.dist create mode 100644 src/Helper/EventSourcing.php create mode 100644 src/Helper/PropertyWrites.php create mode 100644 tests/Unit/Helper/EventSourcingTest.php create mode 100644 tests/Unit/Helper/PropertyWritesTest.php diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000..8d8a2b3 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,46 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +name: "Unit tests" + +on: + pull_request: + push: + branches: + - "[0-9]+.[0-9]+.x" + - "renovate/*" + +concurrency: + group: ${{ github.workflow_ref }} + cancel-in-progress: true + +jobs: + phpunit: + name: "Unit tests" + + runs-on: ${{ matrix.operating-system }} + + strategy: + matrix: + dependencies: + - "locked" + php-version: + - "8.5" + operating-system: + - "ubuntu-latest" + + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + - name: "Install PHP" + uses: "shivammathur/setup-php@2.40.0" + with: + php-version: "${{ matrix.php-version }}" + ini-values: memory_limit=-1, opcache.enable_cli=1 + + - uses: ramsey/composer-install@4.0.0 + with: + dependency-versions: ${{ matrix.dependencies }} + + - name: "PHPUnit" + run: "vendor/bin/phpunit" diff --git a/.gitignore b/.gitignore index 7983535..872b4ad 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ phpunit.xml .phpunit.result.cache .phpcs-cache phpstan.neon +var/ diff --git a/Makefile b/Makefile index 2bcd5ae..7f42b24 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,16 @@ phpstan-baseline: vendor .PHONY: static static: phpstan cs ## run static analyser +.PHONY: phpunit +phpunit: vendor ## run phpunit tests + vendor/bin/phpunit + +.PHONY: test +test: phpunit ## run tests + +.PHONY: dev +dev: static test ## run dev tools + .PHONY: docs docs: docs-extract-php docs-php-lint docs-phpcs docs-inject-php diff --git a/composer.json b/composer.json index 47d511d..dd37c8c 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "patchlevel/coding-standard": "^1.3.0", "patchlevel/event-sourcing": "4.0.x-dev", "phpstan/phpstan-strict-rules": "^2.0.4", + "phpunit/phpunit": "^10.5.0 || ^11.0.0 || ^12.0.0", "roave/security-advisories": "dev-master", "wnx/commonmark-markdown-renderer": "^1.6" }, diff --git a/composer.lock b/composer.lock index 920403d..f862432 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b363046df53e136bcc2353ea4eaa432e", + "content-hash": "39c1a324809341d799879a6b50cacccc", "packages": [ { "name": "phpstan/phpstan", @@ -840,6 +840,66 @@ ], "time": "2022-12-11T20:36:23+00:00" }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, { "name": "nette/schema", "version": "v1.3.6", @@ -998,6 +1058,63 @@ }, "time": "2026-07-17T23:02:45+00:00" }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, { "name": "patchlevel/coding-standard", "version": "1.3.0", @@ -1283,6 +1400,124 @@ }, "time": "2026-10-05T07:23:26+00:00" }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "2.3.6", @@ -1383,354 +1618,478 @@ "time": "2026-10-01T21:30:37+00:00" }, { - "name": "psr/cache", - "version": "3.0.0", + "name": "phpunit/php-code-coverage", + "version": "12.5.8", "source": { "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7440368d94738d54f2c1de24dbd3871793bbb0dd", + "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd", "shasum": "" }, "require": { - "php": ">=8.0.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.9.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0.1", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.37" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common interface for caching libraries", + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", "keywords": [ - "cache", - "psr", - "psr-6" + "coverage", + "testing", + "xunit" ], "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.8" }, - "time": "2021-02-03T23:26:27+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-10-03T14:03:50+00:00" }, { - "name": "psr/clock", - "version": "1.0.0", + "name": "phpunit/php-file-iterator", + "version": "6.0.2", "source": { "type": "git", - "url": "https://github.com/php-fig/clock.git", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", "shasum": "" }, "require": { - "php": "^7.0 || ^8.0" + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33" }, "type": "library", - "autoload": { - "psr-4": { - "Psr\\Clock\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common interface for reading the clock.", - "homepage": "https://github.com/php-fig/clock", + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", "keywords": [ - "clock", - "now", - "psr", - "psr-20", - "time" + "filesystem", + "iterator" ], "support": { - "issues": "https://github.com/php-fig/clock/issues", - "source": "https://github.com/php-fig/clock/tree/1.0.0" + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" }, - "time": "2022-11-25T14:36:26+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:40:53+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "phpunit/php-invoker", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", "shasum": "" }, "require": { - "php": ">=7.4.0" + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-main": "6.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "process" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" }, - "time": "2021-11-05T16:47:00+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" }, { - "name": "psr/event-dispatcher", - "version": "1.0.0", + "name": "phpunit/php-text-template", + "version": "5.0.1", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/babab73b7ef9fae406f2eb3ee11323a61caa63d5", + "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.37" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-main": "5.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\EventDispatcher\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Standard interfaces for event handling.", + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", "keywords": [ - "events", - "psr", - "psr-14" + "template" ], "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.1" }, - "time": "2019-01-08T18:20:26+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-10-03T04:51:29+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "phpunit/php-timer", + "version": "8.0.0", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", "shasum": "" }, "require": { - "php": ">=8.0.0" + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-main": "8.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", "keywords": [ - "log", - "psr", - "psr-3" + "timer" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" }, - "time": "2024-09-11T13:17:53+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" }, { - "name": "psr/simple-cache", - "version": "3.0.0", + "name": "phpunit/phpunit", + "version": "12.5.40", "source": { "type": "git", - "url": "https://github.com/php-fig/simple-cache.git", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "76bd3198f16aeb375a4d5f3626471445aed0966e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/76bd3198f16aeb375a4d5f3626471445aed0966e", + "reference": "76bd3198f16aeb375a4d5f3626471445aed0966e", "shasum": "" }, "require": { - "php": ">=8.0.0" + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.8", + "phpunit/php-file-iterator": "^6.0.2", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.1", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.1", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.1", + "staabm/side-effects-detector": "^1.0.5" }, + "bin": [ + "phpunit" + ], "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0.x-dev" + "dev-main": "12.5-dev" } }, "autoload": { - "psr-4": { - "Psr\\SimpleCache\\": "src/" - } + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common interfaces for simple caching", + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", "keywords": [ - "cache", - "caching", - "psr", - "psr-16", - "simple-cache" + "phpunit", + "testing", + "xunit" ], "support": { - "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.40" }, - "time": "2021-10-29T13:26:27+00:00" + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-16T05:55:07+00:00" }, { - "name": "ramsey/collection", - "version": "2.1.1", + "name": "psr/cache", + "version": "3.0.0", "source": { "type": "git", - "url": "https://github.com/ramsey/collection.git", - "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", - "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", "shasum": "" }, "require": { - "php": "^8.1" - }, - "require-dev": { - "captainhook/plugin-composer": "^5.3", - "ergebnis/composer-normalize": "^2.45", - "fakerphp/faker": "^1.24", - "hamcrest/hamcrest-php": "^2.0", - "jangregor/phpstan-prophecy": "^2.1", - "mockery/mockery": "^1.6", - "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpspec/prophecy-phpunit": "^2.3", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-mockery": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5", - "ramsey/coding-standard": "^2.3", - "ramsey/conventional-commits": "^1.6", - "roave/security-advisories": "dev-latest" + "php": ">=8.0.0" }, "type": "library", "extra": { - "captainhook": { - "force-install": true - }, - "ramsey/conventional-commits": { - "configFile": "conventional-commits.json" + "branch-alias": { + "dev-master": "1.0.x-dev" } }, "autoload": { "psr-4": { - "Ramsey\\Collection\\": "src/" + "Psr\\Cache\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1739,340 +2098,663 @@ ], "authors": [ { - "name": "Ben Ramsey", - "email": "ben@benramsey.com", - "homepage": "https://benramsey.com" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "A PHP library for representing and manipulating collections.", + "description": "Common interface for caching libraries", "keywords": [ - "array", - "collection", - "hash", - "map", - "queue", - "set" + "cache", + "psr", + "psr-6" ], "support": { - "issues": "https://github.com/ramsey/collection/issues", - "source": "https://github.com/ramsey/collection/tree/2.1.1" + "source": "https://github.com/php-fig/cache/tree/3.0.0" }, - "time": "2025-03-22T05:38:12+00:00" + "time": "2021-02-03T23:26:27+00:00" }, { - "name": "ramsey/uuid", - "version": "4.9.4", + "name": "psr/clock", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/ramsey/uuid.git", - "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", - "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", "shasum": "" }, "require": { - "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", - "php": "^8.0", - "ramsey/collection": "^1.2 || ^2.0" - }, - "replace": { - "rhumsaa/uuid": "self.version" - }, - "require-dev": { - "captainhook/captainhook": "^5.25", - "captainhook/plugin-composer": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "ergebnis/composer-normalize": "^2.47", - "mockery/mockery": "^1.6", - "paragonie/random-lib": "^2", - "php-mock/php-mock": "^2.6", - "php-mock/php-mock-mockery": "^1.5", - "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpbench/phpbench": "^1.2.14", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-mockery": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.6", - "slevomat/coding-standard": "^8.18", - "squizlabs/php_codesniffer": "^3.13" - }, - "suggest": { - "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", - "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", - "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", - "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", - "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + "php": "^7.0 || ^8.0" }, "type": "library", - "extra": { - "captainhook": { - "force-install": true - } - }, "autoload": { - "files": [ - "src/functions.php" - ], "psr-4": { - "Ramsey\\Uuid\\": "src/" + "Psr\\Clock\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", - "keywords": [ - "guid", - "identifier", - "uuid" - ], - "support": { - "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.4" + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" }, - "time": "2026-09-16T11:39:30+00:00" + "time": "2022-11-25T14:36:26+00:00" }, { - "name": "roave/security-advisories", - "version": "dev-master", + "name": "psr/container", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "96cf8b77ade13bb6638d1b26d48a72deec75b7d0" + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/96cf8b77ade13bb6638d1b26d48a72deec75b7d0", - "reference": "96cf8b77ade13bb6638d1b26d48a72deec75b7d0", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, - "conflict": { - "3f/pygmentize": "<1.2", - "adaptcms/adaptcms": "<=1.3", - "adawolfa/isdoc": "<1.4.3|>=1.5,<1.5.1|>=1.6,<1.6.1", - "admidio/admidio": "<=5.0.11", - "adodb/adodb-php": "<=5.22.9", - "aheinze/cockpit": "<2.2", - "aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2", - "aimeos/ai-admin-jsonadm": "<2020.10.13|>=2021.04.1,<2021.10.6|>=2022.04.1,<2022.10.3|>=2023.04.1,<2023.10.4|==2024.04.1", - "aimeos/ai-client-html": ">=2020.04.1,<2020.10.27|>=2021.04.1,<2021.10.22|>=2022.04.1,<2022.10.13|>=2023.04.1,<2023.10.15|>=2024.04.1,<2024.04.7", - "aimeos/ai-cms-grapesjs": ">=2021.04.1,<2021.10.8|>=2022.04.1,<2022.10.9|>=2023.04.1,<2023.10.15|>=2024.04.1,<2024.10.8|>=2025.04.1,<2025.10.2", - "aimeos/ai-controller-frontend": "<2020.10.15|>=2021.04.1,<2021.10.8|>=2022.04.1,<2022.10.8|>=2023.04.1,<2023.10.9|==2024.04.1", - "aimeos/aimeos-core": ">=2022.04.1,<2022.10.17|>=2023.04.1,<2023.10.17|>=2024.04.1,<2024.04.7", - "aimeos/aimeos-laravel": "==2021.10", - "aimeos/aimeos-typo3": "<19.10.12|>=20,<20.10.5", - "aimeos/pagible": "<0.10.4", - "airesvsg/acf-to-rest-api": "<=3.1", - "akaunting/akaunting": "<2.1.13", - "akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53", - "alextselegidis/easyappointments": "<=1.5.2", - "alexusmai/laravel-file-manager": "<=3.3.1", - "algolia/algoliasearch-magento-2": "<=3.16.1|>=3.17.0.0-beta1,<=3.17.1", - "almirhodzic/nova-toggle-5": "<1.3", - "alt-design/alt-redirect": "<1.6.4", - "altcha-org/altcha": "<1.3.1", - "alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1", - "amazing/media2click": ">=1,<1.3.3", - "ameos/ameos_tarteaucitron": "<1.2.23", - "amphp/artax": "<1.0.6|>=2,<2.0.6", - "amphp/http": "<=1.7.2|>=2,<=2.1", - "amphp/http-client": ">=4,<4.4", - "amphp/http-server": ">=2.0.0.0-RC1-dev,<2.1.10|>=3.0.0.0-beta1,<3.4.4", - "anchorcms/anchor-cms": "<=0.12.7", - "andreapollastri/cipi": "<=3.1.15", - "andrewhaine/silverstripe-form-capture": ">=0.2,<=0.2.3|>=1,<1.0.2|>=2,<2.2.5", - "aoe/restler": "<1.7.1", - "apache-solr-for-typo3/solr": "<11.2.8|>=11.5,<11.6.6|>=12,<12.1.4|>=13,<13.1.4", - "apache/thrift": "<0.24", - "apereo/phpcas": "<1.6", - "api-platform/core": "<4.1.30|>=4.2,<4.2.26|>=4.3,<4.3.12", - "api-platform/graphql": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5", - "api-platform/hal": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", - "api-platform/json-api": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", - "appwrite/server-ce": "<=1.2.1", - "arc/web": "<3", - "area17/twill": "<1.2.5|>=2,<2.5.3", - "artesaos/seotools": "<0.17.2", - "asymmetricrypt/asymmetricrypt": "<9.9.99", - "athlon1600/php-proxy": "<=5.1", - "athlon1600/php-proxy-app": "<=3", - "athlon1600/youtube-downloader": "<=4", - "aureuserp/aureuserp": "<1.3.0.0-beta1", - "austintoddj/canvas": "<=3.4.2", - "auth0/auth0-php": ">=3.3,<=8.18", - "auth0/login": "<=7.20", - "auth0/symfony": "<=5.8", - "auth0/wordpress": "<=5.5", - "automad/automad": "<=2.0.0.0-beta27", - "automattic/jetpack": "<9.8", - "awesome-support/awesome-support": "<=6.0.7", - "aws/aws-sdk-php": "<=3.371.3", - "ayacoo/redirect-tab": "<2.1.2|>=3,<3.1.7|>=4,<4.0.5", - "azuracast/azuracast": "<=0.23.5", - "b13/seo_basics": "<0.8.2", - "backdrop/backdrop": "<=1.32", - "backpack/crud": "<6.8.15|>=7,<7.0.47", - "backpack/filemanager": "<2.0.2|>=3,<3.0.9", - "bacula-web/bacula-web": "<9.7.1", - "badaso/core": "<=2.9.11", - "bagisto/bagisto": "<=2.3.15", - "barrelstrength/sprout-base-email": "<1.2.7", - "barrelstrength/sprout-forms": "<3.9", - "barryvdh/laravel-translation-manager": "<0.6.8", - "barzahlen/barzahlen-php": "<2.0.1", - "baserproject/basercms": "<=5.2.2", - "bassjobsen/bootstrap-3-typeahead": ">4.0.2", - "bbpress/bbpress": "<2.6.5", - "bcit-ci/codeigniter": "<3.1.3", - "bcosca/fatfree": "<3.7.2", - "bedita/bedita": "<4", - "bednee/cooluri": "<1.0.30", - "bigfork/silverstripe-form-capture": ">=3,<3.1.1", - "billabear/billabear": "<=2025.01.03", - "billz/raspap-webgui": "<3.3.6", - "binarytorch/larecipe": "<2.8.1", - "bk2k/bootstrap-package": ">=7.1,<7.1.2|>=8,<8.0.8|>=9,<9.0.4|>=9.1,<9.1.3|>=10,<10.0.10|>=11,<11.0.3", - "blueimp/jquery-file-upload": "==6.4.4", - "bmarshall511/wordpress_zero_spam": "<5.2.13", - "bolt/bolt": "<3.7.2", - "bolt/core": "<=4.2", - "born05/craft-twofactorauthentication": "<3.3.4", - "bottelet/flarepoint": "<2.2.1", - "bref/bref": "<2.1.17", - "brightlocal/phpwhois": "<=4.2.5", - "brotkrueml/codehighlight": "<2.7", - "brotkrueml/schema": "<1.13.1|>=2,<2.5.1", - "brotkrueml/typo3-matomo-integration": "<1.3.2", - "buddypress/buddypress": "<14.5", - "bugsnag/bugsnag-laravel": ">=2,<2.0.2", - "bvbmedia/multishop": "<2.0.39", - "bytefury/crater": "<6.0.2", - "cachethq/cachet": "<2.5.1", - "cadmium-org/cadmium-cms": "<=0.4.9", - "cakephp/authentication": "<2.11.1|>=3,<3.3.6|>=4,<4.1.1", - "cakephp/cakephp": "<4.5.12|>=4.6,<4.6.5|>=5,<5.1.10|>=5.2,<5.2.15|>=5.3,<5.3.7", - "cakephp/database": ">=3,<4.5.12|>=4.6,<4.6.5|>=5,<5.1.10|>=5.2,<5.2.15|>=5.3,<5.3.7", - "cakephp/debug_kit": "<4.10.3|>=5,<5.2.4", - "cakephp/queue": ">=0.1.10,<2.3.1", - "cardgate/magento2": "<2.0.33", - "cardgate/woocommerce": "<=3.1.15", - "cart2quote/module-quotation": ">=4.1.6,<4.4.6|>=5,<5.4.4", - "cartalyst/sentry": "<2.1.7", - "catfan/medoo": "<1.7.5", - "causal/oidc": "<4", - "cecil/cecil": "<7.47.1", - "centreon/centreon": "<22.10.15", - "cesargb/laravel-magiclink": ">=2,<2.25.1", - "cesnet/simplesamlphp-module-proxystatistics": "<3.1", - "chamilo/chamilo-lms": "<=2", - "chriskacerguis/codeigniter-restserver": "<=2.7.1", - "chrome-php/chrome": "<1.14", - "ci4-cms-erp/ci4ms": "<=0.31.8", - "civicrm/civicrm-core": ">=4.2,<4.2.9|>=4.3,<4.3.3", - "ckeditor/ckeditor": "<4.25", - "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", - "co-stack/fal_sftp": "<0.2.6", - "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", - "codeception/codeception": "<3.1.3|>=4,<4.1.22", - "codeigniter/framework": "<3.1.10", - "codeigniter4/framework": "<4.7.4", - "codeigniter4/shield": "<1.0.0.0-beta8", - "codiad/codiad": "<=2.8.4", - "codingms/additional-tca": ">=1.7,<1.15.17|>=1.16,<1.16.9", - "codingms/modules": "<7.10.4|>=8,<8.1.4", - "commerceteam/commerce": ">=0.9.6,<0.9.9", - "components/jquery": ">=1.0.3,<3.5", - "composer/composer": "<2.2.30|>=2.3,<2.10.3", - "concrete5/concrete5": "<9.5.2", - "concrete5/core": "<8.5.8|>=9,<9.1", - "contao-components/colorbox": ">=1,<1.6.4.3-dev", - "contao-components/mediaelement": ">=2.14.2,<2.21.1", - "contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12", - "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12", - "contao/core": "<3.5.39", - "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12", - "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", - "contao/managed-edition": "<=1.5", - "contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12", - "coreshop/core-shop": "<4.1.9|==5", - "corveda/phpsandbox": "<1.3.5", - "cosenary/instagram": "<=2.3", - "cotonti/cotonti": "<=1", - "couleurcitron/tarteaucitron-wp": "<0.3", - "cpsit/typo3-mailqueue": "<0.4.5|>=0.5,<0.5.2", - "craftcms/aws-s3": ">=2.0.2,<=2.2.4", - "craftcms/azure-blob": ">=2.0.0.0-beta1,<=2.1", - "craftcms/cms": "<4.18.3|>=5,<5.10.8", - "craftcms/commerce": ">=4,<=4.11.1|>=5,<=5.6.4", - "craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1", - "craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21", - "craftcms/google-cloud": ">=2.0.0.0-beta1,<=2.2", - "craftcms/webhooks": ">=3,<3.2", - "croogo/croogo": "<=4.0.7", - "cuyz/valinor": "<0.12", - "czim/file-handling": "<1.5|>=2,<2.3", - "czproject/git-php": "<4.0.3", - "damienharper/auditor-bundle": "<5.2.6", - "dapphp/securimage": "<3.6.6", - "darylldoyle/safe-svg": "<1.9.10", - "datadog/dd-trace": ">=0.30,<0.30.2", - "datahihi1/tiny-env": "<1.0.3|>=1.0.9,<1.0.11", - "datatables/datatables": "<1.10.10", - "david-garcia/phpwhois": "<=4.3.1", - "dbrisinajumi/d2files": "<1", - "dcat/laravel-admin": "<=2.1.3|==2.2.0.0-beta|==2.2.2.0-beta", - "dedoc/scramble": ">=0.13.2,<0.13.22", - "derhansen/fe_change_pwd": "<2.0.5|>=3,<3.0.3", - "derhansen/sf_event_mgt": "<5.9.3|>=6,<6.7.2|>=7,<7.9.3|>=8,<8.6.2|>=9,<9.0.3", - "desperado/xml-bundle": "<=0.1.7", - "dev-lancer/minecraft-motd-parser": "<=1.0.5", - "devcode-it/openstamanager": "<=2.10.1", - "devgroup/dotplant": "<2020.09.14-dev", - "digimix/wp-svg-upload": "<=1", - "directmailteam/direct-mail": "<6.0.3|>=7,<7.0.3|>=8,<9.5.2", - "directorytree/imapengine": "<1.22.3", - "dl/yag": "<3.0.1", - "dmk/webkitpdf": "<1.1.4", - "dnadesign/silverstripe-elemental": "<5.3.12", - "doctrine/annotations": "<1.2.7", - "doctrine/cache": ">=1,<1.3.2|>=1.4,<1.4.2", - "doctrine/common": "<2.4.3|>=2.5,<2.5.1", - "doctrine/dbal": ">=2,<2.0.8|>=2.1,<2.1.2|>=3,<3.1.4", - "doctrine/doctrine-bundle": "<1.5.2", - "doctrine/doctrine-module": "<0.7.2", - "doctrine/mongodb-odm": "<1.0.2", - "doctrine/mongodb-odm-bundle": "<3.0.1", - "doctrine/orm": ">=1,<1.2.4|>=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4", - "dolibarr/dolibarr": "<=23.0.2", - "dompdf/dompdf": "<3.1.6", - "doublethreedigital/guest-entries": "<3.1.2", - "dreamfactory/df-core": "<1.0.4", - "drupal-pattern-lab/unified-twig-extensions": "<=0.1", - "drupal/access_code": "<2.0.5", - "drupal/acquia_dam": "<1.1.5", - "drupal/admin_audit_trail": "<1.0.5", - "drupal/ai": "<1.0.5", - "drupal/alogin": "<2.0.6", - "drupal/cache_utility": "<1.2.1", - "drupal/civictheme": "<1.12", - "drupal/commerce_alphabank_redirect": "<1.0.3", - "drupal/commerce_eurobank_redirect": "<2.1.1", - "drupal/config_split": "<1.10|>=2,<2.0.2", - "drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.5.10|>=10.6,<10.6.9|>=11,<11.2.12|>=11.3,<11.3.10", - "drupal/core-recommended": ">=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8", - "drupal/currency": "<3.5", - "drupal/drupal": ">=5,<5.11|>=6,<6.38|>=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8", - "drupal/email_tfa": "<2.0.6", - "drupal/formatter_suite": "<2.1", - "drupal/gdpr": "<3.0.1|>=3.1,<3.1.2", - "drupal/google_tag": "<1.8|>=2,<2.0.8", - "drupal/ignition": "<1.0.4", + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.4", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.4" + }, + "time": "2026-09-16T11:39:30+00:00" + }, + { + "name": "roave/security-advisories", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/Roave/SecurityAdvisories.git", + "reference": "96cf8b77ade13bb6638d1b26d48a72deec75b7d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/96cf8b77ade13bb6638d1b26d48a72deec75b7d0", + "reference": "96cf8b77ade13bb6638d1b26d48a72deec75b7d0", + "shasum": "" + }, + "conflict": { + "3f/pygmentize": "<1.2", + "adaptcms/adaptcms": "<=1.3", + "adawolfa/isdoc": "<1.4.3|>=1.5,<1.5.1|>=1.6,<1.6.1", + "admidio/admidio": "<=5.0.11", + "adodb/adodb-php": "<=5.22.9", + "aheinze/cockpit": "<2.2", + "aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2", + "aimeos/ai-admin-jsonadm": "<2020.10.13|>=2021.04.1,<2021.10.6|>=2022.04.1,<2022.10.3|>=2023.04.1,<2023.10.4|==2024.04.1", + "aimeos/ai-client-html": ">=2020.04.1,<2020.10.27|>=2021.04.1,<2021.10.22|>=2022.04.1,<2022.10.13|>=2023.04.1,<2023.10.15|>=2024.04.1,<2024.04.7", + "aimeos/ai-cms-grapesjs": ">=2021.04.1,<2021.10.8|>=2022.04.1,<2022.10.9|>=2023.04.1,<2023.10.15|>=2024.04.1,<2024.10.8|>=2025.04.1,<2025.10.2", + "aimeos/ai-controller-frontend": "<2020.10.15|>=2021.04.1,<2021.10.8|>=2022.04.1,<2022.10.8|>=2023.04.1,<2023.10.9|==2024.04.1", + "aimeos/aimeos-core": ">=2022.04.1,<2022.10.17|>=2023.04.1,<2023.10.17|>=2024.04.1,<2024.04.7", + "aimeos/aimeos-laravel": "==2021.10", + "aimeos/aimeos-typo3": "<19.10.12|>=20,<20.10.5", + "aimeos/pagible": "<0.10.4", + "airesvsg/acf-to-rest-api": "<=3.1", + "akaunting/akaunting": "<2.1.13", + "akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53", + "alextselegidis/easyappointments": "<=1.5.2", + "alexusmai/laravel-file-manager": "<=3.3.1", + "algolia/algoliasearch-magento-2": "<=3.16.1|>=3.17.0.0-beta1,<=3.17.1", + "almirhodzic/nova-toggle-5": "<1.3", + "alt-design/alt-redirect": "<1.6.4", + "altcha-org/altcha": "<1.3.1", + "alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1", + "amazing/media2click": ">=1,<1.3.3", + "ameos/ameos_tarteaucitron": "<1.2.23", + "amphp/artax": "<1.0.6|>=2,<2.0.6", + "amphp/http": "<=1.7.2|>=2,<=2.1", + "amphp/http-client": ">=4,<4.4", + "amphp/http-server": ">=2.0.0.0-RC1-dev,<2.1.10|>=3.0.0.0-beta1,<3.4.4", + "anchorcms/anchor-cms": "<=0.12.7", + "andreapollastri/cipi": "<=3.1.15", + "andrewhaine/silverstripe-form-capture": ">=0.2,<=0.2.3|>=1,<1.0.2|>=2,<2.2.5", + "aoe/restler": "<1.7.1", + "apache-solr-for-typo3/solr": "<11.2.8|>=11.5,<11.6.6|>=12,<12.1.4|>=13,<13.1.4", + "apache/thrift": "<0.24", + "apereo/phpcas": "<1.6", + "api-platform/core": "<4.1.30|>=4.2,<4.2.26|>=4.3,<4.3.12", + "api-platform/graphql": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5", + "api-platform/hal": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", + "api-platform/json-api": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", + "appwrite/server-ce": "<=1.2.1", + "arc/web": "<3", + "area17/twill": "<1.2.5|>=2,<2.5.3", + "artesaos/seotools": "<0.17.2", + "asymmetricrypt/asymmetricrypt": "<9.9.99", + "athlon1600/php-proxy": "<=5.1", + "athlon1600/php-proxy-app": "<=3", + "athlon1600/youtube-downloader": "<=4", + "aureuserp/aureuserp": "<1.3.0.0-beta1", + "austintoddj/canvas": "<=3.4.2", + "auth0/auth0-php": ">=3.3,<=8.18", + "auth0/login": "<=7.20", + "auth0/symfony": "<=5.8", + "auth0/wordpress": "<=5.5", + "automad/automad": "<=2.0.0.0-beta27", + "automattic/jetpack": "<9.8", + "awesome-support/awesome-support": "<=6.0.7", + "aws/aws-sdk-php": "<=3.371.3", + "ayacoo/redirect-tab": "<2.1.2|>=3,<3.1.7|>=4,<4.0.5", + "azuracast/azuracast": "<=0.23.5", + "b13/seo_basics": "<0.8.2", + "backdrop/backdrop": "<=1.32", + "backpack/crud": "<6.8.15|>=7,<7.0.47", + "backpack/filemanager": "<2.0.2|>=3,<3.0.9", + "bacula-web/bacula-web": "<9.7.1", + "badaso/core": "<=2.9.11", + "bagisto/bagisto": "<=2.3.15", + "barrelstrength/sprout-base-email": "<1.2.7", + "barrelstrength/sprout-forms": "<3.9", + "barryvdh/laravel-translation-manager": "<0.6.8", + "barzahlen/barzahlen-php": "<2.0.1", + "baserproject/basercms": "<=5.2.2", + "bassjobsen/bootstrap-3-typeahead": ">4.0.2", + "bbpress/bbpress": "<2.6.5", + "bcit-ci/codeigniter": "<3.1.3", + "bcosca/fatfree": "<3.7.2", + "bedita/bedita": "<4", + "bednee/cooluri": "<1.0.30", + "bigfork/silverstripe-form-capture": ">=3,<3.1.1", + "billabear/billabear": "<=2025.01.03", + "billz/raspap-webgui": "<3.3.6", + "binarytorch/larecipe": "<2.8.1", + "bk2k/bootstrap-package": ">=7.1,<7.1.2|>=8,<8.0.8|>=9,<9.0.4|>=9.1,<9.1.3|>=10,<10.0.10|>=11,<11.0.3", + "blueimp/jquery-file-upload": "==6.4.4", + "bmarshall511/wordpress_zero_spam": "<5.2.13", + "bolt/bolt": "<3.7.2", + "bolt/core": "<=4.2", + "born05/craft-twofactorauthentication": "<3.3.4", + "bottelet/flarepoint": "<2.2.1", + "bref/bref": "<2.1.17", + "brightlocal/phpwhois": "<=4.2.5", + "brotkrueml/codehighlight": "<2.7", + "brotkrueml/schema": "<1.13.1|>=2,<2.5.1", + "brotkrueml/typo3-matomo-integration": "<1.3.2", + "buddypress/buddypress": "<14.5", + "bugsnag/bugsnag-laravel": ">=2,<2.0.2", + "bvbmedia/multishop": "<2.0.39", + "bytefury/crater": "<6.0.2", + "cachethq/cachet": "<2.5.1", + "cadmium-org/cadmium-cms": "<=0.4.9", + "cakephp/authentication": "<2.11.1|>=3,<3.3.6|>=4,<4.1.1", + "cakephp/cakephp": "<4.5.12|>=4.6,<4.6.5|>=5,<5.1.10|>=5.2,<5.2.15|>=5.3,<5.3.7", + "cakephp/database": ">=3,<4.5.12|>=4.6,<4.6.5|>=5,<5.1.10|>=5.2,<5.2.15|>=5.3,<5.3.7", + "cakephp/debug_kit": "<4.10.3|>=5,<5.2.4", + "cakephp/queue": ">=0.1.10,<2.3.1", + "cardgate/magento2": "<2.0.33", + "cardgate/woocommerce": "<=3.1.15", + "cart2quote/module-quotation": ">=4.1.6,<4.4.6|>=5,<5.4.4", + "cartalyst/sentry": "<2.1.7", + "catfan/medoo": "<1.7.5", + "causal/oidc": "<4", + "cecil/cecil": "<7.47.1", + "centreon/centreon": "<22.10.15", + "cesargb/laravel-magiclink": ">=2,<2.25.1", + "cesnet/simplesamlphp-module-proxystatistics": "<3.1", + "chamilo/chamilo-lms": "<=2", + "chriskacerguis/codeigniter-restserver": "<=2.7.1", + "chrome-php/chrome": "<1.14", + "ci4-cms-erp/ci4ms": "<=0.31.8", + "civicrm/civicrm-core": ">=4.2,<4.2.9|>=4.3,<4.3.3", + "ckeditor/ckeditor": "<4.25", + "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", + "co-stack/fal_sftp": "<0.2.6", + "cockpit-hq/cockpit": "<=2.14", + "code16/sharp": "<9.22.5", + "codeception/codeception": "<3.1.3|>=4,<4.1.22", + "codeigniter/framework": "<3.1.10", + "codeigniter4/framework": "<4.7.4", + "codeigniter4/shield": "<1.0.0.0-beta8", + "codiad/codiad": "<=2.8.4", + "codingms/additional-tca": ">=1.7,<1.15.17|>=1.16,<1.16.9", + "codingms/modules": "<7.10.4|>=8,<8.1.4", + "commerceteam/commerce": ">=0.9.6,<0.9.9", + "components/jquery": ">=1.0.3,<3.5", + "composer/composer": "<2.2.30|>=2.3,<2.10.3", + "concrete5/concrete5": "<9.5.2", + "concrete5/core": "<8.5.8|>=9,<9.1", + "contao-components/colorbox": ">=1,<1.6.4.3-dev", + "contao-components/mediaelement": ">=2.14.2,<2.21.1", + "contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12", + "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12", + "contao/core": "<3.5.39", + "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12", + "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", + "contao/managed-edition": "<=1.5", + "contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12", + "coreshop/core-shop": "<4.1.9|==5", + "corveda/phpsandbox": "<1.3.5", + "cosenary/instagram": "<=2.3", + "cotonti/cotonti": "<=1", + "couleurcitron/tarteaucitron-wp": "<0.3", + "cpsit/typo3-mailqueue": "<0.4.5|>=0.5,<0.5.2", + "craftcms/aws-s3": ">=2.0.2,<=2.2.4", + "craftcms/azure-blob": ">=2.0.0.0-beta1,<=2.1", + "craftcms/cms": "<4.18.3|>=5,<5.10.8", + "craftcms/commerce": ">=4,<=4.11.1|>=5,<=5.6.4", + "craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1", + "craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21", + "craftcms/google-cloud": ">=2.0.0.0-beta1,<=2.2", + "craftcms/webhooks": ">=3,<3.2", + "croogo/croogo": "<=4.0.7", + "cuyz/valinor": "<0.12", + "czim/file-handling": "<1.5|>=2,<2.3", + "czproject/git-php": "<4.0.3", + "damienharper/auditor-bundle": "<5.2.6", + "dapphp/securimage": "<3.6.6", + "darylldoyle/safe-svg": "<1.9.10", + "datadog/dd-trace": ">=0.30,<0.30.2", + "datahihi1/tiny-env": "<1.0.3|>=1.0.9,<1.0.11", + "datatables/datatables": "<1.10.10", + "david-garcia/phpwhois": "<=4.3.1", + "dbrisinajumi/d2files": "<1", + "dcat/laravel-admin": "<=2.1.3|==2.2.0.0-beta|==2.2.2.0-beta", + "dedoc/scramble": ">=0.13.2,<0.13.22", + "derhansen/fe_change_pwd": "<2.0.5|>=3,<3.0.3", + "derhansen/sf_event_mgt": "<5.9.3|>=6,<6.7.2|>=7,<7.9.3|>=8,<8.6.2|>=9,<9.0.3", + "desperado/xml-bundle": "<=0.1.7", + "dev-lancer/minecraft-motd-parser": "<=1.0.5", + "devcode-it/openstamanager": "<=2.10.1", + "devgroup/dotplant": "<2020.09.14-dev", + "digimix/wp-svg-upload": "<=1", + "directmailteam/direct-mail": "<6.0.3|>=7,<7.0.3|>=8,<9.5.2", + "directorytree/imapengine": "<1.22.3", + "dl/yag": "<3.0.1", + "dmk/webkitpdf": "<1.1.4", + "dnadesign/silverstripe-elemental": "<5.3.12", + "doctrine/annotations": "<1.2.7", + "doctrine/cache": ">=1,<1.3.2|>=1.4,<1.4.2", + "doctrine/common": "<2.4.3|>=2.5,<2.5.1", + "doctrine/dbal": ">=2,<2.0.8|>=2.1,<2.1.2|>=3,<3.1.4", + "doctrine/doctrine-bundle": "<1.5.2", + "doctrine/doctrine-module": "<0.7.2", + "doctrine/mongodb-odm": "<1.0.2", + "doctrine/mongodb-odm-bundle": "<3.0.1", + "doctrine/orm": ">=1,<1.2.4|>=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4", + "dolibarr/dolibarr": "<=23.0.2", + "dompdf/dompdf": "<3.1.6", + "doublethreedigital/guest-entries": "<3.1.2", + "dreamfactory/df-core": "<1.0.4", + "drupal-pattern-lab/unified-twig-extensions": "<=0.1", + "drupal/access_code": "<2.0.5", + "drupal/acquia_dam": "<1.1.5", + "drupal/admin_audit_trail": "<1.0.5", + "drupal/ai": "<1.0.5", + "drupal/alogin": "<2.0.6", + "drupal/cache_utility": "<1.2.1", + "drupal/civictheme": "<1.12", + "drupal/commerce_alphabank_redirect": "<1.0.3", + "drupal/commerce_eurobank_redirect": "<2.1.1", + "drupal/config_split": "<1.10|>=2,<2.0.2", + "drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.5.10|>=10.6,<10.6.9|>=11,<11.2.12|>=11.3,<11.3.10", + "drupal/core-recommended": ">=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8", + "drupal/currency": "<3.5", + "drupal/drupal": ">=5,<5.11|>=6,<6.38|>=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8", + "drupal/email_tfa": "<2.0.6", + "drupal/formatter_suite": "<2.1", + "drupal/gdpr": "<3.0.1|>=3.1,<3.1.2", + "drupal/google_tag": "<1.8|>=2,<2.0.8", + "drupal/ignition": "<1.0.4", "drupal/json_field": "<1.5", "drupal/lightgallery": "<1.6", "drupal/link_field_display_mode_formatter": "<1.6", @@ -2951,42 +3633,975 @@ "zoujingli/thinkadmin": "<=6.1.53", "zumba/json-serializer": "<3.2.3" }, - "type": "metapackage", + "type": "metapackage", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "role": "maintainer" + }, + { + "name": "Ilya Tribusean", + "email": "slash3b@gmail.com", + "role": "maintainer" + } + ], + "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it", + "keywords": [ + "dev" + ], + "support": { + "issues": "https://github.com/Roave/SecurityAdvisories/issues", + "source": "https://github.com/Roave/SecurityAdvisories/tree/latest" + }, + "funding": [ + { + "url": "https://github.com/Ocramius", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/roave/security-advisories", + "type": "tidelift" + } + ], + "time": "2026-10-08T22:58:27+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:35:54+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "role": "maintainer" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Ilya Tribusean", - "email": "slash3b@gmail.com", - "role": "maintainer" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it", - "keywords": [ - "dev" + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "issues": "https://github.com/Roave/SecurityAdvisories/issues", - "source": "https://github.com/Roave/SecurityAdvisories/tree/latest" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { - "url": "https://github.com/Ocramius", + "url": "https://github.com/sebastianbergmann", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/roave/security-advisories", + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", "type": "tidelift" } ], - "time": "2026-10-08T22:58:27+00:00" + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "9e935a16187e14f39b0e79910dabd614972cdb26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/9e935a16187e14f39b0e79910dabd614972cdb26", + "reference": "9e935a16187e14f39b0e79910dabd614972cdb26", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-10-04T05:50:08+00:00" }, { "name": "slevomat/coding-standard", @@ -3132,6 +4747,58 @@ ], "time": "2026-08-06T00:17:32+00:00" }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, { "name": "symfony/console", "version": "v8.1.9", @@ -4531,6 +6198,56 @@ ], "time": "2026-10-07T07:28:40+00:00" }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + }, { "name": "wnx/commonmark-markdown-renderer", "version": "v1.6.0", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 7d408e5..689b591 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -8,4 +8,5 @@ parameters: level: max checkUninitializedProperties: true paths: + - src/ - tests/ \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..ebc05ea --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + tests/Unit + + + + + + src + + + diff --git a/src/AggregateRootExtension.php b/src/AggregateRootExtension.php index dad5e81..6f162ee 100644 --- a/src/AggregateRootExtension.php +++ b/src/AggregateRootExtension.php @@ -4,7 +4,7 @@ namespace Patchlevel\EventSourcingPHPStanExtension; -use Patchlevel\EventSourcing\Aggregate\AggregateRoot; +use Patchlevel\EventSourcingPHPStanExtension\Helper\EventSourcing; use PHPStan\Reflection\PropertyReflection; use PHPStan\Rules\Properties\ReadWritePropertiesExtension; @@ -27,14 +27,6 @@ public function isInitialized(PropertyReflection $property, string $propertyName private function isAggregate(PropertyReflection $property): bool { - $interfaces = $property->getDeclaringClass()->getInterfaces(); - - foreach ($interfaces as $interface) { - if ($interface->getName() === AggregateRoot::class) { - return true; - } - } - - return false; + return EventSourcing::isAggregate($property->getDeclaringClass()); } } diff --git a/src/DontRecordWhenApplyingExtension.php b/src/DontRecordWhenApplyingExtension.php index 01707d7..e924837 100644 --- a/src/DontRecordWhenApplyingExtension.php +++ b/src/DontRecordWhenApplyingExtension.php @@ -4,8 +4,7 @@ namespace Patchlevel\EventSourcingPHPStanExtension; -use Patchlevel\EventSourcing\Aggregate\AggregateRoot; -use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcingPHPStanExtension\Helper\EventSourcing; use PhpParser\Node; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; @@ -18,8 +17,6 @@ use PHPStan\Parser\Parser; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedMethodReflection; -use PHPStan\Reflection\FunctionReflection; -use PHPStan\Reflection\MethodReflection; use PHPStan\Rules\RestrictedUsage\RestrictedMethodUsageExtension; use PHPStan\Rules\RestrictedUsage\RestrictedUsage; @@ -46,7 +43,7 @@ public function isRestrictedMethodUsage( ): RestrictedUsage|null { $declaringClass = $methodReflection->getDeclaringClass(); - if (!$this->isAggregate($declaringClass)) { + if (!EventSourcing::isAggregate($declaringClass)) { return null; } @@ -56,7 +53,7 @@ public function isRestrictedMethodUsage( return null; } - if (!$this->isApplyMethod($function)) { + if (!EventSourcing::isApplyMethod($function)) { return null; } @@ -77,22 +74,6 @@ public function isRestrictedMethodUsage( ); } - private function isAggregate(ClassReflection $classReflection): bool - { - return $classReflection->implementsInterface(AggregateRoot::class); - } - - private function isApplyMethod(FunctionReflection|MethodReflection $function): bool - { - foreach ($function->getAttributes() as $attribute) { - if ($attribute->getName() === Apply::class) { - return true; - } - } - - return false; - } - /** @param array $visited */ private function recordsEvent(ClassReflection $classReflection, string $methodName, array &$visited): bool { diff --git a/src/DontWriteStateWhenNotApplyingRule.php b/src/DontWriteStateWhenNotApplyingRule.php index 306c50d..cf352cc 100644 --- a/src/DontWriteStateWhenNotApplyingRule.php +++ b/src/DontWriteStateWhenNotApplyingRule.php @@ -5,26 +5,14 @@ namespace Patchlevel\EventSourcingPHPStanExtension; use Patchlevel\EventSourcing\Aggregate\AggregateRoot; -use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcingPHPStanExtension\Helper\EventSourcing; +use Patchlevel\EventSourcingPHPStanExtension\Helper\PropertyWrites; use PhpParser\Node; -use PhpParser\Node\Expr; -use PhpParser\Node\Expr\ArrayDimFetch; -use PhpParser\Node\Expr\Assign; -use PhpParser\Node\Expr\AssignOp; -use PhpParser\Node\Expr\AssignRef; -use PhpParser\Node\Expr\List_; -use PhpParser\Node\Expr\PostDec; -use PhpParser\Node\Expr\PostInc; -use PhpParser\Node\Expr\PreDec; -use PhpParser\Node\Expr\PreInc; use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\StaticPropertyFetch; use PhpParser\Node\Identifier; use PhpParser\Node\Name; -use PhpParser\Node\Stmt\Unset_; use PHPStan\Analyser\Scope; -use PHPStan\Reflection\FunctionReflection; -use PHPStan\Reflection\MethodReflection; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -43,7 +31,13 @@ public function getNodeType(): string /** @return list */ public function processNode(Node $node, Scope $scope): array { - $targets = $this->writeTargets($node); + $targets = []; + + foreach (PropertyWrites::targets($node) as $target) { + foreach (PropertyWrites::properties($target) as $property) { + $targets[] = $property; + } + } if ($targets === []) { return []; @@ -51,13 +45,13 @@ public function processNode(Node $node, Scope $scope): array $classReflection = $scope->getClassReflection(); - if ($classReflection === null || !$classReflection->implementsInterface(AggregateRoot::class)) { + if ($classReflection === null || !EventSourcing::isAggregate($classReflection)) { return []; } $function = $scope->getFunction(); - if ($function === null || $this->isApplyMethod($function)) { + if ($function === null || EventSourcing::isApplyMethod($function)) { return []; } @@ -82,77 +76,6 @@ public function processNode(Node $node, Scope $scope): array return $errors; } - /** @return list */ - private function writeTargets(Node $node): array - { - if ( - $node instanceof Assign - || $node instanceof AssignOp - || $node instanceof AssignRef - || $node instanceof PreInc - || $node instanceof PreDec - || $node instanceof PostInc - || $node instanceof PostDec - ) { - return $this->writtenProperties($node->var); - } - - if ($node instanceof Unset_) { - $targets = []; - - foreach ($node->vars as $var) { - foreach ($this->writtenProperties($var) as $target) { - $targets[] = $target; - } - } - - return $targets; - } - - return []; - } - - /** @return list */ - private function writtenProperties(Expr $expr): array - { - while ($expr instanceof ArrayDimFetch) { - $expr = $expr->var; - } - - if ($expr instanceof PropertyFetch || $expr instanceof StaticPropertyFetch) { - return [$expr]; - } - - if ($expr instanceof List_) { - $targets = []; - - foreach ($expr->items as $item) { - if ($item === null) { - continue; - } - - foreach ($this->writtenProperties($item->value) as $target) { - $targets[] = $target; - } - } - - return $targets; - } - - return []; - } - - private function isApplyMethod(FunctionReflection|MethodReflection $function): bool - { - foreach ($function->getAttributes() as $attribute) { - if ($attribute->getName() === Apply::class) { - return true; - } - } - - return false; - } - private function isAggregateState(PropertyFetch|StaticPropertyFetch $target, Scope $scope): bool { $aggregateType = new ObjectType(AggregateRoot::class); diff --git a/src/Helper/EventSourcing.php b/src/Helper/EventSourcing.php new file mode 100644 index 0000000..80dc3b4 --- /dev/null +++ b/src/Helper/EventSourcing.php @@ -0,0 +1,62 @@ +implementsInterface(AggregateRoot::class); + } + + public static function isApplyMethod(FunctionReflection|ExtendedMethodReflection $function): bool + { + return self::attributes($function->getAttributes(), Apply::class) !== []; + } + + /** Checks the attributes of a method or property node, the names are already resolved by the name resolver. */ + public static function hasAttribute(ClassMethod|Property $node, string $attribute): bool + { + foreach ($node->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($attr->name->toString() === $attribute) { + return true; + } + } + } + + return false; + } + + /** + * @param list $attributes + * + * @return list + */ + public static function attributes(array $attributes, string $name): array + { + $result = []; + + foreach ($attributes as $attribute) { + if ($attribute->getName() !== $name) { + continue; + } + + $result[] = $attribute; + } + + return $result; + } +} diff --git a/src/Helper/PropertyWrites.php b/src/Helper/PropertyWrites.php new file mode 100644 index 0000000..077c836 --- /dev/null +++ b/src/Helper/PropertyWrites.php @@ -0,0 +1,130 @@ + + */ + public static function targets(Node $node, bool $withUnset = true): array + { + if ( + $node instanceof Assign + || $node instanceof AssignOp + || $node instanceof AssignRef + || $node instanceof PreInc + || $node instanceof PreDec + || $node instanceof PostInc + || $node instanceof PostDec + ) { + return [$node->var]; + } + + if ($withUnset && $node instanceof Unset_) { + return array_values($node->vars); + } + + return []; + } + + /** + * Collects the write targets of all nodes in the given statements. + * + * @param array $stmts + * + * @return list + */ + public static function findTargets(array $stmts, bool $withUnset = true): array + { + $targets = []; + + $writes = (new NodeFinder())->find( + $stmts, + static fn (Node $node): bool => self::targets($node, $withUnset) !== [], + ); + + foreach ($writes as $write) { + foreach (self::targets($write, $withUnset) as $target) { + $targets[] = $target; + } + } + + return $targets; + } + + /** + * The properties a write target changes, also through array dimensions and list() destructuring. + * + * @return list + */ + public static function properties(Expr $target): array + { + while ($target instanceof ArrayDimFetch) { + $target = $target->var; + } + + if ($target instanceof PropertyFetch || $target instanceof StaticPropertyFetch) { + return [$target]; + } + + if (!$target instanceof List_) { + return []; + } + + $properties = []; + + foreach ($target->items as $item) { + if ($item === null) { + continue; + } + + foreach (self::properties($item->value) as $property) { + $properties[] = $property; + } + } + + return $properties; + } + + /** The name of the property if it is fetched from $this, otherwise null. */ + public static function ownPropertyName(PropertyFetch|StaticPropertyFetch $propertyFetch): string|null + { + if ( + !$propertyFetch instanceof PropertyFetch + || !$propertyFetch->var instanceof Variable + || $propertyFetch->var->name !== 'this' + || !$propertyFetch->name instanceof Identifier + ) { + return null; + } + + return $propertyFetch->name->toString(); + } +} diff --git a/src/UnusedPropertyRule.php b/src/UnusedPropertyRule.php index 57b8c85..9684e27 100644 --- a/src/UnusedPropertyRule.php +++ b/src/UnusedPropertyRule.php @@ -4,24 +4,10 @@ namespace Patchlevel\EventSourcingPHPStanExtension; -use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcingPHPStanExtension\Helper\EventSourcing; +use Patchlevel\EventSourcingPHPStanExtension\Helper\PropertyWrites; use PhpParser\Node; -use PhpParser\Node\Expr; -use PhpParser\Node\Expr\ArrayDimFetch; -use PhpParser\Node\Expr\Assign; -use PhpParser\Node\Expr\AssignOp; -use PhpParser\Node\Expr\AssignRef; -use PhpParser\Node\Expr\List_; -use PhpParser\Node\Expr\PostDec; -use PhpParser\Node\Expr\PostInc; -use PhpParser\Node\Expr\PreDec; -use PhpParser\Node\Expr\PreInc; -use PhpParser\Node\Expr\PropertyFetch; -use PhpParser\Node\Expr\Variable; -use PhpParser\Node\Identifier; -use PhpParser\Node\Stmt\ClassMethod; -use PhpParser\NodeFinder; use PHPStan\Analyser\Scope; use PHPStan\Node\InClassNode; use PHPStan\Rules\IdentifierRuleError; @@ -34,13 +20,6 @@ /** @implements Rule */ final class UnusedPropertyRule implements Rule { - private NodeFinder $nodeFinder; - - public function __construct() - { - $this->nodeFinder = new NodeFinder(); - } - public function getNodeType(): string { return InClassNode::class; @@ -51,7 +30,7 @@ public function processNode(Node $node, Scope $scope): array { $classReflection = $node->getClassReflection(); - if (!$classReflection->implementsInterface(AggregateRoot::class)) { + if (!EventSourcing::isAggregate($classReflection)) { return []; } @@ -64,12 +43,20 @@ public function processNode(Node $node, Scope $scope): array $writtenInApply = []; foreach ($classNode->getMethods() as $method) { - if (!$this->isApplyMethod($method)) { + if (!EventSourcing::hasAttribute($method, Apply::class)) { continue; } - foreach ($this->writtenPropertyNames($method) as $propertyName) { - $writtenInApply[$propertyName] = true; + foreach (PropertyWrites::findTargets($method->stmts ?? [], withUnset: false) as $target) { + foreach (PropertyWrites::properties($target) as $property) { + $propertyName = PropertyWrites::ownPropertyName($property); + + if ($propertyName === null) { + continue; + } + + $writtenInApply[$propertyName] = true; + } } } @@ -105,81 +92,4 @@ public function processNode(Node $node, Scope $scope): array return $errors; } - - private function isApplyMethod(ClassMethod $method): bool - { - foreach ($method->attrGroups as $attrGroup) { - foreach ($attrGroup->attrs as $attr) { - if ($attr->name->toString() === Apply::class) { - return true; - } - } - } - - return false; - } - - /** @return list */ - private function writtenPropertyNames(ClassMethod $method): array - { - $names = []; - - $writes = $this->nodeFinder->find( - $method->stmts ?? [], - static fn (Node $node): bool => $node instanceof Assign - || $node instanceof AssignOp - || $node instanceof AssignRef - || $node instanceof PreInc - || $node instanceof PreDec - || $node instanceof PostInc - || $node instanceof PostDec, - ); - - foreach ($writes as $write) { - /** @var Assign|AssignOp|AssignRef|PreInc|PreDec|PostInc|PostDec $write */ - foreach ($this->propertyNamesOfTarget($write->var) as $name) { - $names[] = $name; - } - } - - return $names; - } - - /** @return list */ - private function propertyNamesOfTarget(Expr $expr): array - { - while ($expr instanceof ArrayDimFetch) { - $expr = $expr->var; - } - - if ($expr instanceof PropertyFetch) { - if ( - $expr->var instanceof Variable - && $expr->var->name === 'this' - && $expr->name instanceof Identifier - ) { - return [$expr->name->toString()]; - } - - return []; - } - - if ($expr instanceof List_) { - $names = []; - - foreach ($expr->items as $item) { - if ($item === null) { - continue; - } - - foreach ($this->propertyNamesOfTarget($item->value) as $name) { - $names[] = $name; - } - } - - return $names; - } - - return []; - } } diff --git a/src/WriteOnlyPropertyRule.php b/src/WriteOnlyPropertyRule.php index 4acba45..a671db2 100644 --- a/src/WriteOnlyPropertyRule.php +++ b/src/WriteOnlyPropertyRule.php @@ -4,26 +4,14 @@ namespace Patchlevel\EventSourcingPHPStanExtension; -use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; +use Patchlevel\EventSourcingPHPStanExtension\Helper\EventSourcing; +use Patchlevel\EventSourcingPHPStanExtension\Helper\PropertyWrites; use PhpParser\Node; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\ArrayDimFetch; -use PhpParser\Node\Expr\Assign; -use PhpParser\Node\Expr\AssignOp; -use PhpParser\Node\Expr\AssignRef; -use PhpParser\Node\Expr\List_; -use PhpParser\Node\Expr\PostDec; -use PhpParser\Node\Expr\PostInc; -use PhpParser\Node\Expr\PreDec; -use PhpParser\Node\Expr\PreInc; use PhpParser\Node\Expr\PropertyFetch; -use PhpParser\Node\Expr\Variable; -use PhpParser\Node\Identifier; use PhpParser\Node\Stmt\ClassMethod; -use PhpParser\Node\Stmt\Property; -use PhpParser\Node\Stmt\Unset_; use PhpParser\NodeFinder; use PHPStan\Analyser\Scope; use PHPStan\Node\InClassNode; @@ -55,7 +43,7 @@ public function processNode(Node $node, Scope $scope): array { $classReflection = $node->getClassReflection(); - if (!$classReflection->implementsInterface(AggregateRoot::class)) { + if (!EventSourcing::isAggregate($classReflection)) { return []; } @@ -69,12 +57,12 @@ public function processNode(Node $node, Scope $scope): array $read = []; foreach ($classNode->getMethods() as $method) { - $writeTargets = $this->writeTargets($method); + $writeTargets = PropertyWrites::findTargets($method->stmts ?? []); - if ($this->isApplyMethod($method)) { + if (EventSourcing::hasAttribute($method, Apply::class)) { foreach ($writeTargets as $writeTarget) { - foreach ($this->basePropertyFetches($writeTarget) as $propertyFetch) { - $propertyName = $this->ownPropertyName($propertyFetch); + foreach (PropertyWrites::properties($writeTarget) as $property) { + $propertyName = PropertyWrites::ownPropertyName($property); if ($propertyName === null) { continue; @@ -97,7 +85,7 @@ public function processNode(Node $node, Scope $scope): array continue; } - if ($this->isLibraryReadProperty($property)) { + if (EventSourcing::hasAttribute($property, Id::class)) { continue; } @@ -127,70 +115,6 @@ public function processNode(Node $node, Scope $scope): array return $errors; } - private function isApplyMethod(ClassMethod $method): bool - { - foreach ($method->attrGroups as $attrGroup) { - foreach ($attrGroup->attrs as $attr) { - if ($attr->name->toString() === Apply::class) { - return true; - } - } - } - - return false; - } - - private function isLibraryReadProperty(Property $property): bool - { - foreach ($property->attrGroups as $attrGroup) { - foreach ($attrGroup->attrs as $attr) { - if ($attr->name->toString() === Id::class) { - return true; - } - } - } - - return false; - } - - /** - * Collects the expressions that are written to in the method: assignment - * targets, operands of increment and decrement, and unset() arguments. - * - * @return list - */ - private function writeTargets(ClassMethod $method): array - { - $targets = []; - - /** @var list $writes */ - $writes = $this->nodeFinder->find( - $method->stmts ?? [], - static fn (Node $node): bool => $node instanceof Assign - || $node instanceof AssignOp - || $node instanceof AssignRef - || $node instanceof PreInc - || $node instanceof PreDec - || $node instanceof PostInc - || $node instanceof PostDec - || $node instanceof Unset_, - ); - - foreach ($writes as $write) { - if ($write instanceof Unset_) { - foreach ($write->vars as $var) { - $targets[] = $var; - } - - continue; - } - - $targets[] = $write->var; - } - - return $targets; - } - /** * Collects the names of all properties the method reads: every $this * property fetch that is not itself the target of a write. @@ -204,8 +128,8 @@ private function readPropertyNames(ClassMethod $method, array $writeTargets): ar $writeBaseIds = []; foreach ($writeTargets as $writeTarget) { - foreach ($this->basePropertyFetches($writeTarget) as $propertyFetch) { - $writeBaseIds[spl_object_id($propertyFetch)] = true; + foreach (PropertyWrites::properties($writeTarget) as $property) { + $writeBaseIds[spl_object_id($property)] = true; } } @@ -218,7 +142,7 @@ private function readPropertyNames(ClassMethod $method, array $writeTargets): ar continue; } - $propertyName = $this->ownPropertyName($propertyFetch); + $propertyName = PropertyWrites::ownPropertyName($propertyFetch); if ($propertyName === null) { continue; @@ -229,47 +153,4 @@ private function readPropertyNames(ClassMethod $method, array $writeTargets): ar return $names; } - - /** @return list */ - private function basePropertyFetches(Expr $expr): array - { - while ($expr instanceof ArrayDimFetch) { - $expr = $expr->var; - } - - if ($expr instanceof PropertyFetch) { - return [$expr]; - } - - if ($expr instanceof List_) { - $fetches = []; - - foreach ($expr->items as $item) { - if ($item === null) { - continue; - } - - foreach ($this->basePropertyFetches($item->value) as $fetch) { - $fetches[] = $fetch; - } - } - - return $fetches; - } - - return []; - } - - private function ownPropertyName(PropertyFetch $propertyFetch): string|null - { - if ( - !$propertyFetch->var instanceof Variable - || $propertyFetch->var->name !== 'this' - || !$propertyFetch->name instanceof Identifier - ) { - return null; - } - - return $propertyFetch->name->toString(); - } } diff --git a/tests/Unit/Helper/EventSourcingTest.php b/tests/Unit/Helper/EventSourcingTest.php new file mode 100644 index 0000000..4068a3a --- /dev/null +++ b/tests/Unit/Helper/EventSourcingTest.php @@ -0,0 +1,103 @@ +getNativeMethod('applyStockCreated'))); + self::assertFalse(EventSourcing::isApplyMethod($stock->getNativeMethod('decreaseStock'))); + } + + public function testHasAttribute(): void + { + $code = <<<'PHP' + createForNewestSupportedVersion(); + $traverser = new NodeTraverser(new NameResolver()); + $class = (new NodeFinder())->findFirstInstanceOf($traverser->traverse($parser->parse($code) ?? []), Class_::class); + + self::assertInstanceOf(Class_::class, $class); + + [$id, $name] = $class->getProperties(); + + self::assertTrue(EventSourcing::hasAttribute($id, Id::class)); + self::assertFalse(EventSourcing::hasAttribute($name, Id::class)); + + $methods = $class->getMethods(); + + self::assertTrue(EventSourcing::hasAttribute($methods[0], Apply::class)); + self::assertTrue(EventSourcing::hasAttribute($methods[1], Apply::class)); + self::assertFalse(EventSourcing::hasAttribute($methods[2], Apply::class)); + self::assertFalse(EventSourcing::hasAttribute($methods[3], Apply::class)); + } + + public function testAttributes(): void + { + $attributes = self::classReflection(Stock::class)->getAttributes(); + + self::assertCount(1, EventSourcing::attributes($attributes, Aggregate::class)); + self::assertSame([], EventSourcing::attributes($attributes, Event::class)); + } + + private static function classReflection(string $class): ClassReflection + { + return self::createReflectionProvider()->getClass($class); + } +} diff --git a/tests/Unit/Helper/PropertyWritesTest.php b/tests/Unit/Helper/PropertyWritesTest.php new file mode 100644 index 0000000..a8fd772 --- /dev/null +++ b/tests/Unit/Helper/PropertyWritesTest.php @@ -0,0 +1,158 @@ +}> */ + public static function provideTargets(): iterable + { + yield 'assign' => ['$this->a = 1;', ['$this->a']]; + yield 'assign op' => ['$this->a += 1;', ['$this->a']]; + yield 'assign coalesce' => ['$this->a ??= 1;', ['$this->a']]; + yield 'assign ref' => ['$this->a = &$b;', ['$this->a']]; + yield 'pre increment' => ['++$this->a;', ['$this->a']]; + yield 'pre decrement' => ['--$this->a;', ['$this->a']]; + yield 'post increment' => ['$this->a++;', ['$this->a']]; + yield 'post decrement' => ['$this->a--;', ['$this->a']]; + yield 'variable' => ['$a = 1;', ['$a']]; + yield 'array dimension' => ['$this->a["x"] = 1;', ['$this->a["x"]']]; + yield 'list' => ['[$this->a, $this->b] = $c;', ['[$this->a, $this->b]']]; + yield 'no write' => ['$this->a;', []]; + yield 'call' => ['$this->a();', []]; + } + + /** @param list $expected */ + #[DataProvider('provideTargets')] + public function testTargets(string $code, array $expected): void + { + self::assertSame($expected, self::print(PropertyWrites::targets(self::expression($code)))); + } + + public function testTargetsOfUnset(): void + { + $unset = self::statements('unset($this->a, $this->b["x"]);')[0]; + + self::assertInstanceOf(Unset_::class, $unset); + self::assertSame(['$this->a', '$this->b["x"]'], self::print(PropertyWrites::targets($unset))); + self::assertSame([], PropertyWrites::targets($unset, withUnset: false)); + } + + public function testFindTargets(): void + { + $statements = self::statements(<<<'PHP' + $this->a = 1; + if ($condition) { + $this->b++; + } + $closure = function () { + $this->c = 2; + }; + unset($this->d); + $this->e(); + PHP); + + self::assertSame( + ['$this->a', '$this->b', '$closure', '$this->c', '$this->d'], + self::print(PropertyWrites::findTargets($statements)), + ); + self::assertSame( + ['$this->a', '$this->b', '$closure', '$this->c'], + self::print(PropertyWrites::findTargets($statements, withUnset: false)), + ); + } + + public function testFindTargetsWithNestedAssignments(): void + { + $statements = self::statements('$this->a = $this->b = 1;'); + + self::assertSame(['$this->a', '$this->b'], self::print(PropertyWrites::findTargets($statements))); + } + + /** @return iterable}> */ + public static function provideProperties(): iterable + { + yield 'property' => ['$this->a', ['$this->a']]; + yield 'property of another object' => ['$other->a', ['$other->a']]; + yield 'static property' => ['self::$a', ['self::$a']]; + yield 'array dimension' => ['$this->a["x"]["y"]', ['$this->a']]; + yield 'array append' => ['$this->a[]', ['$this->a']]; + yield 'list' => ['[$this->a, , [$this->b], "key" => $this->c]', ['$this->a', '$this->b', '$this->c']]; + yield 'list with variable' => ['[$a, $this->b]', ['$this->b']]; + yield 'variable' => ['$a', []]; + yield 'array dimension of variable' => ['$a["x"]', []]; + } + + /** @param list $expected */ + #[DataProvider('provideProperties')] + public function testProperties(string $target, array $expected): void + { + $assign = self::expression($target . ' = $value;'); + + self::assertInstanceOf(Expr\Assign::class, $assign); + self::assertSame($expected, self::print(PropertyWrites::properties($assign->var))); + } + + /** @return iterable */ + public static function provideOwnPropertyName(): iterable + { + yield 'own property' => ['$this->a', 'a']; + yield 'property of another object' => ['$other->a', null]; + yield 'dynamic property name' => ['$this->{$name}', null]; + yield 'static property' => ['self::$a', null]; + } + + #[DataProvider('provideOwnPropertyName')] + public function testOwnPropertyName(string $code, string|null $expected): void + { + $propertyFetch = self::expression($code . ';'); + + self::assertTrue($propertyFetch instanceof PropertyFetch || $propertyFetch instanceof StaticPropertyFetch); + self::assertSame($expected, PropertyWrites::ownPropertyName($propertyFetch)); + } + + /** @return array */ + private static function statements(string $code): array + { + return (new ParserFactory())->createForNewestSupportedVersion()->parse('expr; + } + + /** + * @param list $expressions + * + * @return list + */ + private static function print(array $expressions): array + { + $printer = new Standard(); + + return array_map(static fn (Expr $expression): string => $printer->prettyPrintExpr($expression), $expressions); + } +} From fa258378dfe3f2ce0c07a21197355982f1b956be Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 18 Oct 2026 11:27:36 +0200 Subject: [PATCH 3/4] Add analyser to visualize the event sourced domain This brings the event-sourcing-analyser into the extension. Collectors gather aggregates, events, commands, subscribers, projections and Symfony controllers, and two error formatters render them as a Graphviz diagram inspired by Event Storming or as JSON. It is disabled by default, since the collectors would otherwise run in every analysis, and is enabled with the new analyser parameter. Compared to the standalone package it also understands handlers of the dynamic consistency boundary, so commands handled by a service are linked to the events they append and the projections they read. The Graphviz output no longer needs alom/graphviz. --- README.md | 1 + composer.json | 1 + composer.lock | 361 ++++++++++++- docs/analyser-output.md | 119 +++++ docs/analyser.md | 245 +++++++++ docs/getting-started.md | 4 + docs/introduction.md | 2 + docs/project.json | 4 +- extension.neon | 49 ++ phpunit.xml.dist | 3 + src/Analyser/Collector/AggregateCollector.php | 51 ++ src/Analyser/Collector/CallCollector.php | 164 ++++++ .../Collector/ControllerCollector.php | 43 ++ src/Analyser/Collector/EventCollector.php | 47 ++ src/Analyser/Collector/HandlerCollector.php | 57 ++ .../Collector/ProjectionCollector.php | 54 ++ .../Collector/SubscriberCollector.php | 89 ++++ src/Analyser/Graphviz/Graph.php | 172 ++++++ src/Analyser/GraphvizFormatter.php | 252 +++++++++ src/Analyser/JsonFormatter.php | 44 ++ src/Analyser/Model/Aggregate.php | 20 + src/Analyser/Model/BoundedContext.php | 27 + src/Analyser/Model/Command.php | 20 + src/Analyser/Model/Event.php | 14 + src/Analyser/Model/Project.php | 56 ++ src/Analyser/Model/Projection.php | 16 + src/Analyser/Model/Subscriber.php | 21 + src/Analyser/Model/SubscriberType.php | 12 + src/Analyser/Model/UserInterface.php | 20 + src/Analyser/ProjectFactory.php | 497 ++++++++++++++++++ src/Helper/EventSourcing.php | 88 ++++ tests/Analyser/AnalyserTest.php | 83 +++ .../Fixture/Audit/AuditSubscriber.php | 30 ++ .../Billing/Application/CreateInvoice.php | 13 + .../Application/CreateInvoiceHandler.php | 36 ++ .../Domain/CustomerInvoicesProjection.php | 35 ++ .../Fixture/Billing/Domain/InvoiceCreated.php | 19 + .../Domain/NextInvoiceNumberProjection.php | 29 + .../Infrastructure/FirstInvoiceProcessor.php | 26 + .../Profile/Application/ArchiveProfile.php | 15 + .../Profile/Application/CreateProfile.php | 16 + .../Profile/Application/RenameProfile.php | 16 + .../Profile/Domain/ArchivingBehaviour.php | 13 + .../Fixture/Profile/Domain/Profile.php | 95 ++++ .../Profile/Domain/ProfileArchived.php | 12 + .../Fixture/Profile/Domain/ProfileCreated.php | 20 + .../Fixture/Profile/Domain/ProfileRenamed.php | 16 + .../CreateProfileController.php | 24 + .../Infrastructure/HealthController.php | 17 + .../Infrastructure/ProfileListController.php | 22 + .../Infrastructure/ProfileProjector.php | 35 ++ tests/Analyser/analyser.neon | 10 + tests/Analyser/disabled.neon | 8 + tests/Analyser/snapshot.dot | 51 ++ tests/Analyser/snapshot.json | 183 +++++++ tests/Unit/Analyser/Graphviz/GraphTest.php | 95 ++++ tests/Unit/Helper/EventSourcingTest.php | 85 +++ .../Helper/Fixture/MultiCommandHandler.php | 37 ++ .../Helper/Fixture/StockCountProjection.php | 37 ++ 59 files changed, 3629 insertions(+), 2 deletions(-) create mode 100644 docs/analyser-output.md create mode 100644 docs/analyser.md create mode 100644 src/Analyser/Collector/AggregateCollector.php create mode 100644 src/Analyser/Collector/CallCollector.php create mode 100644 src/Analyser/Collector/ControllerCollector.php create mode 100644 src/Analyser/Collector/EventCollector.php create mode 100644 src/Analyser/Collector/HandlerCollector.php create mode 100644 src/Analyser/Collector/ProjectionCollector.php create mode 100644 src/Analyser/Collector/SubscriberCollector.php create mode 100644 src/Analyser/Graphviz/Graph.php create mode 100644 src/Analyser/GraphvizFormatter.php create mode 100644 src/Analyser/JsonFormatter.php create mode 100644 src/Analyser/Model/Aggregate.php create mode 100644 src/Analyser/Model/BoundedContext.php create mode 100644 src/Analyser/Model/Command.php create mode 100644 src/Analyser/Model/Event.php create mode 100644 src/Analyser/Model/Project.php create mode 100644 src/Analyser/Model/Projection.php create mode 100644 src/Analyser/Model/Subscriber.php create mode 100644 src/Analyser/Model/SubscriberType.php create mode 100644 src/Analyser/Model/UserInterface.php create mode 100644 src/Analyser/ProjectFactory.php create mode 100644 tests/Analyser/AnalyserTest.php create mode 100644 tests/Analyser/Fixture/Audit/AuditSubscriber.php create mode 100644 tests/Analyser/Fixture/Billing/Application/CreateInvoice.php create mode 100644 tests/Analyser/Fixture/Billing/Application/CreateInvoiceHandler.php create mode 100644 tests/Analyser/Fixture/Billing/Domain/CustomerInvoicesProjection.php create mode 100644 tests/Analyser/Fixture/Billing/Domain/InvoiceCreated.php create mode 100644 tests/Analyser/Fixture/Billing/Domain/NextInvoiceNumberProjection.php create mode 100644 tests/Analyser/Fixture/Billing/Infrastructure/FirstInvoiceProcessor.php create mode 100644 tests/Analyser/Fixture/Profile/Application/ArchiveProfile.php create mode 100644 tests/Analyser/Fixture/Profile/Application/CreateProfile.php create mode 100644 tests/Analyser/Fixture/Profile/Application/RenameProfile.php create mode 100644 tests/Analyser/Fixture/Profile/Domain/ArchivingBehaviour.php create mode 100644 tests/Analyser/Fixture/Profile/Domain/Profile.php create mode 100644 tests/Analyser/Fixture/Profile/Domain/ProfileArchived.php create mode 100644 tests/Analyser/Fixture/Profile/Domain/ProfileCreated.php create mode 100644 tests/Analyser/Fixture/Profile/Domain/ProfileRenamed.php create mode 100644 tests/Analyser/Fixture/Profile/Infrastructure/CreateProfileController.php create mode 100644 tests/Analyser/Fixture/Profile/Infrastructure/HealthController.php create mode 100644 tests/Analyser/Fixture/Profile/Infrastructure/ProfileListController.php create mode 100644 tests/Analyser/Fixture/Profile/Infrastructure/ProfileProjector.php create mode 100644 tests/Analyser/analyser.neon create mode 100644 tests/Analyser/disabled.neon create mode 100644 tests/Analyser/snapshot.dot create mode 100644 tests/Analyser/snapshot.json create mode 100644 tests/Unit/Analyser/Graphviz/GraphTest.php create mode 100644 tests/Unit/Helper/Fixture/MultiCommandHandler.php create mode 100644 tests/Unit/Helper/Fixture/StockCountProjection.php diff --git a/README.md b/README.md index da99919..a7b34ad 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ * [Recording in apply methods](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#recording-in-apply-methods) is reported as an error, because recording events while replaying them leads to duplicated events. * [Writing state outside apply methods](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#writing-state-outside-apply-methods) is reported as an error, because state that is not derived from an event is lost when the aggregate is reloaded. * [Typed decisions](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#typed-decisions) for the dynamic consistency boundary, so PHPStan knows the state of every projection a decision reads, also when several are read at once. +* [Analyser](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/analyser) that turns your aggregates, commands, events, decisions and subscribers into a diagram inspired by Event Storming or into JSON for your own tooling. ## Installation diff --git a/composer.json b/composer.json index dd37c8c..5b55a69 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "phpstan/phpstan-strict-rules": "^2.0.4", "phpunit/phpunit": "^10.5.0 || ^11.0.0 || ^12.0.0", "roave/security-advisories": "dev-master", + "symfony/http-kernel": "^6.4.0 || ^7.0.0 || ^8.0.0", "wnx/commonmark-markdown-renderer": "^1.6" }, "config": { diff --git a/composer.lock b/composer.lock index f862432..1b79860 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "39c1a324809341d799879a6b50cacccc", + "content-hash": "24a1ad48d8a47166bb7e1160e4e6b0f1", "packages": [ { "name": "phpstan/phpstan", @@ -4970,6 +4970,87 @@ ], "time": "2026-06-05T06:23:12+00:00" }, + { + "name": "symfony/error-handler", + "version": "v8.1.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "c146daceccd5d4f864c051b233c276641cec527b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/c146daceccd5d4f864c051b233c276641cec527b", + "reference": "c146daceccd5d4f864c051b233c276641cec527b", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^7.4|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v8.1.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-22T18:58:53+00:00" + }, { "name": "symfony/event-dispatcher", "version": "v8.1.5", @@ -5204,6 +5285,197 @@ ], "time": "2026-09-26T06:33:49+00:00" }, + { + "name": "symfony/http-foundation", + "version": "v8.1.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "7850897fb8659f98491b03be79ac48600bdaf9ab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7850897fb8659f98491b03be79ac48600bdaf9ab", + "reference": "7850897fb8659f98491b03be79ac48600bdaf9ab", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v8.1.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-08T16:57:43+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v8.1.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "55c6935bb4c004a72b55f723e79d5404881dcad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/55c6935bb4c004a72b55f723e79d5404881dcad3", + "reference": "55c6935bb4c004a72b55f723e79d5404881dcad3", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/flex": "<2.10", + "symfony/http-client-contracts": "<2.5", + "symfony/serializer": "<7.4.15|>=8.0,<8.0.15|>=8.1,<8.1.2", + "symfony/translation-contracts": "<2.5", + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", + "twig/twig": "<3.21" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v8.1.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-14T06:47:03+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.37.0", @@ -6115,6 +6387,93 @@ ], "time": "2026-09-28T19:13:15+00:00" }, + { + "name": "symfony/var-dumper", + "version": "v8.1.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "b8c168c764c09f5d931f290c9dbdd13f1bf142f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8c168c764c09f5d931f290c9dbdd13f1bf142f3", + "reference": "b8c168c764c09f5d931f290c9dbdd13f1bf142f3", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v8.1.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-13T05:48:51+00:00" + }, { "name": "symfony/var-exporter", "version": "v8.1.9", diff --git a/docs/analyser-output.md b/docs/analyser-output.md new file mode 100644 index 0000000..581078f --- /dev/null +++ b/docs/analyser-output.md @@ -0,0 +1,119 @@ +# Analyser Output + +The [analyser](analyser.md) renders the model of your domain through a PHPStan error formatter. Two formatters ship +with the extension: `eventSourcingGraphviz` draws a diagram inspired by Event Storming, and `eventSourcingJson` +exports the same model as data for your own tooling. You pick one with the `--error-format` option of +`phpstan analyse`. + +:::warning +Both formatters only print the model. Errors PHPStan finds in your code are not shown and the exit code stays `0`, +so run your regular PHPStan check separately. Only if the analysis itself fails, the formatters print those errors +instead, because the model would be incomplete. +::: + +## Graphviz + +The Graphviz formatter prints the model in the [DOT](https://graphviz.org/doc/info/lang.html) language, which you turn +into an image with the `dot` binary: + +```bash +vendor/bin/phpstan analyse --error-format=eventSourcingGraphviz | dot -Tsvg > domain.svg +``` +`dot` is part of [Graphviz](https://graphviz.org/). Install it with your package manager, for example +`brew install graphviz` on macOS or `apt-get install graphviz` on Debian based systems. Use `-Tpng` instead of `-Tsvg` +to get a PNG. + +Every [bounded context](analyser.md#bounded-contexts) becomes a dotted cluster. Inside of it, each aggregate is a +yellow cluster around its commands and events, while subscribers, projections and controllers sit next to the +aggregates. The edges follow the flow of your domain: + +* a command points to the events its handlers record or append +* an event points to the subscribers and projections that use it +* a projection points to the commands whose handlers read it +* a processor or a controller points to the commands it dispatches +* a subscriber points to the controllers that call it + +The colors follow the usual Event Storming palette: + +| Element | Color | +| --- | --- | +| Aggregate | yellow | +| Event | orange | +| Command | blue | +| Subscriber | red | +| Processor | purple | +| Projector | green | +| Projection | light green | +| Controller | gray | + +## JSON + +The JSON formatter exposes the model as data, so you can build your own renderer, a documentation page or a check in +your pipeline on top of it: + +```bash +vendor/bin/phpstan analyse --error-format=eventSourcingJson > domain.json +``` +The top level object holds one collection per element, keyed by the class name, and the bounded contexts keyed by +their name. The bounded contexts only list class names that reference the elements in the other collections: + +```json +{ + "boundedContexts": { + "Profile": { + "name": "Profile", + "aggregates": ["App\\Profile\\Domain\\Profile"], + "events": ["App\\Profile\\Domain\\NameChanged"], + "commands": ["App\\Profile\\Application\\ChangeName"], + "subscribers": [], + "projections": ["App\\Profile\\Domain\\ProfileName"], + "userInterfaces": [] + } + }, + "aggregates": { + "App\\Profile\\Domain\\Profile": { + "name": "profile", + "class": "App\\Profile\\Domain\\Profile", + "events": ["App\\Profile\\Domain\\NameChanged"], + "commands": ["App\\Profile\\Application\\ChangeName"] + } + }, + "events": { + "App\\Profile\\Domain\\NameChanged": { + "name": "profile.name_changed", + "class": "App\\Profile\\Domain\\NameChanged" + } + }, + "commands": { + "App\\Profile\\Application\\ChangeName": { + "name": "ChangeName", + "class": "App\\Profile\\Application\\ChangeName", + "events": ["App\\Profile\\Domain\\NameChanged"], + "projections": ["App\\Profile\\Domain\\ProfileName"] + } + }, + "subscribers": {}, + "projections": { + "App\\Profile\\Domain\\ProfileName": { + "name": "ProfileName", + "class": "App\\Profile\\Domain\\ProfileName", + "events": ["App\\Profile\\Domain\\NameChanged"] + } + }, + "userInterfaces": {} +} +``` +A subscriber has a `type` of `subscriber`, `processor` or `projector`, and its `events` contain `*` if it subscribes +to all events. Aggregates, events and subscribers are named after their attribute, everything else after its short +class name. The collections and lists are sorted, so the output only changes when your domain changes. + +:::tip +Commit the JSON output and compare it in your pipeline to spot unintended changes of your domain, like a command +that suddenly records another event. +::: + +## Learn more + +* [How the analyser detects your domain](analyser.md) +* [How to group your domain into bounded contexts](analyser.md#bounded-contexts) +* [How to configure the extension](getting-started.md#configuration) diff --git a/docs/analyser.md b/docs/analyser.md new file mode 100644 index 0000000..0b0e76d --- /dev/null +++ b/docs/analyser.md @@ -0,0 +1,245 @@ +# Analyser + +The analyser reads your event sourced code during a PHPStan run and turns it into a model of your domain: which +commands are handled, which events they record, which projections a decision reads and which subscribers react to the +events. You render that model as a diagram inspired by [Event Storming](https://www.eventstorming.com/) or export it as +JSON, see [analyser output](analyser-output.md). + +:::note +The analyser does not check anything. It only collects data, so it never adds errors to your normal PHPStan run. +::: + +## Enable the analyser + +The analyser is disabled by default, because collecting the data costs time and space in the result cache on every +run. Enable it in your `phpstan.neon`: + +```neon +parameters: + patchlevelEventSourcing: + analyser: true +``` +If you do not want to pay for the analyser in your regular runs, enable it in a separate configuration file and use +that one only to render the model: + +```neon +includes: + - phpstan.neon + +parameters: + patchlevelEventSourcing: + analyser: true +``` +Save it as `phpstan-analyser.neon` and pass it to PHPStan whenever you want to render the model: + +```bash +vendor/bin/phpstan analyse --configuration=phpstan-analyser.neon --error-format=eventSourcingGraphviz | dot -Tsvg > domain.svg +``` +:::warning +The model only contains the code PHPStan analyses. If you pass single paths to `phpstan analyse`, everything outside +of them is missing from the model. +::: + +## Aggregates + +An aggregate is detected by the `#[Aggregate]` attribute, and its name becomes the label. Every event it records +through `recordThat()` belongs to the aggregate, and every command of a `#[Handle]` method is drawn inside of it. + +```php +use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\Attribute\Handle; +use Patchlevel\EventSourcing\Attribute\Id; +use Patchlevel\EventSourcing\Identifier\Uuid; + +#[Aggregate('profile')] +final class Profile extends BasicAggregateRoot +{ + #[Id] + private Uuid $id; + private string $name; + + #[Handle] + public static function create(CreateProfile $command): self + { + $self = new self(); + $self->recordThat(new ProfileCreated($command->id, $command->name)); + + return $self; + } + + #[Handle(ChangeName::class)] + public function changeName(string $name): void + { + if ($this->name === $name) { + return; + } + + $this->recordNameChanged($name); + } + + #[Apply] + protected function applyProfileCreated(ProfileCreated $event): void + { + $this->id = $event->id; + $this->name = $event->name; + } + + #[Apply] + protected function applyNameChanged(NameChanged $event): void + { + $this->name = $event->name; + } + + private function recordNameChanged(string $name): void + { + $this->recordThat(new NameChanged($this->id, $name)); + } +} +``` +The command of a `#[Handle]` method comes from the type of its first parameter, or from the explicit command class of +the attribute, like the command bus of the library resolves it. + +:::note +The analyser follows the methods of the own class, also the ones of traits and parent classes. The `changeName()` +handler above is connected to `NameChanged`, although the event is recorded in a helper method. +::: + +## Events + +An event is detected by the `#[Event]` attribute, and its name becomes the label: + +```php +use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Identifier\Uuid; + +#[Event('profile.name_changed')] +final class NameChanged +{ + public function __construct( + public readonly Uuid $profileId, + public readonly string $name, + ) { + } +} +``` +An event that is recorded or subscribed to but not analysed, for example because it lives in another package, is +still part of the model and labelled with its short class name. + +## Decisions + +With the [dynamic consistency boundary](https://patchlevel.dev/docs/event-sourcing/latest/dynamic-consistency-boundary), +a command is handled by a service instead of an aggregate. The analyser connects the command to the events the handler +appends to the decision, and to the projections it reads to decide: + +```php +use Patchlevel\EventSourcing\Attribute\Handle; +use Patchlevel\EventSourcing\DecisionModel\Decision; + +final class ChangeNameHandler +{ + #[Handle] + public function __invoke(ChangeName $command, Decision $decision): void + { + $state = $decision->read([ + 'exists' => new ProfileExists($command->profileId), + 'name' => new ProfileName($command->profileId), + ]); + + if (!$state['exists'] || $state['name'] === $command->name) { + return; + } + + $decision->append([new NameChanged($command->profileId, $command->name)]); + } +} +``` +Every class implementing the `Projection` interface is part of the model, together with the events of its `#[Apply]` +methods. In the diagram, the events point to the projections, and the projections point to the commands that read +them. + +## Subscribers + +A subscriber reacts to events. The analyser detects the three kinds of subscribers by their attribute and draws each +kind in its own color: + +* `#[Projector]` builds a read model +* `#[Processor]` runs side effects and may dispatch follow up commands +* `#[Subscriber]` is the generic form + +The events come from the `#[Subscribe]` methods, and every command the subscriber dispatches through the command bus +adds an edge to that command: + +```php +use Patchlevel\EventSourcing\Attribute\Processor; +use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\CommandBus\CommandBus; + +#[Processor('welcome_mail')] +final class WelcomeMailProcessor +{ + public function __construct( + private readonly CommandBus $commandBus, + ) { + } + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event): void + { + $this->commandBus->dispatch(new SendWelcomeMail($event->id)); + } +} +``` +:::note +Commands dispatched through the `CommandBus` of the library and through the `MessageBusInterface` of Symfony Messenger +are detected. +::: + +:::tip +A subscriber with `#[Subscribe(Subscribe::ALL)]` listens to every event. It is labelled with "all events" instead of +an edge from every single event. +::: + +## Symfony controllers + +Controllers close the loop from the request that dispatches a command to the read model a page shows. A class with +the `#[AsController]` attribute or extending the `AbstractController` of Symfony is a controller. It is part of the +model as soon as it dispatches a command or calls a method of a subscriber: + +```php +use Patchlevel\EventSourcing\CommandBus\CommandBus; +use Patchlevel\EventSourcing\Identifier\Uuid; +use Symfony\Component\HttpKernel\Attribute\AsController; + +#[AsController] +final class CreateProfileController +{ + public function __construct( + private readonly CommandBus $commandBus, + ) { + } + + public function __invoke(string $name): void + { + $this->commandBus->dispatch(new CreateProfile(Uuid::generate(), $name)); + } +} +``` +## Bounded contexts + +The analyser groups everything into bounded contexts, so the diagram stays readable for large applications. It reads +the namespace of each class and looks for a `Domain`, `Application` or `Infrastructure` segment. The segment right +before it is the name of the context, so `App\Profile\Domain\Profile` belongs to the `Profile` context and +`App\Billing\Application\CreateInvoice` to the `Billing` context. + +:::tip +A class without one of these segments stays in the model, but outside of any context. Use a consistent +`App\\` namespace layout and every class lands in the right context. +::: + +## Learn more + +* [How to render the model as a diagram or JSON](analyser-output.md) +* [How to configure the extension](getting-started.md#configuration) +* [How to make decisions with the dynamic consistency boundary](https://patchlevel.dev/docs/event-sourcing/latest/dynamic-consistency-boundary) diff --git a/docs/getting-started.md b/docs/getting-started.md index b82cf29..01aa455 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -327,6 +327,9 @@ parameters: noRecordThatWhenApplying: false noStateWriteWhenNotApplying: false ``` +The [analyser](analyser.md) is the other way around. It is disabled by default and you enable it with +`analyser: true`. + ## Result With the extension enabled, PHPStan understands your aggregates: it stops complaining about properties that are @@ -338,5 +341,6 @@ You get accurate static analysis without writing a single annotation. ## Learn more * [What the extension offers](introduction.md) +* [How to visualize your domain with the analyser](analyser.md) * [How to build aggregates with patchlevel/event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) * [How to make decisions with the dynamic consistency boundary](https://patchlevel.dev/docs/event-sourcing/latest/dynamic-consistency-boundary) diff --git a/docs/introduction.md b/docs/introduction.md index 2972c8d..e70f249 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -12,6 +12,7 @@ and it catches common mistakes before they ever reach runtime. * [Recording in apply methods](getting-started.md#recording-in-apply-methods) is reported as an error, because recording events while replaying them leads to duplicated events. * [Writing state outside apply methods](getting-started.md#writing-state-outside-apply-methods) is reported as an error, because state that is not derived from an event is lost when the aggregate is reloaded. * [Typed decisions](getting-started.md#typed-decisions) for the dynamic consistency boundary, so PHPStan knows the state of every projection a decision reads, also when several are read at once. +* [Analyser](analyser.md) that turns your aggregates, commands, events, decisions and subscribers into a diagram inspired by Event Storming or into JSON for your own tooling. ## Installation @@ -26,6 +27,7 @@ includes: ``` That is all the configuration the extension needs. All checks are active as soon as the extension is registered, and single rules can be deactivated through the [configuration parameters](getting-started.md#configuration). +The [analyser](analyser.md) is the only feature you enable explicitly. ## Integration diff --git a/docs/project.json b/docs/project.json index f982690..257d1c7 100644 --- a/docs/project.json +++ b/docs/project.json @@ -1,6 +1,8 @@ { "navigation": [ { "title": "Introduction", "file": "introduction.md" }, - { "title": "Getting Started", "file": "getting-started.md" } + { "title": "Getting Started", "file": "getting-started.md" }, + { "title": "Analyser", "file": "analyser.md" }, + { "title": "Analyser Output", "file": "analyser-output.md" } ] } diff --git a/extension.neon b/extension.neon index 1992403..9be7b0d 100644 --- a/extension.neon +++ b/extension.neon @@ -5,6 +5,7 @@ parameters: writeOnlyProperty: true noRecordThatWhenApplying: true noStateWriteWhenNotApplying: true + analyser: false parametersSchema: patchlevelEventSourcing: structure([ @@ -13,6 +14,7 @@ parametersSchema: writeOnlyProperty: bool() noRecordThatWhenApplying: bool() noStateWriteWhenNotApplying: bool() + analyser: bool() ]) conditionalTags: @@ -26,6 +28,20 @@ conditionalTags: phpstan.restrictedMethodUsageExtension: %patchlevelEventSourcing.noRecordThatWhenApplying% Patchlevel\EventSourcingPHPStanExtension\DontWriteStateWhenNotApplyingRule: phpstan.rules.rule: %patchlevelEventSourcing.noStateWriteWhenNotApplying% + Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\AggregateCollector: + phpstan.collector: %patchlevelEventSourcing.analyser% + Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\EventCollector: + phpstan.collector: %patchlevelEventSourcing.analyser% + Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\SubscriberCollector: + phpstan.collector: %patchlevelEventSourcing.analyser% + Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\ProjectionCollector: + phpstan.collector: %patchlevelEventSourcing.analyser% + Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\HandlerCollector: + phpstan.collector: %patchlevelEventSourcing.analyser% + Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\ControllerCollector: + phpstan.collector: %patchlevelEventSourcing.analyser% + Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\CallCollector: + phpstan.collector: %patchlevelEventSourcing.analyser% services: - @@ -54,3 +70,36 @@ services: class: Patchlevel\EventSourcingPHPStanExtension\ProjectionBuilderExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + + - + class: Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\AggregateCollector + + - + class: Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\EventCollector + + - + class: Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\SubscriberCollector + + - + class: Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\ProjectionCollector + + - + class: Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\HandlerCollector + + - + class: Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\ControllerCollector + + - + class: Patchlevel\EventSourcingPHPStanExtension\Analyser\Collector\CallCollector + + errorFormatter.eventSourcingJson: + class: Patchlevel\EventSourcingPHPStanExtension\Analyser\JsonFormatter + arguments: + enabled: %patchlevelEventSourcing.analyser% + fallbackFormatter: @errorFormatter.table + + errorFormatter.eventSourcingGraphviz: + class: Patchlevel\EventSourcingPHPStanExtension\Analyser\GraphvizFormatter + arguments: + enabled: %patchlevelEventSourcing.analyser% + fallbackFormatter: @errorFormatter.table diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ebc05ea..bc5a39a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,6 +10,9 @@ tests/Unit + + tests/Analyser + diff --git a/src/Analyser/Collector/AggregateCollector.php b/src/Analyser/Collector/AggregateCollector.php new file mode 100644 index 0000000..bce3268 --- /dev/null +++ b/src/Analyser/Collector/AggregateCollector.php @@ -0,0 +1,51 @@ + + */ +final class AggregateCollector implements Collector +{ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** @return AggregateData|null */ + public function processNode(Node $node, Scope $scope): array|null + { + $classReflection = $node->getClassReflection(); + + if (!EventSourcing::isAggregate($classReflection)) { + return null; + } + + foreach (EventSourcing::attributes($classReflection->getAttributes(), Aggregate::class) as $attribute) { + $name = EventSourcing::stringArgument($attribute, 'name'); + + if ($name === null) { + continue; + } + + return [ + 'class' => $classReflection->getName(), + 'name' => $name, + ]; + } + + return null; + } +} diff --git a/src/Analyser/Collector/CallCollector.php b/src/Analyser/Collector/CallCollector.php new file mode 100644 index 0000000..6dc4042 --- /dev/null +++ b/src/Analyser/Collector/CallCollector.php @@ -0,0 +1,164 @@ +} + * @implements Collector + */ +final class CallCollector implements Collector +{ + private const MESSAGE_BUS = 'Symfony\Component\Messenger\MessageBusInterface'; + private const SUBSCRIBER_ATTRIBUTES = [Subscriber::class, Processor::class, Projector::class]; + + public function getNodeType(): string + { + return CallLike::class; + } + + /** @return CallData|null */ + public function processNode(Node $node, Scope $scope): array|null + { + if (!$node instanceof MethodCall && !$node instanceof NullsafeMethodCall && !$node instanceof StaticCall) { + return null; + } + + if ($node->isFirstClassCallable() || !$node->name instanceof Identifier) { + return null; + } + + $caller = $scope->getFunction(); + $callerClass = $scope->getClassReflection(); + + if (!$caller instanceof ExtendedMethodReflection || $callerClass === null) { + return null; + } + + $method = $this->calledMethod($node, $node->name->toString(), $scope); + + if ($method === null) { + return null; + } + + $kindAndTargets = $this->kindAndTargets($node, $method, $callerClass, $scope); + + if ($kindAndTargets === null) { + return null; + } + + return [ + 'class' => $caller->getDeclaringClass()->getName(), + 'method' => $caller->getName(), + 'kind' => $kindAndTargets[0], + 'targets' => $kindAndTargets[1], + ]; + } + + /** @return array{'record'|'read'|'dispatch'|'access'|'call', list}|null */ + private function kindAndTargets( + MethodCall|NullsafeMethodCall|StaticCall $node, + ExtendedMethodReflection $method, + ClassReflection $callerClass, + Scope $scope, + ): array|null { + $declaringClass = $method->getDeclaringClass(); + $name = $method->getName(); + $argument = $this->firstArgumentType($node, $scope); + + if ($name === 'recordThat' && EventSourcing::isAggregate($declaringClass)) { + return $argument === null ? null : ['record', $this->objectClasses($argument)]; + } + + if ($declaringClass->is(Decision::class)) { + if ($argument === null) { + return null; + } + + return match ($name) { + 'append' => ['record', $this->objectClasses($argument->getIterableValueType())], + 'read' => ['read', $this->objectClasses($argument->isObject()->yes() ? $argument : $argument->getIterableValueType())], + default => null, + }; + } + + if ($name === 'dispatch' && ($declaringClass->is(CommandBus::class) || $declaringClass->is(self::MESSAGE_BUS))) { + return $argument === null ? null : ['dispatch', $this->objectClasses($argument)]; + } + + foreach (self::SUBSCRIBER_ATTRIBUTES as $subscriberAttribute) { + if (EventSourcing::attributes($declaringClass->getAttributes(), $subscriberAttribute) !== []) { + return ['access', [$declaringClass->getName()]]; + } + } + + if ($callerClass->is($declaringClass->getName())) { + return ['call', [sprintf('%s::%s', $declaringClass->getName(), $name)]]; + } + + return null; + } + + private function calledMethod( + MethodCall|NullsafeMethodCall|StaticCall $node, + string $name, + Scope $scope, + ): ExtendedMethodReflection|null { + if ($node instanceof StaticCall) { + if (!$node->class instanceof Name) { + return null; + } + + return $scope->getMethodReflection($scope->resolveTypeByName($node->class), $name); + } + + return $scope->getMethodReflection($scope->getType($node->var), $name); + } + + private function firstArgumentType(MethodCall|NullsafeMethodCall|StaticCall $node, Scope $scope): Type|null + { + $argument = $node->getArgs()[0] ?? null; + + if ($argument === null) { + return null; + } + + return $scope->getType($argument->value); + } + + /** @return list */ + private function objectClasses(Type $type): array + { + return $type->getObjectClassNames(); + } +} diff --git a/src/Analyser/Collector/ControllerCollector.php b/src/Analyser/Collector/ControllerCollector.php new file mode 100644 index 0000000..51f2d99 --- /dev/null +++ b/src/Analyser/Collector/ControllerCollector.php @@ -0,0 +1,43 @@ + + */ +final class ControllerCollector implements Collector +{ + private const AS_CONTROLLER = 'Symfony\Component\HttpKernel\Attribute\AsController'; + private const ABSTRACT_CONTROLLER = 'Symfony\Bundle\FrameworkBundle\Controller\AbstractController'; + + public function getNodeType(): string + { + return InClassNode::class; + } + + /** @return ControllerData|null */ + public function processNode(Node $node, Scope $scope): array|null + { + $classReflection = $node->getClassReflection(); + + if ( + EventSourcing::attributes($classReflection->getAttributes(), self::AS_CONTROLLER) === [] + && !$classReflection->is(self::ABSTRACT_CONTROLLER) + ) { + return null; + } + + return ['class' => $classReflection->getName()]; + } +} diff --git a/src/Analyser/Collector/EventCollector.php b/src/Analyser/Collector/EventCollector.php new file mode 100644 index 0000000..4dd0751 --- /dev/null +++ b/src/Analyser/Collector/EventCollector.php @@ -0,0 +1,47 @@ + + */ +final class EventCollector implements Collector +{ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** @return EventData|null */ + public function processNode(Node $node, Scope $scope): array|null + { + $classReflection = $node->getClassReflection(); + + foreach (EventSourcing::attributes($classReflection->getAttributes(), Event::class) as $attribute) { + $name = EventSourcing::stringArgument($attribute, 'name'); + + if ($name === null) { + continue; + } + + return [ + 'class' => $classReflection->getName(), + 'name' => $name, + ]; + } + + return null; + } +} diff --git a/src/Analyser/Collector/HandlerCollector.php b/src/Analyser/Collector/HandlerCollector.php new file mode 100644 index 0000000..eeb65bd --- /dev/null +++ b/src/Analyser/Collector/HandlerCollector.php @@ -0,0 +1,57 @@ +}>} + * @implements Collector + */ +final class HandlerCollector implements Collector +{ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** @return HandlerData|null */ + public function processNode(Node $node, Scope $scope): array|null + { + $classReflection = $node->getClassReflection(); + $handlers = []; + + foreach ($classReflection->getNativeReflection()->getMethods() as $method) { + $methodReflection = $classReflection->getNativeMethod($method->getName()); + $commands = EventSourcing::handledCommands($methodReflection); + + if ($commands === []) { + continue; + } + + $handlers[] = [ + 'class' => $methodReflection->getDeclaringClass()->getName(), + 'method' => $method->getName(), + 'commands' => $commands, + ]; + } + + if ($handlers === []) { + return null; + } + + return [ + 'class' => $classReflection->getName(), + 'handlers' => $handlers, + ]; + } +} diff --git a/src/Analyser/Collector/ProjectionCollector.php b/src/Analyser/Collector/ProjectionCollector.php new file mode 100644 index 0000000..9602b80 --- /dev/null +++ b/src/Analyser/Collector/ProjectionCollector.php @@ -0,0 +1,54 @@ +} + * @implements Collector + */ +final class ProjectionCollector implements Collector +{ + public function getNodeType(): string + { + return InClassNode::class; + } + + /** @return ProjectionData|null */ + public function processNode(Node $node, Scope $scope): array|null + { + $classReflection = $node->getClassReflection(); + + if ($classReflection->isAbstract() || !$classReflection->implementsInterface(Projection::class)) { + return null; + } + + $events = []; + + foreach ($classReflection->getNativeReflection()->getMethods() as $method) { + // the first parameter of an apply method in a projection is the state, the second one the event + $appliedEvents = EventSourcing::appliedEvents($classReflection->getNativeMethod($method->getName()), 1); + + foreach ($appliedEvents as $event) { + $events[$event] = true; + } + } + + return [ + 'class' => $classReflection->getName(), + 'events' => array_keys($events), + ]; + } +} diff --git a/src/Analyser/Collector/SubscriberCollector.php b/src/Analyser/Collector/SubscriberCollector.php new file mode 100644 index 0000000..aca7f2d --- /dev/null +++ b/src/Analyser/Collector/SubscriberCollector.php @@ -0,0 +1,89 @@ +} + * @implements Collector + */ +final class SubscriberCollector implements Collector +{ + private const TYPES = [ + Subscriber::class => 'subscriber', + Processor::class => 'processor', + Projector::class => 'projector', + ]; + + public function getNodeType(): string + { + return InClassNode::class; + } + + /** @return SubscriberData|null */ + public function processNode(Node $node, Scope $scope): array|null + { + $classReflection = $node->getClassReflection(); + + foreach ($classReflection->getAttributes() as $attribute) { + $type = self::TYPES[$attribute->getName()] ?? null; + + if ($type === null) { + continue; + } + + $name = EventSourcing::stringArgument($attribute, 'id'); + + if ($name === null) { + continue; + } + + return [ + 'class' => $classReflection->getName(), + 'type' => $type, + 'name' => $name, + 'events' => $this->subscribedEvents($classReflection), + ]; + } + + return null; + } + + /** @return list */ + private function subscribedEvents(ClassReflection $classReflection): array + { + $events = []; + + foreach ($classReflection->getNativeReflection()->getMethods() as $method) { + $attributes = $classReflection->getNativeMethod($method->getName())->getAttributes(); + + foreach (EventSourcing::attributes($attributes, Subscribe::class) as $attribute) { + $event = EventSourcing::stringArgument($attribute, 'eventClass'); + + if ($event === null) { + continue; + } + + $events[$event] = true; + } + } + + return array_keys($events); + } +} diff --git a/src/Analyser/Graphviz/Graph.php b/src/Analyser/Graphviz/Graph.php new file mode 100644 index 0000000..2902969 --- /dev/null +++ b/src/Analyser/Graphviz/Graph.php @@ -0,0 +1,172 @@ + + */ +final class Graph +{ + /** @var Attributes */ + private array $attributes = []; + + /** @var Attributes */ + private array $nodeDefaults = []; + + /** @var Attributes */ + private array $edgeDefaults = []; + + /** @var array */ + private array $nodes = []; + + /** @var list */ + private array $edges = []; + + /** @var list */ + private array $subgraphs = []; + + public function __construct( + private readonly string $id = 'G', + private readonly bool $isSubgraph = false, + ) { + } + + public function set(string $name, string|int|float|bool $value): self + { + $this->attributes[$name] = $value; + + return $this; + } + + /** @param Attributes $attributes */ + public function nodeDefaults(array $attributes): self + { + $this->nodeDefaults = $attributes; + + return $this; + } + + /** @param Attributes $attributes */ + public function edgeDefaults(array $attributes): self + { + $this->edgeDefaults = $attributes; + + return $this; + } + + /** @param Attributes $attributes */ + public function node(string $id, array $attributes = []): self + { + $this->nodes[$id] = $attributes; + + return $this; + } + + /** @param Attributes $attributes */ + public function edge(string $from, string $to, array $attributes = []): self + { + $this->edges[] = [$from, $to, $attributes]; + + return $this; + } + + public function subgraph(string $id): self + { + $subgraph = new self($id, true); + $this->subgraphs[] = $subgraph; + + return $subgraph; + } + + public function render(): string + { + return $this->renderGraph(0) . "\n"; + } + + private function renderGraph(int $depth): string + { + $indent = str_repeat(' ', $depth + 1); + $lines = []; + + foreach ($this->attributes as $name => $value) { + $lines[] = sprintf('%s%s=%s;', $indent, $name, self::quote($value)); + } + + if ($this->nodeDefaults !== []) { + $lines[] = sprintf('%snode %s;', $indent, self::attributeList($this->nodeDefaults)); + } + + if ($this->edgeDefaults !== []) { + $lines[] = sprintf('%sedge %s;', $indent, self::attributeList($this->edgeDefaults)); + } + + foreach ($this->subgraphs as $subgraph) { + $lines[] = $indent . $subgraph->renderGraph($depth + 1); + } + + foreach ($this->nodes as $id => $attributes) { + $lines[] = sprintf('%s%s%s;', $indent, self::quote($id), self::attributeSuffix($attributes)); + } + + foreach ($this->edges as [$from, $to, $attributes]) { + $lines[] = sprintf( + '%s%s -> %s%s;', + $indent, + self::quote($from), + self::quote($to), + self::attributeSuffix($attributes), + ); + } + + $head = $this->isSubgraph + ? sprintf('subgraph %s {', self::quote($this->id)) + : sprintf('digraph %s {', self::quote($this->id)); + + if ($lines === []) { + return $head . '}'; + } + + return $head . "\n" . implode("\n", $lines) . "\n" . str_repeat(' ', $depth) . '}'; + } + + /** @param Attributes $attributes */ + private static function attributeSuffix(array $attributes): string + { + if ($attributes === []) { + return ''; + } + + return ' ' . self::attributeList($attributes); + } + + /** @param Attributes $attributes */ + private static function attributeList(array $attributes): string + { + $parts = []; + + foreach ($attributes as $name => $value) { + $parts[] = sprintf('%s=%s', $name, self::quote($value)); + } + + return '[' . implode(', ', $parts) . ']'; + } + + private static function quote(string|int|float|bool $value): string + { + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } + + return '"' . str_replace(['\\', '"', "\n"], ['\\\\', '\\"', '\\n'], (string)$value) . '"'; + } +} diff --git a/src/Analyser/GraphvizFormatter.php b/src/Analyser/GraphvizFormatter.php new file mode 100644 index 0000000..bb1080f --- /dev/null +++ b/src/Analyser/GraphvizFormatter.php @@ -0,0 +1,252 @@ +enabled) { + $output->getStyle()->error('The analyser is disabled. Enable it with the "patchlevelEventSourcing.analyser" parameter.'); + + return 1; + } + + // without a complete analysis the model would be incomplete, so the internal errors are shown instead + if ($analysisResult->hasInternalErrors()) { + return $this->fallbackFormatter->formatErrors($analysisResult, $output); + } + + $project = (new ProjectFactory())->create($analysisResult->getCollectedData()); + + $output->writeRaw($this->graph($project)->render()); + + return 0; + } + + private function graph(Project $project): Graph + { + $graph = (new Graph()) + ->set('rankdir', 'LR') + ->set('nodesep', '0.5') + ->set('ranksep', '1') + ->set('fontcolor', self::FONT_COLOR) + ->nodeDefaults([ + 'shape' => 'Mrecord', + 'style' => 'filled', + 'width' => 3, + 'height' => 1, + 'fixedsize' => true, + 'fontcolor' => self::FONT_COLOR, + ]) + ->edgeDefaults(['color' => self::EDGE_COLOR]); + + $grouped = []; + + foreach ($project->boundedContexts as $boundedContext) { + $subgraph = $graph->subgraph('cluster_context_' . $boundedContext->name) + ->set('label', $boundedContext->name) + ->set('style', 'dotted'); + + $this->nodes( + $subgraph, + $project, + $boundedContext->aggregates, + array_merge( + $boundedContext->events, + $boundedContext->commands, + $boundedContext->subscribers, + $boundedContext->projections, + $boundedContext->userInterfaces, + ), + ); + + $grouped = array_merge( + $grouped, + $boundedContext->aggregates, + $boundedContext->events, + $boundedContext->commands, + $boundedContext->subscribers, + $boundedContext->projections, + $boundedContext->userInterfaces, + ); + } + + // everything outside of a bounded context is drawn on the top level + $this->nodes( + $graph, + $project, + array_diff(array_keys($project->aggregates), $grouped), + array_diff( + array_merge( + array_keys($project->events), + array_keys($project->commands), + array_keys($project->subscribers), + array_keys($project->projections), + array_keys($project->userInterfaces), + ), + $grouped, + ), + ); + + $this->edges($graph, $project); + + return $graph; + } + + /** + * Draws the aggregates as clusters around their commands and events, and all other classes as plain nodes. + * + * @param array $aggregates + * @param array $classes + */ + private function nodes(Graph $graph, Project $project, array $aggregates, array $classes): void + { + $drawn = []; + + foreach ($aggregates as $aggregateClass) { + $aggregate = $project->aggregates[$aggregateClass]; + + $subgraph = $graph->subgraph('cluster_aggregate_' . $aggregate->class) + ->set('label', $aggregate->name) + ->set('style', 'filled') + ->set('color', self::AGGREGATE_COLOR) + ->set('penwidth', 0); + + foreach (array_merge($aggregate->commands, $aggregate->events) as $class) { + // an element of another bounded context stays in its own context + if (!in_array($class, $classes, true)) { + continue; + } + + $this->node($subgraph, $project, $class); + $drawn[] = $class; + } + } + + foreach (array_diff($classes, $drawn) as $class) { + $this->node($graph, $project, $class); + } + } + + private function node(Graph $graph, Project $project, string $class): void + { + if (isset($project->events[$class])) { + $graph->node($class, ['label' => $project->events[$class]->name, 'color' => self::EVENT_COLOR]); + + return; + } + + if (isset($project->commands[$class])) { + $graph->node($class, ['label' => $project->commands[$class]->name, 'color' => self::COMMAND_COLOR]); + + return; + } + + if (isset($project->subscribers[$class])) { + $subscriber = $project->subscribers[$class]; + + $graph->node($class, [ + 'label' => in_array('*', $subscriber->events, true) ? $subscriber->name . "\n(all events)" : $subscriber->name, + 'color' => match ($subscriber->type) { + SubscriberType::Subscriber => self::SUBSCRIBER_COLOR, + SubscriberType::Processor => self::PROCESSOR_COLOR, + SubscriberType::Projector => self::PROJECTOR_COLOR, + }, + ]); + + return; + } + + if (isset($project->projections[$class])) { + $graph->node($class, ['label' => $project->projections[$class]->name, 'color' => self::PROJECTION_COLOR]); + + return; + } + + if (!isset($project->userInterfaces[$class])) { + return; + } + + $graph->node($class, ['label' => $project->userInterfaces[$class]->name, 'color' => self::USER_INTERFACE_COLOR]); + } + + private function edges(Graph $graph, Project $project): void + { + foreach ($project->commands as $command) { + foreach ($command->events as $event) { + $graph->edge($command->class, $event); + } + } + + foreach ($project->projections as $projection) { + foreach ($projection->events as $event) { + $graph->edge($event, $projection->class); + } + } + + foreach ($project->commands as $command) { + foreach ($command->projections as $projection) { + $graph->edge($projection, $command->class); + } + } + + foreach ($project->subscribers as $subscriber) { + foreach ($subscriber->events as $event) { + if ($event === '*') { + continue; + } + + $graph->edge($event, $subscriber->class); + } + + foreach ($subscriber->commands as $command) { + $graph->edge($subscriber->class, $command); + } + } + + foreach ($project->userInterfaces as $userInterface) { + foreach ($userInterface->commands as $command) { + $graph->edge($userInterface->class, $command); + } + + foreach ($userInterface->subscribers as $subscriber) { + $graph->edge($subscriber, $userInterface->class); + } + } + } +} diff --git a/src/Analyser/JsonFormatter.php b/src/Analyser/JsonFormatter.php new file mode 100644 index 0000000..2663ec3 --- /dev/null +++ b/src/Analyser/JsonFormatter.php @@ -0,0 +1,44 @@ +enabled) { + $output->getStyle()->error('The analyser is disabled. Enable it with the "patchlevelEventSourcing.analyser" parameter.'); + + return 1; + } + + // without a complete analysis the model would be incomplete, so the internal errors are shown instead + if ($analysisResult->hasInternalErrors()) { + return $this->fallbackFormatter->formatErrors($analysisResult, $output); + } + + $project = (new ProjectFactory())->create($analysisResult->getCollectedData()); + + $output->writeRaw(json_encode($project, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"); + + return 0; + } +} diff --git a/src/Analyser/Model/Aggregate.php b/src/Analyser/Model/Aggregate.php new file mode 100644 index 0000000..5bb4c1c --- /dev/null +++ b/src/Analyser/Model/Aggregate.php @@ -0,0 +1,20 @@ + $events + * @param list $commands + */ + public function __construct( + public readonly string $name, + public readonly string $class, + public readonly array $events, + public readonly array $commands, + ) { + } +} diff --git a/src/Analyser/Model/BoundedContext.php b/src/Analyser/Model/BoundedContext.php new file mode 100644 index 0000000..80038ea --- /dev/null +++ b/src/Analyser/Model/BoundedContext.php @@ -0,0 +1,27 @@ + $aggregates + * @param list $events + * @param list $commands + * @param list $subscribers + * @param list $projections + * @param list $userInterfaces + */ + public function __construct( + public readonly string $name, + public readonly array $aggregates, + public readonly array $events, + public readonly array $commands, + public readonly array $subscribers, + public readonly array $projections, + public readonly array $userInterfaces, + ) { + } +} diff --git a/src/Analyser/Model/Command.php b/src/Analyser/Model/Command.php new file mode 100644 index 0000000..dac167a --- /dev/null +++ b/src/Analyser/Model/Command.php @@ -0,0 +1,20 @@ + $events the events the handlers of the command record or append + * @param list $projections the projections the handlers of the command read to decide + */ + public function __construct( + public readonly string $name, + public readonly string $class, + public readonly array $events, + public readonly array $projections, + ) { + } +} diff --git a/src/Analyser/Model/Event.php b/src/Analyser/Model/Event.php new file mode 100644 index 0000000..ed684ec --- /dev/null +++ b/src/Analyser/Model/Event.php @@ -0,0 +1,14 @@ + $boundedContexts + * @param array $aggregates + * @param array $events + * @param array $commands + * @param array $subscribers + * @param array $projections + * @param array $userInterfaces + */ + public function __construct( + public readonly array $boundedContexts, + public readonly array $aggregates, + public readonly array $events, + public readonly array $commands, + public readonly array $subscribers, + public readonly array $projections, + public readonly array $userInterfaces, + ) { + } + + /** @return array|stdClass> */ + public function jsonSerialize(): array + { + // the collections are keyed by name or class, so an empty one is still an object and not a list + return [ + 'boundedContexts' => self::map($this->boundedContexts), + 'aggregates' => self::map($this->aggregates), + 'events' => self::map($this->events), + 'commands' => self::map($this->commands), + 'subscribers' => self::map($this->subscribers), + 'projections' => self::map($this->projections), + 'userInterfaces' => self::map($this->userInterfaces), + ]; + } + + /** + * @param array $map + * + * @return array|stdClass + */ + private static function map(array $map): array|stdClass + { + return $map === [] ? new stdClass() : $map; + } +} diff --git a/src/Analyser/Model/Projection.php b/src/Analyser/Model/Projection.php new file mode 100644 index 0000000..212b44a --- /dev/null +++ b/src/Analyser/Model/Projection.php @@ -0,0 +1,16 @@ + $events */ + public function __construct( + public readonly string $name, + public readonly string $class, + public readonly array $events, + ) { + } +} diff --git a/src/Analyser/Model/Subscriber.php b/src/Analyser/Model/Subscriber.php new file mode 100644 index 0000000..4fc75e0 --- /dev/null +++ b/src/Analyser/Model/Subscriber.php @@ -0,0 +1,21 @@ + $events contains "*" if the subscriber subscribes to all events + * @param list $commands + */ + public function __construct( + public readonly string $name, + public readonly string $class, + public readonly SubscriberType $type, + public readonly array $events, + public readonly array $commands, + ) { + } +} diff --git a/src/Analyser/Model/SubscriberType.php b/src/Analyser/Model/SubscriberType.php new file mode 100644 index 0000000..f8017ac --- /dev/null +++ b/src/Analyser/Model/SubscriberType.php @@ -0,0 +1,12 @@ + $commands + * @param list $subscribers + */ + public function __construct( + public readonly string $name, + public readonly string $class, + public readonly array $commands, + public readonly array $subscribers, + ) { + } +} diff --git a/src/Analyser/ProjectFactory.php b/src/Analyser/ProjectFactory.php new file mode 100644 index 0000000..88970c0 --- /dev/null +++ b/src/Analyser/ProjectFactory.php @@ -0,0 +1,497 @@ +> */ + private array $callsByMethod = []; + + /** @var array> */ + private array $callsByClass = []; + + /** @param list $collectedData */ + public function create(array $collectedData): Project + { + $data = $this->groupByCollector($collectedData); + + $this->callsByMethod = []; + $this->callsByClass = []; + + /** @var list $callData */ + $callData = $data[CallCollector::class] ?? []; + + foreach ($callData as $call) { + $this->callsByMethod[self::methodKey($call['class'], $call['method'])][] = $call; + $this->callsByClass[$call['class']][] = $call; + } + + /** @var list $eventData */ + $eventData = $data[EventCollector::class] ?? []; + /** @var list $aggregateData */ + $aggregateData = $data[AggregateCollector::class] ?? []; + /** @var list $handlerData */ + $handlerData = $data[HandlerCollector::class] ?? []; + /** @var list $subscriberData */ + $subscriberData = $data[SubscriberCollector::class] ?? []; + /** @var list $projectionData */ + $projectionData = $data[ProjectionCollector::class] ?? []; + /** @var list $controllerData */ + $controllerData = $data[ControllerCollector::class] ?? []; + + $aggregates = $this->aggregates($aggregateData, $handlerData); + $subscribers = $this->subscribers($subscriberData); + $projections = $this->projections($projectionData); + $userInterfaces = $this->userInterfaces($controllerData); + $commands = $this->commands($handlerData, $subscribers, $userInterfaces); + $events = $this->events($eventData, $aggregates, $commands, $subscribers, $projections); + + return new Project( + $this->boundedContexts($aggregates, $events, $commands, $subscribers, $projections, $userInterfaces), + $aggregates, + $events, + $commands, + $subscribers, + $projections, + $userInterfaces, + ); + } + + /** + * The collected data holds the rows of one collector per file. + * + * @param list $collectedData + * + * @return array> + */ + private function groupByCollector(array $collectedData): array + { + $result = []; + + foreach ($collectedData as $dataOfFile) { + /** @var list $rows */ + $rows = $dataOfFile->getData(); + + foreach ($rows as $row) { + $result[$dataOfFile->getCollectorType()][] = $row; + } + } + + return $result; + } + + /** + * @param list $aggregateData + * @param list $handlerData + * + * @return array + */ + private function aggregates(array $aggregateData, array $handlerData): array + { + $result = []; + + foreach ($aggregateData as $data) { + $commands = []; + + foreach ($handlerData as $handlers) { + if ($handlers['class'] !== $data['class']) { + continue; + } + + foreach ($handlers['handlers'] as $handler) { + $commands = array_merge($commands, $handler['commands']); + } + } + + $result[$data['class']] = new Aggregate( + $data['name'], + $data['class'], + $this->targetsInClass($data['class'], 'record'), + self::sorted($commands), + ); + } + + ksort($result); + + return $result; + } + + /** + * @param list $handlerData + * @param array $subscribers + * @param array $userInterfaces + * + * @return array + */ + private function commands(array $handlerData, array $subscribers, array $userInterfaces): array + { + /** @var array, projections: list}> $commands */ + $commands = []; + + foreach ($handlerData as $handlers) { + foreach ($handlers['handlers'] as $handler) { + $method = self::methodKey($handler['class'], $handler['method']); + + foreach ($handler['commands'] as $command) { + $commands[$command] = [ + 'events' => array_merge($commands[$command]['events'] ?? [], $this->resolve($method, 'record')), + 'projections' => array_merge($commands[$command]['projections'] ?? [], $this->resolve($method, 'read')), + ]; + } + } + } + + $dispatched = []; + + foreach ($subscribers as $subscriber) { + $dispatched = array_merge($dispatched, $subscriber->commands); + } + + foreach ($userInterfaces as $userInterface) { + $dispatched = array_merge($dispatched, $userInterface->commands); + } + + foreach ($dispatched as $command) { + $commands[$command] ??= ['events' => [], 'projections' => []]; + } + + $result = []; + + foreach ($commands as $class => $command) { + $result[$class] = new Command( + self::shortName($class), + $class, + self::sorted($command['events']), + self::sorted($command['projections']), + ); + } + + ksort($result); + + return $result; + } + + /** + * Every event of the collectors, plus the events that are recorded or subscribed to but are not analysed. + * + * @param list $eventData + * @param array $aggregates + * @param array $commands + * @param array $subscribers + * @param array $projections + * + * @return array + */ + private function events( + array $eventData, + array $aggregates, + array $commands, + array $subscribers, + array $projections, + ): array { + $result = []; + + foreach ($eventData as $data) { + $result[$data['class']] = new Event($data['name'], $data['class']); + } + + $referenced = []; + + foreach ([...$aggregates, ...$commands, ...$subscribers, ...$projections] as $element) { + $referenced = array_merge($referenced, $element->events); + } + + foreach ($referenced as $class) { + if ($class === '*' || array_key_exists($class, $result)) { + continue; + } + + $result[$class] = new Event(self::shortName($class), $class); + } + + ksort($result); + + return $result; + } + + /** + * @param list $subscriberData + * + * @return array + */ + private function subscribers(array $subscriberData): array + { + $result = []; + + foreach ($subscriberData as $data) { + $result[$data['class']] = new Subscriber( + $data['name'], + $data['class'], + SubscriberType::from($data['type']), + self::sorted($data['events']), + $this->targetsInClass($data['class'], 'dispatch'), + ); + } + + ksort($result); + + return $result; + } + + /** + * @param list $projectionData + * + * @return array + */ + private function projections(array $projectionData): array + { + $result = []; + + foreach ($projectionData as $data) { + $result[$data['class']] = new Projection( + self::shortName($data['class']), + $data['class'], + self::sorted($data['events']), + ); + } + + ksort($result); + + return $result; + } + + /** + * Only controllers that dispatch commands or access subscribers are part of the domain. + * + * @param list $controllerData + * + * @return array + */ + private function userInterfaces(array $controllerData): array + { + $result = []; + + foreach ($controllerData as $data) { + $commands = $this->targetsInClass($data['class'], 'dispatch'); + $subscribers = $this->targetsInClass($data['class'], 'access'); + + if ($commands === [] && $subscribers === []) { + continue; + } + + $result[$data['class']] = new UserInterface( + self::shortName($data['class']), + $data['class'], + $commands, + $subscribers, + ); + } + + ksort($result); + + return $result; + } + + /** + * @param array $aggregates + * @param array $events + * @param array $commands + * @param array $subscribers + * @param array $projections + * @param array $userInterfaces + * + * @return array + */ + private function boundedContexts( + array $aggregates, + array $events, + array $commands, + array $subscribers, + array $projections, + array $userInterfaces, + ): array { + $groups = [ + 'aggregates' => array_keys($aggregates), + 'events' => array_keys($events), + 'commands' => array_keys($commands), + 'subscribers' => array_keys($subscribers), + 'projections' => array_keys($projections), + 'userInterfaces' => array_keys($userInterfaces), + ]; + + /** @var array>> $contexts */ + $contexts = []; + + foreach ($groups as $group => $classes) { + foreach ($classes as $class) { + $context = self::boundedContext($class); + + if ($context === null) { + continue; + } + + $contexts[$context][$group][] = $class; + } + } + + $result = []; + + foreach ($contexts as $name => $context) { + $result[$name] = new BoundedContext( + $name, + $context['aggregates'] ?? [], + $context['events'] ?? [], + $context['commands'] ?? [], + $context['subscribers'] ?? [], + $context['projections'] ?? [], + $context['userInterfaces'] ?? [], + ); + } + + ksort($result); + + return $result; + } + + /** + * The targets of the given kind in the method and in every method of the own class it calls. + * + * @param 'record'|'read'|'dispatch'|'access' $kind + * @param array $visited + * + * @return list + */ + private function resolve(string $method, string $kind, array &$visited = []): array + { + if (array_key_exists($method, $visited)) { + return []; + } + + $visited[$method] = true; + $targets = []; + + foreach ($this->callsByMethod[$method] ?? [] as $call) { + if ($call['kind'] === $kind) { + $targets = array_merge($targets, $call['targets']); + + continue; + } + + if ($call['kind'] !== 'call') { + continue; + } + + foreach ($call['targets'] as $calledMethod) { + $targets = array_merge($targets, $this->resolve($calledMethod, $kind, $visited)); + } + } + + return $targets; + } + + /** + * The targets of the given kind in all methods of the class. + * + * @param 'record'|'read'|'dispatch'|'access' $kind + * + * @return list + */ + private function targetsInClass(string $class, string $kind): array + { + $targets = []; + + foreach ($this->callsByClass[$class] ?? [] as $call) { + if ($call['kind'] !== $kind) { + continue; + } + + $targets = array_merge($targets, $call['targets']); + } + + return self::sorted($targets); + } + + /** + * The bounded context is the namespace segment right before a Domain, Application or Infrastructure layer. + */ + private static function boundedContext(string $class): string|null + { + if (preg_match('#(?:^|\\\\)([^\\\\]+)\\\\(?:Domain|Application|Infrastructure)\\\\#', $class, $matches) !== 1) { + return null; + } + + return $matches[1]; + } + + private static function shortName(string $class): string + { + $position = strrpos($class, '\\'); + + if ($position === false) { + return $class; + } + + return substr($class, $position + 1); + } + + private static function methodKey(string $class, string $method): string + { + return sprintf('%s::%s', $class, $method); + } + + /** + * @param list $values + * + * @return list + */ + private static function sorted(array $values): array + { + $values = array_values(array_unique($values)); + sort($values); + + return $values; + } +} diff --git a/src/Helper/EventSourcing.php b/src/Helper/EventSourcing.php index 80dc3b4..143f18e 100644 --- a/src/Helper/EventSourcing.php +++ b/src/Helper/EventSourcing.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\Attribute\Handle; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Property; use PHPStan\Reflection\AttributeReflection; @@ -13,6 +14,9 @@ use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\FunctionReflection; +use function array_keys; +use function count; + /** @internal */ final class EventSourcing { @@ -40,6 +44,60 @@ public static function hasAttribute(ClassMethod|Property $node, string $attribut return false; } + /** + * The command classes a #[Handle] method handles: the explicit command class of the attribute, + * otherwise the type of the first parameter, like the HandlerFinder of the library resolves them. + * + * @return list + */ + public static function handledCommands(ExtendedMethodReflection $method): array + { + $commands = []; + + foreach (self::attributes($method->getAttributes(), Handle::class) as $attribute) { + $commandClass = self::stringArgument($attribute, 'commandClass'); + + if ($commandClass !== null) { + $commands[$commandClass] = true; + + continue; + } + + foreach (self::parameterClasses($method, 0) as $class) { + $commands[$class] = true; + } + } + + return array_keys($commands); + } + + /** + * The event classes an #[Apply] method applies: the explicit event class of the attribute, + * otherwise the type of the event parameter. + * + * @return list + */ + public static function appliedEvents(ExtendedMethodReflection $method, int $eventParameter): array + { + $events = []; + + foreach (self::attributes($method->getAttributes(), Apply::class) as $attribute) { + $eventClass = self::stringArgument($attribute, 'eventClass'); + + if ($eventClass !== null) { + $events[$eventClass] = true; + + continue; + } + + foreach (self::parameterClasses($method, $eventParameter) as $class) { + $events[$class] = true; + } + } + + return array_keys($events); + } + /** * @param list $attributes * @@ -59,4 +117,34 @@ public static function attributes(array $attributes, string $name): array return $result; } + + /** The value of a constant string argument of the attribute, null if it is missing or not a single constant string. */ + public static function stringArgument(AttributeReflection $attribute, string $name): string|null + { + $argument = $attribute->getArgumentTypes()[$name] ?? null; + + if ($argument === null) { + return null; + } + + $constantStrings = $argument->getConstantStrings(); + + if (count($constantStrings) !== 1) { + return null; + } + + return $constantStrings[0]->getValue(); + } + + /** @return list */ + private static function parameterClasses(ExtendedMethodReflection $method, int $position): array + { + $parameter = ($method->getVariants()[0] ?? null)?->getParameters()[$position] ?? null; + + if ($parameter === null) { + return []; + } + + return $parameter->getNativeType()->getObjectClassNames(); + } } diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php new file mode 100644 index 0000000..a29edc0 --- /dev/null +++ b/tests/Analyser/AnalyserTest.php @@ -0,0 +1,83 @@ +assertSnapshot('eventSourcingJson', __DIR__ . '/snapshot.json'); + } + + public function testGraphviz(): void + { + $this->assertSnapshot('eventSourcingGraphviz', __DIR__ . '/snapshot.dot'); + } + + public function testDisabledAnalyser(): void + { + [$exitCode, $output] = self::analyse('eventSourcingJson', __DIR__ . '/disabled.neon'); + + self::assertSame(1, $exitCode); + self::assertStringContainsString('The analyser is disabled.', $output); + } + + private function assertSnapshot(string $errorFormat, string $snapshot): void + { + [$exitCode, $output] = self::analyse($errorFormat, __DIR__ . '/analyser.neon'); + + if (getenv('UPDATE_SNAPSHOTS') === '1') { + file_put_contents($snapshot, $output); + } + + self::assertSame(0, $exitCode, $output); + self::assertStringEqualsFile($snapshot, $output); + } + + /** @return array{int, string} */ + private static function analyse(string $errorFormat, string $config): array + { + $command = sprintf( + '%s %s analyse --no-progress --configuration=%s --error-format=%s', + escapeshellarg(PHP_BINARY), + escapeshellarg(dirname(__DIR__, 2) . '/vendor/bin/phpstan'), + escapeshellarg($config), + escapeshellarg($errorFormat), + ); + + $output = []; + $exitCode = 0; + + if (exec($command . ' 2>&1', $output, $exitCode) === false) { + throw new RuntimeException(sprintf('Could not run "%s".', $command)); + } + + return [$exitCode, implode("\n", $output) . "\n"]; + } +} diff --git a/tests/Analyser/Fixture/Audit/AuditSubscriber.php b/tests/Analyser/Fixture/Audit/AuditSubscriber.php new file mode 100644 index 0000000..92ef7bd --- /dev/null +++ b/tests/Analyser/Fixture/Audit/AuditSubscriber.php @@ -0,0 +1,30 @@ + */ + private array $events = []; + + /** @return list */ + public function events(): array + { + return $this->events; + } + + #[Subscribe(Subscribe::ALL)] + public function onEvent(Message $message): void + { + $this->events[] = $message->event(); + } +} diff --git a/tests/Analyser/Fixture/Billing/Application/CreateInvoice.php b/tests/Analyser/Fixture/Billing/Application/CreateInvoice.php new file mode 100644 index 0000000..8b61ff9 --- /dev/null +++ b/tests/Analyser/Fixture/Billing/Application/CreateInvoice.php @@ -0,0 +1,13 @@ +read([ + 'nextInvoiceNumber' => new NextInvoiceNumberProjection(), + 'customerInvoices' => new CustomerInvoicesProjection($command->customer), + ]); + + if ($state['customerInvoices'] >= 10) { + throw new RuntimeException('too many invoices'); + } + + $this->appendInvoice($decision, $state['nextInvoiceNumber'], $command->customer); + } + + private function appendInvoice(Decision $decision, int $invoiceNumber, string $customer): void + { + $decision->append([new InvoiceCreated($invoiceNumber, $customer)]); + } +} diff --git a/tests/Analyser/Fixture/Billing/Domain/CustomerInvoicesProjection.php b/tests/Analyser/Fixture/Billing/Domain/CustomerInvoicesProjection.php new file mode 100644 index 0000000..943cd32 --- /dev/null +++ b/tests/Analyser/Fixture/Billing/Domain/CustomerInvoicesProjection.php @@ -0,0 +1,35 @@ + */ +final class CustomerInvoicesProjection extends BasicProjection +{ + public function __construct( + private readonly string $customer, + ) { + } + + /** @return list */ + public function tagFilter(): array + { + return ['customer:' . $this->customer]; + } + + public function initialState(): int + { + return 0; + } + + /** the event is given explicitly */ + #[Apply(InvoiceCreated::class)] + public function countInvoice(int $state): int + { + return $state + 1; + } +} diff --git a/tests/Analyser/Fixture/Billing/Domain/InvoiceCreated.php b/tests/Analyser/Fixture/Billing/Domain/InvoiceCreated.php new file mode 100644 index 0000000..998d7ca --- /dev/null +++ b/tests/Analyser/Fixture/Billing/Domain/InvoiceCreated.php @@ -0,0 +1,19 @@ + */ +final class NextInvoiceNumberProjection extends BasicProjection +{ + /** @return list */ + public function tagFilter(): array + { + return []; + } + + public function initialState(): int + { + return 1; + } + + #[Apply] + public function applyInvoiceCreated(int $state, InvoiceCreated $event): int + { + return $event->invoiceNumber + 1; + } +} diff --git a/tests/Analyser/Fixture/Billing/Infrastructure/FirstInvoiceProcessor.php b/tests/Analyser/Fixture/Billing/Infrastructure/FirstInvoiceProcessor.php new file mode 100644 index 0000000..3f8b7b7 --- /dev/null +++ b/tests/Analyser/Fixture/Billing/Infrastructure/FirstInvoiceProcessor.php @@ -0,0 +1,26 @@ +commandBus->dispatch(new CreateInvoice($event->id->toString())); + } +} diff --git a/tests/Analyser/Fixture/Profile/Application/ArchiveProfile.php b/tests/Analyser/Fixture/Profile/Application/ArchiveProfile.php new file mode 100644 index 0000000..4e23ae8 --- /dev/null +++ b/tests/Analyser/Fixture/Profile/Application/ArchiveProfile.php @@ -0,0 +1,15 @@ +recordThat(new ProfileArchived()); + } +} diff --git a/tests/Analyser/Fixture/Profile/Domain/Profile.php b/tests/Analyser/Fixture/Profile/Domain/Profile.php new file mode 100644 index 0000000..9260a37 --- /dev/null +++ b/tests/Analyser/Fixture/Profile/Domain/Profile.php @@ -0,0 +1,95 @@ +recordThat(new ProfileCreated($command->id, $command->name)); + + return $self; + } + + /** the command is given explicitly, the event is recorded in a helper method */ + #[Handle(RenameProfile::class)] + public function rename(string $name): void + { + $this->guardNotArchived(); + + if ($this->name === $name) { + return; + } + + $this->recordRenamed($name); + } + + /** the event is recorded in a method of a trait */ + #[Handle] + public function archive(ArchiveProfile $command): void + { + $this->guardNotArchived(); + $this->recordArchived(); + } + + public function id(): Uuid + { + return $this->id; + } + + #[Apply] + protected function applyProfileCreated(ProfileCreated $event): void + { + $this->id = $event->id; + $this->name = $event->name; + $this->archived = false; + } + + #[Apply] + protected function applyProfileRenamed(ProfileRenamed $event): void + { + $this->name = $event->name; + } + + #[Apply] + protected function applyProfileArchived(ProfileArchived $event): void + { + $this->archived = true; + } + + private function guardNotArchived(): void + { + if ($this->archived) { + throw new RuntimeException('profile is archived'); + } + } + + private function recordRenamed(string $name): void + { + $this->recordThat(new ProfileRenamed($name)); + } +} diff --git a/tests/Analyser/Fixture/Profile/Domain/ProfileArchived.php b/tests/Analyser/Fixture/Profile/Domain/ProfileArchived.php new file mode 100644 index 0000000..510259d --- /dev/null +++ b/tests/Analyser/Fixture/Profile/Domain/ProfileArchived.php @@ -0,0 +1,12 @@ +commandBus->dispatch(new CreateProfile(Uuid::generate(), $name)); + } +} diff --git a/tests/Analyser/Fixture/Profile/Infrastructure/HealthController.php b/tests/Analyser/Fixture/Profile/Infrastructure/HealthController.php new file mode 100644 index 0000000..f9dac64 --- /dev/null +++ b/tests/Analyser/Fixture/Profile/Infrastructure/HealthController.php @@ -0,0 +1,17 @@ + */ + public function __invoke(): array + { + return $this->profileProjector->names(); + } +} diff --git a/tests/Analyser/Fixture/Profile/Infrastructure/ProfileProjector.php b/tests/Analyser/Fixture/Profile/Infrastructure/ProfileProjector.php new file mode 100644 index 0000000..2825bfd --- /dev/null +++ b/tests/Analyser/Fixture/Profile/Infrastructure/ProfileProjector.php @@ -0,0 +1,35 @@ + */ + private array $names = []; + + /** @return list */ + public function names(): array + { + return $this->names; + } + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event): void + { + $this->names[] = $event->name; + } + + #[Subscribe(ProfileRenamed::class)] + public function onProfileRenamed(ProfileRenamed $event): void + { + $this->names[] = $event->name; + } +} diff --git a/tests/Analyser/analyser.neon b/tests/Analyser/analyser.neon new file mode 100644 index 0000000..fc0cda2 --- /dev/null +++ b/tests/Analyser/analyser.neon @@ -0,0 +1,10 @@ +includes: + - ../../extension.neon + +parameters: + level: 0 + paths: + - Fixture + tmpDir: ../../var/phpstan-analyser + patchlevelEventSourcing: + analyser: true diff --git a/tests/Analyser/disabled.neon b/tests/Analyser/disabled.neon new file mode 100644 index 0000000..9beeb9c --- /dev/null +++ b/tests/Analyser/disabled.neon @@ -0,0 +1,8 @@ +includes: + - ../../extension.neon + +parameters: + level: 0 + paths: + - Fixture + tmpDir: ../../var/phpstan-analyser-disabled diff --git a/tests/Analyser/snapshot.dot b/tests/Analyser/snapshot.dot new file mode 100644 index 0000000..1004c18 --- /dev/null +++ b/tests/Analyser/snapshot.dot @@ -0,0 +1,51 @@ +digraph "G" { + rankdir="LR"; + nodesep="0.5"; + ranksep="1"; + fontcolor="#343a40"; + node [shape="Mrecord", style="filled", width="3", height="1", fixedsize="true", fontcolor="#343a40"]; + edge [color="#343a40"]; + subgraph "cluster_context_Billing" { + label="Billing"; + style="dotted"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\InvoiceCreated" [label="invoice.created", color="#ffc078"]; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Application\\CreateInvoice" [label="CreateInvoice", color="#74c0fc"]; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Infrastructure\\FirstInvoiceProcessor" [label="first_invoice", color="#e599f7"]; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\CustomerInvoicesProjection" [label="CustomerInvoicesProjection", color="#d8f5a2"]; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\NextInvoiceNumberProjection" [label="NextInvoiceNumberProjection", color="#d8f5a2"]; + } + subgraph "cluster_context_Profile" { + label="Profile"; + style="dotted"; + subgraph "cluster_aggregate_Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\Profile" { + label="profile"; + style="filled"; + color="#ffec99"; + penwidth="0"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\ArchiveProfile" [label="ArchiveProfile", color="#74c0fc"]; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\CreateProfile" [label="CreateProfile", color="#74c0fc"]; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\RenameProfile" [label="RenameProfile", color="#74c0fc"]; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileArchived" [label="profile.archived", color="#ffc078"]; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileCreated" [label="profile.created", color="#ffc078"]; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileRenamed" [label="profile.renamed", color="#ffc078"]; + } + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\ProfileProjector" [label="profile", color="#8ce99a"]; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\CreateProfileController" [label="CreateProfileController", color="#dee2e6"]; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\ProfileListController" [label="ProfileListController", color="#dee2e6"]; + } + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Audit\\AuditSubscriber" [label="audit\n(all events)", color="#ffa8a8"]; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Application\\CreateInvoice" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\InvoiceCreated"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\ArchiveProfile" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileArchived"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\CreateProfile" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileCreated"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\RenameProfile" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileRenamed"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\InvoiceCreated" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\CustomerInvoicesProjection"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\InvoiceCreated" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\NextInvoiceNumberProjection"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\CustomerInvoicesProjection" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Application\\CreateInvoice"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\NextInvoiceNumberProjection" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Application\\CreateInvoice"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileCreated" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Infrastructure\\FirstInvoiceProcessor"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Infrastructure\\FirstInvoiceProcessor" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Application\\CreateInvoice"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileCreated" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\ProfileProjector"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileRenamed" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\ProfileProjector"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\CreateProfileController" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\CreateProfile"; + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\ProfileProjector" -> "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\ProfileListController"; +} diff --git a/tests/Analyser/snapshot.json b/tests/Analyser/snapshot.json new file mode 100644 index 0000000..a1aa459 --- /dev/null +++ b/tests/Analyser/snapshot.json @@ -0,0 +1,183 @@ +{ + "boundedContexts": { + "Billing": { + "name": "Billing", + "aggregates": [], + "events": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\InvoiceCreated" + ], + "commands": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Application\\CreateInvoice" + ], + "subscribers": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Infrastructure\\FirstInvoiceProcessor" + ], + "projections": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\CustomerInvoicesProjection", + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\NextInvoiceNumberProjection" + ], + "userInterfaces": [] + }, + "Profile": { + "name": "Profile", + "aggregates": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\Profile" + ], + "events": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileArchived", + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileCreated", + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileRenamed" + ], + "commands": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\ArchiveProfile", + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\CreateProfile", + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\RenameProfile" + ], + "subscribers": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\ProfileProjector" + ], + "projections": [], + "userInterfaces": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\CreateProfileController", + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\ProfileListController" + ] + } + }, + "aggregates": { + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\Profile": { + "name": "profile", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\Profile", + "events": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileArchived", + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileCreated", + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileRenamed" + ], + "commands": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\ArchiveProfile", + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\CreateProfile", + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\RenameProfile" + ] + } + }, + "events": { + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\InvoiceCreated": { + "name": "invoice.created", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\InvoiceCreated" + }, + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileArchived": { + "name": "profile.archived", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileArchived" + }, + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileCreated": { + "name": "profile.created", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileCreated" + }, + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileRenamed": { + "name": "profile.renamed", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileRenamed" + } + }, + "commands": { + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Application\\CreateInvoice": { + "name": "CreateInvoice", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Application\\CreateInvoice", + "events": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\InvoiceCreated" + ], + "projections": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\CustomerInvoicesProjection", + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\NextInvoiceNumberProjection" + ] + }, + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\ArchiveProfile": { + "name": "ArchiveProfile", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\ArchiveProfile", + "events": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileArchived" + ], + "projections": [] + }, + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\CreateProfile": { + "name": "CreateProfile", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\CreateProfile", + "events": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileCreated" + ], + "projections": [] + }, + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\RenameProfile": { + "name": "RenameProfile", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\RenameProfile", + "events": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileRenamed" + ], + "projections": [] + } + }, + "subscribers": { + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Audit\\AuditSubscriber": { + "name": "audit", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Audit\\AuditSubscriber", + "type": "subscriber", + "events": [ + "*" + ], + "commands": [] + }, + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Infrastructure\\FirstInvoiceProcessor": { + "name": "first_invoice", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Infrastructure\\FirstInvoiceProcessor", + "type": "processor", + "events": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileCreated" + ], + "commands": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Application\\CreateInvoice" + ] + }, + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\ProfileProjector": { + "name": "profile", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\ProfileProjector", + "type": "projector", + "events": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileCreated", + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Domain\\ProfileRenamed" + ], + "commands": [] + } + }, + "projections": { + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\CustomerInvoicesProjection": { + "name": "CustomerInvoicesProjection", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\CustomerInvoicesProjection", + "events": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\InvoiceCreated" + ] + }, + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\NextInvoiceNumberProjection": { + "name": "NextInvoiceNumberProjection", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\NextInvoiceNumberProjection", + "events": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Billing\\Domain\\InvoiceCreated" + ] + } + }, + "userInterfaces": { + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\CreateProfileController": { + "name": "CreateProfileController", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\CreateProfileController", + "commands": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Application\\CreateProfile" + ], + "subscribers": [] + }, + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\ProfileListController": { + "name": "ProfileListController", + "class": "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\ProfileListController", + "commands": [], + "subscribers": [ + "Patchlevel\\EventSourcingPHPStanExtension\\Tests\\Analyser\\Fixture\\Profile\\Infrastructure\\ProfileProjector" + ] + } + } +} diff --git a/tests/Unit/Analyser/Graphviz/GraphTest.php b/tests/Unit/Analyser/Graphviz/GraphTest.php new file mode 100644 index 0000000..a28dfdd --- /dev/null +++ b/tests/Unit/Analyser/Graphviz/GraphTest.php @@ -0,0 +1,95 @@ +render()); + } + + public function testGraph(): void + { + $graph = (new Graph('domain')) + ->set('rankdir', 'LR') + ->set('ranksep', 1) + ->nodeDefaults(['shape' => 'box', 'fixedsize' => true]) + ->edgeDefaults(['color' => 'red']) + ->node('a', ['label' => 'A']) + ->node('b') + ->edge('a', 'b') + ->edge('b', 'a', ['style' => 'dashed']); + + $graph->subgraph('cluster_x') + ->set('label', 'X') + ->node('c'); + + $graph->subgraph('cluster_empty'); + + self::assertSame( + <<<'DOT' + digraph "domain" { + rankdir="LR"; + ranksep="1"; + node [shape="box", fixedsize="true"]; + edge [color="red"]; + subgraph "cluster_x" { + label="X"; + "c"; + } + subgraph "cluster_empty" {} + "a" [label="A"]; + "b"; + "a" -> "b"; + "b" -> "a" [style="dashed"]; + } + + DOT, + $graph->render(), + ); + } + + public function testNestedSubgraphsAreIndented(): void + { + $graph = new Graph(); + $graph->subgraph('outer')->subgraph('inner')->node('a'); + + self::assertSame( + <<<'DOT' + digraph "G" { + subgraph "outer" { + subgraph "inner" { + "a"; + } + } + } + + DOT, + $graph->render(), + ); + } + + public function testEscaping(): void + { + $graph = (new Graph()) + ->node('App\Domain\Profile', ['label' => "say \"hello\"\nworld", 'fixedsize' => false]); + + self::assertSame( + <<<'DOT' + digraph "G" { + "App\\Domain\\Profile" [label="say \"hello\"\nworld", fixedsize="false"]; + } + + DOT, + $graph->render(), + ); + } +} diff --git a/tests/Unit/Helper/EventSourcingTest.php b/tests/Unit/Helper/EventSourcingTest.php index 4068a3a..637638b 100644 --- a/tests/Unit/Helper/EventSourcingTest.php +++ b/tests/Unit/Helper/EventSourcingTest.php @@ -9,8 +9,15 @@ use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\EventSourcing\Attribute\Id; use Patchlevel\EventSourcingPHPStanExtension\Helper\EventSourcing; +use Patchlevel\EventSourcingPHPStanExtension\Tests\Unit\Helper\Fixture\MultiCommandHandler; +use Patchlevel\EventSourcingPHPStanExtension\Tests\Unit\Helper\Fixture\StockCountProjection; +use Patchlevel\EventSourcingPHPStanExtension\Tests\Valid\Command\AdjustStockForProduct; +use Patchlevel\EventSourcingPHPStanExtension\Tests\Valid\Command\DecreaseStockForProduct; +use Patchlevel\EventSourcingPHPStanExtension\Tests\Valid\CreateInvoice; use Patchlevel\EventSourcingPHPStanExtension\Tests\Valid\CreateInvoiceHandler; +use Patchlevel\EventSourcingPHPStanExtension\Tests\Valid\Events\StockAdjusted; use Patchlevel\EventSourcingPHPStanExtension\Tests\Valid\Events\StockCreated; +use Patchlevel\EventSourcingPHPStanExtension\Tests\Valid\Events\StockDecreased; use Patchlevel\EventSourcingPHPStanExtension\Tests\Valid\Stock; use PhpParser\Node\Stmt\Class_; use PhpParser\NodeFinder; @@ -88,6 +95,65 @@ public function other(): void {} self::assertFalse(EventSourcing::hasAttribute($methods[3], Apply::class)); } + public function testHandledCommands(): void + { + $stock = self::classReflection(Stock::class); + + self::assertSame([DecreaseStockForProduct::class], EventSourcing::handledCommands($stock->getNativeMethod('decreaseStock'))); + self::assertSame([AdjustStockForProduct::class], EventSourcing::handledCommands($stock->getNativeMethod('adjustStock'))); + self::assertSame([], EventSourcing::handledCommands($stock->getNativeMethod('initialize'))); + + $handler = self::classReflection(CreateInvoiceHandler::class); + + self::assertSame([CreateInvoice::class], EventSourcing::handledCommands($handler->getNativeMethod('__invoke'))); + } + + public function testHandledCommandsFromAttributeArguments(): void + { + $handler = self::classReflection(MultiCommandHandler::class); + + self::assertSame( + [AdjustStockForProduct::class], + EventSourcing::handledCommands($handler->getNativeMethod('handleExplicit')), + ); + self::assertSame( + [AdjustStockForProduct::class, DecreaseStockForProduct::class], + EventSourcing::handledCommands($handler->getNativeMethod('handleRepeated')), + ); + } + + public function testHandledCommandsFromParameterTypes(): void + { + $handler = self::classReflection(MultiCommandHandler::class); + + self::assertSame( + [AdjustStockForProduct::class, DecreaseStockForProduct::class], + EventSourcing::handledCommands($handler->getNativeMethod('handleUnion')), + ); + self::assertSame([], EventSourcing::handledCommands($handler->getNativeMethod('handleWithoutParameter'))); + self::assertSame([], EventSourcing::handledCommands($handler->getNativeMethod('noHandler'))); + } + + public function testAppliedEventsOfAggregate(): void + { + $stock = self::classReflection(Stock::class); + + self::assertSame([StockCreated::class], EventSourcing::appliedEvents($stock->getNativeMethod('applyStockCreated'), 0)); + self::assertSame([StockDecreased::class], EventSourcing::appliedEvents($stock->getNativeMethod('applyStockDecreased'), 0)); + self::assertSame([], EventSourcing::appliedEvents($stock->getNativeMethod('decreaseStock'), 0)); + } + + public function testAppliedEventsOfProjection(): void + { + $projection = self::classReflection(StockCountProjection::class); + + // the first parameter of a projection apply method is the state + self::assertSame([StockDecreased::class], EventSourcing::appliedEvents($projection->getNativeMethod('applyStockDecreased'), 1)); + self::assertSame([], EventSourcing::appliedEvents($projection->getNativeMethod('applyStockDecreased'), 2)); + self::assertSame([StockAdjusted::class], EventSourcing::appliedEvents($projection->getNativeMethod('reset'), 1)); + self::assertSame([], EventSourcing::appliedEvents($projection->getNativeMethod('tagFilter'), 1)); + } + public function testAttributes(): void { $attributes = self::classReflection(Stock::class)->getAttributes(); @@ -96,6 +162,25 @@ public function testAttributes(): void self::assertSame([], EventSourcing::attributes($attributes, Event::class)); } + public function testStringArgument(): void + { + $stock = self::classReflection(Stock::class); + + [$aggregate] = EventSourcing::attributes($stock->getAttributes(), Aggregate::class); + + self::assertSame('stock', EventSourcing::stringArgument($aggregate, 'name')); + self::assertNull(EventSourcing::stringArgument($aggregate, 'missing')); + + $handle = self::classReflection(MultiCommandHandler::class)->getNativeMethod('handleExplicit')->getAttributes()[0]; + + self::assertSame(AdjustStockForProduct::class, EventSourcing::stringArgument($handle, 'commandClass')); + + // #[Handle] without an argument has the default null, which is not a string + $handle = $stock->getNativeMethod('decreaseStock')->getAttributes()[0]; + + self::assertNull(EventSourcing::stringArgument($handle, 'commandClass')); + } + private static function classReflection(string $class): ClassReflection { return self::createReflectionProvider()->getClass($class); diff --git a/tests/Unit/Helper/Fixture/MultiCommandHandler.php b/tests/Unit/Helper/Fixture/MultiCommandHandler.php new file mode 100644 index 0000000..c00eec6 --- /dev/null +++ b/tests/Unit/Helper/Fixture/MultiCommandHandler.php @@ -0,0 +1,37 @@ + */ +final class StockCountProjection extends BasicProjection +{ + /** @return list */ + public function tagFilter(): array + { + return []; + } + + public function initialState(): int + { + return 0; + } + + #[Apply] + public function applyStockDecreased(int $state, StockDecreased $event): int + { + return $state - $event->quantity; + } + + #[Apply(StockAdjusted::class)] + public function reset(int $state): int + { + return 0; + } +} From 42fed2c3df5f6b4a0437538a2652f893a8d7c930 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 18 Oct 2026 11:33:19 +0200 Subject: [PATCH 4/4] Ignore the error output in the analyser snapshot tests The tests mixed stderr into the compared output, so the JIT warning the PHP setup of the CI prints on startup ended up in front of the snapshot. --- tests/Analyser/AnalyserTest.php | 64 +++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index a29edc0..8c590ab 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -12,12 +12,15 @@ use RuntimeException; use function dirname; -use function escapeshellarg; -use function exec; +use function fclose; use function file_put_contents; use function getenv; use function implode; +use function proc_close; +use function proc_open; use function sprintf; +use function stream_get_contents; +use function tmpfile; use const PHP_BINARY; @@ -42,42 +45,59 @@ public function testGraphviz(): void public function testDisabledAnalyser(): void { - [$exitCode, $output] = self::analyse('eventSourcingJson', __DIR__ . '/disabled.neon'); + [$exitCode, $output, $errorOutput] = self::analyse('eventSourcingJson', __DIR__ . '/disabled.neon'); - self::assertSame(1, $exitCode); + self::assertSame(1, $exitCode, $errorOutput); self::assertStringContainsString('The analyser is disabled.', $output); } private function assertSnapshot(string $errorFormat, string $snapshot): void { - [$exitCode, $output] = self::analyse($errorFormat, __DIR__ . '/analyser.neon'); + [$exitCode, $output, $errorOutput] = self::analyse($errorFormat, __DIR__ . '/analyser.neon'); if (getenv('UPDATE_SNAPSHOTS') === '1') { file_put_contents($snapshot, $output); } - self::assertSame(0, $exitCode, $output); - self::assertStringEqualsFile($snapshot, $output); + self::assertSame(0, $exitCode, $output . $errorOutput); + self::assertStringEqualsFile($snapshot, $output, $errorOutput); } - /** @return array{int, string} */ + /** + * Only the standard output is compared, the error output may contain warnings of the PHP setup. + * + * @return array{int, string, string} + */ private static function analyse(string $errorFormat, string $config): array { - $command = sprintf( - '%s %s analyse --no-progress --configuration=%s --error-format=%s', - escapeshellarg(PHP_BINARY), - escapeshellarg(dirname(__DIR__, 2) . '/vendor/bin/phpstan'), - escapeshellarg($config), - escapeshellarg($errorFormat), - ); - - $output = []; - $exitCode = 0; - - if (exec($command . ' 2>&1', $output, $exitCode) === false) { - throw new RuntimeException(sprintf('Could not run "%s".', $command)); + $command = [ + PHP_BINARY, + dirname(__DIR__, 2) . '/vendor/bin/phpstan', + 'analyse', + '--no-progress', + '--configuration=' . $config, + '--error-format=' . $errorFormat, + ]; + + $errorStream = tmpfile(); + + if ($errorStream === false) { + throw new RuntimeException('Could not create a temporary file for the error output.'); } - return [$exitCode, implode("\n", $output) . "\n"]; + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => $errorStream], $pipes); + + if ($process === false) { + throw new RuntimeException(sprintf('Could not run "%s".', implode(' ', $command))); + } + + $output = (string)stream_get_contents($pipes[1]); + fclose($pipes[1]); + $exitCode = proc_close($process); + + $errorOutput = (string)stream_get_contents($errorStream, offset: 0); + fclose($errorStream); + + return [$exitCode, $output, $errorOutput]; } }