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/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 47d511d..5b55a69 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,9 @@ "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", + "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 920403d..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": "b363046df53e136bcc2353ea4eaa432e", + "content-hash": "24a1ad48d8a47166bb7e1160e4e6b0f1", "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", @@ -3270,18 +4937,99 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "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-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": "A generic function and convention to trigger deprecation notices", + "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/error-handler/tree/v8.1.8" }, "funding": [ { @@ -3301,7 +5049,7 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-09-22T18:58:53+00:00" }, { "name": "symfony/event-dispatcher", @@ -3537,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", @@ -4448,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", @@ -4531,6 +6557,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/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 b7a4b9f..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: - @@ -41,8 +57,6 @@ services: class: Patchlevel\EventSourcingPHPStanExtension\DontRecordWhenApplyingExtension arguments: parser: @defaultAnalysisParser - tags: - - phpstan.restrictedMethodUsageExtension - class: Patchlevel\EventSourcingPHPStanExtension\DontWriteStateWhenNotApplyingRule @@ -56,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/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..bc5a39a --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + tests/Unit + + + tests/Analyser + + + + + + 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/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/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..143f18e --- /dev/null +++ b/src/Helper/EventSourcing.php @@ -0,0 +1,150 @@ +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; + } + + /** + * 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 + * + * @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; + } + + /** 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/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/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php new file mode 100644 index 0000000..8c590ab --- /dev/null +++ b/tests/Analyser/AnalyserTest.php @@ -0,0 +1,103 @@ +assertSnapshot('eventSourcingJson', __DIR__ . '/snapshot.json'); + } + + public function testGraphviz(): void + { + $this->assertSnapshot('eventSourcingGraphviz', __DIR__ . '/snapshot.dot'); + } + + public function testDisabledAnalyser(): void + { + [$exitCode, $output, $errorOutput] = self::analyse('eventSourcingJson', __DIR__ . '/disabled.neon'); + + self::assertSame(1, $exitCode, $errorOutput); + self::assertStringContainsString('The analyser is disabled.', $output); + } + + private function assertSnapshot(string $errorFormat, string $snapshot): void + { + [$exitCode, $output, $errorOutput] = self::analyse($errorFormat, __DIR__ . '/analyser.neon'); + + if (getenv('UPDATE_SNAPSHOTS') === '1') { + file_put_contents($snapshot, $output); + } + + self::assertSame(0, $exitCode, $output . $errorOutput); + self::assertStringEqualsFile($snapshot, $output, $errorOutput); + } + + /** + * 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 = [ + 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.'); + } + + $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]; + } +} 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 new file mode 100644 index 0000000..637638b --- /dev/null +++ b/tests/Unit/Helper/EventSourcingTest.php @@ -0,0 +1,188 @@ +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 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(); + + self::assertCount(1, EventSourcing::attributes($attributes, Aggregate::class)); + 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; + } +} 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); + } +}