-
tests/phpstan/OverrideOrDocblockRule.php<pre>@@ -0,0 +1,146 @@+<?php++namespace yourecom\standard\phpstan;++use PhpParser\Node;+use PHPStan\Analyser\Scope;+use PHPStan\Node\InClassMethodNode;+use PHPStan\Reflection\ClassReflection;+use PHPStan\Rules\IdentifierRuleError;+use PHPStan\Rules\Rule;+use PHPStan\Rules\RuleErrorBuilder;++/**+ * YoureCom Standard: every method has either `#[\Override]` (when it overrides or implements a method) or a docblock+ * that explains it. With `#[\Override]` there is no explanation and no `@inheritDoc`, a docblock only keeps tags that+ * PHPStan needs (e.g. `@param`, `@return`). Test methods of PHPUnit (`test*()` or `#[Test]`) need no docblock, their+ * name describes the test. Magic methods that override nothing (`__construct()`, `__invoke()`, `__get()` …, where+ * `#[\Override]` is not allowed) need no explanation either, PHP fixes their purpose; a docblock with `@inheritDoc` is+ * fine.+ * Managed by _dev/standard (apply.php), do not edit here.+ *+ * @author Marcel Beckers+ * @license YoureCom License - Commercial (YCLC) <https://www.yourecom.de/shop/licence-commercial/>+ * @implements Rule<InClassMethodNode>+ */+final class OverrideOrDocblockRule implements Rule+{+ #[\Override]+ public function getNodeType(): string+ {+ return InClassMethodNode::class;+ }++ #[\Override]+ public function processNode(Node $node, Scope $scope): array+ {+ $method = $node->getOriginalNode();+ $class = $node->getClassReflection();+ $name = $method->name->toString();+ $docComment = $method->getDocComment()?->getText();++ $hasOverride = false;+ $isTest = false;+ foreach ($method->attrGroups as $group) {+ foreach ($group->attrs as $attribute) {+ $attributeName = \ltrim($attribute->name->toString(), '\\');+ if (\strcasecmp($attributeName, 'Override') === 0) {+ $hasOverride = true;+ }+ if ($attributeName === 'PHPUnit\Framework\Attributes\Test') {+ $isTest = true;+ }+ }+ }++ // the name of a test method describes the test+ if (($isTest || \str_starts_with($name, 'test')) && $class->isSubclassOf('PHPUnit\Framework\TestCase')) {+ return [];+ }++ $parent = $this->overriddenClass($class, $name);+ if ($parent !== null && $name !== '__construct' && !$hasOverride) {+ return [$this->error("Method {$class->getDisplayName()}::{$name}() overrides {$parent}::{$name}() but has no #[\\Override] attribute.", 'missing')];+ }++ // the overridden method explains it, a docblock only keeps tags for PHPStan (e.g. @param, @return)+ if ($hasOverride) {+ if ($docComment !== null && \preg_match('~@inheritDoc~i', $docComment) === 1) {+ return [$this->error("Method {$class->getDisplayName()}::{$name}() has #[\\Override], remove the @inheritDoc docblock.", 'inheritDoc')];+ }+ if ($docComment !== null && $this->hasDescription($docComment)) {+ return [$this->error("Method {$class->getDisplayName()}::{$name}() has #[\\Override], remove the explanation from the docblock (keep only tags PHPStan needs).", 'description')];+ }++ return [];+ }++ // magic methods that override nothing cannot have #[\Override], PHP fixes their purpose (`@inheritDoc` is fine)+ if (\str_starts_with($name, '__')) {+ return [];+ }+ if ($docComment === null || !$this->hasDescription($docComment)) {+ return [$this->error("Method {$class->getDisplayName()}::{$name}() needs a docblock that explains it (or #[\\Override] if it overrides a method).", 'docblock')];+ }++ return [];+ }++ /**+ * Returns the name of the parent class, interface or trait whose method is overridden or implemented, null if none.+ */+ private function overriddenClass(ClassReflection $class, string $name): ?string+ {+ foreach ($class->getParents() as $parent) {+ if ($parent->hasNativeMethod($name) && !$parent->getNativeMethod($name)->isPrivate()) {+ return $parent->getDisplayName();+ }+ }+ foreach ($class->getInterfaces() as $interface) {+ if ($interface->hasNativeMethod($name)) {+ return $interface->getDisplayName();+ }+ }+ foreach ($class->getTraits(true) as $trait) {+ if ($trait->hasNativeMethod($name) && $trait->getNativeMethod($name)->isAbstract()) {+ return $trait->getDisplayName();+ }+ }++ return null;+ }++ /**+ * Tells whether a docblock has a line of text besides the tags (lines that continue a tag, like a multi-line+ * array shape of `@return`, belong to the tag).+ */+ private function hasDescription(string $docComment): bool+ {+ $inTag = false;+ foreach (\explode("\n", $docComment) as $line) {+ $text = \trim(\preg_replace('~^\s*(?:/\*\*|\*/|\*)~', '', $line) ?? '');+ $text = \trim(\preg_replace('~\*/$~', '', $text) ?? '');+ if ($text === '') {+ $inTag = false;+ continue;+ }+ if (\str_starts_with($text, '@') || \str_starts_with($text, '{@')) {+ $inTag = true;+ continue;+ }+ if (!$inTag) {+ return true;+ }+ }++ return false;+ }++ /**+ * Builds an error of this rule.+ */+ private function error(string $message, string $identifier): IdentifierRuleError+ {+ return RuleErrorBuilder::message($message)->identifier("yourecom.method.{$identifier}")->build();+ }+}
-
tests/phpstan/rules.php<pre>@@ -0,0 +1,18 @@+<?php++/**+ * YoureCom Standard: registers the PHPStan rules of the standard. Managed by _dev/standard (apply.php), do not edit+ * here. Included by phpstan.standard.neon: PHPStan builds its container before it loads bootstrapFiles, so the rule+ * classes are loaded here, while the configuration is read.+ */++require_once __DIR__ . '/OverrideOrDocblockRule.php';++return [+ 'services' => [+ [+ 'class' => \yourecom\standard\phpstan\OverrideOrDocblockRule::class,+ 'tags' => ['phpstan.rules.rule'],+ ],+ ],+];
-
tests/support/IntegrationTestCase.php<pre>@@ -0,0 +1,156 @@+<?php++namespace yourecom\tests;++use Laminas\Diactoros\ServerRequest;+use PHPUnit\Framework\TestCase;+use Psr\Http\Message\ResponseInterface;+use Psr\Http\Server\RequestHandlerInterface;+use wcf\data\user\User;+use wcf\system\user\storage\UserStorageHandler;+use wcf\system\WCF;++/**+ * YoureCom Standard: base class of the integration tests against the local dev system.+ * Managed by _dev/standard (apply.php), do not edit here.+ *+ * Every test runs in a transaction that is rolled back and acts as the administrator (userID 1) by default.+ */+abstract class IntegrationTestCase extends TestCase+{+ protected const ADMIN_ID = 1;++ #[\Override]+ protected function setUp(): void+ {+ WCF::getDB()->beginTransaction();+ $this->actAs(self::ADMIN_ID);+ }++ #[\Override]+ protected function tearDown(): void+ {+ WCF::getDB()->rollBackTransaction();++ // the user storage writes its changes at the end of the request, pending changes of a test must not leak+ $storage = UserStorageHandler::getInstance();+ foreach (['log', 'cache'] as $property) {+ (new \ReflectionProperty($storage, $property))->setValue($storage, []);+ }+ }++ /**+ * Switches the active user (0 = guest). Permission values are strings in the CLI, a web request delivers integers+ * for numeric options, so they are converted.+ */+ protected function actAs(int $userID): void+ {+ WCF::getSession()->changeUser(new User($userID > 0 ? $userID : null), true);+ WCF::getSession()->getPermission('admin.general.canUseAcp');++ $property = new \ReflectionProperty(WCF::getSession(), 'groupData');+ $groupData = $property->getValue(WCF::getSession());+ if (\is_array($groupData)) {+ foreach ($groupData as $name => $value) {+ if (\is_string($value) && \preg_match('~^-?\d+$~', $value) === 1) {+ $groupData[$name] = (int)$value;+ }+ }+ $property->setValue(WCF::getSession(), $groupData);+ }+ }++ /**+ * Returns the ID of the user with the given name, skips the test if the user does not exist.+ */+ protected function userID(string $username): int+ {+ $userID = self::scalar("SELECT userID FROM wcf1_user WHERE username = ?", [$username]);+ if (!\is_numeric($userID)) {+ self::markTestSkipped("The test needs the user '{$username}'.");+ }++ return (int)$userID;+ }++ /**+ * Runs a PSR-15 request handler (action, form dialog) like a browser request.+ *+ * @param array<string, string> $query+ * @param array<string, mixed>|null $body+ */+ protected static function handle(+ RequestHandlerInterface $handler,+ string $method = 'GET',+ array $query = [],+ ?array $body = null,+ ): ResponseInterface {+ $request = (new ServerRequest([], [], 'https://localhost/', $method))->withQueryParams($query);+ if ($body !== null) {+ $request = $request->withParsedBody($body);+ }++ return $handler->handle($request);+ }++ /**+ * Decodes the JSON body of a response.+ *+ * @return array<string, mixed>+ */+ protected static function json(ResponseInterface $response): array+ {+ $data = \json_decode((string)$response->getBody(), true, flags: \JSON_THROW_ON_ERROR);+ if (!\is_array($data)) {+ return [];+ }++ /** @var array<string, mixed> $data */+ return $data;+ }++ /**+ * Returns the first column of the first row.+ *+ * @param list<int|float|string|null> $parameters+ */+ protected static function scalar(string $sql, array $parameters = []): mixed+ {+ $statement = WCF::getDB()->prepare($sql, 1);+ $statement->execute($parameters);++ return $statement->fetchSingleColumn();+ }++ /**+ * Returns the first column of the first row as an integer (e.g. a COUNT(*) or an ID, 0 if there is no row).+ *+ * @param list<int|float|string|null> $parameters+ */+ protected static function int(string $sql, array $parameters = []): int+ {+ $value = self::scalar($sql, $parameters);++ return \is_numeric($value) ? (int)$value : 0;+ }++ /**+ * Returns a string field of a JSON response ('' if it is missing).+ *+ * @param array<string, mixed> $data+ */+ protected static function field(array $data, string $name): string+ {+ return \is_string($data[$name] ?? null) ? $data[$name] : '';+ }++ /**+ * Executes a statement.+ *+ * @param list<int|float|string|null> $parameters+ */+ protected static function execute(string $sql, array $parameters = []): void+ {+ WCF::getDB()->prepare($sql)->execute($parameters);+ }+}