diff --git a/README.md b/README.md index 1739135..4cb8756 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,16 @@ See individual rule documentation for detailed configuration examples. A [full c - [Class Must Be Readonly Rule](docs/rules/Class-Must-Be-Readonly-Rule.md) - [Class Must Have Specification Docblock Rule](docs/rules/Class-Must-Have-Specification-Docblock-Rule.md) - [Classname Must Match Pattern Rule](docs/rules/Classname-Must-Match-Pattern-Rule.md) -- [Dependency Constraints Rule](docs/rules/Dependency-Constraints-Rule.md) +- [Dependency Constraints Rule](docs/rules/Dependency-Constraints-Rule.md) *(deprecated, use Forbidden Dependencies Rule)* +- [Forbidden Accessors Rule](docs/rules/Forbidden-Accessors-Rule.md) +- [Forbidden Dependencies Rule](docs/rules/Forbidden-Dependencies-Rule.md) - [Forbidden Namespaces Rule](docs/rules/Forbidden-Namespaces-Rule.md) +- [Forbidden Static Methods Rule](docs/rules/Forbidden-Static-Methods-Rule.md) - [Method Must Return Type Rule](docs/rules/Method-Must-Return-Type-Rule.md) - [Method Signature Must Match Rule](docs/rules/Method-Signature-Must-Match-Rule.md) - [Methods Returning Bool Must Follow Naming Convention Rule](docs/rules/Methods-Returning-Bool-Must-Follow-Naming-Convention-Rule.md) - [Modular Architecture Rule](docs/rules/Modular-Architecture-Rule.md) +- [Property Must Match Rule](docs/rules/Property-Must-Match-Rule.md) ### Clean Code Rules diff --git a/composer.json b/composer.json index cf053ed..a6397fe 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "type": "library", "require-dev": { "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^12.0", + "phpunit/phpunit": "^12.5.8", "squizlabs/php_codesniffer": "^3.12" }, "autoload": { diff --git a/composer.lock b/composer.lock index 56a4269..bc05ff6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,21 +4,21 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4dbefe67316486c425350e64485ab275", + "content-hash": "ae7582a75038775f4b8e01ad21426704", "packages": [], "packages-dev": [ { "name": "myclabs/deep-copy", - "version": "1.13.0", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -57,7 +57,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -65,20 +65,20 @@ "type": "tidelift" } ], - "time": "2025-02-12T12:17:51+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -97,7 +97,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -121,9 +121,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -245,16 +245,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.11", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "8ca5f79a8f63c49b2359065832a654e1ec70ac30" - }, + "version": "2.1.38", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8ca5f79a8f63c49b2359065832a654e1ec70ac30", - "reference": "8ca5f79a8f63c49b2359065832a654e1ec70ac30", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dfaf1f530e1663aa167bc3e52197adb221582629", + "reference": "dfaf1f530e1663aa167bc3e52197adb221582629", "shasum": "" }, "require": { @@ -299,38 +294,38 @@ "type": "github" } ], - "time": "2025-03-24T13:45:00+00:00" + "time": "2026-01-30T17:12:46+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.1.0", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "d331a5ced3d9a2b917baa9841b2211e72f9e780d" + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d331a5ced3d9a2b917baa9841b2211e72f9e780d", - "reference": "d331a5ced3d9a2b917baa9841b2211e72f9e780d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.4.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.3", "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0", + "sebastian/environment": "^8.0.3", "sebastian/lines-of-code": "^4.0", "sebastian/version": "^6.0", - "theseer/tokenizer": "^1.2.3" + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -339,7 +334,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.1.x-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { @@ -368,28 +363,40 @@ "support": { "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.1.0" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.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/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2025-03-17T13:56:07+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { @@ -429,15 +436,27 @@ "support": { "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.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/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/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:37+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", @@ -625,16 +644,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.0.10", + "version": "12.5.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "6075843014de23bcd6992842d69ca99d25d6a433" + "reference": "1686e30f6b32d35592f878a7f56fd0421d7d56c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6075843014de23bcd6992842d69ca99d25d6a433", - "reference": "6075843014de23bcd6992842d69ca99d25d6a433", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1686e30f6b32d35592f878a7f56fd0421d7d56c5", + "reference": "1686e30f6b32d35592f878a7f56fd0421d7d56c5", "shasum": "" }, "require": { @@ -644,23 +663,24 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.0", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.1.0", - "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.0.0", - "sebastian/comparator": "^7.0.1", + "sebastian/cli-parser": "^4.2.0", + "sebastian/comparator": "^7.1.4", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.0", - "sebastian/exporter": "^7.0.0", - "sebastian/global-state": "^8.0.0", + "sebastian/environment": "^8.0.3", + "sebastian/exporter": "^7.0.2", + "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", - "sebastian/type": "^6.0.2", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -670,7 +690,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.0-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -702,7 +722,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.0.10" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.10" }, "funding": [ { @@ -713,25 +733,33 @@ "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/phpunit", "type": "tidelift" } ], - "time": "2025-03-23T16:03:59+00:00" + "time": "2026-02-08T07:06:48+00:00" }, { "name": "sebastian/cli-parser", - "version": "4.0.0", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "6d584c727d9114bcdc14c86711cd1cad51778e7c" + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/6d584c727d9114bcdc14c86711cd1cad51778e7c", - "reference": "6d584c727d9114bcdc14c86711cd1cad51778e7c", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", "shasum": "" }, "require": { @@ -743,7 +771,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "4.2-dev" } }, "autoload": { @@ -767,28 +795,40 @@ "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.0.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" }, "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": "2025-02-07T04:53:50+00:00" + "time": "2025-09-14T09:36:45+00:00" }, { "name": "sebastian/comparator", - "version": "7.0.1", + "version": "7.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "b478f34614f934e0291598d0c08cbaba9644bee5" + "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b478f34614f934e0291598d0c08cbaba9644bee5", - "reference": "b478f34614f934e0291598d0c08cbaba9644bee5", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", "shasum": "" }, "require": { @@ -799,7 +839,7 @@ "sebastian/exporter": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.2" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -807,7 +847,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "7.1-dev" } }, "autoload": { @@ -847,15 +887,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.0.1" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" }, "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": "2025-03-07T07:00:32+00:00" + "time": "2026-01-24T09:28:48+00:00" }, { "name": "sebastian/complexity", @@ -984,16 +1036,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.0", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "8afe311eca49171bf95405cc0078be9a3821f9f2" + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8afe311eca49171bf95405cc0078be9a3821f9f2", - "reference": "8afe311eca49171bf95405cc0078be9a3821f9f2", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", "shasum": "" }, "require": { @@ -1036,28 +1088,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/environment/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/environment", + "type": "tidelift" } ], - "time": "2025-02-07T04:56:08+00:00" + "time": "2025-08-12T14:11:56+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.0", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "76432aafc58d50691a00d86d0632f1217a47b688" + "reference": "016951ae10980765e4e7aee491eb288c64e505b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/76432aafc58d50691a00d86d0632f1217a47b688", - "reference": "76432aafc58d50691a00d86d0632f1217a47b688", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7", "shasum": "" }, "require": { @@ -1114,28 +1178,40 @@ "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.0" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.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/exporter", + "type": "tidelift" } ], - "time": "2025-02-07T04:56:42+00:00" + "time": "2025-09-24T06:16:11+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.0", + "version": "8.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "570a2aeb26d40f057af686d63c4e99b075fb6cbc" + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/570a2aeb26d40f057af686d63c4e99b075fb6cbc", - "reference": "570a2aeb26d40f057af686d63c4e99b075fb6cbc", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", "shasum": "" }, "require": { @@ -1176,15 +1252,27 @@ "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.0" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.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/global-state", + "type": "tidelift" } ], - "time": "2025-02-07T04:56:59+00:00" + "time": "2025-08-29T11:29:25+00:00" }, { "name": "sebastian/lines-of-code", @@ -1360,16 +1448,16 @@ }, { "name": "sebastian/recursion-context", - "version": "7.0.0", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "c405ae3a63e01b32eb71577f8ec1604e39858a7c" + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/c405ae3a63e01b32eb71577f8ec1604e39858a7c", - "reference": "c405ae3a63e01b32eb71577f8ec1604e39858a7c", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { @@ -1412,28 +1500,40 @@ "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.0" + "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-02-07T05:00:01+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { "name": "sebastian/type", - "version": "6.0.2", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "1d7cd6e514384c36d7a390347f57c385d4be6069" + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/1d7cd6e514384c36d7a390347f57c385d4be6069", - "reference": "1d7cd6e514384c36d7a390347f57c385d4be6069", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", "shasum": "" }, "require": { @@ -1469,15 +1569,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/type/tree/6.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/type", + "type": "tidelift" } ], - "time": "2025-03-18T13:37:31+00:00" + "time": "2025-08-09T06:57:12+00:00" }, { "name": "sebastian/version", @@ -1535,16 +1647,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.12.0", + "version": "3.13.5", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "2d1b63db139c3c6ea0c927698e5160f8b3b8d630" + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/2d1b63db139c3c6ea0c927698e5160f8b3b8d630", - "reference": "2d1b63db139c3c6ea0c927698e5160f8b3b8d630", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", "shasum": "" }, "require": { @@ -1561,11 +1673,6 @@ "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -1615,7 +1722,7 @@ "type": "thanks_dev" } ], - "time": "2025-03-18T05:04:51+00:00" + "time": "2025-11-04T16:30:35+00:00" }, { "name": "staabm/side-effects-detector", @@ -1671,23 +1778,23 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -1709,7 +1816,7 @@ "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/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -1717,7 +1824,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], diff --git a/data/BoolNaming/EdgeCaseMethodBoolClass.php b/data/BoolNaming/EdgeCaseMethodBoolClass.php new file mode 100644 index 0000000..a776278 --- /dev/null +++ b/data/BoolNaming/EdgeCaseMethodBoolClass.php @@ -0,0 +1,30 @@ +name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getAge(): int + { + return $this->age; + } + + public function setAge(int $age): void + { + $this->age = $age; + } + + protected function getActive(): bool + { + return $this->active; + } + + protected function setActive(bool $active): void + { + $this->active = $active; + } + + private function getPrivateValue(): string + { + return 'private'; + } + + private function setPrivateValue(string $value): void + { + // This should not trigger an error (private) + } + + public function doSomething(): void + { + // Regular method, should not trigger + } + + public function get(): void + { + // Should not trigger - no uppercase letter after 'get' + } + + public function set(): void + { + // Should not trigger - no uppercase letter after 'set' + } +} diff --git a/data/ForbiddenAccessors/ServiceWithAccessors.php b/data/ForbiddenAccessors/ServiceWithAccessors.php new file mode 100644 index 0000000..817e89b --- /dev/null +++ b/data/ForbiddenAccessors/ServiceWithAccessors.php @@ -0,0 +1,18 @@ +config; + } + + public function setConfig(string $config): void + { + $this->config = $config; + } +} diff --git a/data/ForbiddenStaticMethods/DynamicCall.php b/data/ForbiddenStaticMethods/DynamicCall.php new file mode 100644 index 0000000..76fe5d8 --- /dev/null +++ b/data/ForbiddenStaticMethods/DynamicCall.php @@ -0,0 +1,20 @@ + **Note:** This rule replaces the deprecated `DependencyConstraintsRule`. See [Dependency Constraints Rule](Dependency-Constraints-Rule.md) for migration details. + +## Configuration Example + +### Basic Usage (Use Statements Only) + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule + arguments: + forbiddenDependencies: [ + '/^App\\Domain(?:\\\w+)*$/': ['/^App\\Controller\\/'] + ] + tags: + - phpstan.rules.rule +``` + +### With FQCN Checking Enabled + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule + arguments: + forbiddenDependencies: [ + '/^App\\Capability(?:\\\w+)*$/': [ + '/^DateTime$/', + '/^DateTimeImmutable$/' + ] + ] + checkFqcn: true + tags: + - phpstan.rules.rule +``` + +### With Selective Reference Types + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule + arguments: + forbiddenDependencies: [ + '/^App\\Capability(?:\\\w+)*$/': [ + '/^DateTime$/', + '/^DateTimeImmutable$/' + ] + ] + checkFqcn: true + fqcnReferenceTypes: ['new', 'param', 'return', 'property'] + tags: + - phpstan.rules.rule +``` + +## Parameters + +- `forbiddenDependencies`: Array where keys are regex patterns for source namespaces and values are arrays of regex patterns for disallowed dependency namespaces. +- `checkFqcn` (optional, default: `false`): Enable checking of fully qualified class names in addition to use statements. +- `fqcnReferenceTypes` (optional, default: all types): Array of reference types to check when `checkFqcn` is enabled. +- `allowedDependencies` (optional, default: `[]`): Whitelist that overrides forbidden dependencies. If a dependency matches both a forbidden pattern and an allowed pattern, it will be allowed. + +## FQCN Reference Types + +When `checkFqcn` is enabled, the following reference types can be checked: + +- `new` - Class instantiations (e.g., `new \DateTime()`) +- `param` - Parameter type hints (e.g., `function foo(\DateTime $date)`) +- `return` - Return type hints (e.g., `function foo(): \DateTime`) +- `property` - Property type hints (e.g., `private \DateTime $date`) +- `static_call` - Static method calls (e.g., `\DateTime::createFromFormat()`) +- `static_property` - Static property access (e.g., `\DateTime::ATOM`) +- `class_const` - Class constant (e.g., `\DateTime::class`) +- `instanceof` - instanceof checks (e.g., `$x instanceof \DateTime`) +- `catch` - catch blocks (e.g., `catch (\Exception $e)`) +- `extends` - class inheritance (e.g., `class Foo extends \DateTime`) +- `implements` - interface implementation (e.g., `class Foo implements \DateTimeInterface`) + +## Use Cases + +### Enforcing Layer Boundaries + +Prevent domain classes from depending on infrastructure or presentation layers: + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule + arguments: + forbiddenDependencies: + '/^App\\Capability\\.*\\Domain/': + - '/^App\\Capability\\.*\\Application/' + - '/^App\\Capability\\.*\\Infrastructure/' + - '/^App\\Capability\\.*\\Presentation/' + '/^App\\Capability\\.*\\Application/': + - '/^App\\Capability\\.*\\Infrastructure/' + - '/^App\\Capability\\.*\\Presentation/' + tags: + - phpstan.rules.rule +``` + +### Preventing DateTime Usage in Domain Layer + +Encourage the use of domain-specific date/time objects instead of PHP's built-in classes: + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule + arguments: + forbiddenDependencies: [ + '/^App\\Capability(?:\\\w+)*$/': [ + '/^DateTime$/', + '/^DateTimeImmutable$/' + ] + ] + checkFqcn: true + tags: + - phpstan.rules.rule +``` + +This will catch: + +- `use DateTime;` (use statement) +- `new \DateTime()` (instantiation) +- `function foo(\DateTime $date)` (parameter type) +- `function bar(): \DateTime` (return type) +- `private \DateTime $date` (property type) +- And all other reference types listed above + +### Whitelist with allowedDependencies + +The `allowedDependencies` parameter lets you create a "forbid everything except X" pattern. Dependencies matching both forbidden and allowed patterns will be allowed. + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule + arguments: + forbiddenDependencies: [ + '/^App\\Capability\\.*\\Domain$/': [ + '/.*\\\\.*/' + ] + ] + checkFqcn: true + allowedDependencies: [ + '/^App\\Capability\\.*\\Domain$/': [ + '/^App\\Shared\\/', + '/^App\\Capability\\/', + '/^Psr\\/' + ] + ] + tags: + - phpstan.rules.rule +``` + +This will: + +- **Allow**: `App\Shared\ValueObject\Money`, `App\Capability\Billing\Invoice`, `Psr\Log\LoggerInterface` +- **Forbid**: `Doctrine\ORM\EntityManager`, `Symfony\Component\HttpFoundation\Request` + +## Diagram + +```mermaid +flowchart TD + A[Check Dependency] --> B{Matches forbidden pattern?} + B -->|No| C[Allow] + B -->|Yes| D{Matches allowed pattern?} + D -->|Yes| E[Allow - Override] + D -->|No| F[Report Error] +``` + +## Backward Compatibility + +By default, `checkFqcn` is `false` and `allowedDependencies` is empty, so existing configurations will continue to work exactly as before, checking only `use` statements. The new FQCN checking and allowedDependencies features must be explicitly enabled. diff --git a/docs/rules/Forbidden-Static-Methods-Rule.md b/docs/rules/Forbidden-Static-Methods-Rule.md new file mode 100644 index 0000000..41acce5 --- /dev/null +++ b/docs/rules/Forbidden-Static-Methods-Rule.md @@ -0,0 +1,116 @@ +# Forbidden Static Methods Rule + +Forbids specific static method calls matching regex patterns. This rule checks static method calls against a configurable list of forbidden patterns and supports namespace-level, class-level, and method-level granularity. + +The rule resolves `self`, `static`, and `parent` keywords to the actual class name before matching, so forbidden patterns work correctly even when these keywords are used. + +## Configuration Example + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenStaticMethodsRule + arguments: + forbiddenStaticMethods: + - '/^App\\Legacy\\.*::.*/' + - '/^App\\Utils\\StaticHelper::.*/' + - '/^DateTime::createFromFormat$/' + tags: + - phpstan.rules.rule +``` + +## Parameters + +- `forbiddenStaticMethods`: Array of regex patterns to match against static method calls. Patterns are matched against the format `FQCN::methodName`. + +## Pattern Granularity + +Patterns match against the fully qualified class name followed by `::` and the method name. This allows you to forbid static calls at different levels of granularity: + +### Namespace-level + +Forbid all static calls to any class in a namespace: + +```neon + forbiddenStaticMethods: + - '/^App\\Legacy\\.*::.*/' +``` + +This forbids calls like `App\Legacy\LegacyHelper::doSomething()` and `App\Legacy\OldService::run()`. + +### Class-level + +Forbid all static calls on a specific class: + +```neon + forbiddenStaticMethods: + - '/^App\\Utils\\StaticHelper::.*/' +``` + +This forbids all static method calls on `App\Utils\StaticHelper`, regardless of the method name. + +### Method-level + +Forbid a specific static method on a specific class: + +```neon + forbiddenStaticMethods: + - '/^DateTime::createFromFormat$/' +``` + +This forbids only `DateTime::createFromFormat()` while allowing other static methods like `DateTime::getLastErrors()`. + +## Use Cases + +### Forbid Legacy Static Helpers + +Prevent usage of legacy static helper classes to encourage dependency injection: + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenStaticMethodsRule + arguments: + forbiddenStaticMethods: + - '/^App\\Legacy\\.*::.*/' + - '/^App\\Helpers\\.*::.*/' + tags: + - phpstan.rules.rule +``` + +### Forbid Specific Factory Methods + +Forbid using static factory methods on certain classes while allowing other static methods: + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenStaticMethodsRule + arguments: + forbiddenStaticMethods: + - '/^DateTime::createFromFormat$/' + - '/^DateTime::createFromTimestamp$/' + tags: + - phpstan.rules.rule +``` + +### Forbid All Static Calls in Domain Layer + +Combine with a broad pattern to forbid all static calls from specific namespaces: + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenStaticMethodsRule + arguments: + forbiddenStaticMethods: + - '/^Illuminate\\Support\\Facades\\.*::.*/' + tags: + - phpstan.rules.rule +``` + +## Handling of self, static, and parent + +The rule resolves the keywords `self`, `static`, and `parent` to the actual fully qualified class name before matching against the forbidden patterns. This means: + +- `self::create()` inside `App\Service\MyService` is matched as `App\Service\MyService::create` +- `static::create()` inside `App\Service\MyService` is matched as `App\Service\MyService::create` +- `parent::create()` inside a child class is matched against the parent class name + +Dynamic class names (e.g., `$class::method()`) and dynamic method names (e.g., `DateTime::$method()`) are skipped. diff --git a/docs/rules/Property-Must-Match-Rule.md b/docs/rules/Property-Must-Match-Rule.md new file mode 100644 index 0000000..4e739be --- /dev/null +++ b/docs/rules/Property-Must-Match-Rule.md @@ -0,0 +1,122 @@ +# Property Must Match Rule + +Ensures that classes matching specified patterns have properties with expected names, types, and visibility scopes. Can optionally enforce that matching classes must have certain properties. + +## Configuration Example + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\PropertyMustMatchRule + arguments: + propertyPatterns: + - + classPattern: '/^.*Controller$/' + properties: + - + name: 'id' + type: 'int' + visibilityScope: 'private' + required: true + - + name: 'repository' + type: 'RepositoryInterface' + visibilityScope: 'private' + required: true + - + classPattern: '/^.*Service$/' + properties: + - + name: 'logger' + type: 'LoggerInterface' + visibilityScope: 'private' + required: false + tags: + - phpstan.rules.rule +``` + +## Parameters + +- `propertyPatterns`: Array of class pattern configurations. + - `classPattern`: Regex to match against class names. + - `properties`: Array of property rules for matching classes. + - `name`: The exact property name to check. + - `type`: Optional expected type (supports scalar types, class names, nullable types like `?int`). + - `visibilityScope`: Optional visibility scope (`public`, `protected`, `private`). + - `required`: Optional boolean (default: `false`). When `true`, enforces that matching classes must have this property. + - `nullable`: Optional boolean (default: `false`). When `true`, allows both the specified type and its nullable variant (e.g., both `int` and `?int`). + +## Required Properties + +When the `required` parameter is set to `true`, the rule will check if classes matching the pattern actually have the specified property. If a matching class is missing the required property, an error will be reported. + +### Example with Required Properties + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\PropertyMustMatchRule + arguments: + propertyPatterns: + - + classPattern: '/^App\\Entity\\.*$/' + properties: + - + name: 'id' + type: 'int' + visibilityScope: 'private' + required: true + - + name: 'createdAt' + type: 'DateTimeImmutable' + visibilityScope: 'private' + required: true + tags: + - phpstan.rules.rule +``` + +In this example, any class in the `App\Entity` namespace must have a private `id` property of type `int` and a private `createdAt` property of type `DateTimeImmutable`. + +## Optional Property Validation + +When `required` is `false` (or omitted), the rule will only validate type and visibility if the property exists. This is useful for optional properties that should follow certain conventions when present. + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\PropertyMustMatchRule + arguments: + propertyPatterns: + - + classPattern: '/^.*Service$/' + properties: + - + name: 'logger' + type: 'Psr\Log\LoggerInterface' + visibilityScope: 'private' + required: false + tags: + - phpstan.rules.rule +``` + +In this example, if a Service class has a `logger` property, it must be of type `Psr\Log\LoggerInterface` and private, but the property itself is not required. + +## Nullable Properties + +When `nullable` is set to `true`, the rule will accept both the exact type and its nullable variant. This is useful when you want to allow properties to be either nullable or non-nullable. + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\PropertyMustMatchRule + arguments: + propertyPatterns: + - + classPattern: '/^.*Handler$/' + properties: + - + name: 'id' + type: 'int' + visibilityScope: 'private' + nullable: true + tags: + - phpstan.rules.rule +``` + +In this example, Handler classes can have an `id` property typed as either `int` or `?int`. Both are valid when `nullable: true`. diff --git a/rule-builder.html b/rule-builder.html new file mode 100644 index 0000000..4cf19bc --- /dev/null +++ b/rule-builder.html @@ -0,0 +1,1815 @@ + + + + + + PHPStan Rule Builder + + + + + +
+
+

PHPStan Rule Builder

+

Configure PHPStan architecture rules with a visual form builder

+
+
+ +
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
Configuration
+
+
+ Select a rule above to start configuring +
+
+
+
+
+
+
+
+ YAML Preview + +
+
+
+ YAML output will appear here +
+
+
+
+
+
+
+ + + + diff --git a/src/Architecture/AttributeRule.php b/src/Architecture/AttributeRule.php index 12fe46f..9133866 100644 --- a/src/Architecture/AttributeRule.php +++ b/src/Architecture/AttributeRule.php @@ -54,6 +54,8 @@ */ class AttributeRule implements Rule { + use ClassNameResolver; + private const ERROR_FORBIDDEN = 'Attribute %s is forbidden on %s %s.'; private const ERROR_NOT_ALLOWED = 'Attribute %s is not in the allowed list for %s %s. Allowed patterns: %s'; @@ -104,14 +106,11 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { - if (!isset($node->name)) { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { return []; } - $className = $node->name->toString(); - $namespaceName = $scope->getNamespace() ?? ''; - $fullClassName = $namespaceName !== '' ? $namespaceName . '\\' . $className : $className; - /** @var list $errors */ $errors = []; diff --git a/src/Architecture/CatchExceptionOfTypeNotAllowedRule.php b/src/Architecture/CatchExceptionOfTypeNotAllowedRule.php index 5175bdd..3fec9d9 100644 --- a/src/Architecture/CatchExceptionOfTypeNotAllowedRule.php +++ b/src/Architecture/CatchExceptionOfTypeNotAllowedRule.php @@ -32,22 +32,27 @@ */ class CatchExceptionOfTypeNotAllowedRule implements Rule { + use ClassNameResolver; + private const ERROR_MESSAGE = 'Catching exception of type %s is not allowed.'; private const IDENTIFIER = 'phauthentic.architecture.catchExceptionOfTypeNotAllowed'; /** - * @var array An array of exception class names that are not allowed to be caught. - * e.g., ['Exception', 'Error', 'Throwable'] + * @var array Normalized forbidden exception types (without leading backslash) */ - private array $forbiddenExceptionTypes; + private array $normalizedForbiddenTypes; /** * @param array $forbiddenExceptionTypes An array of exception class names that are not allowed to be caught. */ public function __construct(array $forbiddenExceptionTypes) { - $this->forbiddenExceptionTypes = $forbiddenExceptionTypes; + // Normalize all forbidden types by removing leading backslash + $this->normalizedForbiddenTypes = array_map( + fn(string $type): string => $this->normalizeClassName($type), + $forbiddenExceptionTypes + ); } public function getNodeType(): string @@ -64,9 +69,11 @@ public function processNode(Node $node, Scope $scope): array foreach ($node->types as $type) { $exceptionType = $type->toString(); + // Normalize the caught exception type for comparison + $normalizedType = $this->normalizeClassName($exceptionType); // Check if the caught exception type is in the forbidden list - if (in_array($exceptionType, $this->forbiddenExceptionTypes, true)) { + if (in_array($normalizedType, $this->normalizedForbiddenTypes, true)) { $errors[] = RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $exceptionType)) ->line($node->getLine()) ->identifier(self::IDENTIFIER) diff --git a/src/Architecture/ClassMustBeFinalRule.php b/src/Architecture/ClassMustBeFinalRule.php index 8d5b720..1cba0e6 100644 --- a/src/Architecture/ClassMustBeFinalRule.php +++ b/src/Architecture/ClassMustBeFinalRule.php @@ -34,6 +34,8 @@ */ class ClassMustBeFinalRule implements Rule { + use ClassNameResolver; + private const ERROR_MESSAGE = 'Class %s must be final.'; private const IDENTIFIER = 'phauthentic.architecture.classMustBeFinal'; @@ -59,7 +61,8 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { - if (!isset($node->name)) { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { return []; } @@ -68,14 +71,8 @@ public function processNode(Node $node, Scope $scope): array return []; } - $className = $node->name->toString(); - $namespaceName = $scope->getNamespace() ?? ''; - $fullClassName = $namespaceName . '\\' . $className; - - foreach ($this->patterns as $pattern) { - if (preg_match($pattern, $fullClassName) && !$node->isFinal()) { - return [$this->buildRuleError($fullClassName)]; - } + if ($this->matchesAnyPattern($fullClassName, $this->patterns) && !$node->isFinal()) { + return [$this->buildRuleError($fullClassName)]; } return []; diff --git a/src/Architecture/ClassMustBeReadonlyRule.php b/src/Architecture/ClassMustBeReadonlyRule.php index d85aa0c..5a315ec 100644 --- a/src/Architecture/ClassMustBeReadonlyRule.php +++ b/src/Architecture/ClassMustBeReadonlyRule.php @@ -32,6 +32,8 @@ */ class ClassMustBeReadonlyRule implements Rule { + use ClassNameResolver; + private const ERROR_MESSAGE = 'Class %s must be readonly.'; private const IDENTIFIER = 'phauthentic.architecture.classMustBeReadonly'; @@ -59,22 +61,17 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { - if (!isset($node->name)) { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { return []; } - $className = $node->name->toString(); - $namespaceName = $scope->getNamespace() ?? ''; - $fullClassName = $namespaceName . '\\' . $className; - - foreach ($this->patterns as $pattern) { - if (preg_match($pattern, $fullClassName) && !$node->isReadonly()) { - return [ - RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $fullClassName)) - ->identifier(self::IDENTIFIER) - ->build(), - ]; - } + if ($this->matchesAnyPattern($fullClassName, $this->patterns) && !$node->isReadonly()) { + return [ + RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $fullClassName)) + ->identifier(self::IDENTIFIER) + ->build(), + ]; } return []; diff --git a/src/Architecture/ClassMustHaveSpecificationDocblockRule.php b/src/Architecture/ClassMustHaveSpecificationDocblockRule.php index 0c37473..0aa32c3 100644 --- a/src/Architecture/ClassMustHaveSpecificationDocblockRule.php +++ b/src/Architecture/ClassMustHaveSpecificationDocblockRule.php @@ -39,6 +39,8 @@ */ class ClassMustHaveSpecificationDocblockRule implements Rule { + use ClassNameResolver; + private const ERROR_MESSAGE_MISSING = '%s %s must have a docblock with a "%s" section.'; private const ERROR_MESSAGE_INVALID = '%s %s has an invalid specification docblock. %s'; private const IDENTIFIER = 'phauthentic.architecture.classMustHaveSpecificationDocblock'; @@ -96,20 +98,18 @@ public function processNode(Node $node, Scope $scope): array return []; } - if (!isset($node->name)) { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { return []; } $errors = []; - $className = $node->name->toString(); - $namespaceName = $scope->getNamespace() ?? ''; - $fullClassName = $namespaceName . '\\' . $className; // Determine the type for error messages $type = $node instanceof Interface_ ? 'Interface' : 'Class'; // Check class/interface docblock - if ($this->matchesPatterns($fullClassName, $this->classPatterns)) { + if ($this->matchesAnyPattern($fullClassName, $this->classPatterns)) { $docComment = $node->getDocComment(); if ($docComment === null) { $errors[] = $this->buildMissingDocblockError($type, $fullClassName, $node); @@ -123,7 +123,7 @@ public function processNode(Node $node, Scope $scope): array $methodName = $method->name->toString(); $fullMethodName = $fullClassName . '::' . $methodName; - if ($this->matchesPatterns($fullMethodName, $this->methodPatterns)) { + if ($this->matchesAnyPattern($fullMethodName, $this->methodPatterns)) { $docComment = $method->getDocComment(); if ($docComment === null) { $errors[] = $this->buildMissingDocblockError('Method', $fullMethodName, $method); @@ -136,20 +136,6 @@ public function processNode(Node $node, Scope $scope): array return $errors; } - /** - * @param array $patterns - */ - private function matchesPatterns(string $target, array $patterns): bool - { - foreach ($patterns as $pattern) { - if (preg_match($pattern, $target)) { - return true; - } - } - - return false; - } - private function isValidSpecificationDocblock(Doc $docComment): bool { $text = $docComment->getText(); diff --git a/src/Architecture/ClassNameResolver.php b/src/Architecture/ClassNameResolver.php new file mode 100644 index 0000000..994ecc6 --- /dev/null +++ b/src/Architecture/ClassNameResolver.php @@ -0,0 +1,109 @@ +name)) { + return null; + } + + $className = $node->name->toString(); + $namespaceName = $scope->getNamespace() ?? ''; + + return $namespaceName !== '' ? $namespaceName . '\\' . $className : $className; + } + + /** + * Convert a type node to its string representation. + * + * Handles Identifier, Name, NullableType, UnionType, and IntersectionType. + * + * @param ComplexType|Identifier|Name|null $type + * @return string|null + */ + protected function getTypeAsString(ComplexType|Identifier|Name|null $type): ?string + { + return match (true) { + $type === null => null, + $type instanceof Identifier => $type->name, + $type instanceof Name => $type->toString(), + $type instanceof NullableType => + ($inner = $this->getTypeAsString($type->type)) !== null + ? '?' . $inner + : null, + $type instanceof UnionType => implode('|', array_filter( + array_map(fn($t) => $this->getTypeAsString($t), $type->types) + )), + $type instanceof IntersectionType => implode('&', array_filter( + array_map(fn($t) => $this->getTypeAsString($t), $type->types) + )), + default => null, + }; + } + + /** + * Check if a subject string matches any of the given regex patterns. + * + * @param string $subject The string to test + * @param array $patterns Array of regex patterns + * @return bool True if any pattern matches + */ + protected function matchesAnyPattern(string $subject, array $patterns): bool + { + foreach ($patterns as $pattern) { + if (preg_match($pattern, $subject) === 1) { + return true; + } + } + + return false; + } + + /** + * Normalize a class name by removing leading backslash. + * + * @param string $className + * @return string + */ + protected function normalizeClassName(string $className): string + { + return ltrim($className, '\\'); + } +} diff --git a/src/Architecture/ForbiddenAccessorsRule.php b/src/Architecture/ForbiddenAccessorsRule.php new file mode 100644 index 0000000..1b5bf1b --- /dev/null +++ b/src/Architecture/ForbiddenAccessorsRule.php @@ -0,0 +1,153 @@ + + */ +class ForbiddenAccessorsRule implements Rule +{ + use ClassNameResolver; + + private const ERROR_MESSAGE_GETTER = 'Class %s must not have a %s getter method %s().'; + private const ERROR_MESSAGE_SETTER = 'Class %s must not have a %s setter method %s().'; + private const IDENTIFIER = 'phauthentic.architecture.forbiddenAccessors'; + + private const GETTER_PATTERN = '/^get[A-Z]/'; + private const SETTER_PATTERN = '/^set[A-Z]/'; + + private const VALID_VISIBILITIES = ['public', 'protected', 'private']; + + /** + * @param array $classPatterns Regex patterns to match against class FQCNs. + * @param bool $forbidGetters Whether to forbid getXxx() methods. + * @param bool $forbidSetters Whether to forbid setXxx() methods. + * @param array $visibility Array of visibilities to check ('public', 'protected', 'private'). + */ + public function __construct( + protected array $classPatterns, + protected bool $forbidGetters = true, + protected bool $forbidSetters = true, + protected array $visibility = ['public'] + ) { + if ($classPatterns === []) { + throw new \InvalidArgumentException('At least one class pattern must be provided.'); + } + + $invalidVisibilities = array_diff($this->visibility, self::VALID_VISIBILITIES); + if ($invalidVisibilities !== []) { + throw new \InvalidArgumentException( + sprintf( + 'Invalid visibility value(s): %s. Must be one of: %s.', + implode(', ', $invalidVisibilities), + implode(', ', self::VALID_VISIBILITIES) + ) + ); + } + } + + public function getNodeType(): string + { + return Class_::class; + } + + /** + * @param Class_ $node + * @param Scope $scope + * @return array<\PHPStan\Rules\RuleError> + */ + public function processNode(Node $node, Scope $scope): array + { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { + return []; + } + + if (!$this->matchesAnyPattern($fullClassName, $this->classPatterns)) { + return []; + } + + $errors = []; + + foreach ($node->getMethods() as $method) { + $methodName = $method->name->toString(); + $methodVisibility = $this->getMethodVisibility($method); + + if (!in_array($methodVisibility, $this->visibility, true)) { + continue; + } + + if ($this->forbidGetters && preg_match(self::GETTER_PATTERN, $methodName)) { + $errors[] = $this->buildGetterError($fullClassName, $methodVisibility, $methodName, $method->getLine()); + } + + if ($this->forbidSetters && preg_match(self::SETTER_PATTERN, $methodName)) { + $errors[] = $this->buildSetterError($fullClassName, $methodVisibility, $methodName, $method->getLine()); + } + } + + return $errors; + } + + /** + * Get the visibility of a method as a string. + */ + private function getMethodVisibility(Node\Stmt\ClassMethod $method): string + { + if ($method->isPublic()) { + return 'public'; + } + + if ($method->isProtected()) { + return 'protected'; + } + + return 'private'; + } + + private function buildGetterError(string $fullClassName, string $visibility, string $methodName, int $line): \PHPStan\Rules\RuleError + { + return RuleErrorBuilder::message( + sprintf(self::ERROR_MESSAGE_GETTER, $fullClassName, $visibility, $methodName) + ) + ->identifier(self::IDENTIFIER) + ->line($line) + ->build(); + } + + private function buildSetterError(string $fullClassName, string $visibility, string $methodName, int $line): \PHPStan\Rules\RuleError + { + return RuleErrorBuilder::message( + sprintf(self::ERROR_MESSAGE_SETTER, $fullClassName, $visibility, $methodName) + ) + ->identifier(self::IDENTIFIER) + ->line($line) + ->build(); + } +} diff --git a/src/Architecture/MethodMustReturnTypeRule.php b/src/Architecture/MethodMustReturnTypeRule.php index 2d5caae..50fe17f 100644 --- a/src/Architecture/MethodMustReturnTypeRule.php +++ b/src/Architecture/MethodMustReturnTypeRule.php @@ -39,6 +39,8 @@ */ class MethodMustReturnTypeRule implements Rule { + use ClassNameResolver; + private const IDENTIFIER = 'phauthentic.architecture.methodMustReturnType'; private const ERROR_MESSAGE_VOID = 'Method %s must have a void return type.'; @@ -78,12 +80,16 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { + return []; + } + $errors = []; - $className = $node->name ? $node->name->toString() : ''; foreach ($node->getMethods() as $method) { $methodName = $method->name->toString(); - $fullName = $className . '::' . $methodName; + $fullName = $fullClassName . '::' . $methodName; foreach ($this->returnTypePatterns as $patternConfig) { if (!preg_match($patternConfig['pattern'], $fullName)) { @@ -449,22 +455,6 @@ private function buildTypeMismatchError(string $fullName, string $expectedType, ->build(); } - private function getTypeAsString(mixed $type): ?string - { - $nullableInner = null; - if ($type instanceof NullableType) { - $nullableInner = $this->getTypeAsString($type->type); - } - - return match (true) { - $type === null => null, - $type instanceof Identifier => $type->name, - $type instanceof Name => $type->toString(), - $type instanceof NullableType => $nullableInner !== null ? '?' . $nullableInner : null, - default => null, - }; - } - private function isNullableType(mixed $type): bool { return $type instanceof NullableType; diff --git a/src/Architecture/MethodSignatureMustMatchRule.php b/src/Architecture/MethodSignatureMustMatchRule.php index d7bcaa1..467bad1 100644 --- a/src/Architecture/MethodSignatureMustMatchRule.php +++ b/src/Architecture/MethodSignatureMustMatchRule.php @@ -18,9 +18,6 @@ use PhpParser\Node; use PhpParser\Node\Expr\Variable; -use PhpParser\Node\Identifier; -use PhpParser\Node\Name; -use PhpParser\Node\NullableType; use PhpParser\Node\Param; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassMethod; @@ -42,6 +39,8 @@ */ class MethodSignatureMustMatchRule implements Rule { + use ClassNameResolver; + private const IDENTIFIER = 'phauthentic.architecture.methodSignatureMustMatch'; private const ERROR_MESSAGE_MISSING_PARAMETER = 'Method %s is missing parameter #%d of type %s.'; @@ -81,11 +80,14 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { - $className = $node->name?->toString() ?? ''; + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { + return []; + } return [ - ...$this->checkRequiredMethods($node, $className), - ...$this->validateMethods($node->getMethods(), $className), + ...$this->checkRequiredMethods($node, $fullClassName), + ...$this->validateMethods($node->getMethods(), $fullClassName), ]; } @@ -290,18 +292,6 @@ private function getParamName(Param $param): ?string return null; } - private function getTypeAsString(mixed $type): ?string - { - return match (true) { - $type === null => null, - $type instanceof Identifier => $type->name, - $type instanceof Name => $type->toString(), - $type instanceof NullableType => - ($inner = $this->getTypeAsString($type->type)) !== null ? '?' . $inner : null, - default => null, - }; - } - /** * Extract class name pattern and method name from a regex pattern. * Expected pattern format: '/^ClassName::methodName$/' or '/ClassName::methodName$/' diff --git a/src/Architecture/MethodsReturningBoolMustFollowNamingConventionRule.php b/src/Architecture/MethodsReturningBoolMustFollowNamingConventionRule.php index af3b0eb..fca7508 100644 --- a/src/Architecture/MethodsReturningBoolMustFollowNamingConventionRule.php +++ b/src/Architecture/MethodsReturningBoolMustFollowNamingConventionRule.php @@ -24,6 +24,7 @@ /** * Specification: + * * - Any class method that returns a boolean must follow the naming convention provided by the regex. * * @implements Rule diff --git a/src/Architecture/ModularArchitectureRule.php b/src/Architecture/ModularArchitectureRule.php index bd7f33d..6a80e62 100644 --- a/src/Architecture/ModularArchitectureRule.php +++ b/src/Architecture/ModularArchitectureRule.php @@ -32,6 +32,7 @@ * 2. Cross-module dependencies (only facades and DTOs allowed) * * Specification: + * * - Domain layer cannot import from Application, Infrastructure, or Presentation * - Application layer can import Domain; cannot import Infrastructure or Presentation * - Infrastructure layer can import Domain and Application; cannot import Presentation diff --git a/src/Architecture/PropertyMustMatchRule.php b/src/Architecture/PropertyMustMatchRule.php new file mode 100644 index 0000000..85bfac9 --- /dev/null +++ b/src/Architecture/PropertyMustMatchRule.php @@ -0,0 +1,337 @@ + + * } + * @implements Rule + */ +class PropertyMustMatchRule implements Rule +{ + use ClassNameResolver; + + private const IDENTIFIER = 'phauthentic.architecture.propertyMustMatch'; + + private const ERROR_MESSAGE_MISSING_PROPERTY = 'Class %s must have property $%s.'; + private const ERROR_MESSAGE_WRONG_TYPE = 'Property %s::$%s should be of type %s, %s given.'; + private const ERROR_MESSAGE_VISIBILITY_SCOPE = 'Property %s::$%s must be %s.'; + + /** + * @param array $propertyPatterns + */ + public function __construct( + protected array $propertyPatterns + ) { + if ($propertyPatterns === []) { + throw new \InvalidArgumentException('At least one property pattern must be provided.'); + } + } + + public function getNodeType(): string + { + return Class_::class; + } + + /** + * @param Class_ $node + * @param Scope $scope + * @return array<\PHPStan\Rules\RuleError> + */ + public function processNode(Node $node, Scope $scope): array + { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { + return []; + } + + $classProperties = $this->getClassProperties($node); + $matchingPatterns = $this->getMatchingPatterns($fullClassName); + + $errors = []; + foreach ($matchingPatterns as $patternConfig) { + $errors = array_merge( + $errors, + $this->validatePatternProperties($patternConfig, $classProperties, $fullClassName, $node->getLine()) + ); + } + + return $errors; + } + + /** + * @return array + */ + private function getMatchingPatterns(string $className): array + { + return array_filter( + $this->propertyPatterns, + function (array $config) use ($className): bool { + $result = @preg_match($config['classPattern'], $className); + if ($result === false) { + throw new \InvalidArgumentException( + sprintf('Invalid regex pattern "%s": %s', $config['classPattern'], preg_last_error_msg()) + ); + } + + return $result === 1; + } + ); + } + + /** + * @param PatternConfig $patternConfig + * @param array $classProperties + * @return array<\PHPStan\Rules\RuleError> + */ + private function validatePatternProperties( + array $patternConfig, + array $classProperties, + string $className, + int $classLine + ): array { + $errors = []; + + foreach ($patternConfig['properties'] as $propertyRule) { + $errors = array_merge( + $errors, + $this->validatePropertyRule($propertyRule, $classProperties, $className, $classLine) + ); + } + + return $errors; + } + + /** + * @param PropertyRule $propertyRule + * @param array $classProperties + * @return array<\PHPStan\Rules\RuleError> + */ + private function validatePropertyRule( + array $propertyRule, + array $classProperties, + string $className, + int $classLine + ): array { + $propertyName = $propertyRule['name']; + + if (!isset($classProperties[$propertyName])) { + return $this->handleMissingProperty($propertyRule, $className, $propertyName, $classLine); + } + + return $this->validateExistingProperty($propertyRule, $classProperties[$propertyName], $className, $propertyName); + } + + /** + * @param PropertyRule $propertyRule + * @return array<\PHPStan\Rules\RuleError> + */ + private function handleMissingProperty( + array $propertyRule, + string $className, + string $propertyName, + int $classLine + ): array { + $isRequired = $propertyRule['required'] ?? false; + + if (!$isRequired) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf(self::ERROR_MESSAGE_MISSING_PROPERTY, $className, $propertyName) + ) + ->identifier(self::IDENTIFIER) + ->line($classLine) + ->build() + ]; + } + + /** + * @param PropertyRule $propertyRule + * @return array<\PHPStan\Rules\RuleError> + */ + private function validateExistingProperty( + array $propertyRule, + Property $property, + string $className, + string $propertyName + ): array { + return array_filter([ + $this->validatePropertyType($propertyRule, $property, $className, $propertyName), + $this->validateVisibilityScope($propertyRule, $property, $className, $propertyName), + ]); + } + + /** + * Get all properties from a class indexed by name. + * + * @param Class_ $node + * @return array + */ + private function getClassProperties(Class_ $node): array + { + $properties = []; + + foreach ($node->getProperties() as $property) { + foreach ($property->props as $prop) { + $properties[$prop->name->toString()] = $property; + } + } + + return $properties; + } + + /** + * Validate property type against expected type. + * + * @param PropertyRule $propertyRule + * @return \PHPStan\Rules\RuleError|null + */ + private function validatePropertyType( + array $propertyRule, + Property $property, + string $className, + string $propertyName + ): ?\PHPStan\Rules\RuleError { + if (!isset($propertyRule['type'])) { + return null; + } + + $expectedType = $propertyRule['type']; + $actualType = $this->getTypeAsString($property->type); + $nullable = $propertyRule['nullable'] ?? false; + + if ($this->typeMatches($actualType, $expectedType, $nullable)) { + return null; + } + + return $this->buildTypeError( + $className, + $propertyName, + $this->formatExpectedType($expectedType, $nullable), + $actualType ?? 'none', + $property->getLine() + ); + } + + private function typeMatches(?string $actualType, string $expectedType, bool $nullable): bool + { + if ($actualType === $expectedType) { + return true; + } + + return $nullable && $actualType === '?' . $expectedType; + } + + private function formatExpectedType(string $expectedType, bool $nullable): string + { + if (!$nullable) { + return $expectedType; + } + + return $expectedType . ' or ?' . $expectedType; + } + + private function buildTypeError( + string $className, + string $propertyName, + string $expectedType, + string $actualType, + int $line + ): \PHPStan\Rules\RuleError { + return RuleErrorBuilder::message( + sprintf( + self::ERROR_MESSAGE_WRONG_TYPE, + $className, + $propertyName, + $expectedType, + $actualType + ) + ) + ->identifier(self::IDENTIFIER) + ->line($line) + ->build(); + } + + /** + * Validate property visibility scope. + * + * @param PropertyRule $propertyRule + * @return \PHPStan\Rules\RuleError|null + */ + private function validateVisibilityScope( + array $propertyRule, + Property $property, + string $className, + string $propertyName + ): ?\PHPStan\Rules\RuleError { + if (!isset($propertyRule['visibilityScope'])) { + return null; + } + + $expectedVisibility = $propertyRule['visibilityScope']; + $isValid = match ($expectedVisibility) { + 'public' => $property->isPublic(), + 'protected' => $property->isProtected(), + 'private' => $property->isPrivate(), + default => throw new \InvalidArgumentException( + sprintf('Invalid visibilityScope "%s". Must be one of: public, protected, private.', $expectedVisibility) + ), + }; + + if (!$isValid) { + return RuleErrorBuilder::message( + sprintf( + self::ERROR_MESSAGE_VISIBILITY_SCOPE, + $className, + $propertyName, + $expectedVisibility + ) + ) + ->identifier(self::IDENTIFIER) + ->line($property->getLine()) + ->build(); + } + + return null; + } +} diff --git a/tests/TestCases/Architecture/CircularModuleDependencyRuleTest.php b/tests/TestCases/Architecture/CircularModuleDependencyRuleTest.php index 9b054a4..09e1841 100644 --- a/tests/TestCases/Architecture/CircularModuleDependencyRuleTest.php +++ b/tests/TestCases/Architecture/CircularModuleDependencyRuleTest.php @@ -34,6 +34,33 @@ public function testNoCircularDependencies(): void ); } + public function testNonModularNamespaceIsSkipped(): void + { + // A class outside the modular namespace should be ignored + $this->analyse( + [__DIR__ . '/../../../data/ModularArchitectureTest/NonModular/OutsideClass.php'], + [] + ); + } + + public function testSameModuleImportIsSkipped(): void + { + // Importing from the same module should not trigger any errors + $this->analyse( + [__DIR__ . '/../../../data/ModularArchitectureTest/Capability/UserManagement/Application/SameModuleImport.php'], + [] + ); + } + + public function testModularFileImportingNonModularClassIsSkipped(): void + { + // A modular file importing a non-modular class (e.g., DateTime) should be skipped + $this->analyse( + [__DIR__ . '/../../../data/ModularArchitectureTest/Capability/ProductCatalog/Application/NonModularImport.php'], + [] + ); + } + public function testCircularDependencyDetection(): void { // Reset to ensure clean state diff --git a/tests/TestCases/Architecture/ClassMustBeFinalRuleTest.php b/tests/TestCases/Architecture/ClassMustBeFinalRuleTest.php index cb1b127..de4929c 100644 --- a/tests/TestCases/Architecture/ClassMustBeFinalRuleTest.php +++ b/tests/TestCases/Architecture/ClassMustBeFinalRuleTest.php @@ -33,4 +33,16 @@ public function testRuleIgnoresAbstractClassesByDefault(): void { $this->analyse([__DIR__ . '/../../../data/Service/AbstractServiceClass.php'], []); } + + public function testFinalClassMatchingPatternPassesRule(): void + { + // A class that matches the pattern but is already final should produce no errors + $this->analyse([__DIR__ . '/../../../data/Service/FinalRuleService.php'], []); + } + + public function testAnonymousClassIsSkipped(): void + { + // Anonymous classes should be skipped (no class name to match) + $this->analyse([__DIR__ . '/../../../data/Service/AnonymousServiceClass.php'], []); + } } diff --git a/tests/TestCases/Architecture/ForbiddenAccessorsRuleExceptionsTest.php b/tests/TestCases/Architecture/ForbiddenAccessorsRuleExceptionsTest.php new file mode 100644 index 0000000..be7e2f7 --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenAccessorsRuleExceptionsTest.php @@ -0,0 +1,25 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one class pattern must be provided.'); + new ForbiddenAccessorsRule(classPatterns: []); + } + + public function testInvalidVisibilityThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid visibility value(s): invalid'); + new ForbiddenAccessorsRule(classPatterns: ['/./'], visibility: ['invalid']); + } +} diff --git a/tests/TestCases/Architecture/ForbiddenAccessorsRuleGettersOnlyTest.php b/tests/TestCases/Architecture/ForbiddenAccessorsRuleGettersOnlyTest.php new file mode 100644 index 0000000..722291e --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenAccessorsRuleGettersOnlyTest.php @@ -0,0 +1,38 @@ + + */ +class ForbiddenAccessorsRuleGettersOnlyTest extends RuleTestCase +{ + protected function getRule(): ForbiddenAccessorsRule + { + return new ForbiddenAccessorsRule( + classPatterns: ['/\\\\Domain\\\\.*Entity$/'], + forbidGetters: true, + forbidSetters: false, + visibility: ['public'] + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/EntityWithAccessors.php'], [ + [ + 'Class App\Domain\UserEntity must not have a public getter method getName().', + 11, + ], + [ + 'Class App\Domain\UserEntity must not have a public getter method getAge().', + 21, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/ForbiddenAccessorsRulePrivateVisibilityTest.php b/tests/TestCases/Architecture/ForbiddenAccessorsRulePrivateVisibilityTest.php new file mode 100644 index 0000000..e64539c --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenAccessorsRulePrivateVisibilityTest.php @@ -0,0 +1,38 @@ + + */ +class ForbiddenAccessorsRulePrivateVisibilityTest extends RuleTestCase +{ + protected function getRule(): ForbiddenAccessorsRule + { + return new ForbiddenAccessorsRule( + classPatterns: ['/\\\\Domain\\\\.*Entity$/'], + forbidGetters: true, + forbidSetters: true, + visibility: ['private'] + ); + } + + public function testPrivateAccessorsAreDetected(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/EntityWithAccessors.php'], [ + [ + 'Class App\Domain\UserEntity must not have a private getter method getPrivateValue().', + 41, + ], + [ + 'Class App\Domain\UserEntity must not have a private setter method setPrivateValue().', + 46, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/ForbiddenAccessorsRuleProtectedVisibilityTest.php b/tests/TestCases/Architecture/ForbiddenAccessorsRuleProtectedVisibilityTest.php new file mode 100644 index 0000000..65d1525 --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenAccessorsRuleProtectedVisibilityTest.php @@ -0,0 +1,54 @@ + + */ +class ForbiddenAccessorsRuleProtectedVisibilityTest extends RuleTestCase +{ + protected function getRule(): ForbiddenAccessorsRule + { + return new ForbiddenAccessorsRule( + classPatterns: ['/\\\\Domain\\\\.*Entity$/'], + forbidGetters: true, + forbidSetters: true, + visibility: ['public', 'protected'] + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/EntityWithAccessors.php'], [ + [ + 'Class App\Domain\UserEntity must not have a public getter method getName().', + 11, + ], + [ + 'Class App\Domain\UserEntity must not have a public setter method setName().', + 16, + ], + [ + 'Class App\Domain\UserEntity must not have a public getter method getAge().', + 21, + ], + [ + 'Class App\Domain\UserEntity must not have a public setter method setAge().', + 26, + ], + [ + 'Class App\Domain\UserEntity must not have a protected getter method getActive().', + 31, + ], + [ + 'Class App\Domain\UserEntity must not have a protected setter method setActive().', + 36, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/ForbiddenAccessorsRuleSettersOnlyTest.php b/tests/TestCases/Architecture/ForbiddenAccessorsRuleSettersOnlyTest.php new file mode 100644 index 0000000..2f3dce2 --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenAccessorsRuleSettersOnlyTest.php @@ -0,0 +1,38 @@ + + */ +class ForbiddenAccessorsRuleSettersOnlyTest extends RuleTestCase +{ + protected function getRule(): ForbiddenAccessorsRule + { + return new ForbiddenAccessorsRule( + classPatterns: ['/\\\\Domain\\\\.*Entity$/'], + forbidGetters: false, + forbidSetters: true, + visibility: ['public'] + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/EntityWithAccessors.php'], [ + [ + 'Class App\Domain\UserEntity must not have a public setter method setName().', + 16, + ], + [ + 'Class App\Domain\UserEntity must not have a public setter method setAge().', + 26, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/ForbiddenAccessorsRuleTest.php b/tests/TestCases/Architecture/ForbiddenAccessorsRuleTest.php new file mode 100644 index 0000000..84e5514 --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenAccessorsRuleTest.php @@ -0,0 +1,57 @@ + + */ +class ForbiddenAccessorsRuleTest extends RuleTestCase +{ + protected function getRule(): ForbiddenAccessorsRule + { + return new ForbiddenAccessorsRule( + classPatterns: ['/\\\\Domain\\\\.*Entity$/'], + forbidGetters: true, + forbidSetters: true, + visibility: ['public'] + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/EntityWithAccessors.php'], [ + [ + 'Class App\Domain\UserEntity must not have a public getter method getName().', + 11, + ], + [ + 'Class App\Domain\UserEntity must not have a public setter method setName().', + 16, + ], + [ + 'Class App\Domain\UserEntity must not have a public getter method getAge().', + 21, + ], + [ + 'Class App\Domain\UserEntity must not have a public setter method setAge().', + 26, + ], + ]); + } + + public function testRuleDoesNotMatchNonEntityClasses(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/ServiceWithAccessors.php'], []); + } + + public function testAnonymousClassIsSkipped(): void + { + // Anonymous classes should be skipped (resolveFullClassName returns null) + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/AnonymousClassWithAccessors.php'], []); + } +} diff --git a/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleSelfStaticParentTest.php b/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleSelfStaticParentTest.php new file mode 100644 index 0000000..842a33f --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleSelfStaticParentTest.php @@ -0,0 +1,46 @@ + + */ +class ForbiddenStaticMethodsRuleSelfStaticParentTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new ForbiddenStaticMethodsRule([ + '/^App\\\\Forbidden\\\\ForbiddenService::.*/', + ]); + } + + public function testSelfAndStaticCalls(): void + { + $this->analyse([__DIR__ . '/../../../data/Forbidden/ForbiddenService.php'], [ + [ + 'Static method call "App\Forbidden\ForbiddenService::create" is forbidden.', + 16, + ], + [ + 'Static method call "App\Forbidden\ForbiddenService::create" is forbidden.', + 21, + ], + ]); + } + + public function testParentCall(): void + { + $this->analyse([__DIR__ . '/../../../data/Forbidden/ChildService.php'], [ + [ + 'Static method call "App\Forbidden\ForbiddenService::create" is forbidden.', + 11, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleTest.php b/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleTest.php index 0648b67..20e4cbf 100644 --- a/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleTest.php +++ b/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleTest.php @@ -65,4 +65,10 @@ public function testAllowedMethodOnPartiallyForbiddenClass(): void // DateTime::getLastErrors is allowed, only createFromFormat is forbidden $this->analyse([__DIR__ . '/../../../data/ForbiddenStaticMethods/AllowedMethodOnForbiddenClass.php'], []); } + + public function testDynamicCallsAreSkipped(): void + { + // Dynamic method names ($method) and dynamic class names ($class) should be skipped + $this->analyse([__DIR__ . '/../../../data/ForbiddenStaticMethods/DynamicCall.php'], []); + } } diff --git a/tests/TestCases/Architecture/MethodMustReturnTypeRuleEdgeCasesTest.php b/tests/TestCases/Architecture/MethodMustReturnTypeRuleEdgeCasesTest.php new file mode 100644 index 0000000..7a04cd9 --- /dev/null +++ b/tests/TestCases/Architecture/MethodMustReturnTypeRuleEdgeCasesTest.php @@ -0,0 +1,106 @@ + + */ +class MethodMustReturnTypeRuleEdgeCasesTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new MethodMustReturnTypeRule([ + [ + 'pattern' => '/^EdgeCaseTestClass::noReturnTypeWithType$/', + 'type' => 'int', + ], + [ + 'pattern' => '/^EdgeCaseTestClass::noReturnTypeWithOneOf$/', + 'oneOf' => ['int', 'string'], + ], + [ + 'pattern' => '/^EdgeCaseTestClass::noReturnTypeWithAllOf$/', + 'allOf' => ['int', 'string'], + ], + [ + 'pattern' => '/^EdgeCaseTestClass::objectReturnsInt$/', + 'type' => 'object', + ], + [ + 'pattern' => '/^EdgeCaseTestClass::anyOfInvalid$/', + 'anyOf' => ['int', 'string'], + ], + [ + 'pattern' => '/^EdgeCaseTestClass::anyOfValid$/', + 'anyOf' => ['int', 'string'], + ], + [ + 'pattern' => '/^EdgeCaseTestClass::regexTypeValid$/', + 'oneOf' => ['regex:/^Some.*Object$/', 'int'], + ], + [ + 'pattern' => '/^EdgeCaseTestClass::regexTypeInvalid$/', + 'oneOf' => ['regex:/^Some.*Object$/', 'int'], + ], + [ + 'pattern' => '/^EdgeCaseTestClass::validInt$/', + 'type' => 'int', + ], + [ + 'pattern' => '/^EdgeCaseTestClass::validNullableString$/', + 'type' => 'string', + 'nullable' => true, + ], + [ + 'pattern' => '/^EdgeCaseTestClass::validVoid$/', + 'void' => true, + ], + [ + 'pattern' => '/^EdgeCaseTestClass::validObject$/', + 'type' => 'object', + 'objectTypePattern' => '/^SomeEdgeCaseObject$/', + ], + [ + 'pattern' => '/^EdgeCaseTestClass::validNullableObject$/', + 'type' => 'object', + 'nullable' => true, + ], + ]); + } + + public function testEdgeCases(): void + { + $this->analyse([__DIR__ . '/../../../data/MethodMustReturnType/EdgeCaseTestClass.php'], [ + [ + 'Method EdgeCaseTestClass::noReturnTypeWithType must have a return type of int.', + 5, + ], + [ + 'Method EdgeCaseTestClass::noReturnTypeWithOneOf must have a return type of one of: int, string.', + 7, + ], + [ + 'Method EdgeCaseTestClass::noReturnTypeWithAllOf must have a return type of all of: int, string.', + 9, + ], + [ + 'Method EdgeCaseTestClass::objectReturnsInt must return an object type.', + 11, + ], + [ + 'Method EdgeCaseTestClass::anyOfInvalid must have one of the return types: int, string, float given.', + 13, + ], + [ + 'Method EdgeCaseTestClass::regexTypeInvalid must have one of the return types: regex:/^Some.*Object$/, int, float given.', + 19, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/MethodSignatureMustMatchRuleRequiredTest.php b/tests/TestCases/Architecture/MethodSignatureMustMatchRuleRequiredTest.php index bd33e69..1b99fbb 100644 --- a/tests/TestCases/Architecture/MethodSignatureMustMatchRuleRequiredTest.php +++ b/tests/TestCases/Architecture/MethodSignatureMustMatchRuleRequiredTest.php @@ -34,14 +34,14 @@ public function testRequiredMethodRule(): void $this->analyse([__DIR__ . '/../../../data/MethodSignatureMustMatch/RequiredMethodTestClass.php'], [ // MyTestController is missing the required execute method [ - 'Class MyTestController must implement method execute with signature: public function execute(int $param1).', + 'Class Phauthentic\PHPStanRules\Tests\Data\MethodSignatureMustMatch\MyTestController must implement method execute with signature: public function execute(int $param1).', 8, ], // AnotherTestController implements the method correctly - no error expected // YetAnotherTestController is missing the required execute method [ - 'Class YetAnotherTestController must implement method execute with signature: public function execute(int $param1).', + 'Class Phauthentic\PHPStanRules\Tests\Data\MethodSignatureMustMatch\YetAnotherTestController must implement method execute with signature: public function execute(int $param1).', 24, ], // NotAController doesn't match the pattern - no error expected diff --git a/tests/TestCases/Architecture/MethodsReturningBoolMustFollowNamingConventionEdgeCasesTest.php b/tests/TestCases/Architecture/MethodsReturningBoolMustFollowNamingConventionEdgeCasesTest.php new file mode 100644 index 0000000..3778b31 --- /dev/null +++ b/tests/TestCases/Architecture/MethodsReturningBoolMustFollowNamingConventionEdgeCasesTest.php @@ -0,0 +1,30 @@ + + */ +class MethodsReturningBoolMustFollowNamingConventionEdgeCasesTest extends RuleTestCase +{ + protected function getRule(): MethodsReturningBoolMustFollowNamingConventionRule + { + return new MethodsReturningBoolMustFollowNamingConventionRule(); + } + + public function testMagicMethodsAndNoReturnTypeAreSkipped(): void + { + $this->analyse([__DIR__ . '/../../../data/BoolNaming/EdgeCaseMethodBoolClass.php'], [ + [ + 'Method App\BoolNaming\EdgeCaseMethodBoolClass::check() returns boolean but does not follow naming convention (regex: /^(is|has|can|should|was|will)[A-Z_]/).', + 26, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleExceptionsTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleExceptionsTest.php new file mode 100644 index 0000000..3d44f34 --- /dev/null +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleExceptionsTest.php @@ -0,0 +1,18 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one property pattern must be provided.'); + new PropertyMustMatchRule([]); + } +} diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleIntersectionTypeTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleIntersectionTypeTest.php new file mode 100644 index 0000000..c8807ac --- /dev/null +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleIntersectionTypeTest.php @@ -0,0 +1,36 @@ + + */ +class PropertyMustMatchRuleIntersectionTypeTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new PropertyMustMatchRule([ + [ + 'classPattern' => '/^.*Command$/', + 'properties' => [ + [ + 'name' => 'collection', + 'type' => 'Countable&Iterator', + 'visibilityScope' => 'private', + ], + ], + ], + ]); + } + + public function testIntersectionTypePropertyMatches(): void + { + $this->analyse([__DIR__ . '/../../../data/PropertyMustMatch/TestClass.php'], []); + } +} diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleInvalidRegexTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleInvalidRegexTest.php new file mode 100644 index 0000000..de6cd39 --- /dev/null +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleInvalidRegexTest.php @@ -0,0 +1,33 @@ + + */ +class PropertyMustMatchRuleInvalidRegexTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new PropertyMustMatchRule([ + [ + 'classPattern' => '/[invalid/', + 'properties' => [ + ['name' => 'id', 'type' => 'int'], + ], + ], + ]); + } + + public function testInvalidRegexThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->analyse([__DIR__ . '/../../../data/PropertyMustMatch/TestClass.php'], []); + } +} diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleInvalidVisibilityTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleInvalidVisibilityTest.php new file mode 100644 index 0000000..50ce555 --- /dev/null +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleInvalidVisibilityTest.php @@ -0,0 +1,37 @@ + + */ +class PropertyMustMatchRuleInvalidVisibilityTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new PropertyMustMatchRule([ + [ + 'classPattern' => '/^.*Controller$/', + 'properties' => [ + [ + 'name' => 'id', + 'visibilityScope' => 'invalid', + ], + ], + ], + ]); + } + + public function testInvalidVisibilityScopeThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid visibilityScope "invalid"'); + $this->analyse([__DIR__ . '/../../../data/PropertyMustMatch/TestClass.php'], []); + } +} diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleNullableTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleNullableTest.php new file mode 100644 index 0000000..1aff146 --- /dev/null +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleNullableTest.php @@ -0,0 +1,43 @@ + + */ +class PropertyMustMatchRuleNullableTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new PropertyMustMatchRule([ + [ + 'classPattern' => '/^.*Handler$/', + 'properties' => [ + [ + 'name' => 'id', + 'type' => 'int', + 'visibilityScope' => 'private', + 'nullable' => true, + ], + ], + ], + ]); + } + + public function testNullableFlag(): void + { + $this->analyse([__DIR__ . '/../../../data/PropertyMustMatch/TestClass.php'], [ + // WrongTypeAllowedHandler - wrong type entirely (string instead of int or ?int) + [ + 'Property App\PropertyMustMatch\WrongTypeAllowedHandler::$id should be of type int or ?int, string given.', + 100, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleTest.php new file mode 100644 index 0000000..192371d --- /dev/null +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleTest.php @@ -0,0 +1,105 @@ + + */ +class PropertyMustMatchRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new PropertyMustMatchRule([ + [ + 'classPattern' => '/^.*Controller$/', + 'properties' => [ + [ + 'name' => 'id', + 'type' => 'int', + 'visibilityScope' => 'private', + 'required' => true, + ], + [ + 'name' => 'repository', + 'type' => 'App\PropertyMustMatch\DummyRepository', + 'visibilityScope' => 'private', + 'required' => true, + ], + ], + ], + [ + 'classPattern' => '/^.*Service$/', + 'properties' => [ + [ + 'name' => 'logger', + 'type' => 'App\PropertyMustMatch\LoggerInterface', + 'visibilityScope' => 'private', + 'required' => false, + ], + ], + ], + ]); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/../../../data/PropertyMustMatch/TestClass.php'], [ + // MissingPropertyController - missing required property 'id' + [ + 'Class App\PropertyMustMatch\MissingPropertyController must have property $id.', + 19, + ], + + // WrongTypeController - wrong type for 'id' (string instead of int) + [ + 'Property App\PropertyMustMatch\WrongTypeController::$id should be of type int, string given.', + 27, + ], + + // WrongVisibilityController - wrong visibility for 'id' (public instead of private) + [ + 'Property App\PropertyMustMatch\WrongVisibilityController::$id must be private.', + 34, + ], + + // MultipleErrorsController - wrong type and wrong visibility for 'id' + [ + 'Property App\PropertyMustMatch\MultipleErrorsController::$id should be of type int, string given.', + 41, + ], + [ + 'Property App\PropertyMustMatch\MultipleErrorsController::$id must be private.', + 41, + ], + // MultipleErrorsController - wrong visibility for 'repository' (protected instead of private) + [ + 'Property App\PropertyMustMatch\MultipleErrorsController::$repository must be private.', + 42, + ], + + // NoTypeController - missing type on 'id' property + [ + 'Property App\PropertyMustMatch\NoTypeController::$id should be of type int, none given.', + 48, + ], + + // NullableTypeController - nullable type doesn't match expected 'int' + [ + 'Property App\PropertyMustMatch\NullableTypeController::$id should be of type int, ?int given.', + 55, + ], + + // WrongLoggerTypeService - wrong type for optional 'logger' property + [ + 'Property App\PropertyMustMatch\WrongLoggerTypeService::$logger should be of type App\PropertyMustMatch\LoggerInterface, string given.', + 74, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/RegexAllOfRuleTest.php b/tests/TestCases/Architecture/RegexAllOfRuleTest.php index 8ca1a0a..91bd315 100644 --- a/tests/TestCases/Architecture/RegexAllOfRuleTest.php +++ b/tests/TestCases/Architecture/RegexAllOfRuleTest.php @@ -37,21 +37,16 @@ protected function getRule(): Rule public function testRule(): void { + // With the improved getTypeAsString() from ClassNameResolver trait, + // union types are now properly parsed, so the valid cases pass. + // Only the invalid cases (missing required types) should report errors. $this->analyse([__DIR__ . '/../../../data/MethodMustReturnType/RegexAllOfTestClass.php'], [ [ - 'Method RegexAllOfTestClass::validUnionWithUser must have a return type of all of: regex:/^UserEntity$/, int.', - 6, - ], - [ - 'Method RegexAllOfTestClass::validUnionWithProduct must have a return type of all of: regex:/^ProductEntity$/, string.', - 7, - ], - [ - 'Method RegexAllOfTestClass::invalidUnionMissingUser must have a return type of all of: regex:/^UserEntity$/, int.', + 'Method RegexAllOfTestClass::invalidUnionMissingUser must have all of the return types: regex:/^UserEntity$/, int, OtherClass|int given.', 10, ], [ - 'Method RegexAllOfTestClass::invalidUnionMissingProduct must have a return type of all of: regex:/^ProductEntity$/, string.', + 'Method RegexAllOfTestClass::invalidUnionMissingProduct must have all of the return types: regex:/^ProductEntity$/, string, UserEntity|OtherClass given.', 11, ], ]);