diff --git a/core.neon b/core.neon index ac84f65..5978d85 100644 --- a/core.neon +++ b/core.neon @@ -1,7 +1,8 @@ # PHPStan Extension for OpenEMR Core # # This configuration includes rules for OpenEMR core development: -# - Database: Forbid legacy SQL functions and Laminas-DB +# - Database: Forbid legacy SQL functions, methods, and Laminas-DB +# - Http: Forbid raw curl_* functions # - Globals: Forbid direct $GLOBALS access # - Testing: Forbid @covers annotations # @@ -23,6 +24,19 @@ services: tags: - phpstan.rules.rule + - class: OpenCoreEMR\PHPStan\Rules\Database\ForbiddenMethodsRule + tags: + - phpstan.rules.rule + + - class: OpenCoreEMR\PHPStan\Rules\Database\ForbiddenStaticMethodsRule + tags: + - phpstan.rules.rule + + # Http Rules + - class: OpenCoreEMR\PHPStan\Rules\Http\ForbiddenCurlFunctionsRule + tags: + - phpstan.rules.rule + # Globals Rules - class: OpenCoreEMR\PHPStan\Rules\Globals\ForbiddenGlobalsAccessRule tags: diff --git a/src/Rules/Database/ForbiddenMethodsRule.php b/src/Rules/Database/ForbiddenMethodsRule.php new file mode 100644 index 0000000..c06e5d7 --- /dev/null +++ b/src/Rules/Database/ForbiddenMethodsRule.php @@ -0,0 +1,75 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license https://github.com/openCoreEMR/openemr-phpstan-rules/blob/main/LICENSE GNU General Public License 3 + */ + +namespace OpenCoreEMR\PHPStan\Rules\Database; + +use PhpParser\Node; +use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Identifier; +use PHPStan\Analyser\Scope; +use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; + +/** + * @implements Rule + */ +class ForbiddenMethodsRule implements Rule +{ + /** + * Map of forbidden methods to their error messages + * + * (Ideally, these would be scoped to a specific class/interface, but most are + * targeting globals lacking sufficient type info) + */ + private const FORBIDDEN_METHODS = [ + 'GenID' => 'Use QueryUtils::generateId() or QueryUtils::ediGenerateId() instead.', + ]; + + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * @param MethodCall $node + * @return array<\PHPStan\Rules\RuleError> + */ + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Identifier)) { + return []; + } + + $methodName = $node->name->toString(); + + // Only check if it's a forbidden method + if (!isset(self::FORBIDDEN_METHODS[$methodName])) { + return []; + } + + $message = sprintf( + '%s() is deprecated. %s', + $methodName, + self::FORBIDDEN_METHODS[$methodName], + ); + + return [ + RuleErrorBuilder::message($message) + ->identifier('openemr.deprecatedMethod') + ->tip('Or use DatabaseQueryTrait in your class') + ->build() + ]; + } +} diff --git a/src/Rules/Database/ForbiddenStaticMethodsRule.php b/src/Rules/Database/ForbiddenStaticMethodsRule.php new file mode 100644 index 0000000..8f3ed34 --- /dev/null +++ b/src/Rules/Database/ForbiddenStaticMethodsRule.php @@ -0,0 +1,90 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license https://github.com/openCoreEMR/openemr-phpstan-rules/blob/main/LICENSE GNU General Public License 3 + */ + +namespace OpenCoreEMR\PHPStan\Rules\Database; + +use PhpParser\Node; +use PhpParser\Node\Expr\StaticCall; +use PhpParser\Node\Identifier; +use PhpParser\Node\Name; +use PHPStan\Analyser\Scope; +use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; + +/** + * @implements Rule + */ +class ForbiddenStaticMethodsRule implements Rule +{ + /** + * Map of forbidden classes and static methods to their error messages + */ + private const FORBIDDEN_METHODS = [ + 'OpenEMR\\Common\\Database\\QueryUtils' => [ + 'startTransaction' => 'Use QueryUtils::inTransaction() wrapper instead.', + 'commitTransaction' => 'Use QueryUtils::inTransaction() wrapper instead.', + 'rollbackTransaction' => 'Use QueryUtils::inTransaction() wrapper instead.', + ], + ]; + + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * @param StaticCall $node + * @return array<\PHPStan\Rules\RuleError> + */ + public function processNode(Node $node, Scope $scope): array + { + // Method name must be an Identifier (not a dynamic call) + if (!($node->name instanceof Identifier)) { + return []; + } + + // Class must be a Name (not a dynamic class) + if (!($node->class instanceof Name)) { + return []; + } + + $className = $node->class->toString(); + $methodName = $node->name->toString(); + + // Check if the class has any forbidden methods + if (!array_key_exists($className, self::FORBIDDEN_METHODS)) { + return []; + } + + // If it does, check if the actual call is one of them + $forbiddenClassMethods = self::FORBIDDEN_METHODS[$className]; + if (!array_key_exists($methodName, $forbiddenClassMethods)) { + return []; + } + + $message = sprintf( + '%s::%s() is deprecated. %s', + $className, + $methodName, + $forbiddenClassMethods[$methodName], + ); + + return [ + RuleErrorBuilder::message($message) + ->identifier('openemr.deprecatedStaticMethod') + ->build() + ]; + } +} diff --git a/src/Rules/Http/ForbiddenCurlFunctionsRule.php b/src/Rules/Http/ForbiddenCurlFunctionsRule.php new file mode 100644 index 0000000..341217f --- /dev/null +++ b/src/Rules/Http/ForbiddenCurlFunctionsRule.php @@ -0,0 +1,69 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license https://github.com/openCoreEMR/openemr-phpstan-rules/blob/main/LICENSE GNU General Public License 3 + */ + +namespace OpenCoreEMR\PHPStan\Rules\Http; + +use PhpParser\Node; +use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Name; +use PHPStan\Analyser\Scope; +use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; + +/** + * @implements Rule + */ +class ForbiddenCurlFunctionsRule implements Rule +{ + /** + * Pattern to match all curl_* functions + */ + private const CURL_FUNCTION_PATTERN = '/^curl_/i'; + + public function getNodeType(): string + { + return FuncCall::class; + } + + /** + * @param FuncCall $node + * @return array<\PHPStan\Rules\RuleError> + */ + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Name)) { + return []; + } + + $functionName = $node->name->toString(); + + // Check if it's a curl_* function + if (!preg_match(self::CURL_FUNCTION_PATTERN, $functionName)) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf( + 'Raw curl function %s() is forbidden. Use a PSR-18 HTTP client instead.', + $functionName + ) + ) + ->identifier('openemr.forbiddenCurlFunction') + ->tip('See https://www.php-fig.org/psr/psr-18/ for PSR-18 HTTP Client information') + ->build() + ]; + } +} diff --git a/tests/Rules/Database/ForbiddenMethodsRuleTest.php b/tests/Rules/Database/ForbiddenMethodsRuleTest.php new file mode 100644 index 0000000..bf6c703 --- /dev/null +++ b/tests/Rules/Database/ForbiddenMethodsRuleTest.php @@ -0,0 +1,45 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license https://github.com/openCoreEMR/openemr-phpstan-rules/blob/main/LICENSE GNU General Public License 3 + */ + +namespace OpenCoreEMR\PHPStan\Tests\Rules\Database; + +use OpenCoreEMR\PHPStan\Rules\Database\ForbiddenMethodsRule; +use PHPStan\Rules\Rule; +use PHPStan\Testing\RuleTestCase; + +/** + * @extends RuleTestCase + */ +class ForbiddenMethodsRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new ForbiddenMethodsRule(); + } + + public function testForbiddenMethods(): void + { + $tip = 'Or use DatabaseQueryTrait in your class'; + $this->analyse([__DIR__ . '/data/forbidden-methods.php'], [ + [ + 'GenID() is deprecated. Use QueryUtils::generateId() or QueryUtils::ediGenerateId() instead.', + 14, + $tip, + ], + ]); + } + + public function testAllowedMethods(): void + { + $this->analyse([__DIR__ . '/data/allowed-methods.php'], []); + } +} diff --git a/tests/Rules/Database/ForbiddenStaticMethodsRuleTest.php b/tests/Rules/Database/ForbiddenStaticMethodsRuleTest.php new file mode 100644 index 0000000..10c9ee0 --- /dev/null +++ b/tests/Rules/Database/ForbiddenStaticMethodsRuleTest.php @@ -0,0 +1,51 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license https://github.com/openCoreEMR/openemr-phpstan-rules/blob/main/LICENSE GNU General Public License 3 + */ + +namespace OpenCoreEMR\PHPStan\Tests\Rules\Database; + +use OpenCoreEMR\PHPStan\Rules\Database\ForbiddenStaticMethodsRule; +use PHPStan\Rules\Rule; +use PHPStan\Testing\RuleTestCase; + +/** + * @extends RuleTestCase + */ +class ForbiddenStaticMethodsRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new ForbiddenStaticMethodsRule(); + } + + public function testForbiddenStaticMethods(): void + { + $this->analyse([__DIR__ . '/data/forbidden-static-methods.php'], [ + [ + 'OpenEMR\Common\Database\QueryUtils::startTransaction() is deprecated. Use QueryUtils::inTransaction() wrapper instead.', + 28, + ], + [ + 'OpenEMR\Common\Database\QueryUtils::commitTransaction() is deprecated. Use QueryUtils::inTransaction() wrapper instead.', + 29, + ], + [ + 'OpenEMR\Common\Database\QueryUtils::rollbackTransaction() is deprecated. Use QueryUtils::inTransaction() wrapper instead.', + 30, + ], + ]); + } + + public function testAllowedStaticMethods(): void + { + $this->analyse([__DIR__ . '/data/allowed-static-methods.php'], []); + } +} diff --git a/tests/Rules/Database/data/allowed-methods.php b/tests/Rules/Database/data/allowed-methods.php new file mode 100644 index 0000000..82ced67 --- /dev/null +++ b/tests/Rules/Database/data/allowed-methods.php @@ -0,0 +1,20 @@ +generateId(); +$id = $service->getId(); diff --git a/tests/Rules/Database/data/allowed-static-methods.php b/tests/Rules/Database/data/allowed-static-methods.php new file mode 100644 index 0000000..57f8da3 --- /dev/null +++ b/tests/Rules/Database/data/allowed-static-methods.php @@ -0,0 +1,35 @@ +GenID('users'); diff --git a/tests/Rules/Database/data/forbidden-static-methods.php b/tests/Rules/Database/data/forbidden-static-methods.php new file mode 100644 index 0000000..5d7d1d8 --- /dev/null +++ b/tests/Rules/Database/data/forbidden-static-methods.php @@ -0,0 +1,30 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license https://github.com/openCoreEMR/openemr-phpstan-rules/blob/main/LICENSE GNU General Public License 3 + */ + +namespace OpenCoreEMR\PHPStan\Tests\Rules\Http; + +use OpenCoreEMR\PHPStan\Rules\Http\ForbiddenCurlFunctionsRule; +use PHPStan\Rules\Rule; +use PHPStan\Testing\RuleTestCase; + +/** + * @extends RuleTestCase + */ +class ForbiddenCurlFunctionsRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new ForbiddenCurlFunctionsRule(); + } + + public function testForbiddenCurlFunctions(): void + { + $tip = 'See https://www.php-fig.org/psr/psr-18/ for PSR-18 HTTP Client information'; + $this->analyse([__DIR__ . '/data/forbidden-curl-functions.php'], [ + [ + 'Raw curl function curl_init() is forbidden. Use a PSR-18 HTTP client instead.', + 5, + $tip, + ], + [ + 'Raw curl function curl_setopt() is forbidden. Use a PSR-18 HTTP client instead.', + 6, + $tip, + ], + [ + 'Raw curl function curl_exec() is forbidden. Use a PSR-18 HTTP client instead.', + 7, + $tip, + ], + [ + 'Raw curl function curl_error() is forbidden. Use a PSR-18 HTTP client instead.', + 8, + $tip, + ], + [ + 'Raw curl function curl_getinfo() is forbidden. Use a PSR-18 HTTP client instead.', + 9, + $tip, + ], + [ + 'Raw curl function curl_close() is forbidden. Use a PSR-18 HTTP client instead.', + 10, + $tip, + ], + ]); + } + + public function testAllowedHttpFunctions(): void + { + $this->analyse([__DIR__ . '/data/allowed-http-functions.php'], []); + } +} diff --git a/tests/Rules/Http/data/allowed-http-functions.php b/tests/Rules/Http/data/allowed-http-functions.php new file mode 100644 index 0000000..a2c6194 --- /dev/null +++ b/tests/Rules/Http/data/allowed-http-functions.php @@ -0,0 +1,25 @@ +get('https://example.com'); diff --git a/tests/Rules/Http/data/forbidden-curl-functions.php b/tests/Rules/Http/data/forbidden-curl-functions.php new file mode 100644 index 0000000..0d81a9d --- /dev/null +++ b/tests/Rules/Http/data/forbidden-curl-functions.php @@ -0,0 +1,10 @@ +