Skip to content

Detect class constants referenced only in PhpDoc types - #393

Merged
janedbal merged 4 commits into
masterfrom
constants-in-phpdoc-types
Jul 8, 2026
Merged

Detect class constants referenced only in PhpDoc types#393
janedbal merged 4 commits into
masterfrom
constants-in-phpdoc-types

Conversation

@janedbal

@janedbal janedbal commented Jul 7, 2026

Copy link
Copy Markdown
Member

Closes #391

Problem

A class constant referenced only inside a PhpDoc type — an int-range bound int<1, self::MAX>, a generic argument, a bare Foo::BAR constant type, etc. — was reported as shipmonk.deadConstant.

PHPStan eagerly collapses such types down to a literal (int<1, self::MAX>int<1, 100>), discarding the "this bound came from self::MAX" link. The reference survives only in the raw PhpDoc type AST, which no collector traversed.

final class PaginationInput
{
    public const MAX_PAGE_SIZE = 100; // was falsely reported as unused

    /** @param int<1, self::MAX_PAGE_SIZE> $pageSize */
    public function __construct(public int $pageSize) {}
}

Fix

ConstantFetchCollector now, for any node carrying a docblock, walks the resolved PhpDoc block for ConstFetchNode occurrences across any type position and emits the matching ClassConstantUsage.

  • The docblock is fetched via FileTypeMapper::getResolvedPhpDoc(), which is memoized — for the docblocks PHPStan already resolved during analysis this is a cache hit, not a re-parse.
  • Class names are resolved through the NameScope (imports, namespace) with self/static/parent handled explicitly; owner/inheritance/enum-case resolution reuses the existing getDeclaringTypesWithConstant().
  • Constant masks (self::SIZE_*) stay out of scope — that is the enum-migration case from Mark constants are used when referenced in PHPDoc with self::SIZE_* #223, deliberately unchanged.

Performance

The per-node scan is gated behind a cheap str_contains($docComment->getText(), '::') pre-filter, so docblocks that cannot hold a class-constant reference short-circuit before any PhpDoc resolution or AST traversal.

@janedbal
janedbal force-pushed the constants-in-phpdoc-types branch from de7084e to 976fa90 Compare July 8, 2026 06:40
@janedbal
janedbal marked this pull request as ready for review July 8, 2026 07:57
janedbal added 4 commits July 8, 2026 12:41
A class constant used solely inside a PhpDoc type (e.g. an int-range
bound `int<1, self::MAX>`, a generic argument, or a bare `Foo::BAR`
constant type) was reported as dead. PHPStan eagerly collapses such
types down to a literal (`int<1, 100>`), discarding the constant
reference, so it only survives in the raw PhpDoc type AST that no
collector traversed.

ConstantFetchCollector now walks the (cache-hit) resolved PhpDoc block
for ConstFetchNode occurrences across any type position and emits the
matching ClassConstantUsage, resolving self/static/parent and imported
names via the NameScope. Constant masks (self::SIZE_*) stay out of
scope (#223).

Closes #391

Co-Authored-By: Claude Code
Pre-filter the per-node PhpDoc scan on a cheap substring check so only
docblocks that can hold a class-constant reference reach getResolvedPhpDoc
and the AST traversal.

Co-Authored-By: Claude Code
Cover the self::SIZE_* skip branch and pin #223 behavior: a masked
class-constant reference in a PhpDoc type does not mark those constants
as used.

Co-Authored-By: Claude Code
Drop the Scope dependency from resolvePhpDocConstFetchOwner and read the
current class from the NameScope, mirroring PHPStan's own
ConstExprNodeResolver.

Co-Authored-By: Claude Code
@janedbal
janedbal force-pushed the constants-in-phpdoc-types branch from aff282b to 8c44622 Compare July 8, 2026 10:41
@janedbal
janedbal merged commit 819316d into master Jul 8, 2026
33 checks passed
@janedbal
janedbal deleted the constants-in-phpdoc-types branch July 8, 2026 10:51
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.

Constants used only as PhpDoc type arguments (e.g. int<1, self::MAX> bounds) are reported as unused

1 participant