Skip to content

Code Quality: Fix the nullCoalesce.property PHPStan error and remove the baseline - #15475

Closed
bgturner wants to merge 17 commits into
WordPress:trunkfrom
bgturner:bt/ticket/65817/nullCoalesce
Closed

bgturner wants to merge 17 commits into
WordPress:trunkfrom
bgturner:bt/ticket/65817/nullCoalesce

Conversation

@bgturner

@bgturner bgturner commented Nov 4, 2026 •

Copy link
Copy Markdown

✅ Committed in r65281 (7032470).


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

Replaces #15474, closed when renaming its branch unexpectedly closed the PR instead of retargeting it.

Fixes the one remaining nullCoalesce.property error and removes tests/phpstan/baselines/nullCoalesce.property.neon.

The error

src/wp-includes/general-template.php:4986
Property WP_Query::$max_num_pages (int) on left side of ?? is not nullable.

WP_Query::$max_num_pages defaults to 0 and is never null, so ?? 1 in paginate_links() never ran.

The fix

In paginate_links(), the property is read directly:

$total = $wp_query->max_num_pages;

The ?? 1, like the isset() ternary it replaced in r61454, only ever applied to an unset property, so a max_num_pages of 0 already passed through as 0. This line has no runtime effect. No cast is needed either: the function casts $args['total'] to int before using it, which also covers plugins that assign an uncast ceil( … ) (a float) to the property.

In WP_Query:

  • $max_num_pages gets @phpstan-var int<0, max>, which records what core already assumes about it.
  • That type turned up a real case where the computed value goes negative. A found_posts filter that returns a negative count, or a filter-injected LIMIT on a posts_per_page = -1 query, made max_num_pages negative (e.g. ceil( -5 / 2 ) = -2, ceil( 3 / -1 ) = -3). set_found_posts() now clamps the result with max( 0, … ).

In WP_Sitemaps_Posts::get_max_num_pages(), the isset() fallback is removed for the same reason: $query is always a fresh WP_Query, so max_num_pages is always set. This retires its isset.property baseline entry.

Why this is safe

This is narrower than the attempt in #13982, where a reviewer asked for this exact line to be reverted because it also added a null guard around $wp_query itself. This PR doesn't touch $wp_query's nullability. A null global query was never supported here anyway. The next line calls get_query_var( 'paged' ), which dereferences $wp_query unconditionally, so paginate_links() fatals without a global query both before and after this change.

The paginate_links() and sitemaps changes don't change behavior. The clamp only affects values that used to be negative, and core's pagination consumers treat 0 the same as a negative total. Possible follow-ups are listed in a comment below.

Tests

  • Tests_Query_NoFoundRows::test_max_num_pages_is_not_negative_when_found_posts_is_filtered_negative covers the clamp for a negative found_posts. It fails on trunk (-2) and passes here.
  • Tests_Query_NoFoundRows::test_max_num_pages_is_not_negative_when_limits_are_filtered_with_postsperpage_minus1 covers the filtered LIMIT on a posts_per_page = -1 query. It fails on trunk (-3) and passes here.

No paginate_links() test is included, because its line has no runtime effect, so no test could fail on trunk and pass here.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5 (medium effort)
Used for: Code review of the branch, follow-up commits (the paginate_links() simplification, the max( 0, … ) clamp in WP_Query::set_found_posts(), and its test), and this description. A survey of plugin code with Veloria showed which values plugins write to $max_num_pages. All changes were reviewed by a human.


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.

Add a test that `paginate_links()` returns null when the global query
reports zero total pages, matching its behavior for a single total page.

See #65817.
…e the baseline.

`WP_Query::$max_num_pages` defaults to `0` and is never `null`, so the
`?? 1` fallback in `paginate_links()` never ran. Use `?:` instead, which
also falls back for `0`, and retire the now-empty baseline.

See #65817.
…ort ternary.

WordPress Coding Standards disallows `?:` (Universal.Operators.DisallowShortTernary).
Use the `$x ? $x : $y` form already used elsewhere in core for a falsy fallback.

See #65817.
@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 bgturner, 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.

@github-actions

github-actions Bot commented Nov 4, 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.

Comment thread src/wp-includes/general-template.php Outdated

// Get max pages and current page out of the current query, if available.
$total = $wp_query->max_num_pages ?? 1;
$total = $wp_query->max_num_pages ? $wp_query->max_num_pages : 1;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is equivalent to the following, right?

Suggested change
$total = $wp_query->max_num_pages ? $wp_query->max_num_pages : 1;
$total = $wp_query->max_num_pages === 0 ? $wp_query->max_num_pages : 1;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I mean, I see this calculated as the following, when it is set:

$this->max_num_pages = (int) ceil( $this->found_posts / $query_vars['posts_per_page'] );

Since $found_posts is int<0, max> and $query_vars['posts_per_page'] can only conceivable by int<1, max>, then $this->max_num_pages can also only be int<0, max>.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In any case, leaving it as a falsy check is backwards-compatible in case someone did erroneously put null here.

westonruter and others added 7 commits November 4, 2026 15:00
Replacing `?? 1` with a plain ternary dropped the null-safe read of
`$wp_query`, so `paginate_links()` would warn when the global query is
not set (e.g. before `wp()` runs, or after a plugin unsets it). Check
`$wp_query instanceof WP_Query` instead, which keeps the fallback to 1
and still satisfies PHPStan.

Also cast to int and clamp to at least 1. Many plugins assign
`ceil(...)` to `max_num_pages` without a cast, which yields a float, and
a `found_posts` filter can drive the computed value negative.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 92a8904b-c6d4-4ac3-8ba5-7d8209aed336
The `int<0, max>` type on `WP_Query::$max_num_pages` exposed that the
computed value can go negative: a `found_posts` filter may return a
negative count, and a filter-injected `LIMIT` on a `posts_per_page = -1`
query divides by -1. Clamp the result with `max( 0, … )` so the property
holds the type it declares.

Also cast `posts_per_page` to int in the division. It is already an int
by this point, so behavior is unchanged, but the query vars array is
otherwise typed as mixed.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 92a8904b-c6d4-4ac3-8ba5-7d8209aed336
The guard did not make `paginate_links()` tolerate a missing global
query: the next line calls `get_query_var()`, which dereferences
`$wp_query` unconditionally, so a null global fatals there either way.
Keep only the int cast and the clamp to at least 1.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 92a8904b-c6d4-4ac3-8ba5-7d8209aed336
A `found_posts` filter returning -5 with `posts_per_page` of 2 used to
yield a `max_num_pages` of -2. Assert it is now clamped to 0, while the
filtered `found_posts` value itself is kept.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 92a8904b-c6d4-4ac3-8ba5-7d8209aed336
@github-actions

This comment has been minimized.

westonruter and others added 2 commits November 4, 2026 15:48
…ges().

`$query` is always a fresh `WP_Query` instance, so `max_num_pages` is
always set -- the `isset()` branch was dead code, and the fallback to
`1` could never run. Remove it and retire the now-unmatched baseline
entry, rather than just patching the baseline regex to match the
narrower `int<0, max>` type.

See #65817.
@bgturner

bgturner commented Nov 4, 2026

Copy link
Copy Markdown
Author

While updating the baseline regex for the narrower int<0, max> annotation on WP_Query::$max_num_pages, noticed the isset.property entry it was patching (in class-wp-sitemaps-posts.php) didn't need to exist at all: $query there is always a fresh WP_Query instance, so max_num_pages is always set and the isset() check -- and its unreachable : 1 fallback -- was dead code.

b429cda drops the isset() guard and removes that baseline entry instead of just keeping the regex in sync. Behavior is unchanged; existing coverage in wpSitemapsPosts.php (zero-results and show_on_front cases) already exercises this path.

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

A requested regression test for the filtered non-empty LIMIT path with posts_per_page = -1 is still missing, leaving an active coverage gap for the described max_num_pages regression.

1 open issue
What changed in this PR

This pull request fixes PHPStan nullability findings around WP_Query::$max_num_pages, enforces non-negative page counts, and removes obsolete PHPStan baseline entries.

Changes:

  • Normalizes pagination totals and preserves the documented fallback behavior.
  • Annotates and clamps WP_Query::$max_num_pages to a non-negative range.
  • Removes redundant sitemap nullability handling.
  • Deletes obsolete PHPStan baselines and adds regression coverage.
File Summary Review notes
tests/​phpunit/​tests/​query/​noFoundRows.php Tests clamping negative filtered post counts. No additional comments.
tests/​phpunit/​tests/​general/​paginateLinks.php Tests pagination behavior with zero maximum pages. No additional comments.
tests/​phpstan/​baselines/​nullCoalesce.property.neon Removes the resolved PHPStan baseline. No additional comments.
tests/​phpstan/​baselines/​isset.property.neon Removes the obsolete isset.property baseline entry. No additional comments.
src/​wp-includes/​sitemaps/​providers/​class-wp-sitemaps-posts.php Removes redundant nullable-property handling. No additional comments.
src/​wp-includes/​general-template.php Normalizes the pagination total with an integer cast and lower bound. No additional comments.
src/​wp-includes/​class-wp-query.php Documents and enforces the non-negative max_num_pages invariant. Moderate: Normalize cached max_num_pages values so legacy negative cache entries cannot violate the invariant. Nit: Add regression coverage for the injected LIMIT with posts_per_page = -1 path.
phpstan.neon.dist Removes the obsolete PHPStan baseline include. No additional comments.

Review effort: Lite

Comment thread src/wp-includes/class-wp-query.php Resolved
The PR description calls out a second path to a negative
max_num_pages: a `post_limits` filter injecting a non-empty LIMIT
clause on a `posts_per_page = -1` query, which otherwise sets
`nopaging` and never runs the found-rows calculation at all. Only
the negative-found_posts path had a regression test.

Verified this new test fails with -3 against the unclamped
`(int) ceil(...)` and passes with the max( 0, ... ) clamp.

See #65817.
@westonruter
westonruter requested a lite review from Copilot November 5, 2026 00:19

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

The changes address the negative page-count invariant with corresponding pagination updates and regression coverage, leaving no active review issues.

No open issues 🎉

1 resolved since last review

Review effort: Lite

@westonruter

Copy link
Copy Markdown
Member

🤖 Comment by Claude Opus 5.5 (medium effort)

A code review of this branch turned up three possible follow-ups. None of them block this PR, since each would change behavior beyond the PHPStan fix. The biggest is that a posts_per_page = -1 query with a filter-injected LIMIT now reports max_num_pages = 0 even when posts were found, where 1 would arguably be more accurate.

Details

1. max_num_pages for an unpaged query with a filter-injected LIMIT

With 3 posts, posts_per_page = -1, and a post_limits filter returning 'LIMIT 0, 2', found_posts is 3 and max_num_pages was ceil( 3 / -1 ) = -3 on trunk. The max( 0, … ) clamp here turns that into 0, which test_max_num_pages_is_not_negative_when_limits_are_filtered_with_postsperpage_minus1 asserts.

Since -1 means "don't paginate", 1 when posts were found would arguably be more accurate. A version that does this was tried locally. It passed both test_max_num_pages_is_not_negative… tests (with the post_limits test expecting 1) and PHPStan level 10 on the changed lines, but it was not committed:

if ( ! empty( $limits ) ) {
	$posts_per_page = (int) $query_vars['posts_per_page'];

	if ( $posts_per_page > 0 ) {
		$this->max_num_pages = max( 0, (int) ceil( $this->found_posts / $posts_per_page ) );
	} else {
		/*
		 * A `posts_per_page` of -1 means no paging, so a filter-injected LIMIT
		 * still leaves all found posts on a single page.
		 */
		$this->max_num_pages = $this->found_posts > 0 ? 1 : 0;
	}
}

Back-compat: none of core's consumers change behavior, whether the value is -3, 0, or 1:

  • paginate_links() and the core/query-pagination-numbers block treat totals < 2 the same way.
  • get_next_posts_link() and core/query-pagination-next compare paged + 1 <= max_page.
  • The max_num_pages > 1 checks in link-template.php and the $done check in media.php give the same result for all three values.

Third-party code is a different matter. Checks such as max_num_pages < 1 or <= 0, or loops like for ( $i = 1; $i <= $q->max_num_pages; $i++ ), give the same answer for -3 and 0 but a different one for 1. A load-more or numbered-pagination plugin could then show a lone "1". So the clamp to 0 was kept here as the smaller change. Moving to 1 is a separate behavior change better decided on Core-65817.

2. A negative found_posts remains on the public property

Only max_num_pages is clamped. If a found_posts filter returns -5, $q->found_posts stays -5, and test_max_num_pages_is_not_negative_when_found_posts_is_filtered_negative asserts that. Anything reading found_posts directly still sees the negative count, REST total headers for example. Clamping found_posts itself would limit what the filter can return, which is a scope decision.

3. The sibling query classes do the same unclamped division

WP_Comment_Query (class-wp-comment-query.php:485), WP_Site_Query (class-wp-site-query.php:386), and WP_Network_Query (class-wp-network-query.php:288) compute max_num_pages as (int) ceil( $found / $number ) from a filterable found count, without the clamp. A negative found_comments gives WP_Comment_Query a negative max_num_pages. blocks.php:3198 only guards against 0, so it then passes that negative value on as paged. Applying the same max( 0, … ) clamp there, plus @phpstan-var int<0, max> on those properties, would make the four classes consistent.

Separately

The cache-hit path at class-wp-query.php:3281 assigns $cached_results['max_num_pages'] without validation. PHPStan level 10 flags it as mixed, and the clamp doesn't cover that path. It was set aside as out of scope for this PR.

`paginate_links()` returns null for any total under 2, so the test passed
both with and without the `max( 1, … )` clamp and could not catch a
regression. The clamp's behavior is covered in `WP_Query` instead.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 92a8904b-c6d4-4ac3-8ba5-7d8209aed336
The `?? 1` fallback, and the `isset()` ternary it replaced, only ever
applied to an unset property, which never happens on `WP_Query`. So a
`max_num_pages` of 0 always passed through as 0, and clamping the
default to at least 1 changed nothing observable: the function returns
null for any total under 2. The cast is unnecessary as well, since
`$args['total']` is cast to int before it is used.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 92a8904b-c6d4-4ac3-8ba5-7d8209aed336
wporg-sync pushed a commit that referenced this pull request Nov 5, 2026
Since `WP_Query::$max_num_pages` is never `null`, the `?? 1` fallback in `paginate_links()` never applied, so the property is now read directly.

The property is also documented as a non-negative integer. This exposed that `WP_Query::set_found_posts()` could compute a negative page count when a `found_posts` filter returns a negative number, or when a filter injects a `LIMIT` into a query with a `posts_per_page` of `-1`, so that result is now clamped to zero. For the same reason the unreachable `isset()` fallback in `WP_Sitemaps_Posts::get_max_num_pages()` is removed, retiring its baseline entry.

Developed in #15475.
Follow-up to r48476, r57648, r61454, r65192.

Props bgturner, westonruter.
See #65817.


git-svn-id: https://develop.svn.wordpress.org/trunk@65281 602fd350-edb4-49c9-b593-d223f7449a82
@westonruter westonruter closed this Nov 5, 2026
wporg-sync pushed a commit to WordPress/WordPress that referenced this pull request Nov 5, 2026
Since `WP_Query::$max_num_pages` is never `null`, the `?? 1` fallback in `paginate_links()` never applied, so the property is now read directly.

The property is also documented as a non-negative integer. This exposed that `WP_Query::set_found_posts()` could compute a negative page count when a `found_posts` filter returns a negative number, or when a filter injects a `LIMIT` into a query with a `posts_per_page` of `-1`, so that result is now clamped to zero. For the same reason the unreachable `isset()` fallback in `WP_Sitemaps_Posts::get_max_num_pages()` is removed, retiring its baseline entry.

Developed in WordPress/wordpress-develop#15475.
Follow-up to r48476, r57648, r61454, r65192.

Props bgturner, westonruter.
See #65817.

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


git-svn-id: http://core.svn.wordpress.org/trunk@64430 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