From ab335ea155f815e2a65091e1d3aaad3a2fae41f7 Mon Sep 17 00:00:00 2001 From: guvra Date: Wed, 25 Mar 2026 16:28:38 +0100 Subject: [PATCH 1/2] Add phpmd3 task --- doc/tasks/phpmd3.md | 68 +++++++++++++ resources/config/tasks.yml | 7 ++ src/Task/PhpMd3.php | 97 +++++++++++++++++++ test/Unit/Task/PhpMdTest3.php | 173 ++++++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 doc/tasks/phpmd3.md create mode 100644 src/Task/PhpMd3.php create mode 100644 test/Unit/Task/PhpMdTest3.php diff --git a/doc/tasks/phpmd3.md b/doc/tasks/phpmd3.md new file mode 100644 index 00000000..26c57dc9 --- /dev/null +++ b/doc/tasks/phpmd3.md @@ -0,0 +1,68 @@ +# PhpMd + +The PhpMd3 task will sniff your code for bad coding standards. + +It is the same as the `phpmd` task, but provides compatibility with the `3.x-dev` branch. +It will be merged into the `phpmd` task when the 3.x becomes stable. + +***Composer*** + +``` +composer require --dev phpmd/phpmd:3.x-dev +``` + +***Config*** + +The task lives under the `phpmd3` namespace and has following configurable parameters: + +```yaml +# grumphp.yml +grumphp: + tasks: + phpmd3: + whitelist_patterns: [] + exclude: [] + report_format: text + ruleset: ['phpmd.xml.dist'] + triggered_by: ['php'] +``` + +**whitelist_patterns** + +*Default: []* + +This is a list of regex patterns that will filter files to validate. With this option you can skip files like tests. This option is used in relation with the parameter `triggered_by`. +For example: whitelist files in `src/FolderA/` and `src/FolderB/` you can use +```yaml +whitelist_patterns: + - /^src\/FolderA\/(.*)/ + - /^src\/FolderB\/(.*)/ +``` + +**exclude** + +*Default: []* + +This is a list of patterns that will be ignored by phpmd. With this option you can skip directories like tests. Leave this option blank to run phpmd for every php file. + +**report_format** + +*Default: text* + +This sets the output [renderer](https://phpmd.org/documentation/#renderers) of phpmd. +Available formats: ansi, text. + +**ruleset** + +*Default: [phpmd.xml.dist]* + +With this parameter you will be able to configure the rule/rulesets you want to use. You can use the standard +sets provided by PhpMd or you can configure your own xml configuration as described in the [PhpMd Documentation](https://phpmd.org/documentation/creating-a-ruleset.html) + +The full list of rules/rulesets can be found at [PhpMd Rules](https://phpmd.org/rules/index.html) + +**triggered_by** + +*Default: [php]* + +This is a list of extensions to be sniffed. diff --git a/resources/config/tasks.yml b/resources/config/tasks.yml index 7861fe32..d5ea98c2 100644 --- a/resources/config/tasks.yml +++ b/resources/config/tasks.yml @@ -289,6 +289,13 @@ services: tags: - {name: grumphp.task, task: phpmd} + GrumPHP\Task\PhpMd3: + arguments: + - '@process_builder' + - '@formatter.raw_process' + tags: + - {name: grumphp.task, task: phpmd3} + GrumPHP\Task\PhpMnd: arguments: - '@process_builder' diff --git a/src/Task/PhpMd3.php b/src/Task/PhpMd3.php new file mode 100644 index 00000000..579491d3 --- /dev/null +++ b/src/Task/PhpMd3.php @@ -0,0 +1,97 @@ + + */ +class PhpMd3 extends AbstractExternalTask +{ + public static function getConfigurableOptions(): ConfigOptionsResolver + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'whitelist_patterns' => [], + 'exclude' => [], + 'report_format' => 'text', + 'ruleset' => ['phpmd.xml.dist'], + 'triggered_by' => ['php'], + ]); + + $resolver->addAllowedTypes('whitelist_patterns', ['array']); + $resolver->addAllowedTypes('exclude', ['array']); + $resolver->addAllowedTypes('report_format', ['string']); + $resolver->addAllowedValues('report_format', ['text', 'ansi']); + $resolver->addAllowedTypes('ruleset', ['array']); + $resolver->addAllowedTypes('triggered_by', ['array']); + + return ConfigOptionsResolver::fromOptionsResolver($resolver); + } + + /** + * {@inheritdoc} + */ + public function canRunInContext(ContextInterface $context): bool + { + return $context instanceof GitPreCommitContext || $context instanceof RunContext; + } + + /** + * {@inheritdoc} + */ + public function run(ContextInterface $context): TaskResultInterface + { + $config = $this->getConfig()->getOptions(); + + $whitelistPatterns = $config['whitelist_patterns']; + $extensions = $config['triggered_by']; + + $files = $context->getFiles(); + if (\count($whitelistPatterns)) { + $files = $files->paths($whitelistPatterns); + } + $files = $files->extensions($extensions); + + if (0 === \count($files)) { + return TaskResult::createSkipped($this, $context); + } + + $arguments = $this->processBuilder->createArgumentsForCommand('phpmd'); + $arguments->add('analyze'); + $arguments->addOptionalArgument('--format=%s', $config['report_format']); + + foreach ($config['ruleset'] as $ruleset) { + $arguments->addOptionalArgument('--ruleset=%s', $ruleset); + } + + foreach ($config['exclude'] as $exclude) { + $arguments->addOptionalArgument('--exclude=%s', $exclude); + } + + foreach ($extensions as $extension) { + $arguments->addOptionalArgument('--suffixes=%s', $extension); + } + + $arguments->addFiles($files); + + $process = $this->processBuilder->buildProcess($arguments); + $process->run(); + + if (!$process->isSuccessful()) { + return TaskResult::createFailed($this, $context, $this->formatter->format($process)); + } + + return TaskResult::createPassed($this, $context); + } +} diff --git a/test/Unit/Task/PhpMdTest3.php b/test/Unit/Task/PhpMdTest3.php new file mode 100644 index 00000000..4f712c01 --- /dev/null +++ b/test/Unit/Task/PhpMdTest3.php @@ -0,0 +1,173 @@ +processBuilder->reveal(), + $this->formatter->reveal() + ); + } + + public static function provideConfigurableOptions(): iterable + { + yield 'defaults' => [ + [], + [ + 'whitelist_patterns' => [], + 'exclude' => [], + 'report_format' => 'text', + 'ruleset' => ['phpmd.xml.dist'], + 'triggered_by' => ['php'], + ] + ]; + + yield 'invalidcase' => [ + [ + 'whitelist_patterns' => 'thisisnotanarray' + ], + null + ]; + } + + public static function provideRunContexts(): iterable + { + yield 'run-context' => [ + true, + self::mockContext(RunContext::class) + ]; + + yield 'pre-commit-context' => [ + true, + self::mockContext(GitPreCommitContext::class) + ]; + + yield 'other' => [ + false, + self::mockContext() + ]; + } + + public static function provideFailsOnStuff(): iterable + { + yield 'exitCode1' => [ + [], + self::mockContext(RunContext::class, ['hello.php']), + function () { + $this->mockProcessBuilder('phpmd', $process = self::mockProcess(1)); + $this->formatter->format($process)->willReturn('nope'); + }, + 'nope' + ]; + } + + public static function providePassesOnStuff(): iterable + { + yield 'exitCode0' => [ + [], + self::mockContext(RunContext::class, ['hello.php']), + function () { + $this->mockProcessBuilder('phpmd', self::mockProcess(0)); + } + ]; + } + + public static function provideSkipsOnStuff(): iterable + { + yield 'no-files' => [ + [], + self::mockContext(RunContext::class), + function () {} + ]; + yield 'no-files-after-triggered-by' => [ + [], + self::mockContext(RunContext::class, ['notaphpfile.txt']), + function () {} + ]; + yield 'no-files-after-whitelist' => [ + [ + 'whitelist_patterns' => ['src/'], + ], + self::mockContext(RunContext::class, ['test/file.php']), + function () {} + ]; + } + + public static function provideExternalTaskRuns(): iterable + { + yield 'defaults' => [ + [], + self::mockContext(RunContext::class, ['hello.php', 'hello2.php']), + 'phpmd', + [ + 'analyze', + '--format=text', + '--ruleset=phpmd.xml.dist', + '--suffixes=php', + 'hello.php', + 'hello2.php', + ] + ]; + + yield 'excludes' => [ + [ + 'exclude' => ['hello.php', 'hello2.php'], + ], + self::mockContext(RunContext::class, ['hello.php', 'hello2.php']), + 'phpmd', + [ + 'analyze', + '--format=text', + '--ruleset=phpmd.xml.dist', + '--exclude=hello.php', + '--exclude=hello2.php', + '--suffixes=php', + 'hello.php', + 'hello2.php', + ] + ]; + + yield 'rulesets' => [ + [ + 'ruleset' => ['cleancode'], + ], + self::mockContext(RunContext::class, ['hello.php', 'hello2.php']), + 'phpmd', + [ + 'analyze', + '--format=text', + '--ruleset=cleancode', + '--suffixes=php', + 'hello.php', + 'hello2.php', + ] + ]; + + yield 'report_formats' => [ + [ + 'report_format' => 'ansi', + ], + self::mockContext(RunContext::class, ['hello.php', 'hello2.php']), + 'phpmd', + [ + 'analyze', + '--format=ansi', + '--ruleset=phpmd.xml.dist', + '--suffixes=php', + 'hello.php', + 'hello2.php', + ] + ]; + } +} From a8919031a1735fc65114bc87584a29a808d768be Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 13:54:59 +0200 Subject: [PATCH 2/2] Address review feedback on phpmd3 task Use addArgumentArray and the fluent file filter like Phpcs. Cover multiple rulesets, triggered_by and whitelist_patterns in the run tests. Link the task from doc/tasks.md, install ^3.0 for phpmd3 and pin the phpmd docs to ^2.0, because a plain require now installs PHPMD 3. --- doc/tasks.md | 2 ++ doc/tasks/phpmd.md | 2 +- doc/tasks/phpmd3.md | 8 +++--- src/Task/PhpMd3.php | 25 +++++------------- test/Unit/Task/PhpMdTest3.php | 49 +++++++++++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 24 deletions(-) diff --git a/doc/tasks.md b/doc/tasks.md index 1818c37b..c2e11473 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -48,6 +48,7 @@ grumphp: phpcsfixer: ~ phplint: ~ phpmd: ~ + phpmd3: ~ phpmnd: ~ phpparser: ~ phpspec: ~ @@ -121,6 +122,7 @@ Every task has its own default configuration. It is possible to overwrite the pa - [PHP-CS-Fixer](tasks/phpcsfixer.md) - [PHPLint](tasks/phplint.md) - [PhpMd](tasks/phpmd.md) +- [PhpMd3](tasks/phpmd3.md) - [PhpMnd](tasks/phpmnd.md) - [PhpParser](tasks/phpparser.md) - [Phpspec](tasks/phpspec.md) diff --git a/doc/tasks/phpmd.md b/doc/tasks/phpmd.md index 9e139abb..81b85cb0 100644 --- a/doc/tasks/phpmd.md +++ b/doc/tasks/phpmd.md @@ -5,7 +5,7 @@ The PhpMd task will sniff your code for bad coding standards. ***Composer*** ``` -composer require --dev phpmd/phpmd +composer require --dev phpmd/phpmd:^2.0 ``` ***Config*** diff --git a/doc/tasks/phpmd3.md b/doc/tasks/phpmd3.md index 26c57dc9..07cc2d35 100644 --- a/doc/tasks/phpmd3.md +++ b/doc/tasks/phpmd3.md @@ -1,14 +1,14 @@ -# PhpMd +# PhpMd3 The PhpMd3 task will sniff your code for bad coding standards. -It is the same as the `phpmd` task, but provides compatibility with the `3.x-dev` branch. -It will be merged into the `phpmd` task when the 3.x becomes stable. +It is the same as the `phpmd` task, but uses the `analyze` command of PHPMD 3. +Use the [`phpmd`](phpmd.md) task for PHPMD 2. ***Composer*** ``` -composer require --dev phpmd/phpmd:3.x-dev +composer require --dev phpmd/phpmd:^3.0 ``` ***Config*** diff --git a/src/Task/PhpMd3.php b/src/Task/PhpMd3.php index 579491d3..e78332d7 100644 --- a/src/Task/PhpMd3.php +++ b/src/Task/PhpMd3.php @@ -54,14 +54,9 @@ public function run(ContextInterface $context): TaskResultInterface { $config = $this->getConfig()->getOptions(); - $whitelistPatterns = $config['whitelist_patterns']; - $extensions = $config['triggered_by']; - - $files = $context->getFiles(); - if (\count($whitelistPatterns)) { - $files = $files->paths($whitelistPatterns); - } - $files = $files->extensions($extensions); + $files = $context->getFiles() + ->extensions($config['triggered_by']) + ->paths($config['whitelist_patterns']); if (0 === \count($files)) { return TaskResult::createSkipped($this, $context); @@ -71,17 +66,9 @@ public function run(ContextInterface $context): TaskResultInterface $arguments->add('analyze'); $arguments->addOptionalArgument('--format=%s', $config['report_format']); - foreach ($config['ruleset'] as $ruleset) { - $arguments->addOptionalArgument('--ruleset=%s', $ruleset); - } - - foreach ($config['exclude'] as $exclude) { - $arguments->addOptionalArgument('--exclude=%s', $exclude); - } - - foreach ($extensions as $extension) { - $arguments->addOptionalArgument('--suffixes=%s', $extension); - } + $arguments->addArgumentArray('--ruleset=%s', $config['ruleset']); + $arguments->addArgumentArray('--exclude=%s', $config['exclude']); + $arguments->addArgumentArray('--suffixes=%s', $config['triggered_by']); $arguments->addFiles($files); diff --git a/test/Unit/Task/PhpMdTest3.php b/test/Unit/Task/PhpMdTest3.php index 4f712c01..80cb1b2e 100644 --- a/test/Unit/Task/PhpMdTest3.php +++ b/test/Unit/Task/PhpMdTest3.php @@ -154,6 +154,55 @@ public static function provideExternalTaskRuns(): iterable ] ]; + yield 'multiple-rulesets' => [ + [ + 'ruleset' => ['cleancode', 'naming'], + ], + self::mockContext(RunContext::class, ['hello.php', 'hello2.php']), + 'phpmd', + [ + 'analyze', + '--format=text', + '--ruleset=cleancode', + '--ruleset=naming', + '--suffixes=php', + 'hello.php', + 'hello2.php', + ] + ]; + + yield 'triggered_by' => [ + [ + 'triggered_by' => ['php', 'phtml'], + ], + self::mockContext(RunContext::class, ['hello.php', 'hello.phtml', 'hello.txt']), + 'phpmd', + [ + 'analyze', + '--format=text', + '--ruleset=phpmd.xml.dist', + '--suffixes=php', + '--suffixes=phtml', + 'hello.php', + 'hello.phtml', + ] + ]; + + yield 'whitelist_patterns' => [ + [ + 'whitelist_patterns' => ['/^src\\/(.*)/'], + ], + self::mockContext(RunContext::class, ['src/hello.php', 'test/hello.php']), + 'phpmd', + [ + 'analyze', + '--format=text', + '--ruleset=phpmd.xml.dist', + '--suffixes=php', + 'src/hello.php', + ] + ]; + yield 'report_formats' => [ [ 'report_format' => 'ansi',