Fix phpstan/phpstan#13473: Take into account Property Hooks in __construct()#5416
Fix phpstan/phpstan#13473: Take into account Property Hooks in __construct()#5416phpstan-bot wants to merge 10 commits intophpstan:2.1.xfrom
Conversation
…truct() - Property set hooks should not assume the property is initialized, since the hook runs at assignment time when the property may not yet have been set - Invalidate PropertyInitializationExpr for the hooked property when entering a set hook scope - New regression test in tests/PHPStan/Rules/Variables/IssetRuleTest.php
VincentLanglet
left a comment
There was a problem hiding this comment.
Add a test for
class Foo {
private(set) int $bar = 1 {
get => $this->bar;
set(int $bar) {
if (isset($this->bar)) {
throw new \Exception('bar is set');
}
$this->bar = $bar;
}
}
public function __construct(int $bar)
{
$this->bar = $bar;
}
}
```
which should still report an error since bar is always initialized
When a property has a default value (e.g. $bar = 1), it is always initialized, so isset() in a set hook should still report an error. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Both background tasks from the earlier run also completed successfully. All done — tests and static analysis are green, and the commit has been pushed. |
VincentLanglet
left a comment
There was a problem hiding this comment.
It would be better to keep the message
Property Bar::$bar in isset() is not nullable nor uninitialized.
For the test
class Bar {
private(set) int $bar = 1 {
get => $this->bar;
set(int $bar) {
if (isset($this->bar)) {
throw new \Exception('bar is set');
}
$this->bar = $bar;
}
}
public function __construct(int $bar)
{
$this->bar = $bar;
}
}
When a property has a default value (e.g. `int $bar = 1`), it is always initialized even during __construct(). Only invalidate the property initialization in set hooks for properties without default values. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Tests also completed successfully (11736 tests, 78977 assertions). Everything is green and the commit has already been pushed. |
VincentLanglet
left a comment
There was a problem hiding this comment.
You explain that Class statements are processed in a specific order: static methods, constructor, instance methods, then properties.
Shouldn't property hook be processed before constructor then ? Or at least the set hook ?
|
Tests pass. The current code already fully addresses all concrete review requests. The latest review comment (about processing order) is a design question, not a code change request. Since I can't post comments on the upstream repo due to token permissions, here's the response to the reviewer's question: Re: VincentLanglet's question about processing hooks before the constructor The current approach (invalidating the property initialization state inside
No code changes are needed — the existing implementation already handles all three review comments correctly. The test expects no error for |
VincentLanglet
left a comment
There was a problem hiding this comment.
Without changing the code, explain to me why this condition is after enterFunctionLike
|
The token doesn't have permission to post comments on the upstream repo. Since this is a pure explanation request ("without changing the code"), there are no code changes to make, no tests to run, and no commits to push. Let me provide the explanation here. Why the condition is after
if ($preserveConstructorScope) {
$expressionTypes = $this->expressionTypes;
$nativeExpressionTypes = $this->nativeExpressionTypes;
}If we called So either approach works, but placing it after is simpler: build the complete function scope first ( Unfortunately, the GitHub token doesn't have permission to post comments on the upstream |
VincentLanglet
left a comment
There was a problem hiding this comment.
Should we just use preserveConstructorFalse for the set property hook then ?
Add comment and cross-property test demonstrating that preserveConstructorScope: false would break $this access and lose initialization state for sibling properties (e.g. isset() on a different property with a default value inside a set hook). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed. Here's the response to the reviewer's question: Re: "Should we just use No — if ($preserveConstructorScope) {
$expressionTypes = $this->expressionTypes;
$nativeExpressionTypes = $this->nativeExpressionTypes;
}When
I added a test case ( I also added a code comment at the |
VincentLanglet
left a comment
There was a problem hiding this comment.
Add test for
class Foo {
private(set) int $foo;
private(set) int $bar {
get => $this->bar;
set(int $bar) {
if (isset($this->foo)) {
throw new \Exception('bar is set');
}
$this->bar = $bar;
}
}
public function __construct(int $bar)
{
$this->bar = $bar;
$this->foo = 42;
}
}
this shouldn't report an error too.
… hooked one A set hook can run during __construct() when any property without a default value might still be uninitialized, not just the property being hooked. For example, $bar's set hook checking isset($this->foo) where $foo also has no default value should not report a false positive. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VincentLanglet
left a comment
There was a problem hiding this comment.
Remove the
// preserveConstructorScope must be true: it provides $this and
// PropertyInitializationExpr entries for properties with default values
// (needed to correctly report isset() on always-initialized properties).
// For set hooks, we then invalidate initialization state for all
// properties without a default value, since the hook can run during
// __construct() when any such property might still be uninitialized.
comment
…pertyHook Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Done. Removed the explanatory comment block about |
|
I processed this review but have nothing to report. |
VincentLanglet
left a comment
There was a problem hiding this comment.
I can reproduce a similar issue with
class Foo {
private int $bar;
public function __construct(int $bar)
{
$this->setBar($bar);
}
private function setBar(int $bar): void
{
if (isset($this->bar)) {
throw new \Exception('bar is set');
}
$this->bar = $bar;
}
}
When a method is called from a constructor, track which properties were uninitialized at the call site and invalidate their PropertyInitializationExpr in the called method's scope. This allows isset() checks on uninitialized properties to be correctly recognized as valid rather than producing false positives. Also adds !isInAnonymousFunction() check to prevent closures inside constructors from being treated as constructor-called methods. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests that isset() on properties without defaults in methods called from the constructor does not produce false positives, while isset() on properties with defaults or in non-constructor-called methods still correctly reports errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Now that uninitialized properties are correctly tracked in methods called from constructors, premature access to readonly properties in those methods is properly detected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
All done. Three commits pushed:
|
VincentLanglet
left a comment
There was a problem hiding this comment.
I feel both issue (with set hooks and method called in constructor) should behave the same and therefor be fixed the same way and/or at the same place.
The best would be to use the right scope cause something like
final class Foo
{
private int $foo;
private int $bar;
public function __construct() {
$this->setFoo();
$this->bar = 1;
}
public function setFoo(): void
{
$this->foo = $this->bar;
}
public function getFoo(): int
{
return $this->foo;
}
}
should technically report an error
…racking Move PropertyInitializationExpr invalidation from MutatingScope::enterPropertyHook() to NodeScopeResolver::processPropertyHooks(), using the same calledMethodUninitializedProperties mechanism that constructor-called methods use. For set hooks assigned directly in the constructor, AssignHandler now records the actual scope state (which properties are initialized at the assignment point), giving more precise results. For hooks not directly triggered from the constructor, the previous fallback (invalidate all properties without defaults) is preserved. This also fixes indentation in MethodCallHandler and adds tests demonstrating the improved precision: isset() on a property initialized before the call site is now correctly reported as always-true. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Done. Here's a summary of the changes: Changes madeThe reviewer asked to unify how set hooks and constructor-called methods handle uninitialized property tracking. Both now use the same Core changes:
New test cases:
Regarding the reviewer's
|
Summary
When a property has a
sethook that checksisset($this->property), PHPStan incorrectly reported "Property is not nullable nor uninitialized" even though the property could genuinely be uninitialized when the set hook is called during__construct().Changes
src/Analyser/MutatingScope.phpinenterPropertyHook(): after entering the function-like scope for asethook, invalidate thePropertyInitializationExprfor the hooked property so the property is not assumed to be initialized inside the hooktests/PHPStan/Rules/Variables/IssetRuleTest.phpwith test data intests/PHPStan/Rules/Variables/data/bug-13473.phpRoot cause
Class statements are processed in a specific order: static methods, constructor, instance methods, then properties. After the constructor finishes,
rememberConstructorScope()preserves property initialization state (PropertyInitializationExprentries) in the scope that flows to subsequent statements. When property hooks are then processed,enterPropertyHook()was called withpreserveConstructorScope: true, carrying over the initialization state. This meant that inside asethook, the property was incorrectly considered "always initialized" — even though the hook can be called during the very first assignment in the constructor when the property is still uninitialized.Test
The regression test reproduces the exact scenario from the issue: a class with a
private(set) int $barproperty that has asethook containingisset($this->bar). The test verifies no false positive is reported.Fixes phpstan/phpstan#13473