diff --git a/config/phpstan.services.neon b/config/phpstan.services.neon index bfbdd2d..af41192 100644 --- a/config/phpstan.services.neon +++ b/config/phpstan.services.neon @@ -23,3 +23,8 @@ services: class: \FiveLab\Component\CiRules\PhpStan\ForbiddenPassArgumentAsReferenceRule arguments: [ PhpParser\Node\Stmt\ClassMethod ] tags: [ phpstan.rules.rule ] + + - + class: \FiveLab\Component\CiRules\PhpStan\MethodCallConsistencyRule + arguments: [ @reflectionProvider ] + tags: [ phpstan.rules.rule ] diff --git a/src/PhpCs/FiveLab/ErrorCodes.php b/src/PhpCs/FiveLab/ErrorCodes.php index 2ae7d88..2295eb2 100644 --- a/src/PhpCs/FiveLab/ErrorCodes.php +++ b/src/PhpCs/FiveLab/ErrorCodes.php @@ -30,4 +30,5 @@ final class ErrorCodes public const LINE_AFTER_NOT_ALLOWED = 'LineAfterNotAllowed'; public const LINE_BEFORE_NOT_ALLOWED = 'LineBeforeNotAllowed'; public const MISSED_CONSTANT_TYPE = 'MissedConstantType'; + public const NAMESPACE_WRONG = 'NamespaceWrong'; } diff --git a/src/PhpCs/FiveLab/Sniffs/Namespace/NamespaceSniff.php b/src/PhpCs/FiveLab/Sniffs/Namespace/NamespaceSniff.php new file mode 100644 index 0000000..728884c --- /dev/null +++ b/src/PhpCs/FiveLab/Sniffs/Namespace/NamespaceSniff.php @@ -0,0 +1,120 @@ +getTokens(); + $endToken = $phpcsFile->findNext(T_SEMICOLON, $stackPtr); + + if (false === $endToken) { + return; + } + + $declaredNamespace = ''; + + for ($i = $stackPtr + 1; $i < $endToken; $i++) { + $declaredNamespace .= $tokens[$i]['content']; + } + + $expectedNamespace = $this->getExpectedNamespace($phpcsFile); + + if (\trim($declaredNamespace) !== $expectedNamespace) { + $phpcsFile->addError( + 'Expected namespace "%s", found "%s".', + $stackPtr, + ErrorCodes::NAMESPACE_WRONG, + [$expectedNamespace, $declaredNamespace] + ); + } + } + + private function getExpectedNamespace(File $phpcsFile): ?string + { + $composerJsonPath = $this->findProjectComposerJson($phpcsFile); + $composerJson = $this->loadComposerJson($composerJsonPath); + + $psr4 = \array_merge( + $composerJson['autoload']['psr-4'] ?? [], + $composerJson['autoload-dev']['psr-4'] ?? [] + ); + + foreach ($psr4 as $namespace => $directory) { + $normalizedDirectory = \realpath(\dirname($composerJsonPath).'/'.$directory); + $normalizedFilePath = \realpath($phpcsFile->path); + + if ($normalizedDirectory && $normalizedFilePath && \str_starts_with($normalizedFilePath, $normalizedDirectory)) { + $relativePath = \substr($normalizedFilePath, \strlen($normalizedDirectory) + 1); + $expectedNamespace = \rtrim($namespace, '\\').'\\'.\str_replace('/', '\\', \dirname($relativePath)); + + return \trim($expectedNamespace, '\\, .'); + } + } + + return null; + } + + /** + * Load composer.json + * + * @param string $composerJsonPath + * + * @return array + * @throws \JsonException + */ + private function loadComposerJson(string $composerJsonPath): array + { + if (!\file_exists($composerJsonPath)) { + throw new \RuntimeException(\sprintf('composer.json not found at "%s".', $composerJsonPath)); + } + + $content = \file_get_contents($composerJsonPath); + + $decoded = $content ? \json_decode($content, true, flags: JSON_THROW_ON_ERROR) : null; + + if (!\is_array($decoded)) { + throw new \RuntimeException(\sprintf('Invalid composer.json format at "%s".', $composerJsonPath)); + } + + return $decoded; + } + + private function findProjectComposerJson(File $phpcsFile): string + { + $currentDir = \dirname($phpcsFile->getFilename()); + + while ($currentDir !== \dirname($currentDir)) { + $composerJsonPath = $currentDir.'/composer.json'; + + if (\file_exists($composerJsonPath)) { + return $composerJsonPath; + } + + $currentDir = \dirname($currentDir); + } + + throw new \RuntimeException('composer.json not found in the project root.'); + } +} diff --git a/src/PhpCs/FiveLab/Sniffs/Strings/AsciiSniff.php b/src/PhpCs/FiveLab/Sniffs/Strings/AsciiSniff.php new file mode 100644 index 0000000..a0c1e65 --- /dev/null +++ b/src/PhpCs/FiveLab/Sniffs/Strings/AsciiSniff.php @@ -0,0 +1,60 @@ +getTokens(); + $content = $tokens[$stackPtr]['content']; + $forbiddenSymbols = []; + + foreach (\mb_str_split($content) as $char) { + $ascii = \ord($char); + + if (10 !== $ascii && (32 > $ascii || 127 < $ascii)) { + $forbiddenSymbols[] = $ascii; + } + } + + if ($forbiddenSymbols) { + $phpcsFile->addError( + 'Use not ASCII printable symbols is forbidden: "%s"', + $stackPtr, + ErrorCodes::PROHIBITED, + [\implode(', ', $forbiddenSymbols)] + ); + } + } +} diff --git a/src/PhpStan/MethodCallConsistencyRule.php b/src/PhpStan/MethodCallConsistencyRule.php new file mode 100644 index 0000000..438f390 --- /dev/null +++ b/src/PhpStan/MethodCallConsistencyRule.php @@ -0,0 +1,209 @@ + + */ +readonly class MethodCallConsistencyRule implements Rule +{ + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Node\Expr::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node instanceof Node\Expr\StaticCall) { + return $this->checkStaticCall($node, $scope); + } + + if ($node instanceof Node\Expr\MethodCall) { + return $this->checkInstanceCall($node, $scope); + } + + return []; + } + + /** + * Check static method call + * + * @param Node\Expr\StaticCall $node + * @param Scope $scope + * + * @return list + * @throws ShouldNotHappenException + */ + private function checkStaticCall(Node\Expr\StaticCall $node, Scope $scope): array + { + $className = $this->resolveClassNameForStaticCall($node, $scope); + + if (!$className) { + return []; + } + + $methodReflection = $this->getMethodReflection($node, $className, $scope); + + if (!$methodReflection?->isStatic()) { + return [ + RuleErrorBuilder::message(\sprintf( + 'Method "%s::%s" is not static but called statically.', + $className, + $methodReflection?->getName() + )) + ->identifier('methodCall.consistency') + ->build(), + ]; + } + + return []; + } + + /** + * Check instance method call + * + * @param Node\Expr\MethodCall $node + * @param Scope $scope + * + * @return list + * @throws ShouldNotHappenException + */ + private function checkInstanceCall(Node\Expr\MethodCall $node, Scope $scope): array + { + $className = $this->resolveClassNameForInstanceCall($node, $scope); + + if (!$className) { + return []; + } + + $methodReflection = $this->getMethodReflection($node, $className, $scope); + + if ($methodReflection?->isStatic()) { + return [ + RuleErrorBuilder::message(\sprintf( + 'Method "%s->%s" is static but called dynamically.', + $className, + $methodReflection->getName() + )) + ->identifier('methodCall.consistency') + ->build(), + ]; + } + + return []; + } + + private function resolveClassNameForStaticCall(Node\Expr\StaticCall $node, Scope $scope): ?string + { + if ($node->class instanceof Node\Name && 'parent' === $node->class->toString()) { + return null; + } + + // self::method() + if ($node->class instanceof Node\Name && 'self' === $node->class->toString()) { + return $scope->getClassReflection()?->getName(); + } + + // ClassName::method() + if ($node->class instanceof Node\Name) { + return $scope->resolveName($node->class); + } + + // $this->property::method() + if ($node->class instanceof Node\Expr\PropertyFetch) { + $type = $scope->getType($node->class); + + if (\method_exists($type, 'getClassName') && $type->isObject()->yes()) { + return $type->getClassName(); + } + } + + // $var::method() + if ($node->class instanceof Node\Expr\Variable) { + $variableName = $node->class->name; + + if (\is_string($variableName)) { + $type = $scope->getType(new Node\Expr\Variable($variableName)); + + if (\method_exists($type, 'getClassName') && $type->isObject()->yes()) { + return $type->getClassName(); + } + } + } + + return null; + } + + private function resolveClassNameForInstanceCall(Node\Expr\MethodCall $node, Scope $scope): ?string + { + // $this->method() + if ($node->var instanceof Node\Expr\Variable && $node->var->name === 'this') { + return $scope->getClassReflection()?->getName(); + } + + // $this->property->method() + if ($node->var instanceof Node\Expr\PropertyFetch) { + $type = $scope->getType($node->var); + + if (\method_exists($type, 'getClassName') && $type->isObject()->yes()) { + return $type->getClassName(); + } + } + + // $var->method() + if ($node->var instanceof Node\Expr\Variable) { + $variableName = $node->var->name; + + if (\is_string($variableName)) { + $type = $scope->getType(new Node\Expr\Variable($variableName)); + + if (\method_exists($type, 'getClassName') && $type->isObject()->yes()) { + return $type->getClassName(); + } + } + } + + return null; + } + + private function getMethodReflection(Node\Expr\StaticCall|Node\Expr\MethodCall $node, string $className, Scope $scope): ?ExtendedMethodReflection + { + $methodName = $node->name instanceof Node\Identifier ? $node->name->name : null; + + if (!$className || !$methodName) { + return null; + } + + $classReflection = $this->reflectionProvider->getClass($className); + + if (!$classReflection->hasMethod($methodName)) { + return null; + } + + return $classReflection->getMethod($methodName, $scope); + } +} diff --git a/tests/PhpCs/FiveLab/Sniffs/Namespace/NamespaceSniffTest.php b/tests/PhpCs/FiveLab/Sniffs/Namespace/NamespaceSniffTest.php new file mode 100644 index 0000000..f918f7d --- /dev/null +++ b/tests/PhpCs/FiveLab/Sniffs/Namespace/NamespaceSniffTest.php @@ -0,0 +1,38 @@ + [ + __DIR__.'/Resources/TestService.php', + [ + 'message' => 'Expected namespace "FiveLab\Component\CiRules\Tests\PhpCs\FiveLab\Sniffs\Namespace\Resources", found " FiveLab\Component\CiRules\Tests".', + 'source' => 'FiveLab.Namespace.Namespace.NamespaceWrong', + ], + ], + ]; + } +} diff --git a/tests/PhpCs/FiveLab/Sniffs/Namespace/Resources/ExampleService.php b/tests/PhpCs/FiveLab/Sniffs/Namespace/Resources/ExampleService.php new file mode 100644 index 0000000..c4bfb8f --- /dev/null +++ b/tests/PhpCs/FiveLab/Sniffs/Namespace/Resources/ExampleService.php @@ -0,0 +1,10 @@ + [ + __DIR__.'/Resources/ascii/wrong.php', + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 209, 208, 209, 209, 209, 208, 208, 209, 209, 208, 208"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 208, 208, 208"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 208, 208, 209, 209"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 208, 208, 208, 208, 208, 208, 208, 208"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 208, 208, 209, 208, 208, 208, 208"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 208, 209, 208, 208, 208, 208, 208, 208, 209"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 209, 208, 208, 208, 209"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 209, 209, 208, 208, 208, 208, 208, 208, 209, 208, 208, 208, 209, 208, 209, 208, 208"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 208, 208, 209, 209"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 208, 208, 208, 208, 208, 209, 208, 209, 208, 208"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 208, 209, 208, 208"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 208, 209, 209"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 208, 209, 208, 209, 209, 208, 208, 209"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 209, 208, 208, 209"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + [ + 'message' => 'Use not ASCII printable symbols is forbidden: "208, 208, 209, 208, 208"', + 'source' => 'FiveLab.Strings.Ascii.Prohibited', + ], + ], + ]; + } +} diff --git a/tests/PhpCs/FiveLab/Sniffs/Strings/Resources/ascii/wrong.php b/tests/PhpCs/FiveLab/Sniffs/Strings/Resources/ascii/wrong.php new file mode 100644 index 0000000..0b27a6d --- /dev/null +++ b/tests/PhpCs/FiveLab/Sniffs/Strings/Resources/ascii/wrong.php @@ -0,0 +1,29 @@ +getByType(ReflectionProvider::class); + + return new MethodCallConsistencyRule($reflectionProvider); + } + + /** + * @test + */ + public function shouldSuccessProcessForIsset(): void + { + $this->analyse( + [__DIR__.'/Resources/MethodCallConsistency/ClassForProperty.php'], + [ + ['Method "FiveLab\Component\CiRules\Tests\PhpStan\Resources\MethodCallConsistency\Example::instanceMethod" is not static but called statically.', 26], + ['Method "FiveLab\Component\CiRules\Tests\PhpStan\Resources\MethodCallConsistency\Example->staticMethod" is static but called dynamically.', 27], + ['Method "FiveLab\Component\CiRules\Tests\PhpStan\Resources\MethodCallConsistency\ClassForProperty::instanceMethod1" is not static but called statically.', 29], + ['Method "FiveLab\Component\CiRules\Tests\PhpStan\Resources\MethodCallConsistency\ClassForProperty->staticMethod1" is static but called dynamically.', 30], + ['Method "FiveLab\Component\CiRules\Tests\PhpStan\Resources\MethodCallConsistency\ClassForProperty::instanceMethod1" is not static but called statically.', 36], + ['Method "FiveLab\Component\CiRules\Tests\PhpStan\Resources\MethodCallConsistency\ClassForProperty->staticMethod1" is static but called dynamically.', 37], + ['Method "FiveLab\Component\CiRules\Tests\PhpStan\Resources\MethodCallConsistency\ClassForProperty::instanceMethod1" is not static but called statically.', 40], + ], + ); + } +} diff --git a/tests/PhpStan/Resources/MethodCallConsistency/ClassForProperty.php b/tests/PhpStan/Resources/MethodCallConsistency/ClassForProperty.php new file mode 100644 index 0000000..ed60a60 --- /dev/null +++ b/tests/PhpStan/Resources/MethodCallConsistency/ClassForProperty.php @@ -0,0 +1,56 @@ +instanceMethod(); + + $this->property->instanceMethod1(); + $this->property::staticMethod1(); + + self::instanceMethod(); + $this->staticMethod(); + + $this->property::instanceMethod1(); + $this->property->staticMethod1(); + + $var = new ClassForProperty(); + $var->instanceMethod1(); + $var::staticMethod1(); + + $var::instanceMethod1(); + $var->staticMethod1(); + + ClassForProperty::staticMethod1(); + ClassForProperty::instanceMethod1(); + } +} + +class ParentClass { + public static function staticMethod2(): void {} + public function instanceMethod2(): void {} +} + +class ChildClass extends ParentClass { + public static function staticMethod2(): void + { + parent::staticMethod2(); + parent::instanceMethod2(); + } +} +