Skip to content

Bump phpdocumentor/reflection-docblock to v6#415

Merged
szepeviktor merged 2 commits intophp-stubs:masterfrom
IanDelMar:reflection-docblock-v6
Jan 18, 2026
Merged

Bump phpdocumentor/reflection-docblock to v6#415
szepeviktor merged 2 commits intophp-stubs:masterfrom
IanDelMar:reflection-docblock-v6

Conversation

@IanDelMar
Copy link
Contributor

Bumps phpdocumentor/reflection-docblock from v5 to v6 (released ~2 weeks ago; see the v6.0.0 release notes).

As of v6, phpdocumentor/reflection-docblock can handle non-negative-int. Closes #406.

With v6, phpDocumentor\Reflection\DocBlock::getTagsByName('param') may return phpDocumentor\Reflection\DocBlock\Tags\InvalidTag entries in addition to phpDocumentor\Reflection\DocBlock\Tags\Param. In v5, invalid @param tags were handled via a fallback to legacy parsing.

In our case, there is one phpDocumentor\Reflection\DocBlock\Tags\InvalidTag for Avifinfo\read_big_endian() (@param binary string $input, which should be @param string $input). The visitor has been updated to skip invalid parameter tags.

I used this to check for invalid tags:

<?php // check-invalid-tags.php

declare(strict_types=1);

use phpDocumentor\Reflection\DocBlock;
use phpDocumentor\Reflection\DocBlock\Tags\InvalidTag;
use phpDocumentor\Reflection\DocBlockFactory;
use phpDocumentor\Reflection\DocBlockFactoryInterface;
use PhpParser\ParserFactory;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor;
use PhpParser\NodeVisitorAbstract;
use PhpParser\Comment\Doc;
use PhpParser\Node;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\ClassLike;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Enum_;
use PhpParser\Node\Stmt\Function_;
use PhpParser\Node\Stmt\Property;
use PhpParser\NodeVisitor\ParentConnectingVisitor;
use PhpParser\NodeVisitor\NameResolver;

require __DIR__ . '/vendor/autoload.php';

$file = __DIR__ . '/wordpress-stubs.php';

if (! file_exists($file)) {
    fwrite(STDERR, "Error: wordpress-stubs.php not found\n");
    exit(1);
}

fwrite(STDOUT, "Loading and parsing wordpress-stubs.php...\n");

$code = file_get_contents($file);
if ($code === false) {
    fwrite(STDERR, "Error: Could not read wordpress-stubs.php\n");
    exit(1);
}

$parser = (new ParserFactory())->createForNewestSupportedVersion();
try {
    $ast = $parser->parse($code);
} catch (\PhpParser\Error $e) {
    fwrite(STDERR, "Parse error: {$e->getMessage()}\n");
    exit(1);
}

if ($ast === null) {
    fwrite(STDERR, "Parse Error\n");
    exit(1);
}

$stubsChecker = new class() extends NodeVisitorAbstract {
    private DocBlockFactoryInterface $docBlockFactory;
    private int $totalChecked = 0;
    private Name $symbolName;
    private Doc $docComment;
    private DocBlock $docBlock;

    /** @var array<string, list<string>> */
    private array $errors = [];

    public function __construct()
    {
        $this->docBlockFactory = DocBlockFactory::createInstance();
    }

    /** @return int|null */
    public function enterNode(Node $node)
    {
        if (! ($node instanceof Function_ || $node instanceof ClassMethod || $node instanceof Property || $node instanceof ClassLike)) {
            return null;
        }

        $docComment = $node->getDocComment();
        if (! $docComment instanceof Doc || strpos($docComment->getText(), '* @') === false) {
            return null;
        }

        $this->docComment = $docComment;
        $this->symbolName = $this->getSymbolName($node);

        if (! $this->isDocBlockParsable()) {
            return null;
        }

        $this->checkTags();

        ++$this->totalChecked;

        if ($node instanceof Enum_ || $node instanceof Property) {
            return NodeVisitor::DONT_TRAVERSE_CHILDREN;
        }

        return null;
    }

    private function getSymbolName(Function_|ClassMethod|ClassLike|Property $node): Name
    {
        if ($node instanceof ClassLike || $node instanceof Function_) {
            return new Name((string)$node->namespacedName);
        }

        $parent = $node->getAttribute('parent');
        assert($parent instanceof ClassLike);
        return new Name(sprintf('%s::%s', (string)$parent->namespacedName, $this->getName($node)));
    }

    private function getName(Function_|ClassMethod|ClassLike|Property $node): string
    {
        if ($node instanceof Property) {
            return sprintf('$%s', $node->props[0]->name->name);
        }

        if ($node->name instanceof Identifier) {
            return $node->name->name;
        }

        return spl_object_hash($node);
    }

    private function isDocBlockParsable(): bool
    {
        try {
            $this->docBlock = $this->docBlockFactory->create($this->docComment->getText());
            return true;
        } catch (\RuntimeException | \InvalidArgumentException $e) {
            $this->addError($this->symbolName->name, 'Docblock could not be parsed.');
            return false;
        }
    }

    private function checkTags(): void
    {
        $ignorableTagsWithIssues = [
            'author',
            'see',
            'since',
        ];

        foreach ($this->docBlock->getTags() as $tag) {
            if (! $tag instanceof InvalidTag) {
                continue;
            }

            if (in_array($tag->getName(), $ignorableTagsWithIssues, true)) {
                continue;
            }

            $message = sprintf('Invalid tag: %s', $tag->render());

            $this->addError($this->symbolName->name, $message);
            continue;
        }
    }

    private function addError(string $symbolName, string $message): void
    {
        if (! isset($this->errors[$symbolName])) {
            $this->errors[$symbolName] = [];
        }
        $this->errors[$symbolName][] = $message;
    }

    /**
     * @return array<string, list<string>>
     */
    public function getErrors(): array
    {
        return $this->errors;
    }

    public function printReport(): void
    {
        $errorCount = count($this->errors);

        if ($errorCount === 0) {
            fwrite(STDOUT, "No issues found!\n\n");
            return;
        }

        foreach ($this->errors as $symbolName => $messages) {
            fwrite(STDOUT, sprintf("\n[%s]\n", $symbolName));
            foreach ($messages as $message) {
                fwrite(STDOUT, sprintf("    %s\n", $message));
            }
        }
        fwrite(STDOUT, "\n\n");
    }
};

$nameResolver = new NameResolver();
$parentConnector = new ParentConnectingVisitor();
$traverser = new NodeTraverser();
$traverser->addVisitor($nameResolver);
$traverser->addVisitor($parentConnector);
$traverser->addVisitor($stubsChecker);

fwrite(STDOUT, "Validating docblocks...\n");

$traverser->traverse($ast);
$stubsChecker->printReport();
exit(empty($stubsChecker->getErrors()) ? 0 : 1);

This check has not been committed.

@szepeviktor
Copy link
Member

What do you think about this?

sed -i -e 's#@param binary string \$input#@param string $input#' source/wordpress/wp-includes/class-avif-info.php

@IanDelMar
Copy link
Contributor Author

Right now the visitor silently skips invalid tags, so we may never notice. Should we add a GitHub Action that detects and fails on invalid tags?

@szepeviktor
Copy link
Member

Should we add a GitHub Action that detects and fails on invalid tags?

No. We should fly blind.

Thank you!

@szepeviktor szepeviktor merged commit 98208ff into php-stubs:master Jan 18, 2026
7 checks passed
@IanDelMar IanDelMar deleted the reflection-docblock-v6 branch January 18, 2026 22:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Simplepie has ultra-modern non-negative-int type

2 participants