Repository navigation
Build/Test Tools: Add types for all dynamic constants in PHPStan. - #14034
rahultank-spaceo wants to merge 25 commits into
Conversation
WP_DEBUG_LOG can be a log file path since WordPress 5.1, but PHPStan inferred it as bool from the bootstrap definition. This caused false function.impossibleType errors for the is_string() checks in wp_debug_mode() and WP_Debug_Data. See #65817.
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Every entry in dynamicConstantNames now declares its type, so the list uses one style. WP_DEBUG_LOG is narrowed to bool|non-falsy-string. See #65817.
Resolve the conflict in `tests/phpstan/base.neon` by keeping the typed `dynamicConstantNames` map from this branch while applying trunk's list changes: `ENFORCE_GZIP` is dropped (retired along with script and style concatenation), and `LOGGED_IN_KEY` and `LOGGED_IN_SALT` are added as `'string'` (from the Secrets API). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`preg_replace()` is typed as returning `string|null`, which the `'string'` type now configured for these dynamic constants does not accept. The `null` return only happens on a regex error, which cannot occur for this fixed pattern, and since `[^/]+` stops at the first slash the appended trailing `/` always survives. The cast reflects that without widening the constants' type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core defines `UPLOADBLOGSDIR`, `WP_LANG_DIR`, and `WPMU_PLUGIN_DIR` as non-empty paths and concatenates them into other paths, and documents `NOBLOGREDIRECT` as a redirect URL (or the `%siteurl%` sentinel), so these become `non-empty-string`. None of them is guarded against an empty value, so the narrower type makes no existing check look redundant. `WP_DEVELOPMENT_MODE` becomes the union of the values core documents, the ones `wp_get_development_mode()` accepts: `'core'`, `'plugin'`, `'theme'`, `'all'`, and `''`. `WPLANG` and `WP_SITEURL` stay `string`, since core explicitly checks them against `''`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each type in `dynamicConstantNames` reflects what core expects. Survey real wp-config.php files, hosting and Docker templates, and WordPress.org plugins and themes, and note where they are known to define wider values, along with how core handles them. The most common source is a config that passes `getenv()` through without converting it, which yields `false` when the variable is unset and otherwise a string, where `'false'` is truthy. Other examples are `1` for the request-context flags, numeric strings for the legacy header image sizes and `WP_POST_REVISIONS`, and `''` or `'0'` for `WP_DEBUG_LOG`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test using WordPress PlaygroundThe 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
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
A constant listed under `dynamicConstantNames` still needs a define() in the PHPStan bootstrap, or PHPStan reports it as not found, but the value given there is ignored in favor of the type configured in `base.neon`. Say so in the bootstrap's docblock, so the placeholder values aren't mistaken for the source of the types. Also change the `NOBLOGREDIRECT` placeholder from `''` to the `%siteurl%` sentinel, so it no longer contradicts its `non-empty-string` type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The note on the salt and key constants is about configs that read environment variables in general, so describe it that way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core defines it as `30` and treats `0` as disabling the trash. A negative number of days is meaningless: the REST API reports items as not trashable, while `wp_scheduled_delete()` computes a cutoff in the future and purges all trash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core only ever writes non-empty values for these: setup and the network setup screen generate 64-character values, and the sample config uses a placeholder phrase. An empty salt is tolerated rather than expected, since `wp_salt()` and `wpdb::placeholder_escape()` treat a falsy value as missing. Note that some configs nonetheless define `''`. `non-falsy-string` would go too far, as it would make those truthiness checks look redundant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`true` or `-1` keeps every revision, `false` or `0` keeps none, and a positive integer keeps that many. Core converts `true` to `-1` in `wp_revisions_to_keep()`, and `wp_save_post_revision()` treats any negative count as unlimited, but `-1` is the documented value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This reverts commit 4f64676. Core never checks for `-1` specifically: `wp_save_post_revision()` treats any negative count as unlimited, and `wp_revisions_enabled()` only checks against `0`. A lower bound of `-1` encodes a documentation convention that no read depends on, so keep `bool|int`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both types were narrowed without a comment, though real configs define values outside them. The WordPress MU sample config defined `NOBLOGREDIRECT` as `''`, which `maybe_redirect_404()` treats as no redirect but `ms_load_current_site_and_network()` passes through as an empty `Location` header. Some sites define `WP_DEVELOPMENT_MODE` as `true`, which `wp_get_development_mode()` treats as `''` while Site Health shows the raw value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Not every typed dynamic constant needs one: PHPStan discovers constants that core defines at the top level of a file, such as `MULTISITE`, and a read inside a defined() check is never reported as not found. Explain that the defines are for constants core only defines inside a function. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both vary by install, but PHPStan took their values from the bootstrap, treating `FORCE_SSL_ADMIN` as always `false` and `COOKIE_DOMAIN` as always `''`. Core defines `FORCE_SSL_ADMIN` from the scheme of the `siteurl` option, and `COOKIE_DOMAIN` as `''`, or as the network's cookie domain on a subdomain multisite network. Configs still commonly define `COOKIE_DOMAIN` as `false`, which was core's own default until r58011, and pass `FORCE_SSL_ADMIN` through from `getenv()`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`wp_scheduled_delete()` is not the only place an empty `EMPTY_TRASH_DAYS` fatals: `_wp_delete_orphaned_draft_menu_items()` multiplies it by `DAY_IN_SECONDS` too. And the `WP_USE_THEMES` comment read as if core treated `0` as true, when only the string `'false'` is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Multiplying an int by an empty string throws a TypeError on PHP 8, but on PHP 7.4 it only warns about a non-numeric value and evaluates to 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The types describe what core expects, so a guard against a value outside one is reported as impossible. Note that such a guard should widen the type in the same change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PHPStan bootstrap pinned every cookie name and path to `''`, though each is derived per install by `wp_cookie_constants()` or `ms_cookie_constants()`, and may be defined earlier by `wp-config.php`, `sunrise.php`, or a must-use plugin. The cookie names become `non-empty-string`: core builds each one as a prefix plus `COOKIEHASH`, and `setcookie()` throws on an empty name. `COOKIEHASH` stays `string`, since core defines it as `''` when there is no `siteurl`. `ADMIN_COOKIE_PATH` and `PLUGINS_COOKIE_PATH` are `string`, like `SITECOOKIEPATH`, from which multisite derives the former. `PLUGINS_COOKIE_PATH` gets the same `(string)` cast on `preg_replace()` as `COOKIEPATH`, which cannot fail for this fixed pattern, and the bootstrap placeholders for the cookie names become core's own prefixes, so they no longer contradict their types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PHPStan bootstrap pinned `ABSPATH`, the content, plugin, and must-use plugin directories and URLs, and the theme paths to fictional literals, though each depends on the install. Core always defines them non-empty: the entry points define `ABSPATH` from `__DIR__`, and the others are concatenations onto it or onto the `siteurl` option. `TEMPLATEPATH` and `STYLESHEETPATH` stay `string`, as core defines them from `get_template_directory()` and `get_stylesheet_directory()`, which return a plain `string`. With `WP_PLUGIN_DIR` no longer a literal, PHPStan cannot resolve the Press This include to a missing file, so its `include.fileNotFound` ignore no longer matches and is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PHPStan bootstrap pinned these to core's defaults, though sites routinely override them, and `WP_START_TIMESTAMP` and the memory limits are computed at runtime. The memory limits are `string`, as core defines them from `ini_get()` or shorthand notation like `'64M'` and passes them to `wp_convert_hr_to_bytes()` and `ini_set()`. The autosave interval, cron lock timeout, and filesystem timeouts are `positive-int`, since a value below 1 changes behavior: autosaves fire on every change or stop, cron runs overlap, and on PHP 8 `ftp_connect()` and `ftp_set_option()` throw. The filesystem modes are plain `int`, as core computes them with `fileperms()`, and setgid modes exceed `0777`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comments on `ABSPATH` and `WP_START_TIMESTAMP` explained why their values were fixed rather than derived at runtime: so error messages, which quoted the values, stayed the same across machines and runs. Both are now typed dynamic constants, so PHPStan ignores the values and messages quote the configured types instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PHPUnit bootstrap defined `WP_MEMORY_LIMIT` and `WP_MAX_MEMORY_LIMIT` as the int `-1`, outside their `string` type. Core reads both through `wp_convert_hr_to_bytes()`, which trims its input to a string anyway, and `ini_set()`, so `'-1'` behaves identically. With core's own code no longer contradicting the type, the comments documenting the mismatch go away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core treats all eight salts and keys alike: `wp_salt()` reads each of them, setup and the network setup screen generate them together, and the repair screen checks them together. Only three were typed, though, so the recovery mode cookie service compared `AUTH_KEY` as untyped right beside a typed `AUTH_SALT`. Give `AUTH_KEY`, `SECURE_AUTH_KEY`, `SECURE_AUTH_SALT`, `NONCE_KEY`, and `NONCE_SALT` the same type and note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WP_DEBUG_LOG to be a string in PHPStan.|
🤖 Comment by Claude Opus 5.5 The PR description and title have been updated for the current scope. Here is how the PR has changed since it was opened, followed by possible follow-ups. Changes from the original PR
Possible follow-upsThese core bugs were found while surveying how the constants are defined, and are not addressed here:
There is also an open question about a type: |
PHPStan treated many install-dependent constants as fixed values. Those missing from `dynamicConstantNames` took their placeholder value from the PHPStan bootstrap, and listed ones without a type took the type of their only visible definition. For `WP_DEBUG_LOG` that definition was `false`, so the `is_string()` checks for a log file path in `wp_debug_mode()` and `WP_Debug_Data` were reported as impossible. Every install-dependent constant now has an explicit type, including those the bootstrap had pinned, such as the content and plugin paths, the cookie names, the memory limits, and the timeouts. Each type is what core expects: the values it defines and documents, and that its reads are designed for. Where real-world configurations are known to define wider values, most often by passing `getenv()` through unconverted, a comment notes them along with how core handles them. Widening the types to cover such values instead would require handling invalid configuration at every read. The bootstrap definitions remain, since PHPStan does not discover constants that core defines inside a function, but their values no longer affect the analysis. The `preg_replace()` results that define the cookie paths are cast to strings to fit their types, and the PHPUnit bootstrap now defines the memory limits as strings to match theirs. Developed in #14034. Follow-up to r61699, r62965, r63191, r63356. Props rahultank, westonruter. See #65817. git-svn-id: https://develop.svn.wordpress.org/trunk@64672 602fd350-edb4-49c9-b593-d223f7449a82
PHPStan treated many install-dependent constants as fixed values. Those missing from `dynamicConstantNames` took their placeholder value from the PHPStan bootstrap, and listed ones without a type took the type of their only visible definition. For `WP_DEBUG_LOG` that definition was `false`, so the `is_string()` checks for a log file path in `wp_debug_mode()` and `WP_Debug_Data` were reported as impossible. Every install-dependent constant now has an explicit type, including those the bootstrap had pinned, such as the content and plugin paths, the cookie names, the memory limits, and the timeouts. Each type is what core expects: the values it defines and documents, and that its reads are designed for. Where real-world configurations are known to define wider values, most often by passing `getenv()` through unconverted, a comment notes them along with how core handles them. Widening the types to cover such values instead would require handling invalid configuration at every read. The bootstrap definitions remain, since PHPStan does not discover constants that core defines inside a function, but their values no longer affect the analysis. The `preg_replace()` results that define the cookie paths are cast to strings to fit their types, and the PHPUnit bootstrap now defines the memory limits as strings to match theirs. Developed in WordPress/wordpress-develop#14034. Follow-up to r61699, r62965, r63191, r63356. Props rahultank, westonruter. See #65817. Built from https://develop.svn.wordpress.org/trunk@64672 git-svn-id: http://core.svn.wordpress.org/trunk@63822 1a063a9b-81f0-0310-95a4-ce76da25c4cd
✅ Committed in r64672 (cd6bc90).
PHPStan treated many install-dependent constants as fixed values. A constant missing from
dynamicConstantNamestook its value fromtests/phpstan/bootstrap.php, such asWP_PLUGIN_DIRas'/var/www/html/wp-content/plugins'orFORCE_SSL_ADMINasfalse. A listed constant without a type took its type from the only definition PHPStan saw. ForWP_DEBUG_LOG, that was the bootstrap'sfalse, so theis_string( WP_DEBUG_LOG )checks inwp_debug_mode()andWP_Debug_Data::get_wp_constants()were reported asfunction.impossibleType.This PR gives every install-dependent constant an explicit type in
tests/phpstan/base.neon, 90 in all:FORCE_SSL_ADMINandCOOKIE_DOMAIN;ABSPATH, the cookie names and paths, the memory limits, the autosave, cron lock, and filesystem timeouts, the filesystem modes,WP_DEFAULT_THEME,WP_START_TIMESTAMP, and the deprecated path constants;Constants that core always defines with the same value (
*_IN_BYTES,*_IN_SECONDS, andWP_FEATURE_BETTER_PASSWORDS) stay fixed.Follow-up to r62965, r63191, and r63356.
How the types are chosen
Each type is what core expects: the values core defines and documents, and that its reads are designed for. Real-world configs often go wider, most commonly by passing
getenv()through unconverted, which yieldsfalsewhen the variable is unset and otherwise any string, where'false'is truthy. The values each constant is defined with were surveyed in realwp-config.phpfiles and hosting templates on GitHub, and in WordPress.org plugins and themes. Where wider values are known, a comment above the entry notes them and how core handles them.A type is narrower than
bool,int, orstringonly where core guarantees it and PHPStan can confirm that everydefine()in core fits, since a mismatch is reported asconstant.defineValue. As the header comment inbase.neonsays, when core starts handling a value outside a type, the type should be widened in the same change.Widening every type to cover real-world values was considered and rejected. PHPStan would then require handling invalid config at every read;
WP_LANG_DIR . '/plugins'is an error whenWP_LANG_DIRisstring|false. That is a separate hardening effort, and it would hide core's contract inside the wider types.Decisions worth noting
WP_DEBUG_LOGisbool|non-falsy-string, notbool|string. Configs do define''and'0', usually throughgetenv(), which core treats as disabling logging, and the comment says so.NOBLOGREDIRECTisnon-empty-string, though the WordPress MU sample config defined''. The comment describes what core then does.COOKIEPATHandSITECOOKIEPATHstaystring. Core's defaults are never empty, but PHPStan cannot prove that at the define sites, and no read depends on it.WP_POST_REVISIONSstaysbool|int. Core treats any negative value as unlimited, so a lower bound of-1would encode a documentation convention that no read depends on.HEADER_IMAGE_WIDTHandHEADER_IMAGE_HEIGHTstayint.non-negative-intwould need clamping inadd_theme_support(), and it would flag the pre-3.4 compatibility code in Twenty Ten and Twenty Eleven.FS_CHMOD_DIRandFS_CHMOD_FILEare plainint, since setgid modes exceed0777.define()s stay. PHPStan does not discover constants that core defines inside a function, and it reports reads of them outside adefined()check as not found; removing the defines produces 188 such errors. For a typed constant the value is ignored, as the bootstrap docblock now explains. Placeholders that contradicted their types now fit them:NOBLOGREDIRECTis'%siteurl%', and the cookie names use core's own prefixes.Source changes
None of these change runtime behavior:
(string)casts on thepreg_replace()results that defineCOOKIEPATH,SITECOOKIEPATH, andPLUGINS_COOKIE_PATH, which PHPStan types asstring|null. The fixed pattern cannot fail.include.fileNotFoundignore insrc/wp-admin/press-this.phpis removed. WithWP_PLUGIN_DIRno longer a literal path, PHPStan does not check that include, so the ignore no longer matched.tests/phpunit/includes/bootstrap.phpdefines the memory limits as'-1'instead of the int-1.wp_convert_hr_to_bytes()trims its input to a string, so it reads both the same.Testing instructions
composer phpstan. It reports no errors, andtests/phpstan/baselines/function.impossibleType.neonno longer has the twois_string()entries.base.neonchanges. No new errors appear. A few existing ones go away, and some change only their text, such asnon-falsy-stringin place of'/var/www/html/wp…'.Trac ticket: https://core.trac.wordpress.org/ticket/65817
Use of AI Tools
AI assistance: Yes
Used for: Initial draft of the change and this description; the change and the verification runs were reviewed and confirmed by me in a local environment.
Later commits, which expand the change to all dynamic constants, survey the values each constant is defined with, and revise this description, were made with Claude Code (Claude Opus 5.5) and reviewed by @westonruter.
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.
🤖 Generated with Claude Code