diff --git a/README.md b/README.md index cfa569b17..85aaa0e49 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ parameters: phpspec: ~ phpstan: ~ phpunit: ~ + phpunitbridge: ~ phpversion: ~ progpilot: ~ psalm: ~ diff --git a/composer.json b/composer.json index 1e00e19ea..c355a47bf 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "symfony/filesystem": "~2.7|~3.0|~4.0", "symfony/finder": "~2.7|~3.0|~4.0", "symfony/options-resolver": "~2.7|~3.0|~4.0", - "symfony/process": "~2.7|~3.0|~4.0", + "symfony/process": "~3.2|~4.0", "symfony/yaml": "~2.7|~3.0|~4.0" }, "require-dev": { @@ -57,6 +57,7 @@ "sensiolabs/security-checker": "Lets GrumPHP be sure that there are no known security issues.", "squizlabs/php_codesniffer": "Lets GrumPHP sniff on your code.", "sstalle/php7cc": "Lets GrumPHP check PHP 5.3 - 5.6 code compatibility with PHP 7.", + "symfony/phpunit-bridge": "Lets GrumPHP run your unit tests with the phpunit-bridge of Symfony.", "vimeo/psalm": "Lets GrumPHP discover errors in your code without running it." }, "authors": [ diff --git a/doc/parameters.md b/doc/parameters.md index ed7968292..cdd940dab 100644 --- a/doc/parameters.md +++ b/doc/parameters.md @@ -13,6 +13,7 @@ parameters: process_async_limit: 10 process_async_wait: 1000 process_timeout: 60 + additonal_info: ~ ascii: failed: resource/grumphp-grumpy.txt succeeded: resource/grumphp-happy.txt @@ -108,6 +109,28 @@ If you've got tools that run more then 60 seconds, you can increase this paramet It is also possible to disable the timeout by setting the value to `null`. When receiving a `Symfony\Component\Process\Exception\ProcessTimedOutException` during the execution of GrumPHP, you probably need to increment this setting. +**additional_info** + +*Default: null* + +This parameter will display additional information at the end of a `success` *or* `error` task. + +```yaml +# grumphp.yml +parameters: + additional_info: "\nTo get full documentation for the project!\nVisit https://docs.example.com\n" +``` + +*Example Result:* +``` +GrumPHP is sniffing your code! +Running task 1/1: Phpcs... ✔ + +To get full documentation for the project! +Visit https://docs.example.com + +``` + **ascii** *Default: {failed: grumphp-grumpy.txt, succeeded: grumphp-happy.txt}* @@ -132,7 +155,15 @@ parameters: - resource/grumphp-happy3.txt ``` -To disable banner set ascii images path to `~`: +To disable all banners set ascii to `~`: + +```yaml +# grumphp.yml +parameters: + ascii: ~ +``` + +To disable a specific banner set ascii image path to `~`: ```yaml # grumphp.yml diff --git a/doc/tasks.md b/doc/tasks.md index c2c8e5e00..c80e770c0 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -44,6 +44,7 @@ parameters: phpspec: ~ phpstan: ~ phpunit: ~ + phpunitbridge: ~ phpversion: ~ progpilot: ~ psalm: ~ @@ -95,6 +96,7 @@ Every task has it's own default configuration. It is possible to overwrite the p - [Phpspec](tasks/phpspec.md) - [PHPStan](tasks/phpstan.md) - [Phpunit](tasks/phpunit.md) +- [Phpunit bridge](tasks/phpunitbridge.md) - [PhpVersion](tasks/phpversion.md) - [Progpilot](tasks/progpilot.md) - [Psalm](tasks/psalm.md) diff --git a/doc/tasks/composer_require_checker.md b/doc/tasks/composer_require_checker.md index 6fb486145..270278ead 100644 --- a/doc/tasks/composer_require_checker.md +++ b/doc/tasks/composer_require_checker.md @@ -23,13 +23,13 @@ parameters: **composer_file** -*Default: ~* +*Default: null* The composer.json of your code base that should be checked. **config_file** -*Default: ~* +*Default: null* Composer Require Checker is configured to whitelist some symbols by default. You can now override this configuration with your own and tell GrumPHP to use that configuration file instead. @@ -44,6 +44,6 @@ This option is only available in version 0.2.0 of `maglnet/composer-require-chec **triggered_by** -*Default: ['composer.json', 'composer.lock', '*.php']* +*Default: ['composer.json', 'composer.lock', '\*.php']* This is a list of file names that should trigger this task. diff --git a/doc/tasks/git_branch_name.md b/doc/tasks/git_branch_name.md index 9ab7b6bf2..886df88d5 100644 --- a/doc/tasks/git_branch_name.md +++ b/doc/tasks/git_branch_name.md @@ -11,6 +11,7 @@ parameters: matchers: Branch name must contain JIRA issue number: /JIRA-\d+/ additional_modifiers: '' + allow_detached_head: true ``` **matchers** diff --git a/doc/tasks/git_commit_message.md b/doc/tasks/git_commit_message.md index d8eaecfb2..33b1c1f40 100644 --- a/doc/tasks/git_commit_message.md +++ b/doc/tasks/git_commit_message.md @@ -10,8 +10,10 @@ parameters: git_commit_message: allow_empty_message: false enforce_capitalized_subject: true + enforce_no_subject_punctuations: false enforce_no_subject_trailing_period: true enforce_single_lined_subject: true + type_scope_conventions: [] max_body_width: 72 max_subject_width: 60 matchers: @@ -33,6 +35,12 @@ Controls whether or not empty commit messages are allowed. Ensures that the commit message subject line starts with a capital letter. +**enforce_no_subject_punctuations** + +*Default: false* + +Ensures that the commit message subject line doesn't have any punctuations characters. + **enforce_no_subject_trailing_period** *Default: true* @@ -95,3 +103,69 @@ additional_modifiers: 'u' additional_modifiers: 'xu' ``` + +**type_scope_conventions** + +*Default: []* + +Enable a commonly used convention for the subject line. + +Format is as follows: +``` +type[(scope)]: subject +``` +*The scope is optional* + +Good examples: +``` +fix: Security issue with password hashing +fix(Password): Security issue with password hashing +``` + +**types** + +*Default: []* + +*To be used with `type_scope_conventions`* + +Specify a list of acceptable types. Default allows ***all*** types. + +Add one or multiple types like: +```yaml +type_scope_conventions: + - types: + - build + - ci + - chore + - docs + - feat + - fix + - perf + - refactor + - revert + - style + - test + - scopes: [] +``` + +**scopes** + +*Default: []* + +*To be used with `type_scope_conventions`* + +Specify a list of acceptable scopes. Default allows ***all*** scopes. + +Add one or multiple scopes like: +```yaml +type_scope_conventions: + - types: [] + - scopes: + - api + - index + - user + - language + - browser + - environment +``` + diff --git a/doc/tasks/phplint.md b/doc/tasks/phplint.md index 7a6e20086..8512c2cb1 100644 --- a/doc/tasks/phplint.md +++ b/doc/tasks/phplint.md @@ -17,6 +17,7 @@ parameters: phplint: exclude: [] jobs: ~ + ignore_patterns: [] triggered_by: ['php', 'phtml', 'php3', 'php4', 'php5'] ``` **exclude** @@ -34,6 +35,12 @@ The number of jobs you wish to use for parallel processing. If no number is given, it is left up to parallel-lint itself, which currently defaults to 10. +**ignore_patterns** + +*Default: []* + +This is a list of patterns that will be ignored by PHPLint. Leave this option blank to run PHPLint for every php file. + **trigered_by** *Default: ['php', 'phtml', 'php3', 'php4', 'php5']* diff --git a/doc/tasks/phpstan.md b/doc/tasks/phpstan.md index 45fcebec8..a7411d6dd 100644 --- a/doc/tasks/phpstan.md +++ b/doc/tasks/phpstan.md @@ -24,13 +24,13 @@ parameters: **autoload_file** -*Default: ~* +*Default: null* With this parameter you can specify the path your project's additional autoload file path. **configuration** -*Default: ~* +*Default: null* With this parameter you can specify the path your project's configuration file. diff --git a/doc/tasks/phpunitbridge.md b/doc/tasks/phpunitbridge.md new file mode 100644 index 000000000..150cd8979 --- /dev/null +++ b/doc/tasks/phpunitbridge.md @@ -0,0 +1,56 @@ +# Phpunit bridge + +The Phpunit Bridge task will run your unit tests thanks to the Symfony Phpunit Bridge. + +***Composer*** + +``` +composer require --dev symfony/phpunit-bridge +``` + +***Config*** + +The task lives under the `phpunitbridge` namespace and has following configurable parameters: + +```yaml +# grumphp.yml +parameters: + tasks: + phpunitbridge: + config_file: ~ + testsuite: ~ + group: [] + always_execute: false +``` + +**config_file** + +*Default: null* + +If your phpunit.xml file is located at an exotic location, you can specify your custom config file location with this option. +This option is set to `null` by default. +This means that `phpunit.xml` or `phpunit.xml.dist` are automatically loaded if one of them exist in the current directory. + + +**testsuite** + +*Default: null* + +If you wish to only run tests from a certain Suite. +`testsuite: unit` + + +**group** + +*Default: array()* + +If you wish to only run tests from a certain Group. +`group: [fast,quick,small]` + + +**always_execute** + +*Default: false* + +Always run the whole test suite, even if no PHP files were changed. + diff --git a/doc/tasks/phpversion.md b/doc/tasks/phpversion.md index 77346124d..7b8a90931 100644 --- a/doc/tasks/phpversion.md +++ b/doc/tasks/phpversion.md @@ -9,7 +9,7 @@ It lives under the `phpversion` namespace and has following configurable paramet parameters: tasks: phpversion: - project: '7.0' + project: '7.2' ``` **project** diff --git a/doc/tasks/psalm.md b/doc/tasks/psalm.md index c5d7e30df..366924dc9 100644 --- a/doc/tasks/psalm.md +++ b/doc/tasks/psalm.md @@ -20,12 +20,13 @@ parameters: report: ~ threads: 1 triggered_by: ['php'] + show_info: false ``` **config** -*Default: ~* +*Default: null* With this parameter you can specify the path your project's configuration file. @@ -48,7 +49,7 @@ With this parameter you can run Psalm without using the cache file. **report** -*Default: ~* +*Default: null* With this path you can specify the path your psalm report file @@ -65,3 +66,9 @@ This parameter defines on how many threads Psalm's analysis stage is ran. *Default: [php]* This is a list of extensions to be sniffed. + +**show_info** + +*Default: false* + +Show non-exception parser findings \ No newline at end of file diff --git a/resources/config/parameters.yml b/resources/config/parameters.yml index 4fc8fe315..f62204fa0 100644 --- a/resources/config/parameters.yml +++ b/resources/config/parameters.yml @@ -3,6 +3,7 @@ parameters: git_dir: . hooks_dir: ~ hooks_preset: local + additional_info: ~ tasks: [] testsuites: [] stop_on_failure: false diff --git a/resources/config/tasks.yml b/resources/config/tasks.yml index 356454e92..8ead48e4f 100644 --- a/resources/config/tasks.yml +++ b/resources/config/tasks.yml @@ -327,6 +327,15 @@ services: tags: - {name: grumphp.task, config: phpunit} + task.phpunitbridge: + class: GrumPHP\Task\PhpunitBridge + arguments: + - '@config' + - '@process_builder' + - '@formatter.raw_process' + tags: + - {name: grumphp.task, config: phpunitbridge} + task.phpversion: class: GrumPHP\Task\PhpVersion arguments: diff --git a/resources/config/util.yml b/resources/config/util.yml index 3a90d423d..c1765487d 100644 --- a/resources/config/util.yml +++ b/resources/config/util.yml @@ -13,3 +13,4 @@ services: '5.6': '2018-12-31 23:59:59' '7.0': '2018-12-03 23:59:59' '7.1': '2019-12-01 23:59:59' + '7.2': '2020-11-30 23:59:59' diff --git a/spec/Configuration/GrumPHPSpec.php b/spec/Configuration/GrumPHPSpec.php index cf16f3a4e..568f766e5 100644 --- a/spec/Configuration/GrumPHPSpec.php +++ b/spec/Configuration/GrumPHPSpec.php @@ -136,4 +136,12 @@ function it_should_know_all_testsuites(ContainerInterface $container) $container->getParameter('grumphp.testsuites')->willReturn($testSuites = new TestSuiteCollection()); $this->getTestSuites()->shouldBe($testSuites); } + + function it_knows_the_additional_info(ContainerInterface $container) + { + $container->getParameter('additional_info') + ->willReturn('https://docs.example.com'); + + $this->getAdditionalInfo()->shouldReturn('https://docs.example.com'); + } } diff --git a/spec/Console/Helper/TaskRunnerHelperSpec.php b/spec/Console/Helper/TaskRunnerHelperSpec.php index 842b4f9e7..c9d3a48e0 100644 --- a/spec/Console/Helper/TaskRunnerHelperSpec.php +++ b/spec/Console/Helper/TaskRunnerHelperSpec.php @@ -39,6 +39,7 @@ function let( $runnerContext->hasTestSuite()->willReturn(false); $runnerContext->skipSuccessOutput()->willReturn(false); + $config->getAdditionalInfo()->willReturn(null); $config->hideCircumventionTip()->willReturn(false); } diff --git a/spec/Task/Git/BranchNameSpec.php b/spec/Task/Git/BranchNameSpec.php index bf24165df..bb06b14fa 100644 --- a/spec/Task/Git/BranchNameSpec.php +++ b/spec/Task/Git/BranchNameSpec.php @@ -36,6 +36,7 @@ function it_should_have_configurable_options() $options->shouldBeAnInstanceOf(OptionsResolver::class); $options->getDefinedOptions()->shouldContain('matchers'); $options->getDefinedOptions()->shouldContain('additional_modifiers'); + $options->getDefinedOptions()->shouldContain('allow_detached_head'); } function it_is_initializable() diff --git a/spec/Task/Git/CommitMessageSpec.php b/spec/Task/Git/CommitMessageSpec.php index 6dd9a9b5d..3f4107219 100644 --- a/spec/Task/Git/CommitMessageSpec.php +++ b/spec/Task/Git/CommitMessageSpec.php @@ -42,6 +42,8 @@ function it_should_have_configurable_options() $options->getDefinedOptions()->shouldContain('multiline'); $options->getDefinedOptions()->shouldContain('matchers'); $options->getDefinedOptions()->shouldContain('additional_modifiers'); + $options->getDefinedOptions()->shouldContain('enforce_no_subject_punctuations'); + $options->getDefinedOptions()->shouldContain('type_scope_conventions'); } function it_is_initializable() @@ -606,4 +608,201 @@ function it_should_pass_when_subject_does_not_contain_a_trailing_period(GrumPHP $result->shouldBeAnInstanceOf(TaskResultInterface::class); $result->isPassed()->shouldBe(true); } + + function it_should_pass_when_enforce_type_scope_conventions_is_false( + GrumPHP $grumPHP, + GitCommitMsgContext $context + ) { + $grumPHP->getTaskConfiguration('git_commit_message')->willReturn([ + 'allow_empty_message' => false, + 'enforce_capitalized_subject' => false, + 'enforce_no_subject_trailing_period' => false, + 'enforce_single_lined_subject' => true, + 'type_scope_conventions' => [], + ]); + + $commitMessage = <<<'MSG' +this subject does not follow the type scope conventions + +pass because we set enforce_type_scope_conventions to false + +And footer #12 +MSG; + + $context->getCommitMessage()->willReturn($commitMessage); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->isPassed()->shouldBe(true); + } + + function it_should_fail_when_type_scope_conventions_does_not_follow_conventions( + GrumPHP $grumPHP, + GitCommitMsgContext $context + ) { + $grumPHP->getTaskConfiguration('git_commit_message')->willReturn([ + 'allow_empty_message' => false, + 'enforce_capitalized_subject' => false, + 'enforce_no_subject_trailing_period' => false, + 'enforce_single_lined_subject' => true, + 'type_scope_conventions' => [ + 'types' => [] + ], + ]); + + $commitMessage = <<<'MSG' +this subject does not follow the type scope conventions + +The body ... + +And footer #12 +MSG; + + $context->getCommitMessage()->willReturn($commitMessage); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->isPassed()->shouldBe(false); + } + + function it_should_fail_when_type_scope_conventions_does_not_use_an_available_type( + GrumPHP $grumPHP, + GitCommitMsgContext $context + ) { + $grumPHP->getTaskConfiguration('git_commit_message')->willReturn([ + 'allow_empty_message' => false, + 'enforce_capitalized_subject' => false, + 'enforce_no_subject_trailing_period' => false, + 'enforce_single_lined_subject' => true, + 'type_scope_conventions' => [ + 'types' => ['fix'] + ], + ]); + + $commitMessage = <<<'MSG' +docs: this type is not in the available types array + +The body ... + +And footer #12 +MSG; + + $context->getCommitMessage()->willReturn($commitMessage); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->isPassed()->shouldBe(false); + } + + function it_should_pass_when_type_scope_conventions_does_use_an_available_type( + GrumPHP $grumPHP, + GitCommitMsgContext $context + ) { + $grumPHP->getTaskConfiguration('git_commit_message')->willReturn([ + 'allow_empty_message' => false, + 'enforce_capitalized_subject' => false, + 'enforce_no_subject_trailing_period' => false, + 'enforce_single_lined_subject' => true, + 'type_scope_conventions' => [ + 'types' => ['fix'] + ], + ]); + + $commitMessage = <<<'MSG' +fix: this type is in the available types array + +The body ... + +And footer #12 +MSG; + + $context->getCommitMessage()->willReturn($commitMessage); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->isPassed()->shouldBe(true); + } + + function it_should_fail_when_type_scope_conventions_does_not_use_an_available_scope( + GrumPHP $grumPHP, + GitCommitMsgContext $context + ) { + $grumPHP->getTaskConfiguration('git_commit_message')->willReturn([ + 'allow_empty_message' => false, + 'enforce_capitalized_subject' => false, + 'enforce_no_subject_trailing_period' => false, + 'enforce_single_lined_subject' => true, + 'type_scope_conventions' => [ + 'scopes' => ['user'] + ], + ]); + + $commitMessage = <<<'MSG' +fix(index): this scope is not in the available scopes array + +The body ... + +And footer #12 +MSG; + + $context->getCommitMessage()->willReturn($commitMessage); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->isPassed()->shouldBe(false); + } + + function it_should_pass_when_type_scope_conventions_does_use_an_available_scope( + GrumPHP $grumPHP, + GitCommitMsgContext $context + ) { + $grumPHP->getTaskConfiguration('git_commit_message')->willReturn([ + 'allow_empty_message' => false, + 'enforce_capitalized_subject' => false, + 'enforce_no_subject_trailing_period' => false, + 'enforce_single_lined_subject' => true, + 'type_scope_conventions' => [ + 'scopes' => ['user'] + ], + ]); + + $commitMessage = <<<'MSG' +fix(user): this scope is in the available scopes array + +The body ... + +And footer #12 +MSG; + + $context->getCommitMessage()->willReturn($commitMessage); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->isPassed()->shouldBe(true); + } + + function it_should_fail_if_subject_contains_punctuations(GrumPHP $grumPHP, GitCommitMsgContext $context) + { + $grumPHP->getTaskConfiguration('git_commit_message')->willReturn([ + 'allow_empty_message' => false, + 'enforce_capitalized_subject' => false, + 'enforce_no_subject_trailing_period' => false, + 'enforce_single_lined_subject' => true, + 'enforce_no_subject_punctuations' => true, + ]); + + $commitMessage = <<<'MSG' +fix(user): this subject has punctuations! + +The body ... + +And footer #12 ? +MSG; + + $context->getCommitMessage()->willReturn($commitMessage); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->isPassed()->shouldBe(false); + } } diff --git a/spec/Task/PhpLintSpec.php b/spec/Task/PhpLintSpec.php index 7b5c7ba5c..1dbccdfb9 100644 --- a/spec/Task/PhpLintSpec.php +++ b/spec/Task/PhpLintSpec.php @@ -16,6 +16,7 @@ use Prophecy\Argument; use Symfony\Component\Finder\SplFileInfo; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Process\InputStream; use Symfony\Component\Process\Process; class PhpLintSpec extends ObjectBehavior @@ -42,6 +43,7 @@ function it_should_have_configurable_options() $options->shouldBeAnInstanceOf(OptionsResolver::class); $options->getDefinedOptions()->shouldContain('jobs'); $options->getDefinedOptions()->shouldContain('exclude'); + $options->getDefinedOptions()->shouldContain('ignore_patterns'); $options->getDefinedOptions()->shouldContain('triggered_by'); } @@ -61,7 +63,9 @@ function it_runs_the_linter(ProcessBuilder $processBuilder, Process $process, Co $processBuilder->createArgumentsForCommand('parallel-lint')->willReturn($arguments); $processBuilder->buildProcess($arguments)->willReturn($process); - $process->run()->shouldBeCalled(); + $process->setInput(null)->shouldBeCalled()->withArguments([new \Prophecy\Argument\Token\TypeToken(InputStream::class)]); + $process->start()->shouldBeCalled(); + $process->wait()->shouldBeCalled(); $process->isSuccessful()->willReturn(true); $context->getFiles()->willReturn(new FilesCollection([ @@ -85,7 +89,9 @@ function it_throws_exception_if_the_process_fails( $processBuilder->createArgumentsForCommand('parallel-lint')->willReturn($arguments); $processBuilder->buildProcess($arguments)->willReturn($process); - $process->run()->shouldBeCalled(); + $process->setInput(null)->shouldBeCalled()->withArguments([new \Prophecy\Argument\Token\TypeToken(InputStream::class)]);; + $process->start()->shouldBeCalled(); + $process->wait()->shouldBeCalled(); $process->isSuccessful()->willReturn(false); $context->getFiles()->willReturn(new FilesCollection([ diff --git a/spec/Task/PhpunitBridgeSpec.php b/spec/Task/PhpunitBridgeSpec.php new file mode 100644 index 000000000..a69c72125 --- /dev/null +++ b/spec/Task/PhpunitBridgeSpec.php @@ -0,0 +1,133 @@ +getTaskConfiguration('phpunitbridge')->willReturn([]); + $this->beConstructedWith($grumPHP, $processBuilder, $formatter); + } + + function it_is_initializable() + { + $this->shouldHaveType(PhpunitBridge::class); + } + + function it_should_have_a_name() + { + $this->getName()->shouldBe('phpunitbridge'); + } + + function it_should_have_configurable_options() + { + $options = $this->getConfigurableOptions(); + $options->shouldBeAnInstanceOf(OptionsResolver::class); + $options->getDefinedOptions()->shouldContain('config_file'); + $options->getDefinedOptions()->shouldContain('testsuite'); + $options->getDefinedOptions()->shouldContain('group'); + $options->getDefinedOptions()->shouldContain('always_execute'); + } + + function it_should_run_in_git_pre_commit_context(GitPreCommitContext $context) + { + $this->canRunInContext($context)->shouldReturn(true); + } + + function it_should_run_in_run_context(RunContext $context) + { + $this->canRunInContext($context)->shouldReturn(true); + } + + function it_does_not_do_anything_if_there_are_no_files(ProcessBuilder $processBuilder, ContextInterface $context) + { + $processBuilder->buildProcess('phpunitbridge')->shouldNotBeCalled(); + $processBuilder->buildProcess()->shouldNotBeCalled(); + $context->getFiles()->willReturn(new FilesCollection()); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->getResultCode()->shouldBe(TaskResult::SKIPPED); + } + + function it_runs_if_there_are_no_files_but_always_execute_is_passed(GrumPHP $grumPHP, Process $process, ProcessBuilder $processBuilder, ContextInterface $context) + { + $grumPHP->getTaskConfiguration('phpunitbridge')->willReturn([ + 'always_execute' => true, + ]); + + $arguments = new ProcessArgumentsCollection(); + $processBuilder->createArgumentsForCommand('simple-phpunit')->willReturn($arguments); + $processBuilder->buildProcess($arguments)->willReturn($process); + + $process->run()->shouldBeCalled(); + $process->isSuccessful()->willReturn(true); + + $context->getFiles()->willReturn(new FilesCollection()); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->isPassed()->shouldBe(true); + } + + function it_runs_the_suite(ProcessBuilder $processBuilder, Process $process, ContextInterface $context) + { + $arguments = new ProcessArgumentsCollection(); + $processBuilder->createArgumentsForCommand('simple-phpunit')->willReturn($arguments); + $processBuilder->buildProcess($arguments)->willReturn($process); + + $process->run()->shouldBeCalled(); + $process->isSuccessful()->willReturn(true); + + $context->getFiles()->willReturn(new FilesCollection([ + new SplFileInfo('test.php', '.', 'test.php') + ])); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->isPassed()->shouldBe(true); + } + + function it_throws_exception_if_the_process_fails( + ProcessBuilder $processBuilder, + Process $process, + ContextInterface $context, + ProcessFormatterInterface $formatter + ) + { + $formatter->format($process)->willReturn('format string'); + + $arguments = new ProcessArgumentsCollection(); + $processBuilder->createArgumentsForCommand('simple-phpunit')->willReturn($arguments); + $processBuilder->buildProcess($arguments)->willReturn($process); + + $process->run()->shouldBeCalled(); + $process->isSuccessful()->willReturn(false); + + $context->getFiles()->willReturn(new FilesCollection([ + new SplFileInfo('test.php', '.', 'test.php') + ])); + + $result = $this->run($context); + $result->shouldBeAnInstanceOf(TaskResultInterface::class); + $result->isPassed()->shouldBe(false); + } +} diff --git a/spec/Task/PsalmSpec.php b/spec/Task/PsalmSpec.php index bca6f58a6..0c2be36ff 100644 --- a/spec/Task/PsalmSpec.php +++ b/spec/Task/PsalmSpec.php @@ -46,6 +46,7 @@ function it_should_have_configurable_options() $options->getDefinedOptions()->shouldContain('report'); $options->getDefinedOptions()->shouldContain('threads'); $options->getDefinedOptions()->shouldContain('triggered_by'); + $options->getDefinedOptions()->shouldContain('show_info'); } function it_should_run_in_git_pre_commit_context(GitPreCommitContext $context) diff --git a/src/Configuration/GrumPHP.php b/src/Configuration/GrumPHP.php index ddf27db5b..d4a099ccf 100644 --- a/src/Configuration/GrumPHP.php +++ b/src/Configuration/GrumPHP.php @@ -79,6 +79,14 @@ public function getProcessTimeout() return (float) $timeout; } + /** + * @return null|string + */ + public function getAdditionalInfo() + { + return $this->container->getParameter('additional_info'); + } + public function getRegisteredTasks(): array { return $this->container->getParameter('grumphp.tasks.registered'); @@ -124,6 +132,10 @@ public function getTestSuites(): TestSuiteCollection */ public function getAsciiContentPath(string $resource) { + if (null === $this->container->getParameter('ascii')) { + return null; + } + $paths = $this->container->getParameter('ascii'); if (!array_key_exists($resource, $paths)) { return null; diff --git a/src/Console/Helper/PathsHelper.php b/src/Console/Helper/PathsHelper.php index 530ed0f22..1bf3d199b 100644 --- a/src/Console/Helper/PathsHelper.php +++ b/src/Console/Helper/PathsHelper.php @@ -148,7 +148,8 @@ public function getGitHooksDir(): string if (is_file($gitRepoPath)) { $fileContent = $this->fileSystem->readFromFileInfo(new SplFileInfo($gitRepoPath)); if (preg_match('/gitdir:\s+(\S+)/', $fileContent, $matches)) { - return $this->getRelativePath($gitPath.$matches[1].'/hooks/'); + $relativePath = $this->getRelativePath($matches[1]); + return $this->getRelativePath($gitPath.$relativePath.'/hooks/'); } } diff --git a/src/Console/Helper/TaskRunnerHelper.php b/src/Console/Helper/TaskRunnerHelper.php index d61a07211..855d3dffa 100644 --- a/src/Console/Helper/TaskRunnerHelper.php +++ b/src/Console/Helper/TaskRunnerHelper.php @@ -107,6 +107,8 @@ private function returnErrorMessages(OutputInterface $output, array $errorMessag ); } + $this->returnAdditionalInfo($output); + return self::CODE_ERROR; } @@ -118,6 +120,7 @@ private function returnSuccessMessage(OutputInterface $output, array $warnings): } $this->returnWarningMessages($output, $warnings); + $this->returnAdditionalInfo($output); return self::CODE_SUCCESS; } @@ -129,6 +132,16 @@ private function returnWarningMessages(OutputInterface $output, array $warningMe } } + /** + * @param OutputInterface $output + */ + private function returnAdditionalInfo(OutputInterface $output) + { + if (null !== $this->config->getAdditionalInfo()) { + $output->writeln($this->config->getAdditionalInfo()); + } + } + /** * {@inheritdoc} */ diff --git a/src/Task/Git/CommitMessage.php b/src/Task/Git/CommitMessage.php index e1f7cfcce..2d143c412 100644 --- a/src/Task/Git/CommitMessage.php +++ b/src/Task/Git/CommitMessage.php @@ -12,6 +12,7 @@ use GrumPHP\Task\Context\GitCommitMsgContext; use GrumPHP\Task\TaskInterface; use GrumPHP\Util\Regex; +use GrumPHP\Util\Str; use Symfony\Component\OptionsResolver\OptionsResolver; class CommitMessage implements TaskInterface @@ -41,18 +42,22 @@ public function getConfigurableOptions(): OptionsResolver $resolver->setDefaults([ 'allow_empty_message' => false, 'enforce_capitalized_subject' => true, + 'enforce_no_subject_punctuations' => false, 'enforce_no_subject_trailing_period' => true, 'enforce_single_lined_subject' => true, 'max_body_width' => 72, 'max_subject_width' => 60, 'case_insensitive' => true, 'multiline' => true, + 'type_scope_conventions' => [], 'matchers' => [], 'additional_modifiers' => '', ]); $resolver->addAllowedTypes('allow_empty_message', ['bool']); + $resolver->addAllowedTypes('type_scope_conventions', ['array']); $resolver->addAllowedTypes('enforce_capitalized_subject', ['bool']); + $resolver->addAllowedTypes('enforce_no_subject_punctuations', ['bool']); $resolver->addAllowedTypes('enforce_no_subject_trailing_period', ['bool']); $resolver->addAllowedTypes('enforce_single_lined_subject', ['bool']); $resolver->addAllowedTypes('max_body_width', ['int']); @@ -100,6 +105,14 @@ public function run(ContextInterface $context): TaskResultInterface ); } + if ((bool) $config['enforce_no_subject_punctuations'] && $this->subjectHasPunctuations($context)) { + return TaskResult::createFailed( + $this, + $context, + 'Please omit all punctuations from commit message subject.' + ); + } + if ((bool) $config['enforce_no_subject_trailing_period'] && $this->subjectHasTrailingPeriod($context)) { return TaskResult::createFailed( $this, @@ -108,6 +121,15 @@ public function run(ContextInterface $context): TaskResultInterface ); } + + if ((bool) $this->enforceTypeScopeConventions()) { + try { + $this->checkTypeScopeConventions($context); + } catch (RuntimeException $e) { + $exceptions[] = $e->getMessage(); + } + } + foreach ($config['matchers'] as $ruleName => $rule) { try { $this->runMatcher($config, $commitMessage, $rule, (string) $ruleName); @@ -192,17 +214,26 @@ private function getSpecialPrefixLength(string $string): int return mb_strlen($match[0]); } - private function subjectHasTrailingPeriod(ContextInterface $context): bool + private function subjectHasPunctuations(ContextInterface $context): bool { - $commitMessage = $context->getCommitMessage(); + $subjectLine = $this->getSubjectLine($context); - if ('' === trim($commitMessage)) { + if (trim($subjectLine) === '') { return false; } - $lines = $this->getCommitMessageLinesWithoutComments($commitMessage); + return Str::containsOneOf($subjectLine, ['.', '!', '?', ',']); + } + + private function subjectHasTrailingPeriod(ContextInterface $context): bool + { + $subjectLine = $this->getSubjectLine($context); + + if ('' === trim($subjectLine)) { + return false; + } - if ('.' !== mb_substr(rtrim($lines[0]), -1)) { + if ('.' !== mb_substr(rtrim($subjectLine), -1)) { return false; } @@ -268,4 +299,69 @@ private function getCommitMessageLinesWithoutComments(string $commitMessage): ar return 0 !== strpos($line, '#'); })); } + + private function enforceTypeScopeConventions() + { + $config = $this->getConfiguration(); + + $conventionsKeys = array_keys($config['type_scope_conventions']); + + return in_array('types', $conventionsKeys) || in_array('scopes', $conventionsKeys); + } + + /** + * @param ContextInterface $context + * + * @return void; + * @throws RuntimeException + */ + private function checkTypeScopeConventions($context) + { + $config = $this->getConfiguration(); + $subjectLine = $this->getSubjectLine($context); + + $types = isset($config['type_scope_conventions']['types']) + ? $config['type_scope_conventions']['types'] + : []; + + $scopes = isset($config['type_scope_conventions']['scopes']) + ? $config['type_scope_conventions']['scopes'] + : []; + + $typesPattern = '([a-zA-Z0-9]+)'; + $scopesPattern = '(:\s|(\(.+\)?:\s))'; + $subjectPattern = '([a-zA-Z0-9-_ #@\'\/\\"]+)'; + $mergePattern = '(Merge branch \'.+\'\s.+|Merge remote-tracking branch \'.+\'|Merge pull request #\d+\s.+)'; + + if (count($types) > 0) { + $types = implode($types, '|'); + $typesPattern = '(' . $types . ')'; + } + + if (count($scopes) > 0) { + $scopes = implode($scopes, '|'); + $scopesPattern = '(:\s|(\(' . $scopes . '\)?:\s))'; + } + + $rule = '/^' . $typesPattern . $scopesPattern . $subjectPattern . '|' . $mergePattern . '/'; + + try { + $this->runMatcher($config, $subjectLine, $rule, 'Invalid Type/Scope Format'); + } catch (RuntimeException $e) { + throw $e; + } + } + + /** + * Gets a clean subject line from the commit message + * + * @param $context + * @return string + */ + private function getSubjectLine($context) + { + $commitMessage = $context->getCommitMessage(); + $lines = $this->getCommitMessageLinesWithoutComments($commitMessage); + return (string) $lines[0]; + } } diff --git a/src/Task/PhpLint.php b/src/Task/PhpLint.php index ee42a4fc4..425418772 100644 --- a/src/Task/PhpLint.php +++ b/src/Task/PhpLint.php @@ -10,6 +10,7 @@ use GrumPHP\Task\Context\GitPreCommitContext; use GrumPHP\Task\Context\RunContext; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Process\InputStream; class PhpLint extends AbstractExternalTask { @@ -24,11 +25,13 @@ public function getConfigurableOptions(): OptionsResolver $resolver->setDefaults([ 'jobs' => null, 'exclude' => [], + 'ignore_patterns' => [], 'triggered_by' => ['php', 'phtml', 'php3', 'php4', 'php5'], ]); $resolver->setAllowedTypes('jobs', ['int', 'null']); $resolver->setAllowedTypes('exclude', 'array'); + $resolver->addAllowedTypes('ignore_patterns', ['array']); $resolver->setAllowedTypes('triggered_by', 'array'); return $resolver; @@ -42,16 +45,25 @@ public function canRunInContext(ContextInterface $context): bool public function run(ContextInterface $context): TaskResultInterface { $config = $this->getConfiguration(); - $files = $context->getFiles()->extensions($config['triggered_by']); + + $files = $context + ->getFiles() + ->notPaths($config['ignore_patterns']) + ->extensions($config['triggered_by']); $arguments = $this->processBuilder->createArgumentsForCommand('parallel-lint'); $arguments->add('--no-colors'); $arguments->addOptionalArgumentWithSeparatedValue('-j', $config['jobs']); $arguments->addArgumentArrayWithSeparatedValue('--exclude', $config['exclude']); - $arguments->addFiles($files); + $arguments->add('--stdin'); + $inputStream = new InputStream(); $process = $this->processBuilder->buildProcess($arguments); - $process->run(); + $process->setInput($inputStream); + $process->start(); + $inputStream->write(\implode($files->toArray(), PHP_EOL)); + $inputStream->close(); + $process->wait(); if (!$process->isSuccessful()) { return TaskResult::createFailed($this, $context, $this->formatter->format($process)); diff --git a/src/Task/PhpunitBridge.php b/src/Task/PhpunitBridge.php new file mode 100644 index 000000000..10776b7ff --- /dev/null +++ b/src/Task/PhpunitBridge.php @@ -0,0 +1,65 @@ +setDefaults([ + 'config_file' => null, + 'testsuite' => null, + 'group' => [], + 'always_execute' => false, + ]); + + $resolver->addAllowedTypes('config_file', ['null', 'string']); + $resolver->addAllowedTypes('testsuite', ['null', 'string']); + $resolver->addAllowedTypes('group', ['array']); + $resolver->addAllowedTypes('always_execute', ['bool']); + + return $resolver; + } + + public function canRunInContext(ContextInterface $context): bool + { + return ($context instanceof GitPreCommitContext || $context instanceof RunContext); + } + + public function run(ContextInterface $context): TaskResultInterface + { + $config = $this->getConfiguration(); + + $files = $context->getFiles()->name('*.php'); + if (0 === count($files) && !$config['always_execute']) { + return TaskResult::createSkipped($this, $context); + } + + $arguments = $this->processBuilder->createArgumentsForCommand('simple-phpunit'); + $arguments->addOptionalArgument('--configuration=%s', $config['config_file']); + $arguments->addOptionalArgument('--testsuite=%s', $config['testsuite']); + $arguments->addOptionalCommaSeparatedArgument('--group=%s', $config['group']); + + $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/src/Task/Psalm.php b/src/Task/Psalm.php index 59eee67a4..27bddb4fc 100644 --- a/src/Task/Psalm.php +++ b/src/Task/Psalm.php @@ -26,13 +26,16 @@ public function getConfigurableOptions(): OptionsResolver 'report' => null, 'threads' => null, 'triggered_by' => ['php'], + 'show_info' => false, ]); + $resolver->addAllowedTypes('config', ['null', 'string']); $resolver->addAllowedTypes('ignore_patterns', ['array']); $resolver->addAllowedTypes('no_cache', ['bool']); $resolver->addAllowedTypes('report', ['null', 'string']); $resolver->addAllowedTypes('threads', ['null', 'int']); $resolver->addAllowedTypes('triggered_by', ['array']); + $resolver->addAllowedTypes('show_info', ['bool']); return $resolver; } @@ -60,6 +63,7 @@ public function run(ContextInterface $context): TaskResultInterface $arguments->addOptionalArgument('--report=%s', $config['report']); $arguments->addOptionalArgument('--no-cache', $config['no_cache']); $arguments->addOptionalArgument('--threads=%d', $config['threads']); + $arguments->addOptionalBooleanArgument('--show-info=%s', $config['show_info'], 'true', 'false'); if ($context instanceof GitPreCommitContext) { $arguments->addFiles($files); diff --git a/src/Util/Str.php b/src/Util/Str.php new file mode 100644 index 000000000..ca672b8de --- /dev/null +++ b/src/Util/Str.php @@ -0,0 +1,24 @@ +