Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions docs/architecture-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -1071,8 +1071,8 @@ and the capability interfaces of [the core idea](design-principles.md#how-servic
the plugin waits until a service's condition can answer before building it.

**Checks:** when `is_needed()` of a class implementing `Conditional` calls a
configured query-dependent function directly, the class also implements
`Delayed`.
configured query- or screen-dependent function directly, the class also
implements `Delayed`.

**Allows:** a `Conditional` that only calls request checks such as
`is_admin()`, classes that do not implement `Conditional`, and calls in other
Expand All @@ -1084,13 +1084,21 @@ Neither proves that the action runs late enough for the condition.
**Configuration:** `queryContextNeedsDelay.queryFunctions` lists the
query-dependent functions: `is_singular`, `is_single`, `is_page`,
`is_front_page`, `is_home`, `is_archive`, `is_search`, `is_404`,
`is_attachment`, `is_feed` and `is_main_query`. The capability interfaces'
namespace comes from `dependencyDirection.infrastructure`.

**Fix:** implement `Delayed` and return `wp` or a later action from
`get_registration_action()`. The plugin waits for that action before asking
`is_needed()`, so the query has run when the condition reads it. See
[I need to hook in later](patterns.md#i-need-to-hook-in-later) and
`is_attachment`, `is_feed` and `is_main_query`.
`queryContextNeedsDelay.screenFunctions` lists the screen-dependent functions:
`get_current_screen`. Set `screenFunctions: []` to disable the screen check.
The capability interfaces' namespace comes from
`dependencyDirection.infrastructure`.

**Fix:** for query-dependent conditions, implement `Delayed` and return `wp`
or a later action from `get_registration_action()`. The plugin waits for that
action before asking `is_needed()`, so the query has run when the condition
reads it.

For screen-dependent conditions, return `current_screen` or a later admin action:
WordPress has not set the admin screen when services are normally checked at
`plugins_loaded`. The two checks have separate messages under the same
identifier. See [I need to hook in later](patterns.md#i-need-to-hook-in-later) and
[I need a service only in some requests](patterns.md#i-need-a-service-only-in-some-requests).

### `architecture.registrationActionKnown`
Expand Down
5 changes: 5 additions & 0 deletions docs/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ When the answer depends on the query, such as `is_singular()`, also implement
`Delayed` and wait for `wp` or a later action as shown in
[I need to hook in later](#i-need-to-hook-in-later).

When the answer depends on the admin screen, such as `get_current_screen()`,
also implement `Delayed` and wait for `current_screen` or a later admin
action. WordPress has not set the screen at `plugins_loaded`, when the
plugin normally asks `is_needed()`.

**Rules:** `architecture.noContextChecksInRegister`,
`architecture.queryContextNeedsDelay`.

Expand Down
3 changes: 3 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,9 @@ parameters:
- is_attachment
- is_feed
- is_main_query
# The admin screen is available from current_screen onward.
screenFunctions:
- get_current_screen
# The prefix of each kind of name the plugin creates, and the class
# constants that hold it (design principle 13). Rename the prefixes
# together with the plugin.
Expand Down
3 changes: 2 additions & 1 deletion tests/php/Architecture/RuleManifest.php
Original file line number Diff line number Diff line change
Expand Up @@ -450,12 +450,13 @@ final class RuleManifest {
'principle' => 'Principle 7: Show the alternative to WordPress anti-patterns',
'principle_anchor' => '7-show-the-alternative-to-wordpress-anti-patterns',
'pattern' => 'i-need-to-hook-in-later',
'configuration' => 'parameters.architecture.queryContextNeedsDelay.queryFunctions',
'configuration' => 'parameters.architecture.queryContextNeedsDelay',
'checked_by' => [
'MWPD\\BasicScaffold\\Tests\\PHPStan\\Rule\\QueryContextNeedsDelayRule',
],
'signatures' => [
'asks about the query before it is available',
'asks about the admin screen before WordPress has set it',
],
],
self::REGISTRATION_ACTION_KNOWN => [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare( strict_types=1 );

namespace MWPD\BasicScaffold\Tests\PHPStan\Fixture\Hooks\Feature;

use MWPD\BasicScaffold\Tests\PHPStan\Fixture\Hooks\Infrastructure\Conditional;
use MWPD\BasicScaffold\Tests\PHPStan\Fixture\Hooks\Infrastructure\Delayed;

final class DelayedScreenConditional implements Conditional, Delayed {

public static function is_needed(): bool {
return get_current_screen() !== null;
}

public static function get_registration_action(): string {
return 'current_screen';
}
}
14 changes: 14 additions & 0 deletions tests/php/PHPStan/Fixture/Hooks/Feature/ScreenConditional.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare( strict_types=1 );

namespace MWPD\BasicScaffold\Tests\PHPStan\Fixture\Hooks\Feature;

use MWPD\BasicScaffold\Tests\PHPStan\Fixture\Hooks\Infrastructure\Conditional;

final class ScreenConditional implements Conditional {

public static function is_needed(): bool {
return get_current_screen() !== null;
}
}
39 changes: 30 additions & 9 deletions tests/php/PHPStan/Rule/QueryContextNeedsDelayRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@
use PHPStan\Rules\RuleErrorBuilder;

/**
* Query-dependent conditions wait until WordPress has run the query.
* Conditions wait until WordPress has run the query or set the admin screen.
*
* Conditional::is_needed() runs before the service is built. Delayed lets
* the plugin wait before asking it, so query-dependent answers are meaningful.
* Only direct calls count: helpers and deferred callbacks are outside this
* check, just as they are for NoContextChecksInRegisterRule.
* the plugin wait before asking it, so query- and screen-dependent answers
* are meaningful. Only direct calls count: helpers and deferred callbacks are
* outside this check, just as they are for NoContextChecksInRegisterRule.
*
* @implements Rule<FuncCall>
*/
Expand All @@ -51,6 +51,13 @@ final class QueryContextNeedsDelayRule implements Rule {
*/
private array $query_functions;

/**
* Screen-dependent functions, in lowercase.
*
* @var array<string>
*/
private array $screen_functions;

/**
* Reflection, to tell which function a call refers to.
*/
Expand All @@ -63,18 +70,21 @@ final class QueryContextNeedsDelayRule implements Rule {
* @param string $infrastructure Namespace of the capability
* interfaces.
* @param array<string> $query_functions Query-dependent functions.
* @param array<string> $screen_functions Screen-dependent functions.
* @param ReflectionProvider $reflection Reflection, to tell which
* function a call refers to.
*/
public function __construct(
PluginNamespace $plugin_namespace,
string $infrastructure,
array $query_functions,
array $screen_functions,
ReflectionProvider $reflection
) {
$this->plugin_namespace = $plugin_namespace;
$this->infrastructure = trim( $infrastructure, '\\' );
$this->query_functions = array_map( 'strtolower', $query_functions );
$this->screen_functions = array_map( 'strtolower', $screen_functions );
$this->reflection = $reflection;
}

Expand All @@ -88,7 +98,7 @@ public function getNodeType(): string {
}

/**
* Report a query-dependent function called directly in is_needed().
* Report a query- or screen-dependent function called directly in is_needed().
*
* @param FuncCall $node Function call to inspect.
* @param Scope $scope Scope of the call.
Expand All @@ -110,16 +120,27 @@ public function processNode( Node $node, Scope $scope ): array {

$called = CalledFunction::name_of( $node, $scope, $this->reflection );

if ( $called === null || ! in_array( $called, $this->query_functions, true ) ) {
if ( $called === null ) {
return [];
}

if ( in_array( $called, $this->query_functions, true ) ) {
$message = '%1$s() in %2$s asks about the query before it is available. Implement Delayed and return'
. ' "wp" or a later action from get_registration_action(), so the plugin waits for the query'
. ' before checking is_needed().';
} elseif ( in_array( $called, $this->screen_functions, true ) ) {
$message = '%1$s() in %2$s asks about the admin screen before WordPress has set it.'
. ' Implement Delayed and return "current_screen" or a later admin action'
. ' from get_registration_action(),'
. ' so the plugin waits for the screen before checking is_needed().';
} else {
return [];
}

return [
RuleErrorBuilder::message(
sprintf(
'%1$s() in %2$s asks about the query before it is available. Implement Delayed and return'
. ' "wp" or a later action from get_registration_action(), so the plugin waits for the query'
. ' before checking is_needed().',
$message,
$called,
CalledFunction::describe_location( $scope )
)
Expand Down
68 changes: 67 additions & 1 deletion tests/php/PHPStan/Rule/QueryContextNeedsDelayRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ final class QueryContextNeedsDelayRuleTest extends RuleTestCase {
*/
private array $query_functions = [ 'IS_SINGULAR' ];

/**
* Screen-dependent functions, including a mixed-case configuration entry.
*
* @var array<string>
*/
private array $screen_functions = [ 'GET_CURRENT_SCREEN' ];

/**
* Namespaces outside the rule's scope.
*
Expand All @@ -46,6 +53,7 @@ protected function getRule(): Rule {
new PluginNamespace( $this->root, $this->excluded ),
$this->root . '\Infrastructure',
$this->query_functions,
$this->screen_functions,
$this->createReflectionProvider()
);
}
Expand Down Expand Up @@ -75,10 +83,52 @@ public function test_only_configured_functions_require_delayed(): void {
);
}

public function test_screen_conditions_require_delayed(): void {
$this->query_functions = [];

$this->analyse(
[
self::FIXTURES . 'Feature/ScreenConditional.php',
self::FIXTURES . 'Feature/DelayedScreenConditional.php',
],
[ $this->screen_error( 'get_current_screen', 'ScreenConditional' ) ]
);
}

public function test_only_configured_screen_functions_require_delayed(): void {
$this->screen_functions = [ 'IS_ADMIN' ];

$this->analyse(
[
self::FIXTURES . 'Feature/ScreenConditional.php',
self::FIXTURES . 'Feature/AdminConditional.php',
],
[ $this->screen_error( 'is_admin', 'AdminConditional' ) ]
);
}

public function test_an_empty_screen_functions_list_disables_the_screen_check(): void {
$this->screen_functions = [];

$this->analyse(
[
self::FIXTURES . 'Feature/ScreenConditional.php',
self::FIXTURES . 'Feature/QueryConditional.php',
],
[ $this->error( 'is_singular', 'QueryConditional' ) ]
);
}

public function test_excluded_namespaces_are_not_checked(): void {
$this->excluded = [ self::HOOKS . '\Feature' ];

$this->analyse( [ self::FIXTURES . 'Feature/QueryConditional.php' ], [] );
$this->analyse(
[
self::FIXTURES . 'Feature/QueryConditional.php',
self::FIXTURES . 'Feature/ScreenConditional.php',
],
[]
);
}

public function test_the_sample_loop_service_passes(): void {
Expand All @@ -104,4 +154,20 @@ private function error( string $called, string $class_name ): array {
. ' Rule: docs/architecture-rules.md#architecturequerycontextneedsdelay',
];
}

/**
* @return array{string, int, string}
*/
private function screen_error( string $called, string $class_name ): array {
return [
$called . '() in ' . self::HOOKS . '\\Feature\\' . $class_name . '::is_needed() asks about the admin screen'
. ' before WordPress has set it. Implement Delayed and return "current_screen" or a later admin action'
. ' from get_registration_action(), so the plugin waits for the screen before checking is_needed().',
12,
'Principle 7: Show the alternative to WordPress anti-patterns, see'
. ' docs/design-principles.md#7-show-the-alternative-to-wordpress-anti-patterns.'
. ' Pattern: docs/patterns.md#i-need-to-hook-in-later.'
. ' Rule: docs/architecture-rules.md#architecturequerycontextneedsdelay',
];
}
}
2 changes: 2 additions & 0 deletions tests/php/PHPStan/rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ parametersSchema:
])
queryContextNeedsDelay: structure([
queryFunctions: listOf(string())
screenFunctions: listOf(string())
])
# An open map keeps every kind optional, including postTypes, taxonomies,
# shortcodes, restRoutes, cron, adminPages, settings and ajax.
Expand Down Expand Up @@ -390,6 +391,7 @@ services:
plugin_namespace: @architecturePluginNamespace
infrastructure: %architecture.dependencyDirection.infrastructure%
query_functions: %architecture.queryContextNeedsDelay.queryFunctions%
screen_functions: %architecture.queryContextNeedsDelay.screenFunctions%
reflection: @PHPStan\Reflection\ReflectionProvider
tags:
- phpstan.rules.rule
Expand Down