From cbab060736dd06b0d0a3682bd11c3fa3aab78e2f Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 21 Jan 2026 12:18:34 +0100 Subject: [PATCH 1/3] add duplicate scenario names --- src/Command/AnalyzeCommand.php | 12 ++--- src/Enum/RuleIdentifier.php | 2 + src/Rule/DuplicatedScenarioNamesRule.php | 56 ++++++++++++++++++++++++ src/RulesRegistry.php | 2 +- 4 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 src/Rule/DuplicatedScenarioNamesRule.php diff --git a/src/Command/AnalyzeCommand.php b/src/Command/AnalyzeCommand.php index c7b607419..8af44e785 100644 --- a/src/Command/AnalyzeCommand.php +++ b/src/Command/AnalyzeCommand.php @@ -43,8 +43,9 @@ public function run(?string $projectDirectory = null, array $skip = []): int $contextFileInfos = BehatMetafilesFinder::findContextFiles([$projectDirectory]); if ($contextFileInfos === []) { $this->outputPrinter->redBackground(sprintf( - 'No *.Context files found in "%s". Please provide correct directory', - $projectDirectory + 'No *.Context files found in "%s".%sPlease provide correct directory', + $projectDirectory, + PHP_EOL )); return ExitCode::ERROR; @@ -53,8 +54,9 @@ public function run(?string $projectDirectory = null, array $skip = []): int $featureFileInfos = BehatMetafilesFinder::findFeatureFiles([$projectDirectory]); if ($featureFileInfos === []) { $this->outputPrinter->redBackground(sprintf( - 'No *.feature files found in "%s". Please provide correct directory', - $projectDirectory + 'No *.feature files found in "%s".%sPlease provide correct directory', + $projectDirectory, + PHP_EOL )); return ExitCode::ERROR; @@ -79,7 +81,7 @@ public function run(?string $projectDirectory = null, array $skip = []): int /** @var RuleError[] $allRuleErrors */ $allRuleErrors = []; foreach ($this->rulesRegistry->all() as $rule) { - if ($skip !== [] && in_array($rule->getIdentifier(), $skip, true)) { + if (in_array($rule->getIdentifier(), $skip, true)) { $this->outputPrinter->writeln(sprintf('Skipping "%s" rule', $rule->getIdentifier())); $this->outputPrinter->newLine(); continue; diff --git a/src/Enum/RuleIdentifier.php b/src/Enum/RuleIdentifier.php index e8f17766f..c508a6552 100644 --- a/src/Enum/RuleIdentifier.php +++ b/src/Enum/RuleIdentifier.php @@ -8,6 +8,8 @@ final class RuleIdentifier { public const string DUPLICATED_CONTENTS = 'duplicated-contents'; + public const string DUPLICATED_SCENARIO_NAMES = 'duplicated-scenario-names'; + public const string DUPLICATED_PATTERNS = 'duplicated-patterns'; public const string UNUSED_DEFINITIONS = 'unused-definitions'; diff --git a/src/Rule/DuplicatedScenarioNamesRule.php b/src/Rule/DuplicatedScenarioNamesRule.php new file mode 100644 index 000000000..f283e31d0 --- /dev/null +++ b/src/Rule/DuplicatedScenarioNamesRule.php @@ -0,0 +1,56 @@ +" + $matches = Regex::match($featureFile->getContents(), '#^\s*Scenario:\s*(["\'])(?P.+?)\1#mi'); + foreach ($matches as $match) { + $scenarioNamesToFiles[$match['name']][] = $featureFile->getRealPath(); + } + } + + dump($scenarioNamesToFiles); + die; + + // $errorMessage = sprintf( + // 'These %d definitions have different patterns, but same method body: %s%s', + // count($duplicatedContextDefinition), + // PHP_EOL, + // $patternStrings + // ); + // + // $ruleErrors[] = new RuleError($errorMessage, $lineFilePaths, $this->getIdentifier()); + + return $ruleErrors; + } + + public function getIdentifier(): string + { + return RuleIdentifier::DUPLICATED_SCENARIO_NAMES; + } +} diff --git a/src/RulesRegistry.php b/src/RulesRegistry.php index 96e1577e3..1664cf966 100644 --- a/src/RulesRegistry.php +++ b/src/RulesRegistry.php @@ -17,7 +17,7 @@ public function __construct( ) { Assert::allObject($rules); Assert::allIsInstanceOf($rules, RuleInterface::class); - Assert::greaterThan(count($rules), 2); + Assert::greaterThan(count($rules), 3); } /** From f09ea0bd9e4998a74b275d148a99dd50bf50c694 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 21 Jan 2026 12:42:17 +0100 Subject: [PATCH 2/3] make use of match all --- .../DuplicatedScenarioNamesAnalyzer.php | 39 +++++++++++++++++++ src/Analyzer/UnusedDefinitionsAnalyzer.php | 3 +- src/Rule/DuplicatedScenarioNamesRule.php | 37 ++++++++---------- src/Rule/UnusedContextDefinitionsRule.php | 2 +- .../DuplicatedScenarioNamesAnalyzerTest.php | 36 +++++++++++++++++ .../Fixture/another.feature | 3 ++ .../Fixture/some.feature | 3 ++ .../UnusedDefinitionsAnalyzerTest.php | 8 +--- 8 files changed, 102 insertions(+), 29 deletions(-) create mode 100644 src/Analyzer/DuplicatedScenarioNamesAnalyzer.php create mode 100644 tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioNamesAnalyzerTest.php create mode 100644 tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/another.feature create mode 100644 tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/some.feature diff --git a/src/Analyzer/DuplicatedScenarioNamesAnalyzer.php b/src/Analyzer/DuplicatedScenarioNamesAnalyzer.php new file mode 100644 index 000000000..ad69fb98b --- /dev/null +++ b/src/Analyzer/DuplicatedScenarioNamesAnalyzer.php @@ -0,0 +1,39 @@ +.*?)\n#'; + + /** + * @param SplFileInfo[] $featureFiles + * @return array + */ + public function analyze(array $featureFiles): array + { + $scenarioNamesToFiles = []; + + foreach ($featureFiles as $featureFile) { + // match Scenario: "" + $matches = Regex::matchAll($featureFile->getContents(), self::SCENARIO_NAME_REGEX); + + foreach ($matches as $match) { + $scenarioName = $match['name']; + $scenarioNamesToFiles[$scenarioName][] = $featureFile->getRealPath(); + } + } + + return array_filter($scenarioNamesToFiles, function (array $files): bool { + return count($files) > 1; + }); + } +} diff --git a/src/Analyzer/UnusedDefinitionsAnalyzer.php b/src/Analyzer/UnusedDefinitionsAnalyzer.php index 44f61579a..7b91dc447 100644 --- a/src/Analyzer/UnusedDefinitionsAnalyzer.php +++ b/src/Analyzer/UnusedDefinitionsAnalyzer.php @@ -28,12 +28,11 @@ public function __construct( } /** - * @param SplFileInfo[] $contextFiles * @param SplFileInfo[] $featureFiles * * @return AbstractPattern[] */ - public function analyse(array $contextFiles, array $featureFiles, PatternCollection $patternCollection): array + public function analyse(array $featureFiles, PatternCollection $patternCollection): array { foreach ($featureFiles as $featureFile) { Assert::endsWith($featureFile->getFilename(), '.feature'); diff --git a/src/Rule/DuplicatedScenarioNamesRule.php b/src/Rule/DuplicatedScenarioNamesRule.php index f283e31d0..209b76e8c 100644 --- a/src/Rule/DuplicatedScenarioNamesRule.php +++ b/src/Rule/DuplicatedScenarioNamesRule.php @@ -4,7 +4,7 @@ namespace Rector\Behastan\Rule; -use Entropy\Utils\Regex; +use Rector\Behastan\Analyzer\DuplicatedScenarioNamesAnalyzer; use Rector\Behastan\Contract\RuleInterface; use Rector\Behastan\Enum\RuleIdentifier; use Rector\Behastan\ValueObject\PatternCollection; @@ -13,6 +13,11 @@ final readonly class DuplicatedScenarioNamesRule implements RuleInterface { + public function __construct( + private DuplicatedScenarioNamesAnalyzer $duplicatedScenarioNamesAnalyzer + ) { + } + /** * @param SplFileInfo[] $contextFiles * @param SplFileInfo[] $featureFiles @@ -25,26 +30,18 @@ public function process( PatternCollection $patternCollection, string $projectDirectory ): array { - $scenarioNamesToFiles = []; - foreach ($featureFiles as $featureFile) { - // match Scenario: "" - $matches = Regex::match($featureFile->getContents(), '#^\s*Scenario:\s*(["\'])(?P.+?)\1#mi'); - foreach ($matches as $match) { - $scenarioNamesToFiles[$match['name']][] = $featureFile->getRealPath(); - } - } + $scenarioNamesToFiles = $this->duplicatedScenarioNamesAnalyzer->analyze($featureFiles); - dump($scenarioNamesToFiles); - die; - - // $errorMessage = sprintf( - // 'These %d definitions have different patterns, but same method body: %s%s', - // count($duplicatedContextDefinition), - // PHP_EOL, - // $patternStrings - // ); - // - // $ruleErrors[] = new RuleError($errorMessage, $lineFilePaths, $this->getIdentifier()); + $ruleErrors = []; + foreach ($scenarioNamesToFiles as $scenarioName => $files) { + // it can be used multiple times in single file + $uniqueFiles = array_unique($files); + $uniqueCount = count($uniqueFiles); + + $errorMessage = sprintf('Scenario name "%s" is duplicated %d-times', $scenarioName, $uniqueCount); + + $ruleErrors[] = new RuleError($errorMessage, $uniqueFiles, $this->getIdentifier()); + } return $ruleErrors; } diff --git a/src/Rule/UnusedContextDefinitionsRule.php b/src/Rule/UnusedContextDefinitionsRule.php index 092eb3968..3aa7e110d 100644 --- a/src/Rule/UnusedContextDefinitionsRule.php +++ b/src/Rule/UnusedContextDefinitionsRule.php @@ -29,7 +29,7 @@ public function process( PatternCollection $patternCollection, string $projectDirectory ): array { - $unusedPatterns = $this->unusedDefinitionsAnalyzer->analyse($contextFiles, $featureFiles, $patternCollection); + $unusedPatterns = $this->unusedDefinitionsAnalyzer->analyse($featureFiles, $patternCollection); $ruleErrors = []; diff --git a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioNamesAnalyzerTest.php b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioNamesAnalyzerTest.php new file mode 100644 index 000000000..ec3d2087e --- /dev/null +++ b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioNamesAnalyzerTest.php @@ -0,0 +1,36 @@ +duplicatedScenarioNamesAnalyzer = $this->make(DuplicatedScenarioNamesAnalyzer::class); + } + + public function test(): void + { + $featureFiles = BehatMetafilesFinder::findFeatureFiles([__DIR__ . '/Fixture']); + $this->assertCount(2, $featureFiles); + + $duplicatedScenarioNamesToFiles = $this->duplicatedScenarioNamesAnalyzer->analyze($featureFiles); + + $this->assertCount(1, $duplicatedScenarioNamesToFiles); + $this->assertArrayHasKey('Same scenario name', $duplicatedScenarioNamesToFiles); + + $givenFiles = $duplicatedScenarioNamesToFiles['Same scenario name']; + + $this->assertSame([__DIR__ . '/Fixture/some.feature', __DIR__ . '/Fixture/another.feature'], $givenFiles); + } +} diff --git a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/another.feature b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/another.feature new file mode 100644 index 000000000..439738da4 --- /dev/null +++ b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/another.feature @@ -0,0 +1,3 @@ +Feature: Some feature + + Scenario: Same scenario name diff --git a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/some.feature b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/some.feature new file mode 100644 index 000000000..c5db63751 --- /dev/null +++ b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/some.feature @@ -0,0 +1,3 @@ +Feature: Another feature + + Scenario: Same scenario name diff --git a/tests/Analyzer/UnusedDefinitionsAnalyzer/UnusedDefinitionsAnalyzerTest.php b/tests/Analyzer/UnusedDefinitionsAnalyzer/UnusedDefinitionsAnalyzerTest.php index dea0f280a..366b272e2 100644 --- a/tests/Analyzer/UnusedDefinitionsAnalyzer/UnusedDefinitionsAnalyzerTest.php +++ b/tests/Analyzer/UnusedDefinitionsAnalyzer/UnusedDefinitionsAnalyzerTest.php @@ -34,11 +34,7 @@ public function testEverythingUsed(): void $patternCollection = $this->definitionPatternsExtractor->extract($contextFiles); - $unusedDefinitions = $this->unusedDefinitionsAnalyzer->analyse( - $contextFiles, - $featureFiles, - $patternCollection - ); + $unusedDefinitions = $this->unusedDefinitionsAnalyzer->analyse($featureFiles, $patternCollection); $this->assertCount(0, $unusedDefinitions); } @@ -53,7 +49,7 @@ public function testFoundPattern(): void $patternCollection = $this->definitionPatternsExtractor->extract($contextFiles); - $unusedPatterns = $this->unusedDefinitionsAnalyzer->analyse($contextFiles, $featureFiles, $patternCollection); + $unusedPatterns = $this->unusedDefinitionsAnalyzer->analyse($featureFiles, $patternCollection); $this->assertCount(1, $unusedPatterns); $this->assertContainsOnlyInstancesOf(AbstractPattern::class, $unusedPatterns); From db6467f873e62dc3c376b3b9734caa1f7e3379f7 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 21 Jan 2026 12:46:00 +0100 Subject: [PATCH 3/3] RM --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index a52bf9d31..879f3362e 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,24 @@ This rule spots definitions that are no longer needed, so you can remove them.
+### 4. Find duplicate scenario names (`duplicate-scenario-names`) + +In Behat, each scenario should have a unique name to ensure clarity and avoid confusion during test execution and later debugging. This rule identifies scenarios that share the same name within your feature files: + +```yaml +Feature: User Authentication + + Scenario: User logs in successfully + When the user enters valid credentials + Then login should be successful + + Scenario: User logs in successfully + When the user enters invalid credentials + Then an error message should be displayed +``` + +
+ *Protip*: Add this command to your CI, to get instant feedback of any changes in every pull-request. That's it!