Skip to content

Commit 16d15b7

Browse files
committed
Add Phpat usage provider
phpat registers architecture tests as services tagged "phpat.test" in the PHPStan DI container, and at runtime invokes their public methods that are either named `test*` or carry the #[TestRule] attribute (see PHPat\Test\TestParser / TestExtractor). These invocations are invisible in source code, so the methods were reported as dead. PhpatUsageProvider resolves the tagged services from the container and marks those test methods as used. Constructors of the test classes are already covered by PhpStanUsageProvider (they are registered DIC services), so this provider only handles the invoked methods. Autoenabled when phpat/phpat is installed. Closes #378 Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01WqA3SLHn3vQEytN5C1KCJj
1 parent 4d86056 commit 16d15b7

10 files changed

Lines changed: 292 additions & 1 deletion

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ parameters:
9999
#### PHPStan:
100100
- constructor calls for DIC services (rules, extensions, ...)
101101

102+
#### PHPat:
103+
- architecture test methods (`test*` or `#[TestRule]`) of classes registered via the `phpat.test` service tag
104+
102105
#### Nette:
103106
- `handleXxx`, `renderXxx`, `actionXxx`, `injectXxx`, `createComponentXxx`
104107
- `SmartObject` magic calls for `@property` annotations

composer-dependency-analyser.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
$config = (new Configuration())
1212
->ignoreErrorsOnPath(__DIR__ . '/src/Provider', [ErrorType::DEV_DEPENDENCY_IN_PROD]) // providers are designed that way
1313
->ignoreErrorsOnExtensionAndPath('ext-simplexml', __DIR__ . '/src/Provider/SymfonyUsageProvider.php', [ErrorType::SHADOW_DEPENDENCY]) // guarded with extension_loaded()
14+
->addPathToExclude(__DIR__ . '/tests/Provider/data')
1415
->addPathToExclude(__DIR__ . '/tests/Rule/data');
1516

1617
return $config;

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"nette/tester": "^2.4",
3030
"nette/utils": "^3.0 || ^4.0",
3131
"nikic/php-parser": "^5.4.0",
32+
"phpat/phpat": "^0.12",
3233
"phpbench/phpbench": "^1.2",
3334
"phpstan/phpstan-phpunit": "^2.0.16",
3435
"phpstan/phpstan-strict-rules": "^2.0.10",
@@ -65,6 +66,7 @@
6566
"ShipMonk\\PHPStan\\DeadCode\\": "tests/"
6667
},
6768
"classmap": [
69+
"tests/Provider/data",
6870
"tests/Rule/data"
6971
]
7072
},

composer.lock

Lines changed: 58 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rules.neon

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ services:
119119
arguments:
120120
enabled: %shipmonkDeadCode.usageProviders.phpstan.enabled%
121121

122+
-
123+
class: ShipMonk\PHPStan\DeadCode\Provider\PhpatUsageProvider
124+
tags:
125+
- shipmonk.deadCode.memberUsageProvider
126+
arguments:
127+
enabled: %shipmonkDeadCode.usageProviders.phpat.enabled%
128+
122129
-
123130
class: ShipMonk\PHPStan\DeadCode\Provider\EloquentUsageProvider
124131
tags:
@@ -268,6 +275,8 @@ parameters:
268275
enabled: true
269276
phpstan:
270277
enabled: true
278+
phpat:
279+
enabled: null
271280
phpunit:
272281
enabled: null
273282
phpbench:
@@ -354,6 +363,9 @@ parametersSchema:
354363
phpstan: structure([
355364
enabled: bool()
356365
])
366+
phpat: structure([
367+
enabled: schema(bool(), nullable())
368+
])
357369
phpunit: structure([
358370
enabled: schema(bool(), nullable())
359371
])
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\PHPStan\DeadCode\Provider;
4+
5+
use Composer\InstalledVersions;
6+
use PHPStan\DependencyInjection\Container;
7+
use ReflectionMethod;
8+
use function is_object;
9+
use function str_starts_with;
10+
11+
/**
12+
* phpat registers architecture tests as services tagged "phpat.test" in the PHPStan DIC.
13+
* At runtime, phpat iterates those services and invokes their public methods that either
14+
* carry the #[TestRule] attribute or whose name starts with "test" (see PHPat\Test\TestParser).
15+
*
16+
* Their constructors are already covered by PhpStanUsageProvider (registered DIC services),
17+
* so this provider only marks the invoked test methods as used.
18+
*/
19+
final class PhpatUsageProvider extends ReflectionBasedMemberUsageProvider
20+
{
21+
22+
private const TEST_TAG = 'phpat.test';
23+
24+
private const TEST_RULE_ATTRIBUTE = 'PHPat\Test\Attributes\TestRule';
25+
26+
private readonly bool $enabled;
27+
28+
private readonly Container $container;
29+
30+
/**
31+
* @var array<string, true>|null Set of class names tagged as phpat tests, lazily resolved.
32+
*/
33+
private ?array $testClasses = null;
34+
35+
public function __construct(
36+
?bool $enabled,
37+
Container $container,
38+
)
39+
{
40+
$this->enabled = $enabled ?? InstalledVersions::isInstalled('phpat/phpat');
41+
$this->container = $container;
42+
}
43+
44+
public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData
45+
{
46+
if (!$this->enabled) {
47+
return null;
48+
}
49+
50+
if (!$method->isPublic()) {
51+
return null;
52+
}
53+
54+
if (!isset($this->getTestClasses()[$method->getDeclaringClass()->getName()])) {
55+
return null;
56+
}
57+
58+
if (!$this->isTestMethod($method)) {
59+
return null;
60+
}
61+
62+
return VirtualUsageData::withNote('Architecture test method invoked by phpat');
63+
}
64+
65+
private function isTestMethod(ReflectionMethod $method): bool
66+
{
67+
if ($method->getAttributes(self::TEST_RULE_ATTRIBUTE) !== []) {
68+
return true;
69+
}
70+
71+
// phpat invokes every public method whose name starts with "test"
72+
return str_starts_with($method->getName(), 'test');
73+
}
74+
75+
/**
76+
* @return array<string, true>
77+
*/
78+
private function getTestClasses(): array
79+
{
80+
if ($this->testClasses === null) {
81+
$this->testClasses = [];
82+
83+
foreach ($this->container->getServicesByTag(self::TEST_TAG) as $service) {
84+
if (is_object($service)) {
85+
$this->testClasses[$service::class] = true;
86+
}
87+
}
88+
}
89+
90+
return $this->testClasses;
91+
}
92+
93+
}

tests/Excluder/TestsUsageExcluderTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public function testAutodetectComposerDevPaths(): void
1919

2020
self::assertSame([
2121
realpath(__DIR__ . '/../../tests'),
22+
realpath(__DIR__ . '/../../tests/Provider/data'),
2223
realpath(__DIR__ . '/../../tests/Rule/data'),
2324
], $devPathsPropertyReflection->getValue($excluder));
2425
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\PHPStan\DeadCode\Provider;
4+
5+
use PhpatTestFixture\NotRegisteredArchitectureTest;
6+
use PhpatTestFixture\RegisteredArchitectureTest;
7+
use PHPStan\DependencyInjection\Container;
8+
use PHPStan\Testing\PHPStanTestCase;
9+
use ReflectionMethod;
10+
11+
final class PhpatUsageProviderTest extends PHPStanTestCase
12+
{
13+
14+
public function testTestMethodsOfRegisteredTestsAreMarkedUsed(): void
15+
{
16+
$provider = $this->createProvider(enabled: true, registeredTests: [new RegisteredArchitectureTest()]);
17+
18+
// invoked by phpat: test-prefixed name
19+
self::assertInstanceOf(VirtualUsageData::class, $this->shouldMarkMethodAsUsed($provider, RegisteredArchitectureTest::class, 'testSrcDoesNotDependOnTests'));
20+
21+
// invoked by phpat: #[TestRule] attribute regardless of name
22+
self::assertInstanceOf(VirtualUsageData::class, $this->shouldMarkMethodAsUsed($provider, RegisteredArchitectureTest::class, 'customNamedRule'));
23+
}
24+
25+
public function testNonTestMethodsAreNotMarkedUsed(): void
26+
{
27+
$provider = $this->createProvider(enabled: true, registeredTests: [new RegisteredArchitectureTest()]);
28+
29+
// public, but neither test-prefixed nor #[TestRule]
30+
self::assertNull($this->shouldMarkMethodAsUsed($provider, RegisteredArchitectureTest::class, 'helperNotInvokedByPhpat'));
31+
32+
// phpat only invokes public methods
33+
self::assertNull($this->shouldMarkMethodAsUsed($provider, RegisteredArchitectureTest::class, 'testPrivate'));
34+
}
35+
36+
public function testMethodsOfUnregisteredClassesAreNotMarkedUsed(): void
37+
{
38+
// only RegisteredArchitectureTest is tagged phpat.test
39+
$provider = $this->createProvider(enabled: true, registeredTests: [new RegisteredArchitectureTest()]);
40+
41+
self::assertNull($this->shouldMarkMethodAsUsed($provider, NotRegisteredArchitectureTest::class, 'testSomething'));
42+
}
43+
44+
public function testDisabledProviderMarksNothing(): void
45+
{
46+
$provider = $this->createProvider(enabled: false, registeredTests: [new RegisteredArchitectureTest()]);
47+
48+
self::assertNull($this->shouldMarkMethodAsUsed($provider, RegisteredArchitectureTest::class, 'testSrcDoesNotDependOnTests'));
49+
}
50+
51+
private function shouldMarkMethodAsUsed(
52+
PhpatUsageProvider $provider,
53+
string $class,
54+
string $method,
55+
): mixed
56+
{
57+
$providerMethod = new ReflectionMethod($provider, 'shouldMarkMethodAsUsed');
58+
59+
return $providerMethod->invoke($provider, new ReflectionMethod($class, $method));
60+
}
61+
62+
/**
63+
* @param list<object> $registeredTests
64+
*/
65+
private function createProvider(
66+
bool $enabled,
67+
array $registeredTests,
68+
): PhpatUsageProvider
69+
{
70+
$container = $this->createMock(Container::class);
71+
$container->method('getServicesByTag')
72+
->willReturnCallback(
73+
static fn (string $tag): array => $tag === 'phpat.test' ? $registeredTests : [],
74+
);
75+
76+
return new PhpatUsageProvider($enabled, $container);
77+
}
78+
79+
}

tests/Provider/data/phpat.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PhpatTestFixture;
4+
5+
use PHPat\Test\Attributes\TestRule;
6+
7+
class RegisteredArchitectureTest
8+
{
9+
10+
public function testSrcDoesNotDependOnTests(): void
11+
{
12+
}
13+
14+
#[TestRule]
15+
public function customNamedRule(): void
16+
{
17+
}
18+
19+
public function helperNotInvokedByPhpat(): void
20+
{
21+
}
22+
23+
private function testPrivate(): void
24+
{
25+
}
26+
27+
}
28+
29+
class NotRegisteredArchitectureTest
30+
{
31+
32+
public function testSomething(): void
33+
{
34+
}
35+
36+
}

tests/Rule/DeadCodeRuleTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
use ShipMonk\PHPStan\DeadCode\Provider\MemberUsageProvider;
5757
use ShipMonk\PHPStan\DeadCode\Provider\NetteTesterUsageProvider;
5858
use ShipMonk\PHPStan\DeadCode\Provider\NetteUsageProvider;
59+
use ShipMonk\PHPStan\DeadCode\Provider\PhpatUsageProvider;
5960
use ShipMonk\PHPStan\DeadCode\Provider\PhpBenchUsageProvider;
6061
use ShipMonk\PHPStan\DeadCode\Provider\PhpStanUsageProvider;
6162
use ShipMonk\PHPStan\DeadCode\Provider\PhpUnitUsageProvider;
@@ -1224,6 +1225,10 @@ private function getMemberUsageProviders(): array
12241225
$this->providersEnabled,
12251226
$this->createPhpStanContainerMock(),
12261227
),
1228+
new PhpatUsageProvider(
1229+
$this->providersEnabled,
1230+
$this->createPhpStanContainerMock(),
1231+
),
12271232
new EloquentUsageProvider(
12281233
$this->providersEnabled,
12291234
),
@@ -1368,6 +1373,8 @@ static function (string $type): array {
13681373
return [];
13691374
},
13701375
);
1376+
$mock->method('getServicesByTag')
1377+
->willReturn([]);
13711378
return $mock;
13721379
}
13731380

0 commit comments

Comments
 (0)