Skip to content

Commit 9abd515

Browse files
phpstan-botstaabmclaude
authored
Validate define() and const values against explicit types in dynamicConstantNames (#5648)
Co-authored-by: staabm <120441+staabm@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Markus Staab <markus.staab@redaxo.de>
1 parent 93d00a6 commit 9abd515

17 files changed

Lines changed: 416 additions & 11 deletions

conf/bleedingEdge.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ parameters:
1818
curlSetOptArrayTypes: true
1919
checkDateIntervalConstructor: true
2020
reportMethodPurityOverride: true
21+
checkDynamicConstantNameValues: true

conf/config.level2.neon

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,20 @@ conditionalTags:
1515
phpstan.restrictedPropertyUsageExtension: %featureToggles.internalTag%
1616
PHPStan\Rules\InternalTag\RestrictedInternalMethodUsageExtension:
1717
phpstan.restrictedMethodUsageExtension: %featureToggles.internalTag%
18+
PHPStan\Rules\Constants\ValueAssignedToDefineRule:
19+
phpstan.rules.rule: %featureToggles.checkDynamicConstantNameValues%
20+
PHPStan\Rules\Constants\ValueAssignedToGlobalConstantRule:
21+
phpstan.rules.rule: %featureToggles.checkDynamicConstantNameValues%
1822

1923
services:
2024
-
2125
class: PHPStan\Rules\InternalTag\RestrictedInternalPropertyUsageExtension
2226

2327
-
2428
class: PHPStan\Rules\InternalTag\RestrictedInternalMethodUsageExtension
29+
30+
-
31+
class: PHPStan\Rules\Constants\ValueAssignedToDefineRule
32+
33+
-
34+
class: PHPStan\Rules\Constants\ValueAssignedToGlobalConstantRule

conf/config.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ parameters:
4545
curlSetOptArrayTypes: false
4646
checkDateIntervalConstructor: false
4747
reportMethodPurityOverride: false
48+
checkDynamicConstantNameValues: false
4849
fileExtensions:
4950
- php
5051
checkAdvancedIsset: false

conf/parametersSchema.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ parametersSchema:
4747
curlSetOptArrayTypes: bool()
4848
checkDateIntervalConstructor: bool()
4949
reportMethodPurityOverride: bool()
50+
checkDynamicConstantNameValues: bool()
5051
])
5152
fileExtensions: listOf(string())
5253
checkAdvancedIsset: bool()

src/Analyser/ConstantResolver.php

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ final class ConstantResolver
4545
/** @var array<string, true> */
4646
private array $currentlyResolving = [];
4747

48+
/** @var array<string, Type|null> */
49+
private array $configuredTypesCache = [];
50+
4851
/**
4952
* @param string[] $dynamicConstantNames
5053
* @param int|array{min: int, max: int}|null $phpVersion
@@ -414,16 +417,52 @@ private function getMaxPhpVersion(): ?PhpVersion
414417
return $this->composerPhpVersionFactory->getMaxVersion();
415418
}
416419

420+
public function getConfiguredGlobalConstantType(string $constantName): ?Type
421+
{
422+
if (array_key_exists($constantName, $this->configuredTypesCache)) {
423+
return $this->configuredTypesCache[$constantName];
424+
}
425+
426+
$result = null;
427+
if (array_key_exists($constantName, $this->dynamicConstantNames)) {
428+
$phpdocTypes = $this->dynamicConstantNames[$constantName];
429+
if ($this->container !== null) {
430+
$typeStringResolver = $this->container->getByType(TypeStringResolver::class);
431+
$result = $typeStringResolver->resolve($phpdocTypes, new NameScope(null, [], className: null));
432+
}
433+
}
434+
435+
$this->configuredTypesCache[$constantName] = $result;
436+
437+
return $result;
438+
}
439+
440+
public function getConfiguredClassConstantType(string $className, string $constantName): ?Type
441+
{
442+
$lookupConstantName = sprintf('%s::%s', $className, $constantName);
443+
if (array_key_exists($lookupConstantName, $this->configuredTypesCache)) {
444+
return $this->configuredTypesCache[$lookupConstantName];
445+
}
446+
447+
$result = null;
448+
if (array_key_exists($lookupConstantName, $this->dynamicConstantNames)) {
449+
$phpdocTypes = $this->dynamicConstantNames[$lookupConstantName];
450+
if ($this->container !== null) {
451+
$typeStringResolver = $this->container->getByType(TypeStringResolver::class);
452+
$result = $typeStringResolver->resolve($phpdocTypes, new NameScope(null, [], $className));
453+
}
454+
}
455+
456+
$this->configuredTypesCache[$lookupConstantName] = $result;
457+
458+
return $result;
459+
}
460+
417461
public function resolveConstantType(string $constantName, Type $constantType): Type
418462
{
419463
if ($constantType->isConstantValue()->yes()) {
420464
if (array_key_exists($constantName, $this->dynamicConstantNames)) {
421-
$phpdocTypes = $this->dynamicConstantNames[$constantName];
422-
if ($this->container !== null) {
423-
$typeStringResolver = $this->container->getByType(TypeStringResolver::class);
424-
return $typeStringResolver->resolve($phpdocTypes, new NameScope(null, [], className: null));
425-
}
426-
return $constantType;
465+
return $this->getConfiguredGlobalConstantType($constantName) ?? $constantType;
427466
}
428467
if (in_array($constantName, $this->dynamicConstantNames, true)) {
429468
return $this->generalizeDynamicConstantType($constantType);
@@ -438,10 +477,9 @@ public function resolveClassConstantType(string $className, string $constantName
438477
$lookupConstantName = sprintf('%s::%s', $className, $constantName);
439478
if (array_key_exists($lookupConstantName, $this->dynamicConstantNames)) {
440479
if ($constantType->isConstantValue()->yes()) {
441-
$phpdocTypes = $this->dynamicConstantNames[$lookupConstantName];
442-
if ($this->container !== null) {
443-
$typeStringResolver = $this->container->getByType(TypeStringResolver::class);
444-
return $typeStringResolver->resolve($phpdocTypes, new NameScope(null, [], $className));
480+
$explicitType = $this->getConfiguredClassConstantType($className, $constantName);
481+
if ($explicitType !== null) {
482+
return $explicitType;
445483
}
446484
}
447485

src/Rules/Constants/ValueAssignedToClassConstantRule.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
namespace PHPStan\Rules\Constants;
44

55
use PhpParser\Node;
6+
use PHPStan\Analyser\ConstantResolver;
67
use PHPStan\Analyser\Scope;
8+
use PHPStan\DependencyInjection\AutowiredParameter;
79
use PHPStan\DependencyInjection\RegisteredRule;
810
use PHPStan\Reflection\ClassReflection;
911
use PHPStan\Rules\IdentifierRuleError;
@@ -23,6 +25,14 @@
2325
final class ValueAssignedToClassConstantRule implements Rule
2426
{
2527

28+
public function __construct(
29+
private ConstantResolver $constantResolver,
30+
#[AutowiredParameter(ref: '%featureToggles.checkDynamicConstantNameValues%')]
31+
private bool $checkDynamicConstantNameValues,
32+
)
33+
{
34+
}
35+
2636
public function getNodeType(): string
2737
{
2838
return Node\Stmt\ClassConst::class;
@@ -62,6 +72,25 @@ private function processSingleConstant(ClassReflection $classReflection, string
6272
$phpDocType = $constantReflection->getPhpDocType();
6373
if ($phpDocType === null) {
6474
if ($nativeType === null) {
75+
if (!$this->checkDynamicConstantNameValues) {
76+
return [];
77+
}
78+
$configuredType = $this->constantResolver->getConfiguredClassConstantType($classReflection->getName(), $constantName);
79+
if ($configuredType !== null) {
80+
$accepts = $configuredType->accepts($valueExprType, true);
81+
if (!$accepts->yes()) {
82+
$verbosity = VerbosityLevel::getRecommendedLevelByType($configuredType, $valueExprType);
83+
return [
84+
RuleErrorBuilder::message(sprintf(
85+
'Configuration defined type for constant %s::%s (%s) does not accept value %s.',
86+
$constantReflection->getDeclaringClass()->getDisplayName(),
87+
$constantName,
88+
$configuredType->describe(VerbosityLevel::typeOnly()),
89+
$valueExprType->describe($verbosity),
90+
))->acceptsReasonsTip($accepts->reasons)->identifier('classConstant.value')->build(),
91+
];
92+
}
93+
}
6594
return [];
6695
}
6796

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Constants;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Expr\FuncCall;
7+
use PHPStan\Analyser\ConstantResolver;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Rules\Rule;
10+
use PHPStan\Rules\RuleErrorBuilder;
11+
use PHPStan\Type\VerbosityLevel;
12+
use function count;
13+
use function sprintf;
14+
use function strtolower;
15+
16+
/**
17+
* @implements Rule<FuncCall>
18+
*/
19+
final class ValueAssignedToDefineRule implements Rule
20+
{
21+
22+
public function __construct(private ConstantResolver $constantResolver)
23+
{
24+
}
25+
26+
public function getNodeType(): string
27+
{
28+
return FuncCall::class;
29+
}
30+
31+
public function processNode(Node $node, Scope $scope): array
32+
{
33+
if (!($node->name instanceof Node\Name)) {
34+
return [];
35+
}
36+
37+
if (strtolower((string) $node->name) !== 'define') {
38+
return [];
39+
}
40+
41+
$args = $node->getArgs();
42+
if (count($args) < 2) {
43+
return [];
44+
}
45+
46+
$constantNameStrings = $scope->getType($args[0]->value)->getConstantStrings();
47+
if (count($constantNameStrings) === 0) {
48+
return [];
49+
}
50+
51+
$valueType = $scope->getType($args[1]->value);
52+
$errors = [];
53+
54+
foreach ($constantNameStrings as $constantNameString) {
55+
$constantName = $constantNameString->getValue();
56+
if ($constantName === '') {
57+
continue;
58+
}
59+
60+
$configuredType = $this->constantResolver->getConfiguredGlobalConstantType($constantName);
61+
if ($configuredType === null) {
62+
continue;
63+
}
64+
65+
$accepts = $configuredType->accepts($valueType, true);
66+
if ($accepts->yes()) {
67+
continue;
68+
}
69+
70+
$verbosity = VerbosityLevel::getRecommendedLevelByType($configuredType, $valueType);
71+
72+
$errors[] = RuleErrorBuilder::message(sprintf(
73+
'Configuration defined type for constant %s (%s) does not accept value %s.',
74+
$constantName,
75+
$configuredType->describe(VerbosityLevel::typeOnly()),
76+
$valueType->describe($verbosity),
77+
))->acceptsReasonsTip($accepts->reasons)->identifier('constant.defineValue')->build();
78+
}
79+
80+
return $errors;
81+
}
82+
83+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Constants;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\ConstantResolver;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Rules\RuleErrorBuilder;
10+
use PHPStan\Type\VerbosityLevel;
11+
use function sprintf;
12+
13+
/**
14+
* @implements Rule<Node\Stmt\Const_>
15+
*/
16+
final class ValueAssignedToGlobalConstantRule implements Rule
17+
{
18+
19+
public function __construct(private ConstantResolver $constantResolver)
20+
{
21+
}
22+
23+
public function getNodeType(): string
24+
{
25+
return Node\Stmt\Const_::class;
26+
}
27+
28+
public function processNode(Node $node, Scope $scope): array
29+
{
30+
$errors = [];
31+
32+
foreach ($node->consts as $const) {
33+
if ($const->namespacedName !== null) {
34+
$constantName = $const->namespacedName->toString();
35+
} else {
36+
$constantName = $const->name->toString();
37+
}
38+
39+
$configuredType = $this->constantResolver->getConfiguredGlobalConstantType($constantName);
40+
if ($configuredType === null) {
41+
continue;
42+
}
43+
44+
$valueType = $scope->getType($const->value);
45+
$accepts = $configuredType->accepts($valueType, true);
46+
if ($accepts->yes()) {
47+
continue;
48+
}
49+
50+
$verbosity = VerbosityLevel::getRecommendedLevelByType($configuredType, $valueType);
51+
52+
$errors[] = RuleErrorBuilder::message(sprintf(
53+
'Configuration defined type for constant %s (%s) does not accept value %s.',
54+
$constantName,
55+
$configuredType->describe(VerbosityLevel::typeOnly()),
56+
$valueType->describe($verbosity),
57+
))->acceptsReasonsTip($accepts->reasons)->identifier('constant.value')->build();
58+
}
59+
60+
return $errors;
61+
}
62+
63+
}

tests/PHPStan/Rules/Constants/ValueAssignedToClassConstantRuleTest.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PHPStan\Rules\Constants;
44

5+
use PHPStan\Analyser\ConstantResolver;
56
use PHPStan\Rules\Rule as TRule;
67
use PHPStan\Testing\RuleTestCase;
78
use PHPUnit\Framework\Attributes\RequiresPhp;
@@ -14,7 +15,10 @@ class ValueAssignedToClassConstantRuleTest extends RuleTestCase
1415

1516
protected function getRule(): TRule
1617
{
17-
return new ValueAssignedToClassConstantRule();
18+
return new ValueAssignedToClassConstantRule(
19+
self::getContainer()->getByType(ConstantResolver::class),
20+
false,
21+
);
1822
}
1923

2024
public function testRule(): void
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Constants;
4+
5+
use PHPStan\Analyser\ConstantResolver;
6+
use PHPStan\Rules\Rule as TRule;
7+
use PHPStan\Testing\RuleTestCase;
8+
9+
/**
10+
* @extends RuleTestCase<ValueAssignedToClassConstantRule>
11+
*/
12+
class ValueAssignedToClassConstantWithDynamicNamesRuleTest extends RuleTestCase
13+
{
14+
15+
protected function getRule(): TRule
16+
{
17+
return new ValueAssignedToClassConstantRule(
18+
self::getContainer()->getByType(ConstantResolver::class),
19+
true,
20+
);
21+
}
22+
23+
public static function getAdditionalConfigFiles(): array
24+
{
25+
return [
26+
__DIR__ . '/value-assigned-dynamic-constant.neon',
27+
];
28+
}
29+
30+
public function testRule(): void
31+
{
32+
$this->analyse([__DIR__ . '/data/value-assigned-to-class-constant-dynamic-names.php'], [
33+
[
34+
'Configuration defined type for constant ValueAssignedToClassConstantDynamicNames\Foo::BAR (int|string|null) does not accept value false.',
35+
12,
36+
],
37+
[
38+
'Configuration defined type for constant ValueAssignedToClassConstantDynamicNames\Foo::MAYBE_BAR (int<1, max>) does not accept value int.',
39+
14,
40+
],
41+
]);
42+
}
43+
44+
}

0 commit comments

Comments
 (0)