Skip to content

Build/Test Tools: Add phpstan-strict-rules and enable rules core complies with - #15399

Closed
westonruter wants to merge 7 commits into
WordPress:trunkfrom
westonruter:worktree-phpstan-strict-rules
Closed

westonruter wants to merge 7 commits into
WordPress:trunkfrom
westonruter:worktree-phpstan-strict-rules

Conversation

@westonruter

@westonruter westonruter commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

✅ Committed in r65265 (985cab8).


This adds phpstan/phpstan-strict-rules 2.1.1 to require-dev, pinned exactly like phpstan/phpstan and phpstan/phpstan-phpunit.

phpstan.neon.dist now registers the four rules which core already complies with (Tier 1 below) under rules:

  • DisallowedBacktickRule and DisallowedShortTernaryRule, since the WordPress Coding Standards prohibit both constructs outright.
  • ClosureUsesThisRule and WrongCaseOfInheritedMethodRule, as guards against regressions.

The one short ternary in core, in tests/phpstan/HashNotationVisitor.php, is replaced with an explicit false check. With that, the default analysis reports no new errors.

The rules are registered individually rather than by including the package's rules.neon with strictRules.allRules: false, which was this PR's first approach. rules.neon also makes several PHPStan parameters stricter regardless of allRules, which would add about 300 errors (see the last section below), so those parameters then had to be reset one by one in phpstan.neon.dist. That had several problems:

  • A local phpstan.neon that also included rules.neon, the usual way to opt into the strict rules, failed with "This file is included multiple times".
  • Setting allRules: true locally enabled every rule but none of the stricter parameters, since the reset values took precedence.
  • reportNonIntStringArrayKey was forced off even with bleeding edge, where rules.neon would enable it.
  • A future release making another parameter stricter would silently change the analysis in CI. With the rules registered by class name instead, an upstream rename fails loudly.

Contributors can opt into more rules from a local phpstan.neon while the codebase works toward them, by including rules.neon as usual. The rules phpstan.neon.dist already registers should then be disabled, or each error they report is reported twice:

includes:
	- phpstan.neon.dist
	- vendor/phpstan/phpstan-strict-rules/rules.neon

parameters:
	strictRules:
		allRules: false
		disallowedLooseComparison: true
		overwriteVariablesWithLoop: true

		# Already registered in phpstan.neon.dist.
		disallowedBacktick: false
		disallowedShortTernary: false
		closureUsesThis: false
		matchingInheritedMethodNames: false

All of this was verified with a fixture file violating each of the four rules: the dist config reports each violation once, and the local config above also reports each once, while without the four false lines each is reported twice.

2.1.x requires PHPStan 2.3.0+ (core is at 2.3.4) and PHP 7.4+. The 2.1.1 release also reports implicit mixed in boolean conditions at level 10, which is useful for anyone running a local level 10 override.

Which rules could be enabled by default?

To ground this in data, PHPStan was run with the phpstan.neon.dist configuration from trunk (level 5, all baselines) plus rules.neon with every rule enabled, and the new errors were tallied by identifier. That run reports 9,372 errors. Each rule is assessed below against the WordPress PHP Coding Standards and core conventions. Counts will be higher at stricter levels, since several rules report more once mixed is checked.

Tier 1: Enabled in this PR (zero or near-zero errors)

Rule Errors Alignment
disallowedBacktick 0 WPCS: "Use of the backtick operator is not allowed." Already enforced by Generic.PHP.BacktickOperator.
disallowedShortTernary 1 WPCS: "The short ternary operator must not be used." Already enforced by Universal.Operators.DisallowShortTernary. The one hit, in tests/phpstan/HashNotationVisitor.php (PHPStan tooling, not shipped code), is fixed in this PR.
closureUsesThis 0 No conflict.
matchingInheritedMethodNames 0 No conflict. Overrides already match the case of the parent method name.

The backtick and short ternary rules duplicate PHPCS, so they mostly serve as a backstop (e.g. for files PHPCS excludes). The rest are free guards against regressions.

numericOperandsInArithmeticOperators also reports 0 at level 5 and was enabled in an earlier revision of this PR, but it is not one rule: it is over a dozen operator rule classes sharing a helper service, which is verbose to register by hand for a rule that reports nothing at the default level. It can be added when the level is raised.

booleansInLoopConditions reported 0 in the initial run only because it is gated on bleedingEdge, which the dist config does not enable. Setting it to true bypasses that gate and reports 32 while/do-while errors, so it is left disabled. It belongs with booleansInConditions in Tier 3.

Tier 2: Fix (or justify) a small number of errors, then enable

Rule Errors Files Notes
disallowedLooseComparison 31 9 WPCS: "Unless absolutely necessary, loose comparisons should not be used." PHPCS only reports Universal.Operators.StrictComparisons as a warning, which is how these survived. 20 are in WP_Query, where query vars may be strings or ints, so each needs care; the "absolutely necessary" ones can carry an inline ignore with a reason.
disallowedImplicitArrayCreation 34 17 Appending to a variable that may not exist. Many are admin globals like $menu/$submenu in wp-admin/menu.php, but others (e.g. $fields in WP_Debug_Data, $role_queries in WP_User_Query) are genuine "variable does not exist" cases.
dynamicCallOnStaticMethod 34 8 $this->static_method(). 15 are Imagick::getResourceLimit() in WP_Debug_Data. Mechanical fix: static::, which also preserves late static binding for subclasses (e.g. WP_Image_Editor::get_extension()).
switchConditionsMatchingType 13 3 twentyeleven and twentynineteen (int vs. string/float case labels), and option.php (null switch condition against 'on'/'yes' cases), which deserves a closer look.
overwriteVariablesWithLoop 8 8 A foreach value variable clobbering a variable still in use afterwards, which is a real bug pattern (e.g. $revision in wp-admin/includes/revision.php, $node in the HTML Processor).
strictFunctionCalls 7 5 All are base64_decode() without $strict. (in_array() without $strict is already prohibited by WordPress.PHP.StrictInArray.) Note that adding true changes behavior on malformed input, so each call needs review rather than a blanket change.
illegalConstructorMethodCall 5 5 Mostly the PHP4-style WP_Widget constructor shims, plus re-invoking __construct() on WP_Meta_Query/WP_Theme. These exist for backward compatibility, so they are likely ignore-with-reason rather than fixes.
requireParentConstructorCall 4 4 Walker_Nav_Menu_Checklist, _WP_List_Table_Compat, wp_xmlrpc_server, and WP_REST_Autosaves_Controller. Each likely deliberate, but worth documenting.
uselessCast 66 36 Since base.neon sets treatPhpDocTypesAsCertain: false, a cast is only reported when the native type already matches, e.g. $post_category in _wp_ajax_add_hierarchical_term() is cast with (array) when assigned and then again when passed to array_map(). So these are redundant, not hardening against PHPDoc types that plugins may violate. The exception is the 9 in output, like echo (int) $post_id;, since the cast is what satisfies WordPress.Security.EscapeOutput; those need an inline ignore or another escaping function.

Each of these could be enabled with a new per-identifier baseline (following the existing one-baseline-per-identifier approach), so new code is held to the rule while the existing errors are worked down under this ticket.

Tier 3: Not recommended as defaults

Rule Errors Why not
disallowedEmpty 4,962 Directly conflicts with WPCS, which carves out ! empty() as the preferred idiom ("An exception would be using ! empty()…").
booleansInConditions 3,700 if: 1,212, !: 1,281, &&: 751, ||: 157, ternary: 246, elseif: 53. Truthiness checks are pervasive and idiomatic in core, and WPCS has no such rule. A good opt-in for new code locally, especially at level 10.
noVariableVariables 123 Almost entirely variable property access (118) and variable method calls (5), e.g. $post->$field and $this->$method(), which core APIs depend on. Actual $$var variable variables: 0.
strictArrayFilter 96 array_filter( $array ) without a callback is a common core idiom. Its loose semantics drop '0', so some call sites may be latent bugs worth auditing, but making this a default would be churn.

Side effects of including rules.neon at all

Even with strictRules.allRules: false, rules.neon changes the defaults of several PHPStan parameters. Under the dist config these add:

Parameter Identifier Errors
reportMaybesInMethodSignatures method.childParameterType, method.childReturnType 113, 77
checkDynamicProperties property.notFound 33
reportMaybesInPropertyPhpDocTypes property.phpDocType 19
checkInternalClassCaseSensitivity class.nameCase 16 (e.g. imagick vs. Imagick)
polluteScopeWithLoopInitialAssignments, etc. variable.undefined 13 (plus 6 existing baseline counts no longer matching)
reportWrongPhpDocTypeInVarTag varTag.type 7
checkExplicitMixedMissingReturn return.missing 4

Since phpstan.neon.dist does not include rules.neon, none of these apply by default. Any of them could be enabled individually in follow-up PRs, with baselines, as with the Tier 2 rules.

The method signature variance errors are the largest group. PHPStan always reports an override whose types are definitely incompatible with the parent's; reportMaybesInMethodSignatures also reports ones that are only maybe compatible. For example, Walker::start_el() documents $data_object as object, while Walker_Category_Checklist::start_el() narrows it to WP_Term, which is not contravariant. Similarly, Language_Pack_Upgrader::clear_destination() returns bool|WP_Error where the parent WP_Upgrader::clear_destination() returns WP_Error|true, which is not covariant. These are PHPDoc types, not native type declarations (whose compatibility PHP enforces itself), so fixing them means correcting docblocks, e.g. widening a parent @return or adding a @template to Walker for its data object type. That does not touch the native signatures plugins and themes override, so this cluster of about 190 errors is a reasonable Tier 2 candidate.

Documentation of the PHPStan configuration files

This also updates how the two configuration files, and overriding them, are documented.

tests/phpstan/base.neon said it was "kept separate from the main PHPStan configuration file to allow for easy overloading while baseline errors are being fixed." The idea was that a local config could include base.neon alone, to get the WordPress-specific setup without the baselines, since a config that includes phpstan.neon.dist cannot remove its includes. That is no longer necessary: NEON's ! suffix replaces a value instead of merging into it, so a local phpstan.neon that includes phpstan.neon.dist and sets ignoreErrors!: [] reports every error the baselines suppress (verified on src/wp-admin/admin.php, which then reports the variable.undefined errors from baselines/variable.undefined.neon).

Merging base.neon into phpstan.neon.dist was considered, but the split is still useful for a different reason. base.neon (~360 lines) is what PHPStan needs to know to analyze the codebase at all, and rarely changes: the WordPress-specific extensions, the paths analyzed and excluded, the supported PHP versions, and dynamicConstantNames. phpstan.neon.dist is the standards the code is held to: the level, the baselines, the errors ignored as not actionable, and now the strict rules. Keeping them apart keeps the latter short enough to review. Merging would also mean rewriting every ../../src path, updating references in the CI workflow, README, bootstrap.php, several extension classes, and generate-baselines.php, and breaking any local config that includes base.neon directly.

So the documentation changes are:

  • The base.neon header now describes it by what it contains, and points to phpstan.neon.dist for the standards.
  • The phpstan.neon.dist header and the README said to copy phpstan.neon.dist to phpstan.neon, which leaves the copy to go stale. They now recommend including it.
  • The README's configuration section explains the role of each file, and gives examples of a local phpstan.neon which raises the level, and of one which uses ignoreErrors!: [] to show every error.
  • A new "Strict rules" section in the README explains how to opt into more of them (as above), and that the while/do-while condition rules are only enabled with bleeding edge unless booleansInLoopConditions is set, so booleansInConditions: true alone does not cover loops.
  • The README notes that, since there is no composer.lock, composer update is needed after pulling a change like this one, or PHPStan fails to load the configuration.

Trac ticket: https://core.trac.wordpress.org/ticket/65817

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5 (medium effort)
Used for: Adding the dependency, enabling the Tier 1 rules, fixing the short ternary, updating the configuration documentation, running PHPStan with the strict rules against core, tallying the errors by identifier, and drafting the analysis against the WordPress Coding Standards; reviewed by me.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

This installs phpstan/phpstan-strict-rules 2.1.1 without including its
rules.neon in phpstan.neon.dist, so the default analysis and CI are
unchanged. Since core does not use phpstan/extension-installer, the rules
take effect only when a local phpstan.neon includes
vendor/phpstan/phpstan-strict-rules/rules.neon, which lets contributors opt
into them, in whole or rule by rule, while the codebase works toward
enabling some of them by default.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: a06fee1f-3af9-4708-8113-35e373c5f1f0
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

westonruter and others added 2 commits November 4, 2026 00:38
The WordPress Coding Standards say the short ternary operator must not be
used. Replace the `?:` fallback for a failed preg_split() with an explicit
false check, so the disallowedShortTernary rule from phpstan-strict-rules
can be enabled.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: a06fee1f-3af9-4708-8113-35e373c5f1f0
Include phpstan-strict-rules in phpstan.neon.dist with all rules disabled,
then enable the ones which report no errors on core today:

* disallowedBacktick and disallowedShortTernary, which the WordPress Coding
  Standards prohibit outright (and PHPCS also enforces).
* closureUsesThis, matchingInheritedMethodNames, and
  numericOperandsInArithmeticOperators, which guard against regressions.

booleansInLoopConditions is left disabled: it is normally gated on
bleedingEdge, and setting it to true bypasses that gate, which reports 32
errors. It belongs with booleansInConditions, which is far from compliant.

Including rules.neon also makes several PHPStan parameters stricter
regardless of strictRules.allRules, which would add roughly 300 errors
(mostly method signature variance, which cannot be fixed without changing
signatures plugins override). Those parameters are reset to the PHPStan
defaults.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: a06fee1f-3af9-4708-8113-35e373c5f1f0
@westonruter westonruter changed the title Build/Test Tools: Add phpstan-strict-rules as a dev dependency Build/Test Tools: Add phpstan-strict-rules and enable rules core complies with Nov 4, 2026
@westonruter
westonruter marked this pull request as ready for review November 4, 2026 17:11
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.


Props Bot commands

You can trigger Props Bot actions by commenting on this PR:

  • @propsbot add <username> to give props to a GitHub user. Requires write access.
  • @propsbot remove <username> to leave a user out of the props. Requires write access.
  • @propsbot refresh to refresh this list. Adding the props-bot label works too.

Resolve the conflict in HashNotationVisitor::split_tags() by keeping the
explicit false check in place of the short ternary, together with trunk's
simplified docblock furniture regex.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: a06fee1f-3af9-4708-8113-35e373c5f1f0
@westonruter

Copy link
Copy Markdown
Member Author

@apermo @szepeviktor Please review. Seems uncontroversial.

The base.neon header said it was kept separate to allow for easy
overloading while baseline errors are being fixed, meaning a local config
could include it alone to skip the baselines. That is no longer needed: a
config including phpstan.neon.dist can drop every ignored error with
`ignoreErrors!: []`. Describe base.neon instead as the configuration for
analyzing the codebase at all, separate from the standards phpstan.neon.dist
holds the code to.

phpstan.neon.dist and the README also said to copy phpstan.neon.dist to
phpstan.neon, which leaves the copy to go stale. Recommend including it
instead, and document examples for raising the level, enabling more strict
rules, and showing the errors the baselines suppress.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: a06fee1f-3af9-4708-8113-35e373c5f1f0

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The configuration still omits checkFunctionNameCase from its reset list, leaving strict function-name case checking enabled and potentially introducing errors into the default analysis.

1 open issue
What changed in this PR

Adds phpstan/phpstan-strict-rules 2.1.1 to WordPress core’s development tooling, enables compatible strict rules, and documents local PHPStan configuration overrides.

Changes:

  • Adds the pinned strict-rules Composer dependency.
  • Enables five compatible rules while restoring selected PHPStan defaults.
  • Replaces the remaining short ternary with an explicit false check.
  • Updates PHPStan configuration documentation.
  • phpstan.neon.dist should also reset checkFunctionNameCase to prevent unintended errors in the default analysis.
File Summary
tests/​phpstan/​README.md Documents the configuration structure, local overrides, strict-rule opt-ins, and removing ignored errors.
tests/​phpstan/​HashNotationVisitor.php Replaces the short-ternary fallback with an explicit false check.
tests/​phpstan/​base.neon Clarifies the separation between analysis setup and project standards.
phpstan.neon.dist Includes strict rules, enables the supported subset, and restores PHPStan defaults; checkFunctionNameCase remains to be reset.
composer.json Adds the pinned phpstan/phpstan-strict-rules development dependency.

This pull request requires changes.

Review effort: Lite

Comment thread phpstan.neon.dist Resolved
westonruter and others added 2 commits November 4, 2026 10:58
phpstan.neon.dist resets the parameters phpstan-strict-rules makes
stricter, but not checkFunctionNameCase, since tests/phpstan/base.neon
already enables it. Say so, so the omission does not look like an
oversight.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: a06fee1f-3af9-4708-8113-35e373c5f1f0
Including phpstan-strict-rules' rules.neon in phpstan.neon.dist, with
only some rules enabled, also made several PHPStan parameters stricter,
which then had to be reset one by one. That approach had several problems:

* A local phpstan.neon which also included rules.neon, the usual way to
  opt into the strict rules, failed with "This file is included multiple
  times".
* Setting strictRules.allRules to true locally enabled every rule but none
  of the stricter parameters, since the reset values took precedence.
* reportNonIntStringArrayKey was forced off even with bleeding edge.
* A future release making another parameter stricter would silently
  change the analysis in CI.

Instead, register the rules core complies with directly under `rules`, and
do not include rules.neon, so no parameters change and the reset list is
gone. If a rule class is renamed upstream, PHPStan fails loudly instead.

numericOperandsInArithmeticOperators is no longer enabled: it consists of
over a dozen rule classes sharing a helper service, and reports nothing at
level 5 anyway.

Document in the README how to opt into more strict rules by including
rules.neon locally, that the rules already registered must then be
disabled to avoid duplicate errors, that loop conditions need
booleansInLoopConditions without bleeding edge, and that `composer update`
is needed after composer.json changes since there is no lock file.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: a06fee1f-3af9-4708-8113-35e373c5f1f0

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain, and the changes are documented and configured consistently for the intended strict-rule tooling update.

No open issues 🎉

1 resolved since last review

Review effort: Lite

@apermo apermo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lgtm

wporg-sync pushed a commit that referenced this pull request Nov 4, 2026
This adds `phpstan/phpstan-strict-rules` as a development dependency so that its rules can be enabled in a local `phpstan.neon` to work toward compliance, and it enables by default the rules which core already complies with or which enforce the WordPress Coding Standards:
* Disallow the backtick operator.
* Disallow short ternaries (the one remaining instance is replaced).
* Disallow `$this` in closures.
* Require inherited method names to match in case.

The rules are registered individually rather than by including the package's `rules.neon`, because that file also makes several PHPStan parameters stricter even when its rules are disabled, and because a local config including it would otherwise fail on the duplicate include. The README documents how to opt into the other rules locally, and the roles of `base.neon` and `phpstan.neon.dist` are clarified so that local configs include the latter rather than copying it.

Developed in #15399.
Follow-up to r61699, r63420.

Props westonruter, apermo.
See #65817.


git-svn-id: https://develop.svn.wordpress.org/trunk@65265 602fd350-edb4-49c9-b593-d223f7449a82
@westonruter westonruter closed this Nov 4, 2026
wporg-sync pushed a commit to WordPress/WordPress that referenced this pull request Nov 4, 2026
This adds `phpstan/phpstan-strict-rules` as a development dependency so that its rules can be enabled in a local `phpstan.neon` to work toward compliance, and it enables by default the rules which core already complies with or which enforce the WordPress Coding Standards:
* Disallow the backtick operator.
* Disallow short ternaries (the one remaining instance is replaced).
* Disallow `$this` in closures.
* Require inherited method names to match in case.

The rules are registered individually rather than by including the package's `rules.neon`, because that file also makes several PHPStan parameters stricter even when its rules are disabled, and because a local config including it would otherwise fail on the duplicate include. The README documents how to opt into the other rules locally, and the roles of `base.neon` and `phpstan.neon.dist` are clarified so that local configs include the latter rather than copying it.

Developed in WordPress/wordpress-develop#15399.
Follow-up to r61699, r63420.

Props westonruter, apermo.
See #65817.

Built from https://develop.svn.wordpress.org/trunk@65265


git-svn-id: http://core.svn.wordpress.org/trunk@64414 1a063a9b-81f0-0310-95a4-ce76da25c4cd
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.

3 participants