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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Select a rule above to start configuring
+
+
+
+
+
+
+
+
+
+
+ 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,
],
]);