From c1ac4f49ef0a2d10738315f6912ffdf6aa85d3c2 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 15 Jul 2026 07:00:14 +0700 Subject: [PATCH 1/3] [perf] Optimize usage classification and rule matching performance --- pyrameter.php | 6 +-- src/Analysis/UsageClassifier.php | 57 +++++++++++++++++--------- src/Detection/ConsumedUsageVisitor.php | 26 +++++++----- src/Rule/UsageRule.php | 34 +++++++++------ 4 files changed, 79 insertions(+), 44 deletions(-) diff --git a/pyrameter.php b/pyrameter.php index a3b273a..7e69e7b 100644 --- a/pyrameter.php +++ b/pyrameter.php @@ -8,9 +8,9 @@ return PyrameterConfig::defaults() ->usesNamespace('Boundwize\\Pyrameter\\Event\\', TestKind::Functional) ->targetShape( - unit: ['min' => 60], - functional: ['max' => 20], + unit: ['min' => 76], + functional: ['max' => 8], integration: ['max' => 16], - e2e: ['max' => 2], + e2e: ['max' => 0], ) ->failOnViolation(); diff --git a/src/Analysis/UsageClassifier.php b/src/Analysis/UsageClassifier.php index a195523..6d312ce 100644 --- a/src/Analysis/UsageClassifier.php +++ b/src/Analysis/UsageClassifier.php @@ -11,33 +11,40 @@ use function array_keys; 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 strpos; use function strtolower; use function substr; final readonly class UsageClassifier { + /** @var array */ + private const USAGE_TYPES_BY_PREFIX = [ + 'class:' => UsageType::ClassLike, + 'function:' => UsageType::Function, + ]; + /** @var array}>> */ private array $exactRules; - /** @var list}> */ - private array $namespaceRules; + /** @var array}>> */ + private array $namespaceRulesByRoot; /** * @param list $rules */ public function __construct(array $rules) { - $exactRules = []; - $namespaceRules = []; + $exactRules = []; + $namespaceRulesByRoot = []; foreach ($rules as $rule) { if ($rule->isNamespaceRule()) { - $namespaceRules[] = [ - 'usage' => $rule->normalizedKey(), + $normalizedKey = $rule->normalizedKey(); + $namespaceRulesByRoot[$this->namespaceRoot($normalizedKey)][] = [ + 'usage' => $normalizedKey, 'kind' => $rule->kind, 'unless' => $rule->normalizedUnlessKeys(), ]; @@ -51,8 +58,8 @@ public function __construct(array $rules) ]; } - $this->exactRules = $exactRules; - $this->namespaceRules = $namespaceRules; + $this->exactRules = $exactRules; + $this->namespaceRulesByRoot = $namespaceRulesByRoot; } /** @@ -81,7 +88,9 @@ public function classify(array $consumedUsages): TestKind } } - foreach ($this->namespaceRules as $namespaceRule) { + $namespaceRoot = $this->namespaceRoot($normalizedConsumedUsage); + + foreach ($this->namespaceRulesByRoot[$namespaceRoot] ?? [] as $namespaceRule) { if ( ! $this->matchesNamespaceRule($normalizedConsumedUsage, $namespaceRule['usage']) || $this->isSuppressed($namespaceRule['unless'], $normalizedConsumedUsages) @@ -127,21 +136,31 @@ private function matchesNamespaceRule(string $consumedUsage, string $namespaceUs && str_starts_with($consumedUsage, $namespaceUsage); } + private function namespaceRoot(string $normalizedUsage): string + { + $separatorPosition = strpos($normalizedUsage, '\\'); + + return $separatorPosition === false + ? $normalizedUsage + : substr($normalizedUsage, 0, $separatorPosition + 1); + } + private function normalizeConsumedUsage(string $consumedUsage): string { - if (! str_contains($consumedUsage, ':')) { + $separatorPosition = strpos($consumedUsage, ':'); + + if ($separatorPosition === false) { return $this->usageKey(UsageType::ClassLike, $this->normalizeUsageName($consumedUsage)); } - foreach (UsageType::cases() as $usageType) { - $prefix = $usageType->value . ':'; + $prefix = substr($consumedUsage, 0, $separatorPosition + 1); + $usageType = self::USAGE_TYPES_BY_PREFIX[$prefix] ?? null; - if (str_starts_with($consumedUsage, $prefix)) { - return $this->usageKey( - $usageType, - $this->normalizeUsageName(substr($consumedUsage, strlen($prefix))) - ); - } + if ($usageType !== null) { + return $this->usageKey( + $usageType, + $this->normalizeUsageName(substr($consumedUsage, $separatorPosition + 1)) + ); } return $this->usageKey(UsageType::ClassLike, $this->normalizeUsageName($consumedUsage)); diff --git a/src/Detection/ConsumedUsageVisitor.php b/src/Detection/ConsumedUsageVisitor.php index 6e06b4d..d16f148 100644 --- a/src/Detection/ConsumedUsageVisitor.php +++ b/src/Detection/ConsumedUsageVisitor.php @@ -31,7 +31,6 @@ use PhpParser\NodeVisitorAbstract; use function array_keys; -use function in_array; use function ltrim; use function sprintf; use function strtolower; @@ -41,13 +40,20 @@ final class ConsumedUsageVisitor extends NodeVisitorAbstract /** @var array */ private array $consumedUsages = []; - /** @var list */ - private array $mockMethods = [ - 'createMock', - 'createStub', - 'createConfiguredMock', - 'createPartialMock', - 'getMockBuilder', + /** @var array */ + private const MOCK_METHODS = [ + 'createMock' => true, + 'createStub' => true, + 'createConfiguredMock' => true, + 'createPartialMock' => true, + 'getMockBuilder' => true, + ]; + + /** @var array */ + private const RESERVED_CLASS_NAMES = [ + 'self' => true, + 'static' => true, + 'parent' => true, ]; public function enterNode(Node $node): null @@ -189,7 +195,7 @@ private function addUsage(string $usage): void { $usage = ltrim($usage, '\\'); - if ($usage === '' || in_array(strtolower($usage), ['self', 'static', 'parent'], true)) { + if ($usage === '' || isset(self::RESERVED_CLASS_NAMES[strtolower($usage)])) { return; } @@ -209,7 +215,7 @@ private function isClassConstant(ClassConstFetch $classConstFetch): bool private function markMockTarget(MethodCall|StaticCall $call): void { - if (! $call->name instanceof Identifier || ! in_array($call->name->toString(), $this->mockMethods, true)) { + if (! $call->name instanceof Identifier || ! isset(self::MOCK_METHODS[$call->name->toString()])) { return; } diff --git a/src/Rule/UsageRule.php b/src/Rule/UsageRule.php index 6b41565..9bae45f 100644 --- a/src/Rule/UsageRule.php +++ b/src/Rule/UsageRule.php @@ -17,6 +17,11 @@ final readonly class UsageRule { + private string $normalizedUsage; + + /** @var list */ + private array $normalizedUnlessKeys; + /** * @param list $unless */ @@ -24,13 +29,24 @@ public function __construct( public string $usage, public TestKind $kind, private UsageType $usageType = UsageType::ClassLike, - private array $unless = [], + array $unless = [], ) { + $normalizedUnlessKeys = []; + + foreach ($unless as $unlessUsage) { + $normalizedUnlessKeys[] = $this->usageKey( + UsageType::ClassLike, + $this->normalize($unlessUsage), + ); + } + + $this->normalizedUsage = $this->normalize($usage); + $this->normalizedUnlessKeys = $normalizedUnlessKeys; } public function matches(string $consumedUsage): bool { - $configuredUsage = $this->normalizedUsage(); + $configuredUsage = $this->normalizedUsage; [$consumedUsageType, $consumedUsage] = $this->normalizeConsumedUsage($consumedUsage); if ($consumedUsageType !== $this->usageType) { @@ -46,17 +62,17 @@ public function matches(string $consumedUsage): bool public function normalizedUsage(): string { - return $this->normalize($this->usage); + return $this->normalizedUsage; } public function isNamespaceRule(): bool { - return $this->usageType === UsageType::ClassLike && 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()); + return $this->usageKey($this->usageType, $this->normalizedUsage); } /** @@ -64,13 +80,7 @@ public function normalizedKey(): string */ public function normalizedUnlessKeys(): array { - $normalizedUnlessKeys = []; - - foreach ($this->unless as $unlessUsage) { - $normalizedUnlessKeys[] = $this->usageKey(UsageType::ClassLike, $this->normalize($unlessUsage)); - } - - return $normalizedUnlessKeys; + return $this->normalizedUnlessKeys; } private function normalize(string $usage): string From 90385ae89f91a69579412a0d548a3c4150bcc31f Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 15 Jul 2026 07:04:07 +0700 Subject: [PATCH 2/3] add more test --- tests/Rule/UsageRuleTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Rule/UsageRuleTest.php b/tests/Rule/UsageRuleTest.php index f9b8b25..3af2ee5 100644 --- a/tests/Rule/UsageRuleTest.php +++ b/tests/Rule/UsageRuleTest.php @@ -42,6 +42,13 @@ public function testItNormalizesUsageCaseAndLeadingSlash(): void $this->assertTrue($usageRule->matches('function:\FILE_GET_CONTENTS')); } + public function testItExposesNormalizedUsage(): void + { + $usageRule = new UsageRule('\APP\Service', TestKind::Integration); + + $this->assertSame('app\service', $usageRule->normalizedUsage()); + } + public function testItDoesNotMatchDifferentUsageTypes(): void { $usageRule = new UsageRule('file_get_contents', TestKind::Integration, UsageType::Function); From 69845d3b377a2ede5694765b667262d6bb1e1909 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 15 Jul 2026 07:07:41 +0700 Subject: [PATCH 3/3] remove unused normalizedUsage() method --- src/Rule/UsageRule.php | 5 ----- tests/Rule/UsageRuleTest.php | 7 ------- 2 files changed, 12 deletions(-) diff --git a/src/Rule/UsageRule.php b/src/Rule/UsageRule.php index 9bae45f..587ee96 100644 --- a/src/Rule/UsageRule.php +++ b/src/Rule/UsageRule.php @@ -60,11 +60,6 @@ public function matches(string $consumedUsage): bool return $this->matchesNamespacePrefix($consumedUsage, $configuredUsage); } - public function normalizedUsage(): string - { - return $this->normalizedUsage; - } - public function isNamespaceRule(): bool { return $this->usageType === UsageType::ClassLike && str_ends_with($this->normalizedUsage, '\\'); diff --git a/tests/Rule/UsageRuleTest.php b/tests/Rule/UsageRuleTest.php index 3af2ee5..f9b8b25 100644 --- a/tests/Rule/UsageRuleTest.php +++ b/tests/Rule/UsageRuleTest.php @@ -42,13 +42,6 @@ public function testItNormalizesUsageCaseAndLeadingSlash(): void $this->assertTrue($usageRule->matches('function:\FILE_GET_CONTENTS')); } - public function testItExposesNormalizedUsage(): void - { - $usageRule = new UsageRule('\APP\Service', TestKind::Integration); - - $this->assertSame('app\service', $usageRule->normalizedUsage()); - } - public function testItDoesNotMatchDifferentUsageTypes(): void { $usageRule = new UsageRule('file_get_contents', TestKind::Integration, UsageType::Function);