From efc743565867b64a0652bd56108106e63ae17c65 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Mon, 17 Nov 2025 11:54:13 +0100 Subject: [PATCH 1/2] remove stats command, no pratical use --- src/Command/DuplicatedDefinitionsCommand.php | 76 ++++++++++------- src/Command/StatsCommand.php | 87 -------------------- src/DependencyInjection/ContainerFactory.php | 7 +- 3 files changed, 47 insertions(+), 123 deletions(-) delete mode 100644 src/Command/StatsCommand.php diff --git a/src/Command/DuplicatedDefinitionsCommand.php b/src/Command/DuplicatedDefinitionsCommand.php index 6b93631e5..34b3a8ebd 100644 --- a/src/Command/DuplicatedDefinitionsCommand.php +++ b/src/Command/DuplicatedDefinitionsCommand.php @@ -7,6 +7,7 @@ use Rector\Behastan\Analyzer\ClassMethodContextDefinitionsAnalyzer; use Rector\Behastan\Enum\Option; use Rector\Behastan\Finder\BehatMetafilesFinder; +use Rector\Behastan\ValueObject\ClassMethodContextDefinition; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -49,10 +50,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::FAILURE; } - $classMethodContextDefinitionByClassMethodHash = $this->classMethodContextDefinitionsAnalyzer->resolveAndGroupByContentHash( - $contextFileInfos - ); - // 1. find duplicated masks, e.g. if 2 methods have the same mask, its a race condition problem $classMethodContextDefinitions = $this->classMethodContextDefinitionsAnalyzer->resolve($contextFileInfos); $groupedByMask = []; @@ -79,55 +76,74 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // 2. find duplicate method contents + $classMethodContextDefinitionByClassMethodHash = $this->classMethodContextDefinitionsAnalyzer->resolveAndGroupByContentHash( + $contextFileInfos + ); + + return $this->reportDuplicateMethodBodyContents( + $classMethodContextDefinitionByClassMethodHash, + $testDirectories[0] + ); + } + + /** + * @template TItem as object + * + * @param array $items + * @return array + */ + private function filterOutNotDuplicated(array $items): array + { + foreach ($items as $hash => $classAndMethods) { + if (count($classAndMethods) < 2) { + unset($items[$hash]); + } + } + return $items; + } + + /** + * @param array $classMethodContextDefinitionByClassMethodHash + */ + private function reportDuplicateMethodBodyContents( + array $classMethodContextDefinitionByClassMethodHash, + string $testDirectory + ): int { // keep only duplicated $classMethodContextDefinitionByClassMethodHash = $this->filterOutNotDuplicated( $classMethodContextDefinitionByClassMethodHash ); + if ($classMethodContextDefinitionByClassMethodHash === []) { + return self::SUCCESS; + } + $i = 0; foreach ($classMethodContextDefinitionByClassMethodHash as $classMethodContextDefinition) { - $this->symfonyStyle->section(sprintf('%d)', $i + 1)); + $this->symfonyStyle->writeln(str_repeat('-', 80)); + $this->symfonyStyle->newLine(); foreach ($classMethodContextDefinition as $classAndMethod) { - $relativeFilePath = substr( - $classAndMethod->getFilePath(), - strlen((string) $testDirectories[0]) + 1 - ); + $relativeFilePath = substr($classAndMethod->getFilePath(), strlen((string) $testDirectory) + 1); + $this->symfonyStyle->writeln('Mask: "' . $classAndMethod->getMask() . '"'); $this->symfonyStyle->writeln($relativeFilePath . ':' . $classAndMethod->getMethodLine()); - $this->symfonyStyle->writeln('Mask: "' . $classAndMethod->getMask() . '"'); $this->symfonyStyle->newLine(); } - $this->symfonyStyle->newLine(); ++$i; } + $this->symfonyStyle->newLine(); + $this->symfonyStyle->error( - sprintf('Found %d duplicated class method contents', count( + sprintf('Found %d definitions with different masks, but same method body', count( $classMethodContextDefinitionByClassMethodHash )) ); - return Command::FAILURE; - } - - /** - * @template TItem as object - * - * @param array $items - * @return array - */ - private function filterOutNotDuplicated(array $items): array - { - foreach ($items as $hash => $classAndMethods) { - if (count($classAndMethods) < 2) { - unset($items[$hash]); - } - } - - return $items; + return self::FAILURE; } } diff --git a/src/Command/StatsCommand.php b/src/Command/StatsCommand.php deleted file mode 100644 index e5b2a0d40..000000000 --- a/src/Command/StatsCommand.php +++ /dev/null @@ -1,87 +0,0 @@ -setName('stats'); - - $this->setDescription('Get Definition usage stats'); - - $this->addArgument( - 'test-directory', - InputArgument::REQUIRED | InputArgument::IS_ARRAY, - 'Directories with *.Context.php and feature.yml files' - ); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $testDirectories = (array) $input->getArgument('test-directory'); - Assert::allDirectory($testDirectories); - - $featureFiles = BehatMetafilesFinder::findFeatureFiles($testDirectories); - if ($featureFiles === []) { - $this->symfonyStyle->error('No *.feature files found. Please provide correct test directory'); - return self::FAILURE; - } - - $contextFiles = BehatMetafilesFinder::findContextFiles($testDirectories); - if ($contextFiles === []) { - $this->symfonyStyle->error('No *Context.php files found. Please provide correct test directory'); - return self::FAILURE; - } - - $this->symfonyStyle->title('Usage stats for PHP definitions in *Feature files'); - - $featureInstructions = $this->usedInstructionResolver->resolveInstructionsFromFeatureFiles($featureFiles); - $classMethodContextDefinitions = $this->classMethodContextDefinitionsAnalyzer->resolve($contextFiles); - - $i = 0; - foreach ($classMethodContextDefinitions as $classMethodContextDefinition) { - // @todo handle later, as dynamic - if (MaskAnalyzer::isRegex($classMethodContextDefinition->getMask())) { - continue; - } - - if (MaskAnalyzer::isValueMask($classMethodContextDefinition->getMask())) { - continue; - } - - $this->symfonyStyle->writeln( - sprintf('%d) %s', $i + 1, $classMethodContextDefinition->getMask()) - ); - - $classMethodContextDefinition->recordUsage($featureInstructions); - - $this->symfonyStyle->writeln(' * ' . $classMethodContextDefinition->getUsageCount() . ' usages'); - $this->symfonyStyle->newLine(); - ++$i; - } - - return Command::SUCCESS; - } -} diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index 4659cfcd2..3e50eafbc 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -8,7 +8,6 @@ use PhpParser\Parser; use PhpParser\ParserFactory; use Rector\Behastan\Command\DuplicatedDefinitionsCommand; -use Rector\Behastan\Command\StatsCommand; use Rector\Behastan\Command\UnusedDefinitionsCommand; use Symfony\Component\Console\Application; use Symfony\Component\Console\Input\ArrayInput; @@ -29,11 +28,7 @@ public function create(): Container $application = new Application('Behastan'); // register commands - foreach ([ - DuplicatedDefinitionsCommand::class, - UnusedDefinitionsCommand::class, - StatsCommand::class, - ] as $commandClass) { + foreach ([DuplicatedDefinitionsCommand::class, UnusedDefinitionsCommand::class] as $commandClass) { $command = $container->make($commandClass); $application->add($command); } From 624da27c3e75785ec7041db4f317475c84111692 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Mon, 17 Nov 2025 14:42:17 +0100 Subject: [PATCH 2/2] misc intro analyse command --- README.md | 2 +- phpstan.neon | 2 - ...zer.php => ContextDefinitionsAnalyzer.php} | 57 +++++--- src/Analyzer/UnusedDefinitionsAnalyzer.php | 12 +- src/Command/AnalyzeCommand.php | 138 ++++++++++++++++++ src/Command/DuplicatedDefinitionsCommand.php | 113 +------------- src/Command/UnusedDefinitionsCommand.php | 70 ++------- src/Contract/RuleInterface.php | 25 ++++ ...olver.php => DefinitionMasksExtractor.php} | 14 +- src/DependencyInjection/ContainerFactory.php | 34 ++++- src/Enum/Option.php | 2 +- src/Finder/BehatMetafilesFinder.php | 29 ++-- src/Reporting/MaskCollectionStatsPrinter.php | 38 +++-- ...uplicatedContextDefinitionContentsRule.php | 78 ++++++++++ src/Rule/DuplicatedMaskRule.php | 62 ++++++++ src/Rule/UnusedContextDefinitionsRule.php | 46 ++++++ src/UsedInstructionResolver.php | 2 +- ...xtDefinition.php => ContextDefinition.php} | 2 +- src/ValueObject/RuleError.php | 30 ++++ .../UnusedDefinitionsAnalyzerTest.php | 13 +- .../DefinitionMasksExtractorTest.php} | 16 +- .../Fixture/AnotherBehatContext.php | 2 +- 22 files changed, 536 insertions(+), 251 deletions(-) rename src/Analyzer/{ClassMethodContextDefinitionsAnalyzer.php => ContextDefinitionsAnalyzer.php} (60%) create mode 100644 src/Command/AnalyzeCommand.php create mode 100644 src/Contract/RuleInterface.php rename src/{DefinitionMasksResolver.php => DefinitionMasksExtractor.php} (91%) create mode 100644 src/Rule/DuplicatedContextDefinitionContentsRule.php create mode 100644 src/Rule/DuplicatedMaskRule.php create mode 100644 src/Rule/UnusedContextDefinitionsRule.php rename src/ValueObject/{ClassMethodContextDefinition.php => ContextDefinition.php} (96%) create mode 100644 src/ValueObject/RuleError.php rename tests/{DefinitionMasksResolver/DefinitionMasksResolverTest.php => DefinitionMasksExtractor/DefinitionMasksExtractorTest.php} (70%) rename tests/{DefinitionMasksResolver => DefinitionMasksExtractor}/Fixture/AnotherBehatContext.php (84%) diff --git a/README.md b/README.md index 4d9bd680e..1d978a0c8 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ composer require behastan/behastan --dev Some definitions have very similar masks, but even identical contents. Better use a one definitions with exact mask, to make your tests more precise and easier to maintain: ```bash -vendor/bin/behastan duplicated-definitions tests +vendor/bin/behastan analyze ``` diff --git a/phpstan.neon b/phpstan.neon index dcc255a26..80b10f296 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -11,5 +11,3 @@ parameters: excludePaths: - */Fixture/* - */Source/* - - ignoreErrors: diff --git a/src/Analyzer/ClassMethodContextDefinitionsAnalyzer.php b/src/Analyzer/ContextDefinitionsAnalyzer.php similarity index 60% rename from src/Analyzer/ClassMethodContextDefinitionsAnalyzer.php rename to src/Analyzer/ContextDefinitionsAnalyzer.php index 57f8f5204..33b8dbebd 100644 --- a/src/Analyzer/ClassMethodContextDefinitionsAnalyzer.php +++ b/src/Analyzer/ContextDefinitionsAnalyzer.php @@ -11,45 +11,54 @@ use PhpParser\PrettyPrinter\Standard; use Rector\Behastan\PhpParser\SimplePhpParser; use Rector\Behastan\Resolver\ClassMethodMasksResolver; -use Rector\Behastan\ValueObject\ClassMethodContextDefinition; +use Rector\Behastan\ValueObject\ContextDefinition; use Symfony\Component\Finder\SplFileInfo; -final readonly class ClassMethodContextDefinitionsAnalyzer +final class ContextDefinitionsAnalyzer { + /** + * @var array> + */ + private array $contextDefinitionsByContentHash = []; + public function __construct( - private SimplePhpParser $simplePhpParser, - private NodeFinder $nodeFinder, - private Standard $printerStandard, - private ClassMethodMasksResolver $classMethodMasksResolver, + private readonly SimplePhpParser $simplePhpParser, + private readonly NodeFinder $nodeFinder, + private readonly Standard $printerStandard, + private readonly ClassMethodMasksResolver $classMethodMasksResolver, ) { } /** * @param SplFileInfo[] $contextFileInfos - * @return ClassMethodContextDefinition[] + * @return ContextDefinition[] */ public function resolve(array $contextFileInfos): array { - $classMethodContextDefinitionByClassMethodHash = $this->resolveAndGroupByContentHash($contextFileInfos); - - $classMethodContextDefinitions = []; - foreach ($classMethodContextDefinitionByClassMethodHash as $classMethodContextDefinition) { - $classMethodContextDefinitions = array_merge( - $classMethodContextDefinitions, - $classMethodContextDefinition - ); + $contextDefinitionByClassMethodHash = $this->resolveAndGroupByContentHash($contextFileInfos); + + $allContextDefinitions = []; + foreach ($contextDefinitionByClassMethodHash as $contextDefinition) { + $allContextDefinitions = array_merge($allContextDefinitions, $contextDefinition); } - return $classMethodContextDefinitions; + return $allContextDefinitions; } /** * @param SplFileInfo[] $contextFileInfos - * @return array + * @return array */ public function resolveAndGroupByContentHash(array $contextFileInfos): array { - $classMethodContextDefinitionByClassMethodHash = []; + // re-use cached result if already done + $cacheKey = sha1((string) json_encode($contextFileInfos)); + + if (isset($this->contextDefinitionsByContentHash[$cacheKey])) { + return $this->contextDefinitionsByContentHash[$cacheKey]; + } + + $contextDefinitionByContentsHash = []; foreach ($contextFileInfos as $contextFileInfo) { $contextClassStmts = $this->simplePhpParser->parseFilePath($contextFileInfo->getRealPath()); @@ -58,6 +67,7 @@ public function resolveAndGroupByContentHash(array $contextFileInfos): array if (! $class instanceof Class_) { continue; } + if (! $class->namespacedName instanceof Name) { continue; } @@ -68,11 +78,12 @@ public function resolveAndGroupByContentHash(array $contextFileInfos): array if (! $classMethod->isPublic()) { continue; } + if ($classMethod->isMagic()) { continue; } - $classMethodHash = $this->createClassMethodHash($classMethod); + $classMethodHash = $this->createClassMethodHash($classMethod); $rawMasks = $this->classMethodMasksResolver->resolve($classMethod); // no masks :( @@ -80,7 +91,7 @@ public function resolveAndGroupByContentHash(array $contextFileInfos): array continue; } - $classMethodContextDefinition = new ClassMethodContextDefinition( + $contextDefinition = new ContextDefinition( $contextFileInfo->getRealPath(), $className, $classMethod->name->toString(), @@ -89,11 +100,13 @@ public function resolveAndGroupByContentHash(array $contextFileInfos): array $classMethod->getStartLine() ); - $classMethodContextDefinitionByClassMethodHash[$classMethodHash][] = $classMethodContextDefinition; + $contextDefinitionByContentsHash[$classMethodHash][] = $contextDefinition; } } - return $classMethodContextDefinitionByClassMethodHash; + $this->contextDefinitionsByContentHash[$cacheKey] = $contextDefinitionByContentsHash; + + return $contextDefinitionByContentsHash; } private function createClassMethodHash(ClassMethod $classMethod): string diff --git a/src/Analyzer/UnusedDefinitionsAnalyzer.php b/src/Analyzer/UnusedDefinitionsAnalyzer.php index 621f8117a..5cdfaf5ba 100644 --- a/src/Analyzer/UnusedDefinitionsAnalyzer.php +++ b/src/Analyzer/UnusedDefinitionsAnalyzer.php @@ -5,7 +5,7 @@ namespace Rector\Behastan\Analyzer; use Nette\Utils\Strings; -use Rector\Behastan\DefinitionMasksResolver; +use Rector\Behastan\DefinitionMasksExtractor; use Rector\Behastan\Reporting\MaskCollectionStatsPrinter; use Rector\Behastan\UsedInstructionResolver; use Rector\Behastan\ValueObject\Mask\AbstractMask; @@ -13,6 +13,7 @@ use Rector\Behastan\ValueObject\Mask\NamedMask; use Rector\Behastan\ValueObject\Mask\RegexMask; use Rector\Behastan\ValueObject\Mask\SkippedMask; +use Rector\Behastan\ValueObject\MaskCollection; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Finder\SplFileInfo; use Webmozart\Assert\Assert; @@ -29,8 +30,8 @@ public function __construct( private SymfonyStyle $symfonyStyle, - private DefinitionMasksResolver $definitionMasksResolver, private UsedInstructionResolver $usedInstructionResolver, + private DefinitionMasksExtractor $definitionMasksExtractor, private MaskCollectionStatsPrinter $maskCollectionStatsPrinter, ) { } @@ -41,7 +42,7 @@ public function __construct( * * @return AbstractMask[] */ - public function analyse(array $contextFiles, array $featureFiles): array + public function analyse(array $contextFiles, array $featureFiles, MaskCollection $maskCollection): array { Assert::allIsInstanceOf($contextFiles, SplFileInfo::class); foreach ($contextFiles as $contextFile) { @@ -53,9 +54,8 @@ public function analyse(array $contextFiles, array $featureFiles): array Assert::endsWith($featureFile->getFilename(), '.feature'); } - $maskCollection = $this->definitionMasksResolver->resolve($contextFiles); - - $this->maskCollectionStatsPrinter->printStats($maskCollection); + $maskCollection = $this->definitionMasksExtractor->extract($contextFiles); + $this->maskCollectionStatsPrinter->print($maskCollection); $featureInstructions = $this->usedInstructionResolver->resolveInstructionsFromFeatureFiles($featureFiles); $maskProgressBar = $this->symfonyStyle->createProgressBar($maskCollection->count()); diff --git a/src/Command/AnalyzeCommand.php b/src/Command/AnalyzeCommand.php new file mode 100644 index 000000000..c22839187 --- /dev/null +++ b/src/Command/AnalyzeCommand.php @@ -0,0 +1,138 @@ +setName('analyze'); + $this->setDescription('Run complete static analysis on Behat definitions and features'); + + $this->addArgument( + Option::PROJECT_DIRECTORY, + InputArgument::OPTIONAL, + 'Project directory (we find *.Context.php definition files and *.feature script files there)', + getcwd() + ); + + $this->addOption( + 'skip', + null, + InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, + 'Skip a rule by identifier' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $testDirectory = $input->getArgument(Option::PROJECT_DIRECTORY); + Assert::directory($testDirectory); + + $contextFileInfos = BehatMetafilesFinder::findContextFiles([$testDirectory]); + if ($contextFileInfos === []) { + $this->symfonyStyle->error(sprintf( + 'No *.Context files found in "%s". Please provide correct test directory', + $testDirectory + )); + return self::FAILURE; + } + + $featureFileInfos = BehatMetafilesFinder::findFeatureFiles([$testDirectory]); + if ($featureFileInfos === []) { + $this->symfonyStyle->error(sprintf( + 'No *.feature files found in "%s". Please provide correct test directory', + $testDirectory + )); + return self::FAILURE; + } + + $this->symfonyStyle->writeln(sprintf( + 'Found %d Context and %d feature files', + count($contextFileInfos), + count($featureFileInfos) + )); + $this->symfonyStyle->writeln('Extracting definitions masks...'); + + $maskCollection = $this->definitionMasksExtractor->extract($contextFileInfos); + $this->symfonyStyle->newLine(); + + $this->maskCollectionStatsPrinter->print($maskCollection); + + $this->symfonyStyle->newLine(); + + // @todo skip by "--skip" option + + $this->symfonyStyle->writeln('Running analysis...'); + + /** @var RuleError[] $allRuleErrors */ + $allRuleErrors = []; + foreach ($this->rules as $rule) { + $ruleErrors = $rule->process($contextFileInfos, $featureFileInfos, $maskCollection, $testDirectory); + $allRuleErrors = array_merge($allRuleErrors, $ruleErrors); + } + + if ($allRuleErrors === []) { + $this->symfonyStyle->success('No errors found. Good job!'); + + return self::SUCCESS; + } + + $this->symfonyStyle->newLine(2); + + $i = 1; + foreach ($allRuleErrors as $allRuleError) { + $this->symfonyStyle->writeln(sprintf('%d) %s', $i, $allRuleError->getMessage())); + foreach ($allRuleError->getLineFilePaths() as $lineFilePath) { + // compared to listing() this allow to make paths clickable in IDE + $this->symfonyStyle->writeln($lineFilePath); + } + + $this->symfonyStyle->newLine(2); + + ++$i; + } + + $this->symfonyStyle->newLine(); + $this->symfonyStyle->error(sprintf( + 'Found %d error%s', + count($allRuleErrors), + count($allRuleErrors) > 1 ? 's' : '' + )); + + return self::FAILURE; + } +} diff --git a/src/Command/DuplicatedDefinitionsCommand.php b/src/Command/DuplicatedDefinitionsCommand.php index 34b3a8ebd..35bb756be 100644 --- a/src/Command/DuplicatedDefinitionsCommand.php +++ b/src/Command/DuplicatedDefinitionsCommand.php @@ -4,22 +4,20 @@ namespace Rector\Behastan\Command; -use Rector\Behastan\Analyzer\ClassMethodContextDefinitionsAnalyzer; use Rector\Behastan\Enum\Option; -use Rector\Behastan\Finder\BehatMetafilesFinder; -use Rector\Behastan\ValueObject\ClassMethodContextDefinition; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Webmozart\Assert\Assert; +/** + * @deprecated This command was deprecated, to avoid granular rules. Use "analyze" command instead + */ final class DuplicatedDefinitionsCommand extends Command { public function __construct( private readonly SymfonyStyle $symfonyStyle, - private readonly ClassMethodContextDefinitionsAnalyzer $classMethodContextDefinitionsAnalyzer, ) { parent::__construct(); } @@ -29,11 +27,11 @@ protected function configure(): void $this->setName('duplicated-definitions'); $this->setDescription( - 'Find duplicated definitions in *Context.php, use just one to keep definitions clear and to the point' + '[DEPRECATED] Find duplicated definitions in *Context.php, use just one to keep definitions clear and to the point' ); $this->addArgument( - Option::TEST_DIRECTORY, + Option::PROJECT_DIRECTORY, InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'Director with *.Context.php definition files' ); @@ -41,107 +39,8 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $testDirectories = (array) $input->getArgument(Option::TEST_DIRECTORY); - Assert::allDirectory($testDirectories); - - $contextFileInfos = BehatMetafilesFinder::findContextFiles($testDirectories); - if ($contextFileInfos === []) { - $this->symfonyStyle->error('No *.Context files found. Please provide correct test directory'); - return self::FAILURE; - } - - // 1. find duplicated masks, e.g. if 2 methods have the same mask, its a race condition problem - $classMethodContextDefinitions = $this->classMethodContextDefinitionsAnalyzer->resolve($contextFileInfos); - $groupedByMask = []; - foreach ($classMethodContextDefinitions as $classMethodContextDefinition) { - $groupedByMask[$classMethodContextDefinition->getMask()][] = $classMethodContextDefinition; - } - - foreach ($groupedByMask as $mask => $sameMaksClassMethodContextDefinitions) { - if (count($sameMaksClassMethodContextDefinitions) === 1) { - continue; - } - - // two or more methods have the same mask - $this->symfonyStyle->section('Duplicated mask: "' . $mask . '"'); - foreach ($sameMaksClassMethodContextDefinitions as $classMethodContextDefinition) { - $relativeFilePath = substr( - $classMethodContextDefinition->getFilePath(), - strlen((string) $testDirectories[0]) + 1 - ); - $this->symfonyStyle->writeln($relativeFilePath . ':' . $classMethodContextDefinition->getMethodLine()); - } - - $this->symfonyStyle->newLine(); - } - - // 2. find duplicate method contents - $classMethodContextDefinitionByClassMethodHash = $this->classMethodContextDefinitionsAnalyzer->resolveAndGroupByContentHash( - $contextFileInfos - ); - - return $this->reportDuplicateMethodBodyContents( - $classMethodContextDefinitionByClassMethodHash, - $testDirectories[0] - ); - } - - /** - * @template TItem as object - * - * @param array $items - * @return array - */ - private function filterOutNotDuplicated(array $items): array - { - foreach ($items as $hash => $classAndMethods) { - if (count($classAndMethods) < 2) { - unset($items[$hash]); - } - } - - return $items; - } - - /** - * @param array $classMethodContextDefinitionByClassMethodHash - */ - private function reportDuplicateMethodBodyContents( - array $classMethodContextDefinitionByClassMethodHash, - string $testDirectory - ): int { - // keep only duplicated - $classMethodContextDefinitionByClassMethodHash = $this->filterOutNotDuplicated( - $classMethodContextDefinitionByClassMethodHash - ); - - if ($classMethodContextDefinitionByClassMethodHash === []) { - return self::SUCCESS; - } - - $i = 0; - foreach ($classMethodContextDefinitionByClassMethodHash as $classMethodContextDefinition) { - $this->symfonyStyle->writeln(str_repeat('-', 80)); - $this->symfonyStyle->newLine(); - - foreach ($classMethodContextDefinition as $classAndMethod) { - $relativeFilePath = substr($classAndMethod->getFilePath(), strlen((string) $testDirectory) + 1); - - $this->symfonyStyle->writeln('Mask: "' . $classAndMethod->getMask() . '"'); - $this->symfonyStyle->writeln($relativeFilePath . ':' . $classAndMethod->getMethodLine()); - - $this->symfonyStyle->newLine(); - } - - ++$i; - } - - $this->symfonyStyle->newLine(); - $this->symfonyStyle->error( - sprintf('Found %d definitions with different masks, but same method body', count( - $classMethodContextDefinitionByClassMethodHash - )) + 'This command was deprecated, to avoid granular rules. Use "analyze" command that runs them all instead' ); return self::FAILURE; diff --git a/src/Command/UnusedDefinitionsCommand.php b/src/Command/UnusedDefinitionsCommand.php index c4d469a32..049232b98 100644 --- a/src/Command/UnusedDefinitionsCommand.php +++ b/src/Command/UnusedDefinitionsCommand.php @@ -4,22 +4,20 @@ namespace Rector\Behastan\Command; -use Rector\Behastan\Analyzer\UnusedDefinitionsAnalyzer; use Rector\Behastan\Enum\Option; -use Rector\Behastan\Finder\BehatMetafilesFinder; -use Rector\Behastan\ValueObject\Mask\AbstractMask; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Webmozart\Assert\Assert; +/** + * @deprecated this rule is deprecated, to avoid granular rules. Use "analyze" command instead + */ final class UnusedDefinitionsCommand extends Command { public function __construct( private readonly SymfonyStyle $symfonyStyle, - private readonly UnusedDefinitionsAnalyzer $unusedDefinitionsAnalyzer, ) { parent::__construct(); } @@ -28,10 +26,12 @@ protected function configure(): void { $this->setName('unused-definitions'); - $this->setDescription('Checks Behat definitions in *Context.php files and feature files to spot unused ones'); + $this->setDescription( + '[DEPRECATED] Checks Behat definitions in *Context.php files and feature files to spot unused ones' + ); $this->addArgument( - Option::TEST_DIRECTORY, + Option::PROJECT_DIRECTORY, InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'One or more paths to check or *.Context.php and feature.yml files' ); @@ -39,60 +39,10 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $testDirectories = (array) $input->getArgument(Option::TEST_DIRECTORY); - Assert::allDirectory($testDirectories); - - $featureFiles = BehatMetafilesFinder::findFeatureFiles($testDirectories); - if ($featureFiles === []) { - $this->symfonyStyle->error('No *.feature files found. Please provide correct test directory'); - return self::FAILURE; - } - - $contextFiles = BehatMetafilesFinder::findContextFiles($testDirectories); - if ($contextFiles === []) { - $this->symfonyStyle->error('No *Context.php files found. Please provide correct test directory'); - return self::FAILURE; - } - - $this->symfonyStyle->title( - sprintf('Checking static, named and regex masks from %d *Feature files', count($featureFiles)) + $this->symfonyStyle->error( + 'This command was deprecated, to avoid granular rules. Use "analyze" command that runs them all instead' ); - $unusedMasks = $this->unusedDefinitionsAnalyzer->analyse($contextFiles, $featureFiles); - - $this->symfonyStyle->newLine(2); - - if ($unusedMasks === []) { - $this->symfonyStyle->success('All definitions are used'); - return Command::SUCCESS; - } - - $this->reportUnusedDefinitions($unusedMasks); - - return Command::FAILURE; - } - - /** - * @param AbstractMask[] $unusedMasks - */ - private function reportUnusedDefinitions(array $unusedMasks): void - { - foreach ($unusedMasks as $unusedMask) { - $this->printMask($unusedMask); - } - - $this->symfonyStyle->error(sprintf('Found %d unused definitions', count($unusedMasks))); - } - - private function printMask(AbstractMask $unusedMask): void - { - $this->symfonyStyle->writeln($unusedMask->mask); - - // make path relative - $relativeFilePath = str_replace(getcwd() . '/', '', $unusedMask->filePath); - $filePathWithLine = $relativeFilePath . ':' . $unusedMask->line; - - $this->symfonyStyle->writeln($filePathWithLine); - $this->symfonyStyle->newLine(); + return self::FAILURE; } } diff --git a/src/Contract/RuleInterface.php b/src/Contract/RuleInterface.php new file mode 100644 index 000000000..8164205e4 --- /dev/null +++ b/src/Contract/RuleInterface.php @@ -0,0 +1,25 @@ +isAnonymous()) { continue; } + if (! $class->namespacedName instanceof Name) { continue; } @@ -124,7 +126,7 @@ private function resolveMasksFromFiles(array $fileInfos): array $rawMasks = $this->classMethodMasksResolver->resolve($classMethod); foreach ($rawMasks as $rawMask) { - $classMethodContextDefinitions[] = new ClassMethodContextDefinition( + $classMethodContextDefinitions[] = new ContextDefinition( $fileInfo->getRealPath(), $className, $classMethod->name->toString(), diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index 3e50eafbc..ed82b6d98 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -7,12 +7,19 @@ use Illuminate\Container\Container; use PhpParser\Parser; use PhpParser\ParserFactory; +use Rector\Behastan\Analyzer\ContextDefinitionsAnalyzer; +use Rector\Behastan\Command\AnalyzeCommand; use Rector\Behastan\Command\DuplicatedDefinitionsCommand; use Rector\Behastan\Command\UnusedDefinitionsCommand; +use Rector\Behastan\Contract\RuleInterface; +use Rector\Behastan\Rule\DuplicatedContextDefinitionContentsRule; +use Rector\Behastan\Rule\DuplicatedMaskRule; +use Rector\Behastan\Rule\UnusedContextDefinitionsRule; use Symfony\Component\Console\Application; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Style\SymfonyStyle; +use Webmozart\Assert\Assert; final class ContainerFactory { @@ -25,10 +32,14 @@ public function create(): Container // console $container->singleton(Application::class, function (Container $container): Application { - $application = new Application('Behastan'); + $application = new Application('Behastan', '0.4'); // register commands - foreach ([DuplicatedDefinitionsCommand::class, UnusedDefinitionsCommand::class] as $commandClass) { + foreach ([ + DuplicatedDefinitionsCommand::class, + UnusedDefinitionsCommand::class, + AnalyzeCommand::class, + ] as $commandClass) { $command = $container->make($commandClass); $application->add($command); } @@ -45,6 +56,9 @@ public function create(): Container return $phpParserFactory->createForHostVersion(); }); + // to re-use + $container->singleton(ContextDefinitionsAnalyzer::class); + // silence in PHPUnit tests to keep output clear $consoleOutput = new ConsoleOutput(); $consoleOutput->setVerbosity( @@ -56,6 +70,14 @@ public function create(): Container static fn (): SymfonyStyle => new SymfonyStyle(new ArrayInput([]), $consoleOutput) ); + $this->registerRule($container, DuplicatedMaskRule::class); + $this->registerRule($container, DuplicatedContextDefinitionContentsRule::class); + $this->registerRule($container, UnusedContextDefinitionsRule::class); + + $container->when(AnalyzeCommand::class) + ->needs('$rules') + ->giveTagged(RuleInterface::class); + return $container; } @@ -70,4 +92,12 @@ public function hideDefaultCommands(Application $application): void $application->get('help') ->setHidden(); } + + private function registerRule(Container $container, string $ruleClass): void + { + Assert::isAOf($ruleClass, RuleInterface::class); + + $container->singleton($ruleClass); + $container->tag($ruleClass, RuleInterface::class); + } } diff --git a/src/Enum/Option.php b/src/Enum/Option.php index bb8dad9a2..6492127cf 100644 --- a/src/Enum/Option.php +++ b/src/Enum/Option.php @@ -9,5 +9,5 @@ final class Option /** * @var string */ - public const TEST_DIRECTORY = 'test-directory'; + public const PROJECT_DIRECTORY = 'test-directory'; } diff --git a/src/Finder/BehatMetafilesFinder.php b/src/Finder/BehatMetafilesFinder.php index 060e1aef5..12633fcb5 100644 --- a/src/Finder/BehatMetafilesFinder.php +++ b/src/Finder/BehatMetafilesFinder.php @@ -16,13 +16,8 @@ final class BehatMetafilesFinder */ public static function findContextFiles(array $directories): array { - Assert::allString($directories); - Assert::allDirectory($directories); - - $filesFinder = Finder::create() - ->files() - ->name('*Context.php') - ->in($directories); + $filesFinder = self::createFinder($directories) + ->name('*Context.php'); return iterator_to_array($filesFinder->getIterator()); } @@ -32,15 +27,27 @@ public static function findContextFiles(array $directories): array * @return SplFileInfo[] */ public static function findFeatureFiles(array $directories): array + { + $filesFinder = self::createFinder($directories) + ->name('*.feature'); + + return iterator_to_array($filesFinder->getIterator()); + } + + /** + * @param string[] $directories + */ + private static function createFinder(array $directories): Finder { Assert::allString($directories); Assert::allDirectory($directories); - $filesFinder = Finder::create() + return Finder::create() ->files() - ->name('*.feature') + ->notPath('vendor') + ->notPath('node_modules') + // test data + ->notPath('Fixture') ->in($directories); - - return iterator_to_array($filesFinder->getIterator()); } } diff --git a/src/Reporting/MaskCollectionStatsPrinter.php b/src/Reporting/MaskCollectionStatsPrinter.php index bc8f4eedd..2e0a2e613 100644 --- a/src/Reporting/MaskCollectionStatsPrinter.php +++ b/src/Reporting/MaskCollectionStatsPrinter.php @@ -4,7 +4,6 @@ namespace Rector\Behastan\Reporting; -use Rector\Behastan\ValueObject\Mask\AbstractMask; use Rector\Behastan\ValueObject\Mask\ExactMask; use Rector\Behastan\ValueObject\Mask\NamedMask; use Rector\Behastan\ValueObject\Mask\RegexMask; @@ -19,35 +18,34 @@ public function __construct( ) { } - public function printStats(MaskCollection $maskCollection): void + public function print(MaskCollection $maskCollection): void { $this->symfonyStyle->writeln(sprintf('Found %d masks:', $maskCollection->count())); - $this->symfonyStyle->newLine(); - $this->symfonyStyle->writeln(sprintf(' * %d exact', $maskCollection->countByType(ExactMask::class))); $this->symfonyStyle->writeln(sprintf(' * %d /regex/', $maskCollection->countByType(RegexMask::class))); $this->symfonyStyle->writeln(sprintf(' * %d :named', $maskCollection->countByType(NamedMask::class))); - $this->symfonyStyle->writeln(sprintf(' * %d skipped', $maskCollection->countByType(SkippedMask::class))); - $skippedMasks = $maskCollection->byType(SkippedMask::class); - if ($skippedMasks !== []) { - $this->symfonyStyle->newLine(); + $this->printSkippedMasks($maskCollection); + } - foreach ($skippedMasks as $skippedMask) { - $this->printMask($skippedMask); - } + private function printSkippedMasks(MaskCollection $maskCollection): void + { + $skippedMasks = $maskCollection->byType(SkippedMask::class); + if ($skippedMasks === []) { + return; + } - $this->symfonyStyle->newLine(); + $skippedMasksValues = []; + foreach ($skippedMasks as $skippedMask) { + $skippedMasksValues[] = $skippedMask->mask; } - } - private function printMask(AbstractMask $unusedMask): void - { - $this->symfonyStyle->writeln($unusedMask->mask); + $skippedMasksString = implode('", "', $skippedMasksValues); - // make path relative - $relativeFilePath = str_replace(getcwd() . '/', '', $unusedMask->filePath); - $this->symfonyStyle->writeln($relativeFilePath); - $this->symfonyStyle->newLine(); + $this->symfonyStyle->writeln(sprintf( + ' * %d skipped ("%s")', + $maskCollection->countByType(SkippedMask::class), + $skippedMasksString + )); } } diff --git a/src/Rule/DuplicatedContextDefinitionContentsRule.php b/src/Rule/DuplicatedContextDefinitionContentsRule.php new file mode 100644 index 000000000..8046496c4 --- /dev/null +++ b/src/Rule/DuplicatedContextDefinitionContentsRule.php @@ -0,0 +1,78 @@ +contextDefinitionsAnalyzer->resolveAndGroupByContentHash( + $contextFiles + ); + + $ruleErrors = []; + + // keep only duplicated + $duplicatedContextDefinitionByContentsHash = $this->filterOutNotDuplicated($contextDefinitionByContentHash); + + foreach ($duplicatedContextDefinitionByContentsHash as $duplicatedContextDefinition) { + $maskStrings = ''; + $lineFilePaths = []; + foreach ($duplicatedContextDefinition as $contextDefinition) { + $maskStrings .= ' * ' . $contextDefinition->getMask() . "\n"; + $lineFilePaths[] = $contextDefinition->getFilePath() . ':' . $contextDefinition->getMethodLine(); + } + + $errorMessage = sprintf( + 'These %d definitions have different masks, but same method body: %s%s', + count($duplicatedContextDefinition), + PHP_EOL, + $maskStrings + ); + + $ruleErrors[] = new RuleError($errorMessage, $lineFilePaths); + } + + return $ruleErrors; + } + + /** + * @template TItem as object + * + * @param array $items + * @return array + */ + private function filterOutNotDuplicated(array $items): array + { + foreach ($items as $hash => $classAndMethods) { + if (count($classAndMethods) < 2) { + unset($items[$hash]); + } + } + + return $items; + } +} diff --git a/src/Rule/DuplicatedMaskRule.php b/src/Rule/DuplicatedMaskRule.php new file mode 100644 index 000000000..dda8adcf0 --- /dev/null +++ b/src/Rule/DuplicatedMaskRule.php @@ -0,0 +1,62 @@ +classMethodContextDefinitionsAnalyzer->resolve($contextFiles); + + $groupedByMask = []; + foreach ($classMethodContextDefinitions as $classMethodContextDefinition) { + $groupedByMask[$classMethodContextDefinition->getMask()][] = $classMethodContextDefinition; + } + + $ruleErrors = []; + + foreach ($groupedByMask as $mask => $sameMaksClassMethodContextDefinitions) { + /** @var ContextDefinition[] $sameMaksClassMethodContextDefinitions */ + if (count($sameMaksClassMethodContextDefinitions) === 1) { + continue; + } + + $lineFilePaths = []; + foreach ($sameMaksClassMethodContextDefinitions as $sameMakClassMethodContextDefinition) { + $relativeFilePath = substr( + $sameMakClassMethodContextDefinition->getFilePath(), + strlen($projectDirectory) + 1 + ); + $lineFilePaths[] = $relativeFilePath . ':' . $sameMakClassMethodContextDefinition->getMethodLine(); + } + + $ruleErrors[] = new RuleError(sprintf('Duplicated mask "%s"', $mask), $lineFilePaths); + } + + return $ruleErrors; + } +} diff --git a/src/Rule/UnusedContextDefinitionsRule.php b/src/Rule/UnusedContextDefinitionsRule.php new file mode 100644 index 000000000..796fd2b9a --- /dev/null +++ b/src/Rule/UnusedContextDefinitionsRule.php @@ -0,0 +1,46 @@ +unusedDefinitionsAnalyzer->analyse($contextFiles, $featureFiles, $maskCollection); + + $ruleErrors = []; + + foreach ($unusedMasks as $unusedMask) { + $ruleErrors[] = new RuleError(sprintf( + 'The mask "%s" and its definition %s::%s() is never used', + $unusedMask->mask, + $unusedMask->className, + $unusedMask->methodName + ), [$unusedMask->filePath . ':' . $unusedMask->line]); + } + + return $ruleErrors; + } +} diff --git a/src/UsedInstructionResolver.php b/src/UsedInstructionResolver.php index 4678b0801..9b828635e 100644 --- a/src/UsedInstructionResolver.php +++ b/src/UsedInstructionResolver.php @@ -31,7 +31,7 @@ public function resolveInstructionsFromFeatureFiles(array $featureFileInfos): ar if ($matches === []) { // there should be at least one instruction in each feature file throw new RuntimeException(sprintf( - 'Unable to resolve instructions from %s file', + 'Unable to extract instructions from %s file', $featureFileInfo->getRealPath() )); } diff --git a/src/ValueObject/ClassMethodContextDefinition.php b/src/ValueObject/ContextDefinition.php similarity index 96% rename from src/ValueObject/ClassMethodContextDefinition.php rename to src/ValueObject/ContextDefinition.php index a350ac966..0cf61366f 100644 --- a/src/ValueObject/ClassMethodContextDefinition.php +++ b/src/ValueObject/ContextDefinition.php @@ -4,7 +4,7 @@ namespace Rector\Behastan\ValueObject; -final class ClassMethodContextDefinition +final class ContextDefinition { private int $usageCount = 0; diff --git a/src/ValueObject/RuleError.php b/src/ValueObject/RuleError.php new file mode 100644 index 000000000..14091799e --- /dev/null +++ b/src/ValueObject/RuleError.php @@ -0,0 +1,30 @@ +message; + } + + /** + * @return string[] + */ + public function getLineFilePaths(): array + { + return $this->lineFilePaths; + } +} diff --git a/tests/Analyzer/UnusedDefinitionsAnalyzer/UnusedDefinitionsAnalyzerTest.php b/tests/Analyzer/UnusedDefinitionsAnalyzer/UnusedDefinitionsAnalyzerTest.php index e1c682563..6c2098564 100644 --- a/tests/Analyzer/UnusedDefinitionsAnalyzer/UnusedDefinitionsAnalyzerTest.php +++ b/tests/Analyzer/UnusedDefinitionsAnalyzer/UnusedDefinitionsAnalyzerTest.php @@ -5,6 +5,7 @@ namespace Rector\Behastan\Tests\Analyzer\UnusedDefinitionsAnalyzer; use Rector\Behastan\Analyzer\UnusedDefinitionsAnalyzer; +use Rector\Behastan\DefinitionMasksExtractor; use Rector\Behastan\Finder\BehatMetafilesFinder; use Rector\Behastan\Tests\AbstractTestCase; use Rector\Behastan\ValueObject\Mask\AbstractMask; @@ -13,11 +14,14 @@ final class UnusedDefinitionsAnalyzerTest extends AbstractTestCase { private UnusedDefinitionsAnalyzer $unusedDefinitionsAnalyzer; + private DefinitionMasksExtractor $definitionMasksExtractor; + protected function setUp(): void { parent::setUp(); $this->unusedDefinitionsAnalyzer = $this->make(UnusedDefinitionsAnalyzer::class); + $this->definitionMasksExtractor = $this->make(DefinitionMasksExtractor::class); } public function testEverythingUsed(): void @@ -28,7 +32,9 @@ public function testEverythingUsed(): void $this->assertCount(1, $featureFiles); $this->assertCount(1, $contextFiles); - $unusedDefinitions = $this->unusedDefinitionsAnalyzer->analyse($contextFiles, $featureFiles); + $maskCollection = $this->definitionMasksExtractor->extract($contextFiles); + + $unusedDefinitions = $this->unusedDefinitionsAnalyzer->analyse($contextFiles, $featureFiles, $maskCollection); $this->assertCount(0, $unusedDefinitions); } @@ -41,7 +47,10 @@ public function testFoundMask(): void $this->assertCount(1, $featureFiles); $this->assertCount(1, $contextFiles); - $unusedMasks = $this->unusedDefinitionsAnalyzer->analyse($contextFiles, $featureFiles); + $maskCollection = $this->definitionMasksExtractor->extract($contextFiles); + + $unusedMasks = $this->unusedDefinitionsAnalyzer->analyse($contextFiles, $featureFiles, $maskCollection); + $this->assertCount(1, $unusedMasks); $this->assertContainsOnlyInstancesOf(AbstractMask::class, $unusedMasks); diff --git a/tests/DefinitionMasksResolver/DefinitionMasksResolverTest.php b/tests/DefinitionMasksExtractor/DefinitionMasksExtractorTest.php similarity index 70% rename from tests/DefinitionMasksResolver/DefinitionMasksResolverTest.php rename to tests/DefinitionMasksExtractor/DefinitionMasksExtractorTest.php index 5c7e21503..b9c8140d0 100644 --- a/tests/DefinitionMasksResolver/DefinitionMasksResolverTest.php +++ b/tests/DefinitionMasksExtractor/DefinitionMasksExtractorTest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Rector\Behastan\Tests\DefinitionMasksResolver; +namespace Rector\Behastan\Tests\DefinitionMasksExtractor; -use Rector\Behastan\DefinitionMasksResolver; +use Rector\Behastan\DefinitionMasksExtractor; use Rector\Behastan\Finder\BehatMetafilesFinder; use Rector\Behastan\Tests\AbstractTestCase; -use Rector\Behastan\Tests\DefinitionMasksResolver\Fixture\AnotherBehatContext; +use Rector\Behastan\Tests\DefinitionMasksExtractor\Fixture\AnotherBehatContext; use Rector\Behastan\ValueObject\Mask\ExactMask; -final class DefinitionMasksResolverTest extends AbstractTestCase +final class DefinitionMasksExtractorTest extends AbstractTestCase { - private DefinitionMasksResolver $definitionMasksResolver; + private DefinitionMasksExtractor $definitionMasksExtractor; private BehatMetafilesFinder $behatMetafilesFinder; @@ -20,15 +20,14 @@ protected function setUp(): void { parent::setUp(); - $this->definitionMasksResolver = $this->make(DefinitionMasksResolver::class); - + $this->definitionMasksExtractor = $this->make(DefinitionMasksExtractor::class); $this->behatMetafilesFinder = $this->make(BehatMetafilesFinder::class); } public function test(): void { $contextFileInfos = $this->behatMetafilesFinder->findContextFiles([__DIR__ . '/Fixture']); - $maskCollection = $this->definitionMasksResolver->resolve($contextFileInfos); + $maskCollection = $this->definitionMasksExtractor->extract($contextFileInfos); $this->assertCount(3, $maskCollection->all()); @@ -37,6 +36,7 @@ public function test(): void $this->assertContainsOnlyInstancesOf(ExactMask::class, $exactMasks); $firstExactMask = $exactMasks[0]; + $this->assertSame('I click homepage', $firstExactMask->mask); $this->assertSame(AnotherBehatContext::class, $firstExactMask->className); $this->assertSame(__DIR__ . '/Fixture/AnotherBehatContext.php', $firstExactMask->filePath); diff --git a/tests/DefinitionMasksResolver/Fixture/AnotherBehatContext.php b/tests/DefinitionMasksExtractor/Fixture/AnotherBehatContext.php similarity index 84% rename from tests/DefinitionMasksResolver/Fixture/AnotherBehatContext.php rename to tests/DefinitionMasksExtractor/Fixture/AnotherBehatContext.php index e8bc66ee8..ec8b2fa0c 100644 --- a/tests/DefinitionMasksResolver/Fixture/AnotherBehatContext.php +++ b/tests/DefinitionMasksExtractor/Fixture/AnotherBehatContext.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Rector\Behastan\Tests\DefinitionMasksResolver\Fixture; +namespace Rector\Behastan\Tests\DefinitionMasksExtractor\Fixture; use Behat\Step\Then;