From 30b04f85a3b535d94201a2e5cc2a3ccc1abd58c9 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 18 Jun 2026 20:00:08 +0700 Subject: [PATCH 01/12] [perf] Pre compile rules --- src/Rule/UsageRule.php | 17 +++++- src/UsageClassifier.php | 116 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 122 insertions(+), 11 deletions(-) diff --git a/src/Rule/UsageRule.php b/src/Rule/UsageRule.php index 9f6ba09..5815344 100644 --- a/src/Rule/UsageRule.php +++ b/src/Rule/UsageRule.php @@ -22,7 +22,7 @@ public function __construct( public function matches(string $consumedUsage): bool { - $configuredUsage = $this->normalize($this->classOrNamespace); + $configuredUsage = $this->normalizedUsage(); $consumedUsage = $this->normalize($consumedUsage); if ($consumedUsage === $configuredUsage) { @@ -36,6 +36,21 @@ public function matches(string $consumedUsage): bool return str_starts_with($consumedUsage, $configuredUsage); } + public function normalizedUsage(): string + { + return $this->normalize($this->classOrNamespace); + } + + public function isNamespaceRule(): bool + { + return str_ends_with($this->normalizedUsage(), '\\'); + } + + public function isCaseInsensitive(): bool + { + return $this->caseInsensitive; + } + private function normalize(string $usage): string { if (! $this->caseInsensitive) { diff --git a/src/UsageClassifier.php b/src/UsageClassifier.php index e8aae75..e471124 100644 --- a/src/UsageClassifier.php +++ b/src/UsageClassifier.php @@ -6,14 +6,62 @@ use Boundwize\Pyrameter\Rule\UsageRule; +use function ltrim; +use function str_starts_with; +use function strtolower; + final readonly class UsageClassifier { + /** @var array */ + private array $exactRules; + + /** @var array */ + private array $caseInsensitiveExactRules; + + /** @var list */ + private array $namespaceRules; + + /** @var list */ + private array $caseInsensitiveNamespaceRules; + /** * @param list $rules */ - public function __construct( - private array $rules, - ) { + public function __construct(array $rules) + { + $exactRules = []; + $caseInsensitiveExactRules = []; + $namespaceRules = []; + $caseInsensitiveNamespaceRules = []; + + foreach ($rules as $rule) { + if ($rule->isNamespaceRule()) { + $namespaceRule = [ + 'usage' => $rule->normalizedUsage(), + 'kind' => $rule->kind, + ]; + + if ($rule->isCaseInsensitive()) { + $caseInsensitiveNamespaceRules[] = $namespaceRule; + continue; + } + + $namespaceRules[] = $namespaceRule; + continue; + } + + if ($rule->isCaseInsensitive()) { + $this->addExactRule($caseInsensitiveExactRules, $rule->normalizedUsage(), $rule->kind); + continue; + } + + $this->addExactRule($exactRules, $rule->normalizedUsage(), $rule->kind); + } + + $this->exactRules = $exactRules; + $this->caseInsensitiveExactRules = $caseInsensitiveExactRules; + $this->namespaceRules = $namespaceRules; + $this->caseInsensitiveNamespaceRules = $caseInsensitiveNamespaceRules; } /** @@ -24,21 +72,69 @@ public function classify(array $consumedClasses): TestKind $kind = TestKind::Unit; foreach ($consumedClasses as $consumedClass) { - foreach ($this->rules as $rule) { - if (! $rule->matches($consumedClass)) { + if (isset($this->exactRules[$consumedClass])) { + $kind = $this->heaviest($kind, $this->exactRules[$consumedClass]); + + if ($kind === TestKind::E2E) { + return $kind; + } + } + + $caseInsensitiveConsumedClass = $this->caseInsensitiveConsumedClass($consumedClass); + + if (isset($this->caseInsensitiveExactRules[$caseInsensitiveConsumedClass])) { + $kind = $this->heaviest($kind, $this->caseInsensitiveExactRules[$caseInsensitiveConsumedClass]); + + if ($kind === TestKind::E2E) { + return $kind; + } + } + + foreach ($this->namespaceRules as $rule) { + if (! str_starts_with($consumedClass, $rule['usage'])) { + continue; + } + + $kind = $this->heaviest($kind, $rule['kind']); + + if ($kind === TestKind::E2E) { + return $kind; + } + } + + foreach ($this->caseInsensitiveNamespaceRules as $caseInsensitiveNamespaceRule) { + if (! str_starts_with($caseInsensitiveConsumedClass, $caseInsensitiveNamespaceRule['usage'])) { continue; } - if ($rule->kind->weight() > $kind->weight()) { - $kind = $rule->kind; + $kind = $this->heaviest($kind, $caseInsensitiveNamespaceRule['kind']); - if ($kind === TestKind::E2E) { - return $kind; - } + if ($kind === TestKind::E2E) { + return $kind; } } } return $kind; } + + /** + * @param array $rules + */ + private function addExactRule(array &$rules, string $usage, TestKind $testKind): void + { + $rules[$usage] = isset($rules[$usage]) + ? $this->heaviest($rules[$usage], $testKind) + : $testKind; + } + + private function heaviest(TestKind $left, TestKind $right): TestKind + { + return $right->weight() > $left->weight() ? $right : $left; + } + + private function caseInsensitiveConsumedClass(string $consumedClass): string + { + return strtolower(ltrim($consumedClass, '\\')); + } } From ddeb9e858ecb6a52d1723e4bc058c818fe96e047 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 18 Jun 2026 20:04:43 +0700 Subject: [PATCH 02/12] add more test --- src/UsageClassifier.php | 6 ++-- tests/UsageClassifierTest.php | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 tests/UsageClassifierTest.php diff --git a/src/UsageClassifier.php b/src/UsageClassifier.php index e471124..f539352 100644 --- a/src/UsageClassifier.php +++ b/src/UsageClassifier.php @@ -90,12 +90,12 @@ public function classify(array $consumedClasses): TestKind } } - foreach ($this->namespaceRules as $rule) { - if (! str_starts_with($consumedClass, $rule['usage'])) { + foreach ($this->namespaceRules as $namespaceRule) { + if (! str_starts_with($consumedClass, $namespaceRule['usage'])) { continue; } - $kind = $this->heaviest($kind, $rule['kind']); + $kind = $this->heaviest($kind, $namespaceRule['kind']); if ($kind === TestKind::E2E) { return $kind; diff --git a/tests/UsageClassifierTest.php b/tests/UsageClassifierTest.php new file mode 100644 index 0000000..888fe0d --- /dev/null +++ b/tests/UsageClassifierTest.php @@ -0,0 +1,65 @@ +assertSame(TestKind::E2E, $usageClassifier->classify(['App\Tests\Browser\CheckoutTest'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['App\Tests\Browsering\CheckoutTest'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['app\Tests\Browser\CheckoutTest'])); + } + + public function testExactRulesDoNotMatchNamespacePrefixes(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('App\Tests\Browser', TestKind::E2E), + ]); + + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['App\Tests\Browser'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['App\Tests\Browser\CheckoutTest'])); + } + + public function testCaseInsensitiveNamespaceRulesNormalizeConfiguredAndConsumedUsage(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('App\Tests\Browser\\', TestKind::E2E, true), + ]); + + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['\\APP\Tests\Browser\CheckoutTest'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['\\APP\Tests\Browsering\CheckoutTest'])); + } + + public function testCaseInsensitiveExactRulesDoNotMatchPrefixes(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('file_get_contents', TestKind::Integration, true), + ]); + + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['FILE_GET_CONTENTS'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['FILE_GET_CONTENTS_EXTRA'])); + } + + public function testHeaviestMatchingRuleWinsAfterRulesArePrecompiled(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('App\\', TestKind::Functional), + new UsageRule('App\Tests\\', TestKind::Integration), + new UsageRule('App\Tests\Browser\\', TestKind::E2E), + ]); + + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['App\Tests\Browser\CheckoutTest'])); + } +} From dec4a5f57ccf96de657bac66a888c728eecbf25c Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 18 Jun 2026 20:09:01 +0700 Subject: [PATCH 03/12] add more test --- tests/UsageClassifierTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/UsageClassifierTest.php b/tests/UsageClassifierTest.php index 888fe0d..9bef24b 100644 --- a/tests/UsageClassifierTest.php +++ b/tests/UsageClassifierTest.php @@ -52,6 +52,26 @@ public function testCaseInsensitiveExactRulesDoNotMatchPrefixes(): void $this->assertSame(TestKind::Unit, $usageClassifier->classify(['FILE_GET_CONTENTS_EXTRA'])); } + public function testCaseInsensitiveExactRulesCanShortCircuitOnE2E(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('run_browser_session', TestKind::E2E, true), + new UsageRule('file_get_contents', TestKind::Integration, true), + ]); + + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['RUN_BROWSER_SESSION'])); + } + + public function testDuplicateExactRulesUseTheHeaviestKind(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('App\Service', TestKind::Functional), + new UsageRule('App\Service', TestKind::Integration), + ]); + + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['App\Service'])); + } + public function testHeaviestMatchingRuleWinsAfterRulesArePrecompiled(): void { $usageClassifier = new UsageClassifier([ From c9fac805cfd94550daab8522e71df7b49f6376d5 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 18 Jun 2026 20:09:06 +0700 Subject: [PATCH 04/12] add more test --- tests/Rule/UsageRuleTest.php | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/Rule/UsageRuleTest.php diff --git a/tests/Rule/UsageRuleTest.php b/tests/Rule/UsageRuleTest.php new file mode 100644 index 0000000..a8a7d01 --- /dev/null +++ b/tests/Rule/UsageRuleTest.php @@ -0,0 +1,41 @@ +assertTrue($usageRule->matches('App\Service')); + } + + public function testExactUsageDoesNotMatchPrefixes(): void + { + $usageRule = new UsageRule('App\Service', TestKind::Integration); + + $this->assertFalse($usageRule->matches('App\Service\Child')); + } + + public function testItMatchesNamespacePrefixes(): void + { + $usageRule = new UsageRule('App\\', TestKind::Functional); + + $this->assertTrue($usageRule->matches('App\Service')); + $this->assertFalse($usageRule->matches('Library\Service')); + } + + public function testItMatchesCaseInsensitiveUsage(): void + { + $usageRule = new UsageRule('\file_get_contents', TestKind::Integration, true); + + $this->assertTrue($usageRule->matches('\FILE_GET_CONTENTS')); + } +} From bd1c783d7cb29300d98c8a6542c0e4748a8b7411 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 18 Jun 2026 20:18:59 +0700 Subject: [PATCH 05/12] remove case sensitive check --- README.md | 2 +- src/Config/PyrameterConfig.php | 2 +- src/Rule/UsageRule.php | 10 ----- src/UsageClassifier.php | 61 +++++----------------------- tests/Config/PyrameterConfigTest.php | 17 ++++++++ tests/Rule/UsageRuleTest.php | 4 +- tests/UsageClassifierTest.php | 18 ++++---- 7 files changed, 40 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 85b9c1c..727f87e 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ return PyrameterConfig::create() With `create()`, only the rules you add are used for heavier classifications; all other executed tests stay `unit`. -Use `usesClass()` for a specific class, `usesNamespace()` for a namespace prefix, and `usesFunction()` for a function call that should classify a test as a heavier kind. Function matching is case-insensitive and accepts names with or without a leading backslash. +Use `usesClass()` for a specific class, `usesNamespace()` for a namespace prefix, and `usesFunction()` for a function call that should classify a test as a heavier kind. Class, namespace, and function matching is case-insensitive and accepts names with or without a leading backslash. To load a config file from another path, pass the `config` parameter to the PHPUnit extension: diff --git a/src/Config/PyrameterConfig.php b/src/Config/PyrameterConfig.php index a43bbc6..8c233f6 100644 --- a/src/Config/PyrameterConfig.php +++ b/src/Config/PyrameterConfig.php @@ -141,7 +141,7 @@ public function usesNamespace(string $namespace, TestKind $testKind): self public function usesFunction(string $functionName, TestKind $testKind): self { - $this->usageRules[] = new UsageRule(ltrim($functionName, '\\'), $testKind, caseInsensitive: true); + $this->usageRules[] = new UsageRule(ltrim($functionName, '\\'), $testKind); return $this; } diff --git a/src/Rule/UsageRule.php b/src/Rule/UsageRule.php index 5815344..99ab2d2 100644 --- a/src/Rule/UsageRule.php +++ b/src/Rule/UsageRule.php @@ -16,7 +16,6 @@ public function __construct( public string $classOrNamespace, public TestKind $kind, - private bool $caseInsensitive = false, ) { } @@ -46,17 +45,8 @@ public function isNamespaceRule(): bool return str_ends_with($this->normalizedUsage(), '\\'); } - public function isCaseInsensitive(): bool - { - return $this->caseInsensitive; - } - private function normalize(string $usage): string { - if (! $this->caseInsensitive) { - return $usage; - } - return strtolower(ltrim($usage, '\\')); } } diff --git a/src/UsageClassifier.php b/src/UsageClassifier.php index f539352..05da7b2 100644 --- a/src/UsageClassifier.php +++ b/src/UsageClassifier.php @@ -15,53 +15,32 @@ /** @var array */ private array $exactRules; - /** @var array */ - private array $caseInsensitiveExactRules; - /** @var list */ private array $namespaceRules; - /** @var list */ - private array $caseInsensitiveNamespaceRules; - /** * @param list $rules */ public function __construct(array $rules) { - $exactRules = []; - $caseInsensitiveExactRules = []; - $namespaceRules = []; - $caseInsensitiveNamespaceRules = []; + $exactRules = []; + $namespaceRules = []; foreach ($rules as $rule) { if ($rule->isNamespaceRule()) { - $namespaceRule = [ + $namespaceRules[] = [ 'usage' => $rule->normalizedUsage(), 'kind' => $rule->kind, ]; - if ($rule->isCaseInsensitive()) { - $caseInsensitiveNamespaceRules[] = $namespaceRule; - continue; - } - - $namespaceRules[] = $namespaceRule; - continue; - } - - if ($rule->isCaseInsensitive()) { - $this->addExactRule($caseInsensitiveExactRules, $rule->normalizedUsage(), $rule->kind); continue; } $this->addExactRule($exactRules, $rule->normalizedUsage(), $rule->kind); } - $this->exactRules = $exactRules; - $this->caseInsensitiveExactRules = $caseInsensitiveExactRules; - $this->namespaceRules = $namespaceRules; - $this->caseInsensitiveNamespaceRules = $caseInsensitiveNamespaceRules; + $this->exactRules = $exactRules; + $this->namespaceRules = $namespaceRules; } /** @@ -72,18 +51,10 @@ public function classify(array $consumedClasses): TestKind $kind = TestKind::Unit; foreach ($consumedClasses as $consumedClass) { - if (isset($this->exactRules[$consumedClass])) { - $kind = $this->heaviest($kind, $this->exactRules[$consumedClass]); + $normalizedConsumedClass = $this->normalizeConsumedClass($consumedClass); - if ($kind === TestKind::E2E) { - return $kind; - } - } - - $caseInsensitiveConsumedClass = $this->caseInsensitiveConsumedClass($consumedClass); - - if (isset($this->caseInsensitiveExactRules[$caseInsensitiveConsumedClass])) { - $kind = $this->heaviest($kind, $this->caseInsensitiveExactRules[$caseInsensitiveConsumedClass]); + if (isset($this->exactRules[$normalizedConsumedClass])) { + $kind = $this->heaviest($kind, $this->exactRules[$normalizedConsumedClass]); if ($kind === TestKind::E2E) { return $kind; @@ -91,7 +62,7 @@ public function classify(array $consumedClasses): TestKind } foreach ($this->namespaceRules as $namespaceRule) { - if (! str_starts_with($consumedClass, $namespaceRule['usage'])) { + if (! str_starts_with($normalizedConsumedClass, $namespaceRule['usage'])) { continue; } @@ -101,18 +72,6 @@ public function classify(array $consumedClasses): TestKind return $kind; } } - - foreach ($this->caseInsensitiveNamespaceRules as $caseInsensitiveNamespaceRule) { - if (! str_starts_with($caseInsensitiveConsumedClass, $caseInsensitiveNamespaceRule['usage'])) { - continue; - } - - $kind = $this->heaviest($kind, $caseInsensitiveNamespaceRule['kind']); - - if ($kind === TestKind::E2E) { - return $kind; - } - } } return $kind; @@ -133,7 +92,7 @@ private function heaviest(TestKind $left, TestKind $right): TestKind return $right->weight() > $left->weight() ? $right : $left; } - private function caseInsensitiveConsumedClass(string $consumedClass): string + private function normalizeConsumedClass(string $consumedClass): string { return strtolower(ltrim($consumedClass, '\\')); } diff --git a/tests/Config/PyrameterConfigTest.php b/tests/Config/PyrameterConfigTest.php index 95b9f3a..a1cc225 100644 --- a/tests/Config/PyrameterConfigTest.php +++ b/tests/Config/PyrameterConfigTest.php @@ -8,6 +8,7 @@ use Boundwize\Pyrameter\TestKind; use Boundwize\Pyrameter\UsageClassifier; use InvalidArgumentException; +use PDO; use PHPUnit\Framework\TestCase; final class PyrameterConfigTest extends TestCase @@ -51,6 +52,22 @@ public function testUsesNamespaceNormalizesTrailingBackslash(): void $this->assertSame(TestKind::E2E, $usageClassifier->classify(['App\Tests\Browser\Checkout'])); } + public function testUsesClassMatchesClassUsageCaseInsensitively(): void + { + $pyrameterConfig = PyrameterConfig::create()->usesClass(PDO::class, TestKind::Integration); + $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); + + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['pdo'])); + } + + public function testUsesNamespaceMatchesNamespaceUsageCaseInsensitively(): void + { + $pyrameterConfig = PyrameterConfig::create()->usesNamespace('App\Tests\Browser', TestKind::E2E); + $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); + + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['app\tests\browser\Checkout'])); + } + public function testUsesFunctionMatchesFunctionUsageCaseInsensitively(): void { $pyrameterConfig = PyrameterConfig::create()->usesFunction('file_get_contents', TestKind::Integration); diff --git a/tests/Rule/UsageRuleTest.php b/tests/Rule/UsageRuleTest.php index a8a7d01..c37d0f9 100644 --- a/tests/Rule/UsageRuleTest.php +++ b/tests/Rule/UsageRuleTest.php @@ -32,9 +32,9 @@ public function testItMatchesNamespacePrefixes(): void $this->assertFalse($usageRule->matches('Library\Service')); } - public function testItMatchesCaseInsensitiveUsage(): void + public function testItNormalizesUsageCaseAndLeadingSlash(): void { - $usageRule = new UsageRule('\file_get_contents', TestKind::Integration, true); + $usageRule = new UsageRule('\file_get_contents', TestKind::Integration); $this->assertTrue($usageRule->matches('\FILE_GET_CONTENTS')); } diff --git a/tests/UsageClassifierTest.php b/tests/UsageClassifierTest.php index 9bef24b..2034b48 100644 --- a/tests/UsageClassifierTest.php +++ b/tests/UsageClassifierTest.php @@ -11,7 +11,7 @@ final class UsageClassifierTest extends TestCase { - public function testNamespaceRulesMatchOnlyConfiguredPrefix(): void + public function testNamespaceRulesMatchOnlyConfiguredPrefixCaseInsensitively(): void { $usageClassifier = new UsageClassifier([ new UsageRule('App\Tests\Browser\\', TestKind::E2E), @@ -19,7 +19,7 @@ public function testNamespaceRulesMatchOnlyConfiguredPrefix(): void $this->assertSame(TestKind::E2E, $usageClassifier->classify(['App\Tests\Browser\CheckoutTest'])); $this->assertSame(TestKind::Unit, $usageClassifier->classify(['App\Tests\Browsering\CheckoutTest'])); - $this->assertSame(TestKind::Unit, $usageClassifier->classify(['app\Tests\Browser\CheckoutTest'])); + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['app\Tests\Browser\CheckoutTest'])); } public function testExactRulesDoNotMatchNamespacePrefixes(): void @@ -32,31 +32,31 @@ public function testExactRulesDoNotMatchNamespacePrefixes(): void $this->assertSame(TestKind::Unit, $usageClassifier->classify(['App\Tests\Browser\CheckoutTest'])); } - public function testCaseInsensitiveNamespaceRulesNormalizeConfiguredAndConsumedUsage(): void + public function testNamespaceRulesNormalizeConfiguredAndConsumedUsage(): void { $usageClassifier = new UsageClassifier([ - new UsageRule('App\Tests\Browser\\', TestKind::E2E, true), + new UsageRule('App\Tests\Browser\\', TestKind::E2E), ]); $this->assertSame(TestKind::E2E, $usageClassifier->classify(['\\APP\Tests\Browser\CheckoutTest'])); $this->assertSame(TestKind::Unit, $usageClassifier->classify(['\\APP\Tests\Browsering\CheckoutTest'])); } - public function testCaseInsensitiveExactRulesDoNotMatchPrefixes(): void + public function testExactRulesNormalizeConfiguredAndConsumedUsageButDoNotMatchPrefixes(): void { $usageClassifier = new UsageClassifier([ - new UsageRule('file_get_contents', TestKind::Integration, true), + new UsageRule('file_get_contents', TestKind::Integration), ]); $this->assertSame(TestKind::Integration, $usageClassifier->classify(['FILE_GET_CONTENTS'])); $this->assertSame(TestKind::Unit, $usageClassifier->classify(['FILE_GET_CONTENTS_EXTRA'])); } - public function testCaseInsensitiveExactRulesCanShortCircuitOnE2E(): void + public function testExactRulesCanShortCircuitOnE2EAfterNormalization(): void { $usageClassifier = new UsageClassifier([ - new UsageRule('run_browser_session', TestKind::E2E, true), - new UsageRule('file_get_contents', TestKind::Integration, true), + new UsageRule('run_browser_session', TestKind::E2E), + new UsageRule('file_get_contents', TestKind::Integration), ]); $this->assertSame(TestKind::E2E, $usageClassifier->classify(['RUN_BROWSER_SESSION'])); From 7e2a15f80dcf69f254322be10f5a7846af9065a6 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 18 Jun 2026 20:21:18 +0700 Subject: [PATCH 06/12] clean up --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 727f87e..c4260cd 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ return PyrameterConfig::create() With `create()`, only the rules you add are used for heavier classifications; all other executed tests stay `unit`. -Use `usesClass()` for a specific class, `usesNamespace()` for a namespace prefix, and `usesFunction()` for a function call that should classify a test as a heavier kind. Class, namespace, and function matching is case-insensitive and accepts names with or without a leading backslash. +Use `usesClass()` for a specific class, `usesNamespace()` for a namespace prefix, and `usesFunction()` for a function call that should classify a test as a heavier kind. Names can be configured with or without a leading backslash. To load a config file from another path, pass the `config` parameter to the PHPUnit extension: From 68b737493be0c357159ae8278653c1a7ce9149b5 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 18 Jun 2026 20:24:10 +0700 Subject: [PATCH 07/12] clean up --- tests/Config/PyrameterConfigTest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Config/PyrameterConfigTest.php b/tests/Config/PyrameterConfigTest.php index a1cc225..cb5826b 100644 --- a/tests/Config/PyrameterConfigTest.php +++ b/tests/Config/PyrameterConfigTest.php @@ -11,6 +11,8 @@ use PDO; use PHPUnit\Framework\TestCase; +use function strtolower; + final class PyrameterConfigTest extends TestCase { public function testTargetShapePercentagesMustNotBeNegative(): void @@ -57,7 +59,7 @@ public function testUsesClassMatchesClassUsageCaseInsensitively(): void $pyrameterConfig = PyrameterConfig::create()->usesClass(PDO::class, TestKind::Integration); $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); - $this->assertSame(TestKind::Integration, $usageClassifier->classify(['pdo'])); + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['\\' . strtolower(PDO::class)])); } public function testUsesNamespaceMatchesNamespaceUsageCaseInsensitively(): void @@ -65,7 +67,7 @@ public function testUsesNamespaceMatchesNamespaceUsageCaseInsensitively(): void $pyrameterConfig = PyrameterConfig::create()->usesNamespace('App\Tests\Browser', TestKind::E2E); $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); - $this->assertSame(TestKind::E2E, $usageClassifier->classify(['app\tests\browser\Checkout'])); + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['\aPp\tEsTs\bRoWsEr\Checkout'])); } public function testUsesFunctionMatchesFunctionUsageCaseInsensitively(): void @@ -73,6 +75,6 @@ public function testUsesFunctionMatchesFunctionUsageCaseInsensitively(): void $pyrameterConfig = PyrameterConfig::create()->usesFunction('file_get_contents', TestKind::Integration); $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); - $this->assertSame(TestKind::Integration, $usageClassifier->classify(['FILE_GET_CONTENTS'])); + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['\FILE_GET_CONTENTS'])); } } From 419cf6fac3d3e4f95903dc67aaf34a1e52e05e40 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 18 Jun 2026 20:41:13 +0700 Subject: [PATCH 08/12] clean up --- src/Config/PyrameterConfig.php | 3 +- src/Detection/ConsumedUsageVisitor.php | 32 +++++++- src/Detection/ScanResult.php | 10 +-- src/Event/CollectTestResultSubscriber.php | 4 +- src/Rule/UsageRule.php | 49 ++++++++++-- src/TestRecord.php | 4 +- src/UsageClassifier.php | 52 ++++++++++--- tests/Config/PyrameterConfigTest.php | 2 +- .../Detection/ConsumedUsageExtractorTest.php | 76 +++++++++---------- tests/Rule/UsageRuleTest.php | 25 +++++- tests/UsageClassificationTest.php | 66 +++++++++++++++- tests/UsageClassifierTest.php | 31 ++++++-- 12 files changed, 275 insertions(+), 79 deletions(-) diff --git a/src/Config/PyrameterConfig.php b/src/Config/PyrameterConfig.php index 8c233f6..fedebed 100644 --- a/src/Config/PyrameterConfig.php +++ b/src/Config/PyrameterConfig.php @@ -5,6 +5,7 @@ namespace Boundwize\Pyrameter\Config; use Boundwize\Pyrameter\Rule\UsageRule; +use Boundwize\Pyrameter\Rule\UsageType; use Boundwize\Pyrameter\TestKind; use InvalidArgumentException; use mysqli; @@ -141,7 +142,7 @@ public function usesNamespace(string $namespace, TestKind $testKind): self public function usesFunction(string $functionName, TestKind $testKind): self { - $this->usageRules[] = new UsageRule(ltrim($functionName, '\\'), $testKind); + $this->usageRules[] = new UsageRule(ltrim($functionName, '\\'), $testKind, UsageType::Function); return $this; } diff --git a/src/Detection/ConsumedUsageVisitor.php b/src/Detection/ConsumedUsageVisitor.php index 334810f..c64ab3b 100644 --- a/src/Detection/ConsumedUsageVisitor.php +++ b/src/Detection/ConsumedUsageVisitor.php @@ -4,6 +4,7 @@ namespace Boundwize\Pyrameter\Detection; +use Boundwize\Pyrameter\Rule\UsageType; use PhpParser\Node; use PhpParser\Node\Arg; use PhpParser\Node\Attribute; @@ -25,6 +26,7 @@ use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\Interface_; use PhpParser\Node\Stmt\Property; +use PhpParser\Node\Stmt\Trait_; use PhpParser\Node\Stmt\TraitUse; use PhpParser\Node\UnionType; use PhpParser\NodeVisitorAbstract; @@ -32,6 +34,7 @@ use function array_keys; use function in_array; use function ltrim; +use function sprintf; use function strtolower; final class ConsumedUsageVisitor extends NodeVisitorAbstract @@ -39,6 +42,9 @@ final class ConsumedUsageVisitor extends NodeVisitorAbstract /** @var array */ private array $consumedUsages = []; + /** @var array */ + private array $declaredUsages = []; + /** @var list */ private array $mockMethods = [ 'createMock', @@ -50,6 +56,10 @@ final class ConsumedUsageVisitor extends NodeVisitorAbstract public function enterNode(Node $node): null { + if ($node instanceof Class_ || $node instanceof Interface_ || $node instanceof Trait_) { + $this->addDeclaredUsage($node); + } + if ($node instanceof Class_) { $this->addName($node->extends); @@ -139,6 +149,7 @@ public function consumedUsages(): array public function reset(): void { $this->consumedUsages = []; + $this->declaredUsages = []; } private function addType(null|Identifier|Name|ComplexType $type): void @@ -180,7 +191,7 @@ private function addFunctionName(Name $name): void return; } - $this->consumedUsages[$functionName] = true; + $this->consumedUsages[$this->usageKey(UsageType::Function, $functionName)] = true; } private function addUsage(string $usage): void @@ -191,7 +202,24 @@ private function addUsage(string $usage): void return; } - $this->consumedUsages[$usage] = true; + if (isset($this->declaredUsages[strtolower($usage)])) { + return; + } + + $this->consumedUsages[$this->usageKey(UsageType::ClassLike, $usage)] = true; + } + + private function addDeclaredUsage(Class_|Interface_|Trait_ $node): void + { + $namespacedName = $node->getAttribute('namespacedName'); + $declaredUsage = $namespacedName instanceof Name ? $namespacedName->toString() : (string) $node->name; + + $this->declaredUsages[strtolower(ltrim($declaredUsage, '\\'))] = true; + } + + private function usageKey(UsageType $usageType, string $usage): string + { + return sprintf('%s:%s', $usageType->value, $usage); } private function isClassConstant(ClassConstFetch $classConstFetch): bool diff --git a/src/Detection/ScanResult.php b/src/Detection/ScanResult.php index 67513fc..623f90b 100644 --- a/src/Detection/ScanResult.php +++ b/src/Detection/ScanResult.php @@ -7,21 +7,21 @@ final readonly class ScanResult { /** - * @param list $consumedClasses Consumed class, namespace, and function usages. + * @param list $consumedUsages Consumed class-like and function usages. */ private function __construct( public bool $inspectable, - public array $consumedClasses, + public array $consumedUsages, public ?string $errorMessage = null, ) { } /** - * @param list $consumedClasses + * @param list $consumedUsages */ - public static function inspectable(array $consumedClasses): self + public static function inspectable(array $consumedUsages): self { - return new self(true, $consumedClasses); + return new self(true, $consumedUsages); } public static function uninspectable(string $errorMessage): self diff --git a/src/Event/CollectTestResultSubscriber.php b/src/Event/CollectTestResultSubscriber.php index 941a723..d40564d 100644 --- a/src/Event/CollectTestResultSubscriber.php +++ b/src/Event/CollectTestResultSubscriber.php @@ -43,13 +43,13 @@ public function notify(Finished $event): void $kind = $this->kindByTestClassName[$testClassName] ??= $scanResult->inspectable - ? $this->usageClassifier->classify($scanResult->consumedClasses) + ? $this->usageClassifier->classify($scanResult->consumedUsages) : TestKind::Unit; $this->testCollector->add(new TestRecord( testClassName: $testClassName, testMethodName: $testMethodName, - consumedClasses: $scanResult->consumedClasses, + consumedUsages: $scanResult->consumedUsages, kind: $kind, )); } diff --git a/src/Rule/UsageRule.php b/src/Rule/UsageRule.php index 99ab2d2..32ecda9 100644 --- a/src/Rule/UsageRule.php +++ b/src/Rule/UsageRule.php @@ -7,22 +7,31 @@ use Boundwize\Pyrameter\TestKind; use function ltrim; +use function sprintf; +use function str_contains; use function str_ends_with; use function str_starts_with; +use function strlen; use function strtolower; +use function substr; final readonly class UsageRule { public function __construct( - public string $classOrNamespace, + public string $usage, public TestKind $kind, + private UsageType $usageType = UsageType::ClassLike, ) { } public function matches(string $consumedUsage): bool { - $configuredUsage = $this->normalizedUsage(); - $consumedUsage = $this->normalize($consumedUsage); + $configuredUsage = $this->normalizedUsage(); + [$consumedUsageType, $consumedUsage] = $this->normalizeConsumedUsage($consumedUsage); + + if ($consumedUsageType !== $this->usageType) { + return false; + } if ($consumedUsage === $configuredUsage) { return true; @@ -37,16 +46,46 @@ public function matches(string $consumedUsage): bool public function normalizedUsage(): string { - return $this->normalize($this->classOrNamespace); + return $this->normalize($this->usage); } public function isNamespaceRule(): bool { - return str_ends_with($this->normalizedUsage(), '\\'); + return $this->usageType === UsageType::ClassLike && str_ends_with($this->normalizedUsage(), '\\'); + } + + public function normalizedKey(): string + { + return $this->usageKey($this->usageType, $this->normalizedUsage()); } private function normalize(string $usage): string { return strtolower(ltrim($usage, '\\')); } + + /** + * @return array{UsageType, string} + */ + private function normalizeConsumedUsage(string $consumedUsage): array + { + if (! str_contains($consumedUsage, ':')) { + return [$this->usageType, $this->normalize($consumedUsage)]; + } + + foreach (UsageType::cases() as $usageType) { + $prefix = $usageType->value . ':'; + + if (str_starts_with($consumedUsage, $prefix)) { + return [$usageType, $this->normalize(substr($consumedUsage, strlen($prefix)))]; + } + } + + return [$this->usageType, $this->normalize($consumedUsage)]; + } + + private function usageKey(UsageType $usageType, string $normalizedUsage): string + { + return sprintf('%s:%s', $usageType->value, $normalizedUsage); + } } diff --git a/src/TestRecord.php b/src/TestRecord.php index 3381822..107fa0d 100644 --- a/src/TestRecord.php +++ b/src/TestRecord.php @@ -7,12 +7,12 @@ final readonly class TestRecord { /** - * @param list $consumedClasses Consumed class, namespace, and function usages. + * @param list $consumedUsages Consumed class-like and function usages. */ public function __construct( public string $testClassName, public string $testMethodName, - public array $consumedClasses, + public array $consumedUsages, public TestKind $kind, ) { } diff --git a/src/UsageClassifier.php b/src/UsageClassifier.php index 05da7b2..1e67105 100644 --- a/src/UsageClassifier.php +++ b/src/UsageClassifier.php @@ -5,10 +5,15 @@ namespace Boundwize\Pyrameter; use Boundwize\Pyrameter\Rule\UsageRule; +use Boundwize\Pyrameter\Rule\UsageType; use function ltrim; +use function sprintf; +use function str_contains; use function str_starts_with; +use function strlen; use function strtolower; +use function substr; final readonly class UsageClassifier { @@ -29,14 +34,14 @@ public function __construct(array $rules) foreach ($rules as $rule) { if ($rule->isNamespaceRule()) { $namespaceRules[] = [ - 'usage' => $rule->normalizedUsage(), + 'usage' => $rule->normalizedKey(), 'kind' => $rule->kind, ]; continue; } - $this->addExactRule($exactRules, $rule->normalizedUsage(), $rule->kind); + $this->addExactRule($exactRules, $rule->normalizedKey(), $rule->kind); } $this->exactRules = $exactRules; @@ -44,17 +49,17 @@ public function __construct(array $rules) } /** - * @param list $consumedClasses + * @param list $consumedUsages */ - public function classify(array $consumedClasses): TestKind + public function classify(array $consumedUsages): TestKind { $kind = TestKind::Unit; - foreach ($consumedClasses as $consumedClass) { - $normalizedConsumedClass = $this->normalizeConsumedClass($consumedClass); + foreach ($consumedUsages as $consumedUsage) { + $normalizedConsumedUsage = $this->normalizeConsumedUsage($consumedUsage); - if (isset($this->exactRules[$normalizedConsumedClass])) { - $kind = $this->heaviest($kind, $this->exactRules[$normalizedConsumedClass]); + if (isset($this->exactRules[$normalizedConsumedUsage])) { + $kind = $this->heaviest($kind, $this->exactRules[$normalizedConsumedUsage]); if ($kind === TestKind::E2E) { return $kind; @@ -62,7 +67,7 @@ public function classify(array $consumedClasses): TestKind } foreach ($this->namespaceRules as $namespaceRule) { - if (! str_starts_with($normalizedConsumedClass, $namespaceRule['usage'])) { + if (! str_starts_with($normalizedConsumedUsage, $namespaceRule['usage'])) { continue; } @@ -92,8 +97,33 @@ private function heaviest(TestKind $left, TestKind $right): TestKind return $right->weight() > $left->weight() ? $right : $left; } - private function normalizeConsumedClass(string $consumedClass): string + private function normalizeConsumedUsage(string $consumedUsage): string { - return strtolower(ltrim($consumedClass, '\\')); + if (! str_contains($consumedUsage, ':')) { + return $this->usageKey(UsageType::ClassLike, $this->normalizeUsageName($consumedUsage)); + } + + foreach (UsageType::cases() as $usageType) { + $prefix = $usageType->value . ':'; + + if (str_starts_with($consumedUsage, $prefix)) { + return $this->usageKey( + $usageType, + $this->normalizeUsageName(substr($consumedUsage, strlen($prefix))) + ); + } + } + + return $this->usageKey(UsageType::ClassLike, $this->normalizeUsageName($consumedUsage)); + } + + private function normalizeUsageName(string $usageName): string + { + return strtolower(ltrim($usageName, '\\')); + } + + private function usageKey(UsageType $usageType, string $normalizedUsage): string + { + return sprintf('%s:%s', $usageType->value, $normalizedUsage); } } diff --git a/tests/Config/PyrameterConfigTest.php b/tests/Config/PyrameterConfigTest.php index cb5826b..d02e33f 100644 --- a/tests/Config/PyrameterConfigTest.php +++ b/tests/Config/PyrameterConfigTest.php @@ -75,6 +75,6 @@ public function testUsesFunctionMatchesFunctionUsageCaseInsensitively(): void $pyrameterConfig = PyrameterConfig::create()->usesFunction('file_get_contents', TestKind::Integration); $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); - $this->assertSame(TestKind::Integration, $usageClassifier->classify(['\FILE_GET_CONTENTS'])); + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['function:\FILE_GET_CONTENTS'])); } } diff --git a/tests/Detection/ConsumedUsageExtractorTest.php b/tests/Detection/ConsumedUsageExtractorTest.php index e802416..d87f728 100644 --- a/tests/Detection/ConsumedUsageExtractorTest.php +++ b/tests/Detection/ConsumedUsageExtractorTest.php @@ -62,27 +62,27 @@ interface ChildInterface extends \Vendor\Contracts\ParentA, \Vendor\Contracts\Pa sort($usages); $this->assertSame([ - 'Vendor\Attributes\ExampleAttribute', - 'Vendor\BaseClass', - 'Vendor\Constructed\Thing', - 'Vendor\Contracts\FirstContract', - 'Vendor\Contracts\ParentA', - 'Vendor\Contracts\ParentB', - 'Vendor\Contracts\SecondContract', - 'Vendor\Grouped\AnotherGroupedClass', - 'Vendor\Grouped\GroupedClass', - 'Vendor\ImportedThing', - 'Vendor\Params\Input', - 'Vendor\Returns\Output', - 'Vendor\StaticCall\Thing', - 'Vendor\StaticProperty\Thing', - 'Vendor\Traits\FirstTrait', - 'Vendor\Traits\SecondTrait', - 'Vendor\Types\IntersectionA', - 'Vendor\Types\IntersectionB', - 'Vendor\Types\NullableType', - 'Vendor\Types\UnionA', - 'Vendor\Types\UnionB', + 'class:Vendor\Attributes\ExampleAttribute', + 'class:Vendor\BaseClass', + 'class:Vendor\Constructed\Thing', + 'class:Vendor\Contracts\FirstContract', + 'class:Vendor\Contracts\ParentA', + 'class:Vendor\Contracts\ParentB', + 'class:Vendor\Contracts\SecondContract', + 'class:Vendor\Grouped\AnotherGroupedClass', + 'class:Vendor\Grouped\GroupedClass', + 'class:Vendor\ImportedThing', + 'class:Vendor\Params\Input', + 'class:Vendor\Returns\Output', + 'class:Vendor\StaticCall\Thing', + 'class:Vendor\StaticProperty\Thing', + 'class:Vendor\Traits\FirstTrait', + 'class:Vendor\Traits\SecondTrait', + 'class:Vendor\Types\IntersectionA', + 'class:Vendor\Types\IntersectionB', + 'class:Vendor\Types\NullableType', + 'class:Vendor\Types\UnionA', + 'class:Vendor\Types\UnionB', ], $usages); } @@ -96,7 +96,7 @@ public function testItIgnoresNonClassImportsWithinAMixedGroupUse(): void new GroupedClass(); PHP); - $this->assertSame(['Vendor\MixedGroup\GroupedClass'], $usages); + $this->assertSame(['class:Vendor\MixedGroup\GroupedClass'], $usages); } public function testItIgnoresUnusedImports(): void @@ -110,7 +110,7 @@ public function testItIgnoresUnusedImports(): void new UsedThing(); PHP); - $this->assertSame(['Vendor\Used\Thing'], $usages); + $this->assertSame(['class:Vendor\Used\Thing'], $usages); } public function testItExtractsClassesFromInstanceofAndCatchTypes(): void @@ -143,11 +143,11 @@ public function method(object $value, string $className): void sort($usages); $this->assertSame([ - 'Vendor\Checks\DirectCheck', - 'Vendor\Checks\ImportedCheck', - 'Vendor\Exceptions\AliasedException', - 'Vendor\Exceptions\FirstException', - 'Vendor\Exceptions\SecondException', + 'class:Vendor\Checks\DirectCheck', + 'class:Vendor\Checks\ImportedCheck', + 'class:Vendor\Exceptions\AliasedException', + 'class:Vendor\Exceptions\FirstException', + 'class:Vendor\Exceptions\SecondException', ], $usages); } @@ -172,11 +172,11 @@ function_call(\Vendor\ClassConstant\FunctionArgument::class); sort($usages); $this->assertSame([ - 'Vendor\ClassConstant\Direct', - 'Vendor\ClassConstant\DynamicMethod', - 'Vendor\ClassConstant\FunctionArgument', - 'Vendor\ClassConstant\NotFirstArgument', - 'function_call', + 'class:Vendor\ClassConstant\Direct', + 'class:Vendor\ClassConstant\DynamicMethod', + 'class:Vendor\ClassConstant\FunctionArgument', + 'class:Vendor\ClassConstant\NotFirstArgument', + 'function:function_call', ], $usages); } @@ -206,9 +206,9 @@ public function method(): void sort($usages); $this->assertSame([ - 'file_get_contents', - 'file_put_contents', - 'vendor\filesystem\readfixture', + 'function:file_get_contents', + 'function:file_put_contents', + 'function:vendor\filesystem\readfixture', ], $usages); } @@ -230,8 +230,8 @@ public function testItResetsConsumedUsagesBetweenExtractions(): void $this->assertIsArray($firstNodes); $this->assertIsArray($secondNodes); - $this->assertSame(['Vendor\FirstUsage'], $consumedUsageExtractor->extract(array_values($firstNodes))); - $this->assertSame(['Vendor\SecondUsage'], $consumedUsageExtractor->extract(array_values($secondNodes))); + $this->assertSame(['class:Vendor\FirstUsage'], $consumedUsageExtractor->extract(array_values($firstNodes))); + $this->assertSame(['class:Vendor\SecondUsage'], $consumedUsageExtractor->extract(array_values($secondNodes))); } public function testItIgnoresEmptyFunctionNames(): void diff --git a/tests/Rule/UsageRuleTest.php b/tests/Rule/UsageRuleTest.php index c37d0f9..f8e3da4 100644 --- a/tests/Rule/UsageRuleTest.php +++ b/tests/Rule/UsageRuleTest.php @@ -5,6 +5,7 @@ namespace Boundwize\Pyrameter\Tests\Rule; use Boundwize\Pyrameter\Rule\UsageRule; +use Boundwize\Pyrameter\Rule\UsageType; use Boundwize\Pyrameter\TestKind; use PHPUnit\Framework\TestCase; @@ -34,8 +35,28 @@ public function testItMatchesNamespacePrefixes(): void public function testItNormalizesUsageCaseAndLeadingSlash(): void { - $usageRule = new UsageRule('\file_get_contents', TestKind::Integration); + $usageRule = new UsageRule('\file_get_contents', TestKind::Integration, UsageType::Function); - $this->assertTrue($usageRule->matches('\FILE_GET_CONTENTS')); + $this->assertTrue($usageRule->matches('function:\FILE_GET_CONTENTS')); + } + + public function testItDoesNotMatchDifferentUsageTypes(): void + { + $usageRule = new UsageRule('file_get_contents', TestKind::Integration, UsageType::Function); + + $this->assertFalse($usageRule->matches('class:file_get_contents')); + } + + public function testItTreatsUnknownTypedConsumedUsageAsRawUsage(): void + { + $usageRule = new UsageRule('custom:file_get_contents', TestKind::Integration); + + $this->assertTrue($usageRule->matches('custom:FILE_GET_CONTENTS')); + } + + public function testUsageTypesExposeStablePrefixes(): void + { + $this->assertSame('class', UsageType::ClassLike->value); + $this->assertSame('function', UsageType::Function->value); } } diff --git a/tests/UsageClassificationTest.php b/tests/UsageClassificationTest.php index 9a50f84..b8f538c 100644 --- a/tests/UsageClassificationTest.php +++ b/tests/UsageClassificationTest.php @@ -5,6 +5,7 @@ namespace Boundwize\Pyrameter\Tests; use Boundwize\Pyrameter\Config\PyrameterConfig; +use Boundwize\Pyrameter\Detection\ConsumedUsageExtractor; use Boundwize\Pyrameter\Detection\TestUsageScanner; use Boundwize\Pyrameter\TestKind; use Boundwize\Pyrameter\Tests\Fixtures\ContainerGetHeavyFixture; @@ -21,9 +22,12 @@ use Boundwize\Pyrameter\Tests\Fixtures\SymfonyFunctionalFixture; use Boundwize\Pyrameter\Tests\Fixtures\WebDriverE2EFixture; use Boundwize\Pyrameter\UsageClassifier; +use PhpParser\ParserFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use function array_values; + final class UsageClassificationTest extends TestCase { /** @@ -38,7 +42,7 @@ public function testItClassifiesUsageFromTheTestFile(string $fixtureClass, TestK $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); $this->assertTrue($scanResult->inspectable, $scanResult->errorMessage ?? ''); - $this->assertSame($testKind, $usageClassifier->classify($scanResult->consumedClasses)); + $this->assertSame($testKind, $usageClassifier->classify($scanResult->consumedUsages)); } /** @@ -69,7 +73,7 @@ public function testMockTargetsAreRemovedFromConsumedClasses(): void $scanResult = (new TestUsageScanner())->scan(MockedHeavyFixture::class); $this->assertTrue($scanResult->inspectable); - $this->assertNotContains('PDO', $scanResult->consumedClasses); + $this->assertNotContains('class:PDO', $scanResult->consumedUsages); } /** @@ -103,7 +107,7 @@ public function testDefaultRulesClassifyFileOperationFunctionsAsIntegration(stri $pyrameterConfig = PyrameterConfig::defaults(); $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); - $this->assertSame(TestKind::Integration, $usageClassifier->classify([$functionName])); + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['function:' . $functionName])); } /** @@ -179,7 +183,61 @@ public function testUninspectableTestHasNoConsumedClasses(): void $scanResult = (new TestUsageScanner())->scan('Boundwize\Pyrameter\Tests\Fixtures\MissingFixture'); $this->assertFalse($scanResult->inspectable); - $this->assertSame([], $scanResult->consumedClasses); + $this->assertSame([], $scanResult->consumedUsages); $this->assertNotNull($scanResult->errorMessage); } + + public function testClassNamedLikeFileOperationFunctionStaysUnit(): void + { + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $nodes = $parser->parse(<<<'PHP' +assertInstanceOf('file_get_contents', new file_get_contents()); + } +} +PHP); + + $this->assertIsArray($nodes); + + $consumedUsages = (new ConsumedUsageExtractor())->extract(array_values($nodes)); + $pyrameterConfig = PyrameterConfig::defaults(); + $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); + + $this->assertSame([], $consumedUsages); + $this->assertSame(TestKind::Unit, $usageClassifier->classify($consumedUsages)); + } + + public function testClassNamedLikeFileOperationFunctionDeclaredElsewhereStaysUnit(): void + { + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $nodes = $parser->parse(<<<'PHP' +assertInstanceOf('file_get_contents', new file_get_contents()); + } +} +PHP); + + $this->assertIsArray($nodes); + + $consumedUsages = (new ConsumedUsageExtractor())->extract(array_values($nodes)); + $pyrameterConfig = PyrameterConfig::defaults(); + $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); + + $this->assertSame(['class:file_get_contents'], $consumedUsages); + $this->assertSame(TestKind::Unit, $usageClassifier->classify($consumedUsages)); + } } diff --git a/tests/UsageClassifierTest.php b/tests/UsageClassifierTest.php index 2034b48..3904692 100644 --- a/tests/UsageClassifierTest.php +++ b/tests/UsageClassifierTest.php @@ -5,6 +5,7 @@ namespace Boundwize\Pyrameter\Tests; use Boundwize\Pyrameter\Rule\UsageRule; +use Boundwize\Pyrameter\Rule\UsageType; use Boundwize\Pyrameter\TestKind; use Boundwize\Pyrameter\UsageClassifier; use PHPUnit\Framework\TestCase; @@ -45,21 +46,39 @@ public function testNamespaceRulesNormalizeConfiguredAndConsumedUsage(): void public function testExactRulesNormalizeConfiguredAndConsumedUsageButDoNotMatchPrefixes(): void { $usageClassifier = new UsageClassifier([ - new UsageRule('file_get_contents', TestKind::Integration), + new UsageRule('file_get_contents', TestKind::Integration, UsageType::Function), ]); - $this->assertSame(TestKind::Integration, $usageClassifier->classify(['FILE_GET_CONTENTS'])); - $this->assertSame(TestKind::Unit, $usageClassifier->classify(['FILE_GET_CONTENTS_EXTRA'])); + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['function:FILE_GET_CONTENTS'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['function:FILE_GET_CONTENTS_EXTRA'])); + } + + public function testFunctionRulesDoNotMatchClassLikeUsages(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('file_get_contents', TestKind::Integration, UsageType::Function), + ]); + + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['class:file_get_contents'])); + } + + public function testUnknownTypedConsumedUsageFallsBackToClassLikeMatching(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('custom:file_get_contents', TestKind::Integration), + ]); + + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['custom:FILE_GET_CONTENTS'])); } public function testExactRulesCanShortCircuitOnE2EAfterNormalization(): void { $usageClassifier = new UsageClassifier([ - new UsageRule('run_browser_session', TestKind::E2E), - new UsageRule('file_get_contents', TestKind::Integration), + new UsageRule('run_browser_session', TestKind::E2E, UsageType::Function), + new UsageRule('file_get_contents', TestKind::Integration, UsageType::Function), ]); - $this->assertSame(TestKind::E2E, $usageClassifier->classify(['RUN_BROWSER_SESSION'])); + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['function:RUN_BROWSER_SESSION'])); } public function testDuplicateExactRulesUseTheHeaviestKind(): void From f4b58be5619199eef0dd32c12f02df88a150f7d2 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 18 Jun 2026 20:41:17 +0700 Subject: [PATCH 09/12] clean up --- src/Rule/UsageType.php | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/Rule/UsageType.php diff --git a/src/Rule/UsageType.php b/src/Rule/UsageType.php new file mode 100644 index 0000000..a923226 --- /dev/null +++ b/src/Rule/UsageType.php @@ -0,0 +1,11 @@ + Date: Thu, 18 Jun 2026 20:43:48 +0700 Subject: [PATCH 10/12] clean up declare usage --- src/Detection/ConsumedUsageVisitor.php | 21 --------------------- tests/UsageClassificationTest.php | 2 +- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/src/Detection/ConsumedUsageVisitor.php b/src/Detection/ConsumedUsageVisitor.php index c64ab3b..6e06b4d 100644 --- a/src/Detection/ConsumedUsageVisitor.php +++ b/src/Detection/ConsumedUsageVisitor.php @@ -26,7 +26,6 @@ use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\Interface_; use PhpParser\Node\Stmt\Property; -use PhpParser\Node\Stmt\Trait_; use PhpParser\Node\Stmt\TraitUse; use PhpParser\Node\UnionType; use PhpParser\NodeVisitorAbstract; @@ -42,9 +41,6 @@ final class ConsumedUsageVisitor extends NodeVisitorAbstract /** @var array */ private array $consumedUsages = []; - /** @var array */ - private array $declaredUsages = []; - /** @var list */ private array $mockMethods = [ 'createMock', @@ -56,10 +52,6 @@ final class ConsumedUsageVisitor extends NodeVisitorAbstract public function enterNode(Node $node): null { - if ($node instanceof Class_ || $node instanceof Interface_ || $node instanceof Trait_) { - $this->addDeclaredUsage($node); - } - if ($node instanceof Class_) { $this->addName($node->extends); @@ -149,7 +141,6 @@ public function consumedUsages(): array public function reset(): void { $this->consumedUsages = []; - $this->declaredUsages = []; } private function addType(null|Identifier|Name|ComplexType $type): void @@ -202,21 +193,9 @@ private function addUsage(string $usage): void return; } - if (isset($this->declaredUsages[strtolower($usage)])) { - return; - } - $this->consumedUsages[$this->usageKey(UsageType::ClassLike, $usage)] = true; } - private function addDeclaredUsage(Class_|Interface_|Trait_ $node): void - { - $namespacedName = $node->getAttribute('namespacedName'); - $declaredUsage = $namespacedName instanceof Name ? $namespacedName->toString() : (string) $node->name; - - $this->declaredUsages[strtolower(ltrim($declaredUsage, '\\'))] = true; - } - private function usageKey(UsageType $usageType, string $usage): string { return sprintf('%s:%s', $usageType->value, $usage); diff --git a/tests/UsageClassificationTest.php b/tests/UsageClassificationTest.php index b8f538c..c9f553e 100644 --- a/tests/UsageClassificationTest.php +++ b/tests/UsageClassificationTest.php @@ -212,7 +212,7 @@ public function method(): void $pyrameterConfig = PyrameterConfig::defaults(); $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); - $this->assertSame([], $consumedUsages); + $this->assertSame(['class:file_get_contents'], $consumedUsages); $this->assertSame(TestKind::Unit, $usageClassifier->classify($consumedUsages)); } From 872954edd8ce59c67a20505d87bfde5c356ef4b3 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 18 Jun 2026 20:49:06 +0700 Subject: [PATCH 11/12] handle namespace prefix --- src/Rule/UsageRule.php | 15 +++++++++------ src/UsageClassifier.php | 10 +++++++++- tests/Rule/UsageRuleTest.php | 2 ++ tests/UsageClassifierTest.php | 11 +++++++++++ 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/Rule/UsageRule.php b/src/Rule/UsageRule.php index 32ecda9..7450b06 100644 --- a/src/Rule/UsageRule.php +++ b/src/Rule/UsageRule.php @@ -33,15 +33,11 @@ public function matches(string $consumedUsage): bool return false; } - if ($consumedUsage === $configuredUsage) { - return true; - } - if (! str_ends_with($configuredUsage, '\\')) { - return false; + return $consumedUsage === $configuredUsage; } - return str_starts_with($consumedUsage, $configuredUsage); + return $this->matchesNamespacePrefix($consumedUsage, $configuredUsage); } public function normalizedUsage(): string @@ -64,6 +60,13 @@ private function normalize(string $usage): string return strtolower(ltrim($usage, '\\')); } + private function matchesNamespacePrefix(string $consumedUsage, string $namespaceUsage): bool + { + return str_ends_with($namespaceUsage, '\\') + && strlen($consumedUsage) > strlen($namespaceUsage) + && str_starts_with($consumedUsage, $namespaceUsage); + } + /** * @return array{UsageType, string} */ diff --git a/src/UsageClassifier.php b/src/UsageClassifier.php index 1e67105..8bf4136 100644 --- a/src/UsageClassifier.php +++ b/src/UsageClassifier.php @@ -10,6 +10,7 @@ use function ltrim; use function sprintf; use function str_contains; +use function str_ends_with; use function str_starts_with; use function strlen; use function strtolower; @@ -67,7 +68,7 @@ public function classify(array $consumedUsages): TestKind } foreach ($this->namespaceRules as $namespaceRule) { - if (! str_starts_with($normalizedConsumedUsage, $namespaceRule['usage'])) { + if (! $this->matchesNamespaceRule($normalizedConsumedUsage, $namespaceRule['usage'])) { continue; } @@ -97,6 +98,13 @@ private function heaviest(TestKind $left, TestKind $right): TestKind return $right->weight() > $left->weight() ? $right : $left; } + private function matchesNamespaceRule(string $consumedUsage, string $namespaceUsage): bool + { + return str_ends_with($namespaceUsage, '\\') + && strlen($consumedUsage) > strlen($namespaceUsage) + && str_starts_with($consumedUsage, $namespaceUsage); + } + private function normalizeConsumedUsage(string $consumedUsage): string { if (! str_contains($consumedUsage, ':')) { diff --git a/tests/Rule/UsageRuleTest.php b/tests/Rule/UsageRuleTest.php index f8e3da4..6863fb8 100644 --- a/tests/Rule/UsageRuleTest.php +++ b/tests/Rule/UsageRuleTest.php @@ -30,6 +30,8 @@ public function testItMatchesNamespacePrefixes(): void $usageRule = new UsageRule('App\\', TestKind::Functional); $this->assertTrue($usageRule->matches('App\Service')); + $this->assertFalse($usageRule->matches('App\\')); + $this->assertFalse($usageRule->matches('Application\Service')); $this->assertFalse($usageRule->matches('Library\Service')); } diff --git a/tests/UsageClassifierTest.php b/tests/UsageClassifierTest.php index 3904692..403814f 100644 --- a/tests/UsageClassifierTest.php +++ b/tests/UsageClassifierTest.php @@ -19,6 +19,7 @@ public function testNamespaceRulesMatchOnlyConfiguredPrefixCaseInsensitively(): ]); $this->assertSame(TestKind::E2E, $usageClassifier->classify(['App\Tests\Browser\CheckoutTest'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['class:App\Tests\Browser\\'])); $this->assertSame(TestKind::Unit, $usageClassifier->classify(['App\Tests\Browsering\CheckoutTest'])); $this->assertSame(TestKind::E2E, $usageClassifier->classify(['app\Tests\Browser\CheckoutTest'])); } @@ -43,6 +44,16 @@ public function testNamespaceRulesNormalizeConfiguredAndConsumedUsage(): void $this->assertSame(TestKind::Unit, $usageClassifier->classify(['\\APP\Tests\Browsering\CheckoutTest'])); } + public function testNamespaceRulesRespectNamespaceSeparators(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('App\\', TestKind::Functional), + ]); + + $this->assertSame(TestKind::Functional, $usageClassifier->classify(['App\Service'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['Application\Service'])); + } + public function testExactRulesNormalizeConfiguredAndConsumedUsageButDoNotMatchPrefixes(): void { $usageClassifier = new UsageClassifier([ From 1ac0765f775f9e84348b3a47559787ae95df61c2 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 18 Jun 2026 20:53:58 +0700 Subject: [PATCH 12/12] more tst --- tests/Config/PyrameterConfigTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Config/PyrameterConfigTest.php b/tests/Config/PyrameterConfigTest.php index d02e33f..0c97ea2 100644 --- a/tests/Config/PyrameterConfigTest.php +++ b/tests/Config/PyrameterConfigTest.php @@ -54,6 +54,24 @@ public function testUsesNamespaceNormalizesTrailingBackslash(): void $this->assertSame(TestKind::E2E, $usageClassifier->classify(['App\Tests\Browser\Checkout'])); } + public function testUsesNamespaceDoesNotMatchPartialNamespaceSegment(): void + { + $pyrameterConfig = PyrameterConfig::create()->usesNamespace('App', TestKind::Functional); + $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); + + $this->assertSame(TestKind::Functional, $usageClassifier->classify(['App\Foo'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['Application\Foo'])); + } + + public function testUsesNamespaceWithTrailingBackslashDoesNotMatchPartialNamespaceSegment(): void + { + $pyrameterConfig = PyrameterConfig::create()->usesNamespace('App\\', TestKind::Functional); + $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); + + $this->assertSame(TestKind::Functional, $usageClassifier->classify(['App\Foo'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['Application\Foo'])); + } + public function testUsesClassMatchesClassUsageCaseInsensitively(): void { $pyrameterConfig = PyrameterConfig::create()->usesClass(PDO::class, TestKind::Integration);