From 03c8e81295a3a160b17dcd4168274c55135c060b Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 21 Jan 2026 14:52:34 +0100 Subject: [PATCH 1/2] add test for multiline different --- .../DuplicatedScenarioNamesAnalyzerTest.php | 16 +++++++++++++--- .../Fixture/no-multi-line/another.feature | 4 ++++ .../Fixture/no-multi-line/some.feature | 5 +++++ .../Fixture/{ => simple}/another.feature | 0 .../Fixture/{ => simple}/some.feature | 0 5 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/another.feature create mode 100644 tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/some.feature rename tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/{ => simple}/another.feature (100%) rename tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/{ => simple}/some.feature (100%) diff --git a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioNamesAnalyzerTest.php b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioNamesAnalyzerTest.php index ec3d2087e..cb95cf4ce 100644 --- a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioNamesAnalyzerTest.php +++ b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioNamesAnalyzerTest.php @@ -19,9 +19,9 @@ protected function setUp(): void $this->duplicatedScenarioNamesAnalyzer = $this->make(DuplicatedScenarioNamesAnalyzer::class); } - public function test(): void + public function testSpot(): void { - $featureFiles = BehatMetafilesFinder::findFeatureFiles([__DIR__ . '/Fixture']); + $featureFiles = BehatMetafilesFinder::findFeatureFiles([__DIR__ . '/Fixture/simple']); $this->assertCount(2, $featureFiles); $duplicatedScenarioNamesToFiles = $this->duplicatedScenarioNamesAnalyzer->analyze($featureFiles); @@ -31,6 +31,16 @@ public function test(): void $givenFiles = $duplicatedScenarioNamesToFiles['Same scenario name']; - $this->assertSame([__DIR__ . '/Fixture/some.feature', __DIR__ . '/Fixture/another.feature'], $givenFiles); + $this->assertSame([__DIR__ . '/Fixture/simple/some.feature', __DIR__ . '/Fixture/simple/another.feature'], $givenFiles); + } + + public function testSkipSecondLineDifferent(): void + { + $featureFiles = BehatMetafilesFinder::findFeatureFiles([__DIR__ . '/Fixture/no-multi-line']); + $this->assertCount(2, $featureFiles); + + $duplicatedScenarioNamesToFiles = $this->duplicatedScenarioNamesAnalyzer->analyze($featureFiles); + + $this->assertCount(0, $duplicatedScenarioNamesToFiles); } } diff --git a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/another.feature b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/another.feature new file mode 100644 index 000000000..6462ae716 --- /dev/null +++ b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/another.feature @@ -0,0 +1,4 @@ +Feature: Some feature + + Scenario: Same scenario name + But second line is different diff --git a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/some.feature b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/some.feature new file mode 100644 index 000000000..23b08a1f8 --- /dev/null +++ b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/some.feature @@ -0,0 +1,5 @@ +Feature: Another feature + + Scenario: Same scenario name + But second line is not the same + diff --git a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/another.feature b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/simple/another.feature similarity index 100% rename from tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/another.feature rename to tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/simple/another.feature diff --git a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/some.feature b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/simple/some.feature similarity index 100% rename from tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/some.feature rename to tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/simple/some.feature From 87922de32d1e9efbb95196b09151ade0b45aac9f Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 21 Jan 2026 17:04:18 +0100 Subject: [PATCH 2/2] add gehrking parser to be sure --- README.md | 2 +- composer.json | 4 +- ...p => DuplicatedScenarioTitlesAnalyzer.php} | 21 ++++----- src/Enum/RuleIdentifier.php | 2 +- src/Gherkin/GherkinParser.php | 44 +++++++++++++++++++ ...le.php => DuplicatedScenarioTitleRule.php} | 8 ++-- ... DuplicatedScenarioTitlesAnalyzerTest.php} | 13 +++--- .../Fixture/no-multi-line/another.feature | 4 +- .../Fixture/no-multi-line/some.feature | 5 +-- 9 files changed, 76 insertions(+), 27 deletions(-) rename src/Analyzer/{DuplicatedScenarioNamesAnalyzer.php => DuplicatedScenarioTitlesAnalyzer.php} (52%) create mode 100644 src/Gherkin/GherkinParser.php rename src/Rule/{DuplicatedScenarioNamesRule.php => DuplicatedScenarioTitleRule.php} (82%) rename tests/Analyzer/DuplicatedScenarioNamesAnalyzer/{DuplicatedScenarioNamesAnalyzerTest.php => DuplicatedScenarioTitlesAnalyzerTest.php} (75%) diff --git a/README.md b/README.md index 879f3362e..93cee658a 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ This rule spots definitions that are no longer needed, so you can remove them.
-### 4. Find duplicate scenario names (`duplicate-scenario-names`) +### 4. Find duplicate scenario titles (`duplicate-scenario-titles`) 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: diff --git a/composer.json b/composer.json index 9bf539a48..c2008b020 100644 --- a/composer.json +++ b/composer.json @@ -7,8 +7,9 @@ ], "require": { "php": "^8.3", - "nette/utils": "^4.0", + "behat/gherkin": "^4.16", "entropy/entropy": "dev-main", + "nette/utils": "^4.0", "nikic/php-parser": "^5.6", "symfony/finder": "^7.0", "webmozart/assert": "^1.12" @@ -19,6 +20,7 @@ "phpunit/phpunit": "^12.5", "rector/jack": "^0.5.1", "rector/rector": "^2.3.2", + "shipmonk/composer-dependency-analyser": "^1.8", "symplify/easy-coding-standard": "^13.0", "tomasvotruba/class-leak": "^2.1", "tracy/tracy": "^2.10" diff --git a/src/Analyzer/DuplicatedScenarioNamesAnalyzer.php b/src/Analyzer/DuplicatedScenarioTitlesAnalyzer.php similarity index 52% rename from src/Analyzer/DuplicatedScenarioNamesAnalyzer.php rename to src/Analyzer/DuplicatedScenarioTitlesAnalyzer.php index ad69fb98b..ce8a082db 100644 --- a/src/Analyzer/DuplicatedScenarioNamesAnalyzer.php +++ b/src/Analyzer/DuplicatedScenarioTitlesAnalyzer.php @@ -5,14 +5,17 @@ namespace Rector\Behastan\Analyzer; use Entropy\Attributes\RelatedTest; -use Entropy\Utils\Regex; -use Rector\Behastan\Tests\Analyzer\DuplicatedScenarioNamesAnalyzer\DuplicatedScenarioNamesAnalyzerTest; +use Rector\Behastan\Gherkin\GherkinParser; +use Rector\Behastan\Tests\Analyzer\DuplicatedScenarioNamesAnalyzer\DuplicatedScenarioTitlesAnalyzerTest; use Symfony\Component\Finder\SplFileInfo; -#[RelatedTest(DuplicatedScenarioNamesAnalyzerTest::class)] -final class DuplicatedScenarioNamesAnalyzer +#[RelatedTest(DuplicatedScenarioTitlesAnalyzerTest::class)] +final readonly class DuplicatedScenarioTitlesAnalyzer { - private const string SCENARIO_NAME_REGEX = '#\s+Scenario:\s+(?.*?)\n#'; + public function __construct( + private GherkinParser $gherkinParser + ) { + } /** * @param SplFileInfo[] $featureFiles @@ -23,12 +26,10 @@ public function analyze(array $featureFiles): array $scenarioNamesToFiles = []; foreach ($featureFiles as $featureFile) { - // match Scenario: "" - $matches = Regex::matchAll($featureFile->getContents(), self::SCENARIO_NAME_REGEX); + $featureGherkin = $this->gherkinParser->parseFile($featureFile->getRealPath()); - foreach ($matches as $match) { - $scenarioName = $match['name']; - $scenarioNamesToFiles[$scenarioName][] = $featureFile->getRealPath(); + foreach ($featureGherkin->getScenarios() as $scenario) { + $scenarioNamesToFiles[$scenario->getTitle()][] = $featureFile->getRealPath(); } } diff --git a/src/Enum/RuleIdentifier.php b/src/Enum/RuleIdentifier.php index c508a6552..3f89a4dd8 100644 --- a/src/Enum/RuleIdentifier.php +++ b/src/Enum/RuleIdentifier.php @@ -8,7 +8,7 @@ final class RuleIdentifier { public const string DUPLICATED_CONTENTS = 'duplicated-contents'; - public const string DUPLICATED_SCENARIO_NAMES = 'duplicated-scenario-names'; + public const string DUPLICATED_SCENARIO_TITLES = 'duplicated-scenario-titles'; public const string DUPLICATED_PATTERNS = 'duplicated-patterns'; diff --git a/src/Gherkin/GherkinParser.php b/src/Gherkin/GherkinParser.php new file mode 100644 index 000000000..a8dce5118 --- /dev/null +++ b/src/Gherkin/GherkinParser.php @@ -0,0 +1,44 @@ + [ + 'feature' => 'Feature', + 'background' => 'Background', + 'scenario' => 'Scenario', + 'scenario_outline' => 'Scenario Outline|Scenario Template', + 'examples' => 'Examples|Scenarios', + 'given' => 'Given', + 'when' => 'When', + 'then' => 'Then', + 'and' => 'And', + 'but' => 'But', + ], + ]); + + $this->parser = new Parser(new Lexer($arrayKeywords)); + } + + public function parseFile(string $filePath): FeatureNode + { + $featureNode = $this->parser->parseFile($filePath); + Assert::isInstanceOf($featureNode, FeatureNode::class); + + return $featureNode; + } +} diff --git a/src/Rule/DuplicatedScenarioNamesRule.php b/src/Rule/DuplicatedScenarioTitleRule.php similarity index 82% rename from src/Rule/DuplicatedScenarioNamesRule.php rename to src/Rule/DuplicatedScenarioTitleRule.php index 68606fda5..2e424e855 100644 --- a/src/Rule/DuplicatedScenarioNamesRule.php +++ b/src/Rule/DuplicatedScenarioTitleRule.php @@ -4,17 +4,17 @@ namespace Rector\Behastan\Rule; -use Rector\Behastan\Analyzer\DuplicatedScenarioNamesAnalyzer; +use Rector\Behastan\Analyzer\DuplicatedScenarioTitlesAnalyzer; use Rector\Behastan\Contract\RuleInterface; use Rector\Behastan\Enum\RuleIdentifier; use Rector\Behastan\ValueObject\PatternCollection; use Rector\Behastan\ValueObject\RuleError; use Symfony\Component\Finder\SplFileInfo; -final readonly class DuplicatedScenarioNamesRule implements RuleInterface +final readonly class DuplicatedScenarioTitleRule implements RuleInterface { public function __construct( - private DuplicatedScenarioNamesAnalyzer $duplicatedScenarioNamesAnalyzer + private DuplicatedScenarioTitlesAnalyzer $duplicatedScenarioNamesAnalyzer ) { } @@ -47,6 +47,6 @@ public function process( public function getIdentifier(): string { - return RuleIdentifier::DUPLICATED_SCENARIO_NAMES; + return RuleIdentifier::DUPLICATED_SCENARIO_TITLES; } } diff --git a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioNamesAnalyzerTest.php b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioTitlesAnalyzerTest.php similarity index 75% rename from tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioNamesAnalyzerTest.php rename to tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioTitlesAnalyzerTest.php index cb95cf4ce..ef4e58f6c 100644 --- a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioNamesAnalyzerTest.php +++ b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/DuplicatedScenarioTitlesAnalyzerTest.php @@ -4,19 +4,19 @@ namespace Rector\Behastan\Tests\Analyzer\DuplicatedScenarioNamesAnalyzer; -use Rector\Behastan\Analyzer\DuplicatedScenarioNamesAnalyzer; +use Rector\Behastan\Analyzer\DuplicatedScenarioTitlesAnalyzer; use Rector\Behastan\Finder\BehatMetafilesFinder; use Rector\Behastan\Tests\AbstractTestCase; -final class DuplicatedScenarioNamesAnalyzerTest extends AbstractTestCase +final class DuplicatedScenarioTitlesAnalyzerTest extends AbstractTestCase { - private DuplicatedScenarioNamesAnalyzer $duplicatedScenarioNamesAnalyzer; + private DuplicatedScenarioTitlesAnalyzer $duplicatedScenarioNamesAnalyzer; protected function setUp(): void { parent::setUp(); - $this->duplicatedScenarioNamesAnalyzer = $this->make(DuplicatedScenarioNamesAnalyzer::class); + $this->duplicatedScenarioNamesAnalyzer = $this->make(DuplicatedScenarioTitlesAnalyzer::class); } public function testSpot(): void @@ -31,7 +31,10 @@ public function testSpot(): void $givenFiles = $duplicatedScenarioNamesToFiles['Same scenario name']; - $this->assertSame([__DIR__ . '/Fixture/simple/some.feature', __DIR__ . '/Fixture/simple/another.feature'], $givenFiles); + $this->assertSame( + [__DIR__ . '/Fixture/simple/some.feature', __DIR__ . '/Fixture/simple/another.feature'], + $givenFiles + ); } public function testSkipSecondLineDifferent(): void diff --git a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/another.feature b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/another.feature index 6462ae716..1aaa9439b 100644 --- a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/another.feature +++ b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/another.feature @@ -1,4 +1,4 @@ Feature: Some feature - Scenario: Same scenario name - But second line is different + Scenario: Same scenario name But second line is different + Given that diff --git a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/some.feature b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/some.feature index 23b08a1f8..dfd56c829 100644 --- a/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/some.feature +++ b/tests/Analyzer/DuplicatedScenarioNamesAnalyzer/Fixture/no-multi-line/some.feature @@ -1,5 +1,4 @@ Feature: Another feature - Scenario: Same scenario name - But second line is not the same - + Scenario: Same scenario name But second line is not the same + Given This