Skip to content

Commit e9d6cdd

Browse files
ondrejmirtesclaude
andcommitted
Keep the source visibility of properties the phar build makes public
The inlining step marks each property it opens up with #[PHPStan\Reflection\Attribute\PrivateProperty] (or ProtectedProperty), on the same line so line numbers stay put, and PhpClassReflectionExtension reports such a property with its source visibility: code analysed against the phar still cannot access it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRLigetKoiZzYBe4iRQb3J
1 parent cd5c22a commit e9d6cdd

8 files changed

Lines changed: 127 additions & 7 deletions

File tree

‎build/readonly-property.neon‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
parameters:
22
excludePaths:
33
- ../tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php
4+
5+
ignoreErrors:
6+
-
7+
message: '#^Class PrivatePropertyAttribute\\Foo not found\.$#'
8+
path: ../tests/PHPStan/Reflection/ClassReflectionTest.php

‎compiler/src/InlineEditsApplier.php‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
use function file_put_contents;
1111
use function json_decode;
1212
use function preg_quote;
13-
use function preg_replace;
13+
use function preg_replace_callback;
1414
use function sprintf;
1515
use function substr;
1616
use function usort;
@@ -81,10 +81,17 @@ public function apply(string $editsJsonFile): array
8181
}
8282
foreach (array_keys($names) as $name) {
8383
// a declaration, a promoted constructor parameter or a trait property:
84-
// visibility, optional modifiers and type, then the variable
85-
$source = preg_replace(
84+
// visibility, optional modifiers and type, then the variable — made
85+
// public, with the source visibility recorded in an attribute PHPStan's
86+
// own reflection honours (same line, so line numbers stay)
87+
$source = preg_replace_callback(
8688
'/\b(private|protected)(\s+(?:readonly\s+|static\s+)*(?:[?\w\\\\|&()]+\s+)?)\$' . preg_quote($name, '/') . '\b/',
87-
'public$2$' . $name,
89+
static fn (array $matches): string => sprintf(
90+
'#[\PHPStan\Reflection\Attribute\%s] public%s$%s',
91+
$matches[1] === 'private' ? 'PrivateProperty' : 'ProtectedProperty',
92+
$matches[2],
93+
$name,
94+
),
8895
$source,
8996
-1,
9097
$count,

‎compiler/tests/InlineEditsApplierTest.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public function testApply(): void
5959

6060
self::assertSame(['edits' => 2, 'files' => 1, 'properties' => 2], $stats);
6161
self::assertSame("<?php\n\$a = \$foo->bar;\n\$b = \$foo->bar;\n", file_get_contents($caller));
62-
self::assertSame("<?php\nclass Foo {\n\tpublic int \$bar = 1;\n\tpublic function __construct(public readonly ?string \$baz = null)\n\t{\n\t}\n}\n", file_get_contents($callee));
62+
self::assertSame("<?php\nclass Foo {\n\t#[\\PHPStan\\Reflection\\Attribute\\PrivateProperty] public int \$bar = 1;\n\tpublic function __construct(#[\\PHPStan\\Reflection\\Attribute\\PrivateProperty] public readonly ?string \$baz = null)\n\t{\n\t}\n}\n", file_get_contents($callee));
6363

6464
unlink($caller);
6565
unlink($callee);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Reflection\Attribute;
4+
5+
use Attribute;
6+
7+
/**
8+
* Marks a property the phar build made public that is private in the
9+
* source. The build inlines single-return getters at their call sites
10+
* (compiler's PrepareCommand), so the properties those getters read have to
11+
* be readable from the callers; for PHPStan's own reflection the property
12+
* stays private (PhpClassReflectionExtension), so code analysed against
13+
* the phar cannot access it.
14+
*/
15+
#[Attribute(flags: Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)]
16+
final class PrivateProperty
17+
{
18+
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Reflection\Attribute;
4+
5+
use Attribute;
6+
7+
/**
8+
* Marks a property the phar build made public that is protected in the
9+
* source. The build inlines single-return getters at their call sites
10+
* (compiler's PrepareCommand), so the properties those getters read have to
11+
* be readable from the callers; for PHPStan's own reflection the property
12+
* stays protected (PhpClassReflectionExtension), so code analysed against
13+
* the phar cannot access it from outside the class hierarchy.
14+
*/
15+
#[Attribute(flags: Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)]
16+
final class ProtectedProperty
17+
{
18+
19+
}

‎src/Reflection/Php/PhpClassReflectionExtension.php‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
use PHPStan\Reflection\Annotations\AnnotationsMethodsClassReflectionExtension;
2626
use PHPStan\Reflection\Annotations\AnnotationsPropertiesClassReflectionExtension;
2727
use PHPStan\Reflection\Assertions;
28+
use PHPStan\Reflection\Attribute\PrivateProperty;
29+
use PHPStan\Reflection\Attribute\ProtectedProperty;
2830
use PHPStan\Reflection\AttributeReflectionFactory;
2931
use PHPStan\Reflection\ClassMemberAccessAnswerer;
3032
use PHPStan\Reflection\ClassReflection;
@@ -435,6 +437,16 @@ private function createProperty(
435437
}
436438
}
437439

440+
// a property the phar build made public for its inlined getters keeps its source visibility here
441+
$isPrivate = $propertyReflection->isPrivate();
442+
$isPublic = $propertyReflection->isPublic();
443+
if ($isPublic && count($propertyReflection->getAttributes(PrivateProperty::class)) > 0) {
444+
$isPrivate = true;
445+
$isPublic = false;
446+
} elseif ($isPublic && count($propertyReflection->getAttributes(ProtectedProperty::class)) > 0) {
447+
$isPublic = false;
448+
}
449+
438450
$nativeProperty = new PhpPropertyReflection(
439451
$declaringClassReflection,
440452
$declaringTrait,
@@ -454,8 +466,8 @@ private function createProperty(
454466
$isFinal,
455467
true,
456468
true,
457-
$propertyReflection->isPrivate(),
458-
$propertyReflection->isPublic(),
469+
$isPrivate,
470+
$isPublic,
459471
);
460472

461473
if (

‎tests/PHPStan/Reflection/ClassReflectionTest.php‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,34 @@ public function testClassHierarchyDistances(
112112
);
113113
}
114114

115+
public function testPrivatePropertyAttribute(): void
116+
{
117+
// the phar build makes the properties its inlined getters read public
118+
// and records the source visibility in an attribute
119+
$reflectionProvider = self::createReflectionProvider();
120+
$classReflection = $reflectionProvider->getClass(\PrivatePropertyAttribute\Foo::class);
121+
122+
$madePublic = $classReflection->getNativeProperty('madePublic');
123+
$this->assertTrue($madePublic->isPrivate());
124+
$this->assertFalse($madePublic->isPublic());
125+
126+
$madePublicFromProtected = $classReflection->getNativeProperty('madePublicFromProtected');
127+
$this->assertFalse($madePublicFromProtected->isPrivate());
128+
$this->assertFalse($madePublicFromProtected->isPublic());
129+
130+
$promoted = $classReflection->getNativeProperty('promoted');
131+
$this->assertTrue($promoted->isPrivate());
132+
$this->assertFalse($promoted->isPublic());
133+
134+
$reallyPublic = $classReflection->getNativeProperty('reallyPublic');
135+
$this->assertFalse($reallyPublic->isPrivate());
136+
$this->assertTrue($reallyPublic->isPublic());
137+
138+
$reallyPrivate = $classReflection->getNativeProperty('reallyPrivate');
139+
$this->assertTrue($reallyPrivate->isPrivate());
140+
$this->assertFalse($reallyPrivate->isPublic());
141+
}
142+
115143
public function testVariadicTraitMethod(): void
116144
{
117145
$reflectionProvider = self::createReflectionProvider();
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace PrivatePropertyAttribute;
6+
7+
use PHPStan\Reflection\Attribute\PrivateProperty;
8+
use PHPStan\Reflection\Attribute\ProtectedProperty;
9+
10+
class Foo
11+
{
12+
13+
#[PrivateProperty]
14+
public int $madePublic = 1;
15+
16+
#[ProtectedProperty]
17+
public int $madePublicFromProtected = 2;
18+
19+
public int $reallyPublic = 3;
20+
21+
private int $reallyPrivate = 4;
22+
23+
public function __construct(
24+
#[PrivateProperty]
25+
public readonly string $promoted = 'x',
26+
)
27+
{
28+
}
29+
30+
}

0 commit comments

Comments
 (0)