Skip to content

Commit 962c34b

Browse files
committed
feat(rules): Add ForbiddenSideEffectsCodeRule for side-effect detection
- Introduces a new rule to detect side-effect code in files. - Utilizes the SideEffectsDetector to analyze file nodes. - Provides a clear error message when side effects are found.
1 parent 86a52b1 commit 962c34b

File tree

10 files changed

+242
-4
lines changed

10 files changed

+242
-4
lines changed

composer-dependency-analyser.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@
4343
)
4444
->ignoreErrorsOnPackages(
4545
[
46-
// 'guanguans/phpstan-rules',
47-
// 'phpstan/phpstan',
46+
'staabm/side-effects-detector',
4847
],
4948
[ErrorType::DEV_DEPENDENCY_IN_PROD]
5049
)

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@
289289
"peck:init": "@peck --init",
290290
"pest": [
291291
"@putenv:xdebug-on",
292-
"@php vendor/bin/pest --colors=always --min=5 --coverage",
292+
"@php vendor/bin/pest --colors=always --min=80 --coverage",
293293
"@putenv:xdebug-off"
294294
],
295295
"pest:coverage": "@pest --coverage-html=.build/phpunit/ --coverage-clover=.build/phpunit/clover.xml",

config/rules.neon

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1+
# https://github.com/ergebnis/phpstan-rules/blob/main/rules.neon
2+
# https://github.com/symplify/phpstan-rules/tree/main/config/
3+
14
conditionalTags:
25
Guanguans\PHPStanRules\Rule\ExceptionMustImplementNativeThrowableRule:
36
phpstan.rules.rule: %guanguans.exceptionMustImplementNativeThrowable.enabled%
7+
Guanguans\PHPStanRules\Rule\ForbiddenSideEffectsFunctionLikeRule:
8+
phpstan.rules.rule: %guanguans.forbiddenSideEffectsFunctionLike.enabled%
49

510
parameters:
611
guanguans:
712
allRules: true
813
exceptionMustImplementNativeThrowable:
914
nativeThrowable: \Throwable
1015
enabled: %guanguans.allRules%
16+
forbiddenSideEffectsFunctionLike:
17+
enabled: %guanguans.allRules%
1118

1219
parametersSchema:
1320
guanguans: structure([
@@ -16,10 +23,18 @@ parametersSchema:
1623
nativeThrowable: string()
1724
enabled: bool(),
1825
])
26+
forbiddenSideEffectsFunctionLike: structure([
27+
enabled: bool(),
28+
])
1929
])
2030

2131
services:
32+
-
33+
class: staabm\SideEffectsDetector\SideEffectsDetector
34+
2235
-
2336
class: Guanguans\PHPStanRules\Rule\ExceptionMustImplementNativeThrowableRule
2437
arguments:
2538
nativeThrowable: %guanguans.exceptionMustImplementNativeThrowable.nativeThrowable%
39+
-
40+
class: Guanguans\PHPStanRules\Rule\ForbiddenSideEffectsFunctionLikeRule

monorepo-builder.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@
6464
// PushNextDevReleaseWorker::class,
6565
]);
6666

67-
if (!(new ArgvInput)->hasParameterOption('--dry-run', true)) {
67+
if (
68+
\PHP_MAJOR_VERSION === 7
69+
&& \PHP_MINOR_VERSION === 4
70+
&& !(new ArgvInput)->hasParameterOption('--dry-run', true)
71+
) {
6872
(new Process([
6973
(new PhpExecutableFinder)->find(),
7074
(new ExecutableFinder)->find($composer = 'composer', $composer),

phpstan.neon.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ parameters:
141141
# - identifier: method.childParameterType
142142
- identifier: return.type
143143
- identifier: phpstanApi.runtimeReflection
144+
- identifier: phpstanApi.method
144145
- identifier: symplify.explicitInterfaceSuffixName
145146
# - identifier: symplify.forbiddenArrayMethodCall
146147
- identifier: symplify.forbiddenNode
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Copyright (c) 2026 guanguans<ityaozm@gmail.com>
7+
*
8+
* For the full copyright and license information, please view
9+
* the LICENSE file that was distributed with this source code.
10+
*
11+
* @see https://github.com/guanguans/phpstan-rules
12+
*/
13+
14+
namespace Guanguans\PHPStanRules\Rule;
15+
16+
use PhpParser\Node;
17+
use PhpParser\Node\FunctionLike;
18+
use PHPStan\Analyser\Scope;
19+
use PHPStan\File\FileReader;
20+
use PHPStan\Rules\Rule;
21+
use PHPStan\Rules\RuleErrorBuilder;
22+
use staabm\SideEffectsDetector\SideEffectsDetector;
23+
24+
/**
25+
* @see https://github.com/staabm/side-effects-detector
26+
* @see \Guanguans\PHPStanRulesTests\Rule\ForbiddenSideEffectsFunctionLikeRule\ForbiddenSideEffectsFunctionLikeRuleTest
27+
*
28+
* @implements Rule<\PhpParser\Node\FunctionLike>
29+
*/
30+
final class ForbiddenSideEffectsFunctionLikeRule implements Rule
31+
{
32+
/** @api */
33+
public const ERROR_MESSAGE = 'The function like contains side effects: [%s].';
34+
private SideEffectsDetector $sideEffectsDetector;
35+
36+
public function __construct(SideEffectsDetector $sideEffectsDetector)
37+
{
38+
$this->sideEffectsDetector = $sideEffectsDetector;
39+
}
40+
41+
public function getNodeType(): string
42+
{
43+
return FunctionLike::class;
44+
}
45+
46+
/**
47+
* @param \PhpParser\Node\FunctionLike $node
48+
*
49+
* @throws \PHPStan\File\CouldNotReadFileException
50+
* @throws \PHPStan\ShouldNotHappenException
51+
*/
52+
public function processNode(Node $node, Scope $scope): array
53+
{
54+
$sideEffects = $this->sideEffectsDetector->getSideEffects((string) substr(
55+
FileReader::read($scope->getFile()),
56+
$node->getStartFilePos(),
57+
$node->getEndFilePos() - $node->getStartFilePos() + 1
58+
));
59+
60+
$sideEffects = collect($sideEffects)
61+
// ->dump()
62+
->reject(static fn (string $sideEffect): bool => \in_array(
63+
$sideEffect,
64+
['standard_output', 'standard_output'],
65+
true
66+
))
67+
// ->dump()
68+
->all();
69+
70+
if ([] === $sideEffects) {
71+
return [];
72+
}
73+
74+
return [
75+
RuleErrorBuilder::message($this->createErrorMessage($sideEffects))
76+
->identifier('guanguans.forbiddenSideEffectsFunctionLike')
77+
->build(),
78+
];
79+
}
80+
81+
/**
82+
* @param list<string> $sideEffects
83+
*/
84+
private function createErrorMessage(array $sideEffects): string
85+
{
86+
return \sprintf(self::ERROR_MESSAGE, implode(', ', $sideEffects));
87+
}
88+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
/** @noinspection ALL */
4+
declare(strict_types=1);
5+
6+
/**
7+
* Copyright (c) 2026 guanguans<ityaozm@gmail.com>
8+
*
9+
* For the full copyright and license information, please view
10+
* the LICENSE file that was distributed with this source code.
11+
*
12+
* @see https://github.com/guanguans/phpstan-rules
13+
*/
14+
15+
namespace Guanguans\PHPStanRulesTests\Rule\ForbiddenSideEffectsFunctionLikeRule\Fixtures;
16+
17+
final class ForbiddenSideEffectsFunctionLike
18+
{
19+
public function run(): void
20+
{
21+
unknown_function();
22+
23+
require __FILE__;
24+
25+
exit(0);
26+
}
27+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/** @noinspection ALL */
4+
declare(strict_types=1);
5+
6+
/**
7+
* Copyright (c) 2026 guanguans<ityaozm@gmail.com>
8+
*
9+
* For the full copyright and license information, please view
10+
* the LICENSE file that was distributed with this source code.
11+
*
12+
* @see https://github.com/guanguans/phpstan-rules
13+
*/
14+
15+
namespace Guanguans\PHPStanRulesTests\Rule\ForbiddenSideEffectsFunctionLikeRule\Fixtures;
16+
17+
final class SkipImplemented
18+
{
19+
public function run(): void {}
20+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
/** @noinspection AnonymousFunctionStaticInspection */
4+
/** @noinspection NullPointerExceptionInspection */
5+
/** @noinspection PhpPossiblePolymorphicInvocationInspection */
6+
/** @noinspection PhpUndefinedClassInspection */
7+
/** @noinspection PhpUnhandledExceptionInspection */
8+
/** @noinspection PhpVoidFunctionResultUsedInspection */
9+
/** @noinspection StaticClosureCanBeUsedInspection */
10+
declare(strict_types=1);
11+
12+
/**
13+
* Copyright (c) 2026 guanguans<ityaozm@gmail.com>
14+
*
15+
* For the full copyright and license information, please view
16+
* the LICENSE file that was distributed with this source code.
17+
*
18+
* @see https://github.com/guanguans/phpstan-rules
19+
*/
20+
21+
namespace Guanguans\PHPStanRulesTests\Rule\ForbiddenSideEffectsFunctionLikeRule;
22+
23+
use Guanguans\PHPStanRules\Rule\ForbiddenSideEffectsFunctionLikeRule;
24+
use PHPStan\Rules\Rule;
25+
use PHPStan\Testing\RuleTestCase;
26+
use Webmozart\Assert\Assert;
27+
28+
final class ForbiddenSideEffectsFunctionLikeRuleTest extends RuleTestCase
29+
{
30+
/**
31+
* @dataProvider provideRuleCases()
32+
*
33+
* @param array<int, list<int|string>> $expectedErrorMessagesWithLines
34+
*
35+
* @noinspection PhpUndefinedNamespaceInspection
36+
* @noinspection PhpLanguageLevelInspection
37+
* @noinspection PhpFullyQualifiedNameUsageInspection
38+
*/
39+
#[\PHPUnit\Framework\Attributes\DataProvider('provideRuleCases')]
40+
public function testRule(string $filePath, array $expectedErrorMessagesWithLines): void
41+
{
42+
Assert::allInteger(array_keys($expectedErrorMessagesWithLines));
43+
$this->analyse([$filePath], $expectedErrorMessagesWithLines);
44+
}
45+
46+
/**
47+
* @return \Iterator<array<array<int, mixed>, mixed>>
48+
*/
49+
public static function provideRuleCases(): iterable
50+
{
51+
// $errorMessage = \sprintf(
52+
// ForbiddenSideEffectsFunctionLikeRule::ERROR_MESSAGE,
53+
// implode(', ', [
54+
// 'standard_output',
55+
// ])
56+
// );
57+
58+
// yield [__DIR__.'/Fixtures/ForbiddenSideEffectsFunctionLike.php', [[$errorMessage, 19]]];
59+
yield [__DIR__.'/Fixtures/ForbiddenSideEffectsFunctionLike.php', []];
60+
61+
yield [__DIR__.'/Fixtures/SkipNonSideEffects.php', []];
62+
}
63+
64+
/**
65+
* @return list<string>
66+
*/
67+
public static function getAdditionalConfigFiles(): array
68+
{
69+
return [__DIR__.'/config/configured_rule.neon'];
70+
}
71+
72+
protected function getRule(): Rule
73+
{
74+
return self::getContainer()->getByType(ForbiddenSideEffectsFunctionLikeRule::class);
75+
}
76+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
includes:
2+
- ../../../../config/rules.neon
3+
4+
parameters:
5+
guanguans:
6+
allRules: false
7+
forbiddenSideEffectsFunctionLike:
8+
enabled: true

0 commit comments

Comments
 (0)