Skip to content

Commit 7f3897e

Browse files
jrfnlgsherwood
authored andcommitted
PHP 8.2 | Ruleset: prevent notices about dynamic properties being set
The BC-breaks can be broken down as follows: If a property is set via a ruleset for an **_individual_** sniff and that sniff does not have the property explicitly declared, nor does the sniff declare the PHP magic `__set()` method or `extend stdClass`, the ruleset will now be regarded as invalid. In practice, this means the PHPCS run will halt and will show an informative notice to the end-user about the invalid property setting and the PHPCS run will exit with exit code `3`. For properties being set for a complete category of sniffs or a complete standard, the PHPCS run will not halt, nor show an error when the property isn't supported for all sniffs in the category/standard. In that case, the property will only be set on those sniffs which support it (i.e. either have the property explicitly declared on the sniff, or have the magic `__set()` method declared on the sniff, or `extend stdClass`) and will be silently ignored for all other sniffs. :point_right: Aside from possibly surfacing some incidental typos in property settings in rulesets, I expect this change to go mostly unnoticed by end-users. 1. The format of the `$ruleset['sniff code']['properties']['property name']` sub-array has changed from a `array|string` type property value to a sub-array containing two entries: 1. `'value'` - containing the `array|string` type property value. 2. `'scope'` - containing a string, either `sniff` or `standard`, to indicate whether the ruleset property directive was for an individual sniff or for a whole standard/category of sniffs. 2. The third parameter for the `Ruleset::setSniffProperty()` method has been changed to expect the above mentioned array of `$settings` instead of just a property value. This change is necessary to allow for throwing the error notice when an invalid property is being set on an invalid sniff versus silently ignoring the invalid property if the ruleset specified it on a standard/category. :point_right: The impact of this BC-break is expected to be small, as it would require for an external standard or integration of PHPCS to: * `extend` the `Ruleset` class **and** overload the `setSniffProperty()` method to have any impact; * or to call the `setSniffProperty()` method on the `Ruleset` object directly (which _may_ happen in custom test frameworks for external standards). Note: * The change to the `processRule()` method cannot be overloaded as it is a `private` method. * The change in the `populateTokenListeners()` method is only cosmetic and has no functional impact. --- For consistent handling of properties set in a (custom) ruleset across PHP versions, I have chosen to only support setting properties when: * A property is explicitly declared on a sniff class. * A sniff class explicitly declares (or inherits) the magic `__set()` method. * A sniff class `extends` `stdClass`. Note: no additional check has been added to verify that a declared property is `public`. If that is not the case a PHP native error would be thrown previously and still will be now. This behaviour has not changed. I have chosen **not** to respect the `#[\AllowDynamicProperties]` attribute as it is not possible (without tokenizing the sniff files which would create a lot of overhead) to determine whether that attribute exists on a class when PHPCS runs on PHP < 8.0 as the Reflection method needed to detect the attribute is only available on PHP 8.0+. In other words, adding support for the attribute would introduce an inconsistency in how properties set in a ruleset are handled based on the PHP version on which PHPCS is being run. It would also mean that the error notice for invalid properties set on individual sniffs would only be available on PHP 8.2+, which would greatly reduce the usefulness of the error notice. In my opinion, consistency here is more important than supporting the attribute, especially as there are three fully supported ways to allow for supporting properties to be set from a ruleset. The three above mentioned ways to allow for setting properties from a ruleset for a sniff are all fully supported on all PHP versions currently supported by PHPCS, so there is no compelling reason to also allow for the attribute. > PHP 8.2: prevent deprecation notices for properties set in a (custom) ruleset for complete standards/complete sniff categories > - Invalid properties set for individual sniffs will now result in an error and halt the execution of PHPCS. > - Properties set for complete standards/complete sniff categories will now only be set on sniffs which explicitly support the property. > The property will be silently ignored for those sniffs which do not support the property. > - For sniff developers: it is strongly recommended for sniffs to explicitly declare any user-adjustable `public` properties. > If dynamic properties need to be supported for a sniff, either declare the magic `__set()`/`__get()`/`__isset()`/`__unset()` methods on the sniff or let the sniff extend `stdClass`. > Note: The `#[\AllowDynamicProperties]` attribute will have **no effect** for properties which are being set in rulesets. > - Sniff developers/integrators of PHPCS may need to make some small adjustments to allow for changes to the PHPCS internals. See the description of PR 3629 for full details. The changes are accompanied by a full set of tests covering the change. There is a little overlap between the `RuleInclusionTest` class and the new `SetSniffPropertyTest` class, but for the most part, the tests compliment each other. Includes: * Correct handling of properties being set via `phpcs:set` annotations. * The `RuleInclusionTest` XML fixture and test expectations have been adjusted to allow for these changes. 1. As we now need "settable" properties to exist on sniffs to use for the `testSettingProperties()` test, the `PSR1` ruleset is not sufficient as the sniffs in that ruleset don't have public properties. For that reason, the "set property for complete standard" test has been changed to use `PSR2` instead and set the `indent` property. The test expectations has been adjusted to match. 2. Along the same lines, the `Zend.NamingConventions` sniffs don't have public properties, so the "setting property for complete category" test has been switched to `PSR12.Operators` and set the `ignoreSpacingBeforeAssignments` property. The test expectations has been adjusted to match. 3. In both these cases, the XML `<rule>` now contains both a property which is valid for at least some sniffs in the standard/category and a property which is invalid in for all sniffs. For the _invalid_ properties, a separate `testSettingInvalidPropertiesOnStandardsAndCategoriesSilentlyFails()` test with data provider has been added to verify these will not be set. 4. As the ruleset has changed, the expectations for the `testHasSniffCodes()` test and the `testRegisteredSniffCodes()` test have been updated to match. 5. The test descriptions for the test cases in the `dataSettingProperties()` have also been updated to make it more explicit what each test case is testing. * The PHPCS ruleset has been updated to exclude sniffs which function as test fixtures from the PHPCS check for this repo. * A minor tweak has been made to the `ValidatePEARPackageXML` script to prevent a warning about sniffs which function as test fixtures. These test fixtures should receive the `role="test"` attribute, not a `role="php"` attribute. Fixes 3489
1 parent a9f6260 commit 7f3897e

18 files changed

+707
-43
lines changed

phpcs.xml.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
<exclude-pattern>*/src/Standards/*/Tests/*\.(inc|css|js)$</exclude-pattern>
1313
<exclude-pattern>*/tests/Core/*/*\.(inc|css|js)$</exclude-pattern>
14+
<exclude-pattern>*/tests/Core/*/Fixtures/*\.php$</exclude-pattern>
1415

1516
<arg name="basepath" value="."/>
1617
<arg name="colors"/>

src/Files/File.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,9 +361,12 @@ public function process()
361361
$listenerCode = $token['sniffCode'];
362362
if (isset($this->ruleset->sniffCodes[$listenerCode]) === true) {
363363
$propertyCode = $token['sniffProperty'];
364-
$propertyValue = $token['sniffPropertyValue'];
364+
$settings = [
365+
'value' => $token['sniffPropertyValue'],
366+
'scope' => 'sniff',
367+
];
365368
$listenerClass = $this->ruleset->sniffCodes[$listenerCode];
366-
$this->ruleset->setSniffProperty($listenerClass, $propertyCode, $propertyValue);
369+
$this->ruleset->setSniffProperty($listenerClass, $propertyCode, $settings);
367370
}
368371
}
369372
}//end if

src/Ruleset.php

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use PHP_CodeSniffer\Exceptions\RuntimeException;
1515
use PHP_CodeSniffer\Util\Common;
1616
use PHP_CodeSniffer\Util\Standards;
17+
use stdClass;
1718

1819
class Ruleset
1920
{
@@ -932,6 +933,11 @@ private function processRule($rule, $newSniffs, $depth=0)
932933
if (isset($rule->properties) === true
933934
&& $this->shouldProcessElement($rule->properties) === true
934935
) {
936+
$propertyScope = 'standard';
937+
if ($code === $ref || substr($ref, -9) === 'Sniff.php') {
938+
$propertyScope = 'sniff';
939+
}
940+
935941
foreach ($rule->properties->property as $prop) {
936942
if ($this->shouldProcessElement($prop) === false) {
937943
continue;
@@ -952,9 +958,9 @@ private function processRule($rule, $newSniffs, $depth=0)
952958
$values = [];
953959
if (isset($prop['extend']) === true
954960
&& (string) $prop['extend'] === 'true'
955-
&& isset($this->ruleset[$code]['properties'][$name]) === true
961+
&& isset($this->ruleset[$code]['properties'][$name]['value']) === true
956962
) {
957-
$values = $this->ruleset[$code]['properties'][$name];
963+
$values = $this->ruleset[$code]['properties'][$name]['value'];
958964
}
959965

960966
if (isset($prop->element) === true) {
@@ -978,7 +984,10 @@ private function processRule($rule, $newSniffs, $depth=0)
978984
$printValue = rtrim($printValue, ',');
979985
}
980986

981-
$this->ruleset[$code]['properties'][$name] = $values;
987+
$this->ruleset[$code]['properties'][$name] = [
988+
'value' => $values,
989+
'scope' => $propertyScope,
990+
];
982991
if (PHP_CODESNIFFER_VERBOSITY > 1) {
983992
$statusMessage = "=> array property \"$name\" set to \"$printValue\"";
984993
if ($code !== $ref) {
@@ -988,7 +997,10 @@ private function processRule($rule, $newSniffs, $depth=0)
988997
Common::printStatusMessage($statusMessage, ($depth + 2));
989998
}
990999
} else {
991-
$this->ruleset[$code]['properties'][$name] = (string) $prop['value'];
1000+
$this->ruleset[$code]['properties'][$name] = [
1001+
'value' => (string) $prop['value'],
1002+
'scope' => $propertyScope,
1003+
];
9921004
if (PHP_CODESNIFFER_VERBOSITY > 1) {
9931005
$statusMessage = "=> property \"$name\" set to \"".(string) $prop['value'].'"';
9941006
if ($code !== $ref) {
@@ -1177,8 +1189,8 @@ public function populateTokenListeners()
11771189

11781190
// Set custom properties.
11791191
if (isset($this->ruleset[$sniffCode]['properties']) === true) {
1180-
foreach ($this->ruleset[$sniffCode]['properties'] as $name => $value) {
1181-
$this->setSniffProperty($sniffClass, $name, $value);
1192+
foreach ($this->ruleset[$sniffCode]['properties'] as $name => $settings) {
1193+
$this->setSniffProperty($sniffClass, $name, $settings);
11821194
}
11831195
}
11841196

@@ -1234,18 +1246,48 @@ public function populateTokenListeners()
12341246
*
12351247
* @param string $sniffClass The class name of the sniff.
12361248
* @param string $name The name of the property to change.
1237-
* @param string $value The new value of the property.
1249+
* @param array $settings Array with the new value of the property and the scope of the property being set.
12381250
*
12391251
* @return void
1252+
*
1253+
* @throws \PHP_CodeSniffer\Exceptions\RuntimeException When attempting to set a non-existent property on a sniff
1254+
* which doesn't declare the property or explicitly supports
1255+
* dynamic properties.
12401256
*/
1241-
public function setSniffProperty($sniffClass, $name, $value)
1257+
public function setSniffProperty($sniffClass, $name, $settings)
12421258
{
12431259
// Setting a property for a sniff we are not using.
12441260
if (isset($this->sniffs[$sniffClass]) === false) {
12451261
return;
12461262
}
12471263

1248-
$name = trim($name);
1264+
$name = trim($name);
1265+
$propertyName = $name;
1266+
if (substr($propertyName, -2) === '[]') {
1267+
$propertyName = substr($propertyName, 0, -2);
1268+
}
1269+
1270+
$isSettable = false;
1271+
$sniffObject = $this->sniffs[$sniffClass];
1272+
if (property_exists($sniffObject, $propertyName) === true
1273+
|| ($sniffObject instanceof stdClass) === true
1274+
|| method_exists($sniffObject, '__set') === true
1275+
) {
1276+
$isSettable = true;
1277+
}
1278+
1279+
if ($isSettable === false) {
1280+
if ($settings['scope'] === 'sniff') {
1281+
$notice = "Ruleset invalid. Property \"$propertyName\" does not exist on sniff ";
1282+
$notice .= array_search($sniffClass, $this->sniffCodes, true);
1283+
throw new RuntimeException($notice);
1284+
}
1285+
1286+
return;
1287+
}
1288+
1289+
$value = $settings['value'];
1290+
12491291
if (is_string($value) === true) {
12501292
$value = trim($value);
12511293
}
@@ -1260,7 +1302,7 @@ public function setSniffProperty($sniffClass, $name, $value)
12601302
} else if ($value === 'false') {
12611303
$value = false;
12621304
} else if (substr($name, -2) === '[]') {
1263-
$name = substr($name, 0, -2);
1305+
$name = $propertyName;
12641306
$values = [];
12651307
if ($value !== null) {
12661308
foreach (explode(',', $value) as $val) {
@@ -1276,7 +1318,7 @@ public function setSniffProperty($sniffClass, $name, $value)
12761318
$value = $values;
12771319
}
12781320

1279-
$this->sniffs[$sniffClass]->$name = $value;
1321+
$sniffObject->$name = $value;
12801322

12811323
}//end setSniffProperty()
12821324

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
/**
3+
* Test fixture.
4+
*
5+
* @see \PHP_CodeSniffer\Tests\Core\Ruleset\SetSniffPropertyTest
6+
*/
7+
8+
namespace Fixtures\Sniffs\Category;
9+
10+
use PHP_CodeSniffer\Files\File;
11+
use PHP_CodeSniffer\Sniffs\Sniff;
12+
13+
class SetPropertyAllowedAsDeclaredSniff implements Sniff
14+
{
15+
16+
public $arbitrarystring;
17+
public $arbitraryarray;
18+
19+
public function register()
20+
{
21+
return [T_WHITESPACE];
22+
}
23+
24+
public function process(File $phpcsFile, $stackPtr)
25+
{
26+
// Do something.
27+
}
28+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
/**
3+
* Test fixture.
4+
*
5+
* @see \PHP_CodeSniffer\Tests\Core\Ruleset\SetSniffPropertyTest
6+
*/
7+
8+
namespace Fixtures\Sniffs\Category;
9+
10+
use PHP_CodeSniffer\Files\File;
11+
use PHP_CodeSniffer\Sniffs\Sniff;
12+
13+
class SetPropertyAllowedViaMagicMethodSniff implements Sniff
14+
{
15+
private $magic = [];
16+
17+
public function __set($name, $value)
18+
{
19+
$this->magic[$name] = $value;
20+
}
21+
22+
public function __get($name)
23+
{
24+
if (isset($this->magic[$name])) {
25+
return $this->magic[$name];
26+
}
27+
28+
return null;
29+
}
30+
31+
public function register()
32+
{
33+
return [T_WHITESPACE];
34+
}
35+
36+
public function process(File $phpcsFile, $stackPtr)
37+
{
38+
// Do something.
39+
}
40+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
/**
3+
* Test fixture.
4+
*
5+
* @see \PHP_CodeSniffer\Tests\Core\Ruleset\SetSniffPropertyTest
6+
*/
7+
8+
namespace Fixtures\Sniffs\Category;
9+
10+
use PHP_CodeSniffer\Files\File;
11+
use PHP_CodeSniffer\Sniffs\Sniff;
12+
use stdClass;
13+
14+
class SetPropertyAllowedViaStdClassSniff extends stdClass implements Sniff
15+
{
16+
17+
public function register()
18+
{
19+
return [T_WHITESPACE];
20+
}
21+
22+
public function process(File $phpcsFile, $stackPtr)
23+
{
24+
// Do something.
25+
}
26+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
/**
3+
* Test fixture.
4+
*
5+
* @see \PHP_CodeSniffer\Tests\Core\Ruleset\SetSniffPropertyTest
6+
*/
7+
8+
namespace Fixtures\Sniffs\Category;
9+
10+
use AllowDynamicProperties;
11+
use PHP_CodeSniffer\Files\File;
12+
use PHP_CodeSniffer\Sniffs\Sniff;
13+
14+
#[AllowDynamicProperties]
15+
class SetPropertyNotAllowedViaAttributeSniff implements Sniff
16+
{
17+
18+
public function register()
19+
{
20+
return [T_WHITESPACE];
21+
}
22+
23+
public function process(File $phpcsFile, $stackPtr)
24+
{
25+
// Do something.
26+
}
27+
}

0 commit comments

Comments
 (0)