diff --git a/composer.json b/composer.json index 61530d36e..0d145f906 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,7 @@ "atoum/atoum": "Lets GrumPHP run your unit tests.", "behat/behat": "Lets GrumPHP validate your project features.", "brianium/paratest": "Lets GrumPHP run PHPUnit in parallel.", + "carthage-software/mago": "Lets GrumPHP help you write better PHP code.", "codeception/codeception": "Lets GrumPHP run your project's full stack tests", "consolidation/robo": "Lets GrumPHP run your automated PHP tasks.", "designsecurity/progpilot": "Lets GrumPHP be sure that there are no vulnerabilities in your code.", diff --git a/doc/tasks.md b/doc/tasks.md index d9cdbcf1d..1818c37b0 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -32,6 +32,10 @@ grumphp: infection: ~ jsonlint: ~ kahlan: ~ + mago_analyze: ~ + mago_format: ~ + mago_guard: ~ + mago_lint: ~ make: ~ npm_script: ~ paratest: ~ @@ -99,6 +103,11 @@ Every task has its own default configuration. It is possible to overwrite the pa - [Infection](tasks/infection.md) - [JsonLint](tasks/jsonlint.md) - [Kahlan](tasks/kahlan.md) +- [Mago](tasks/mago.md) + - [Mago Analyzer](tasks/mago/analyzer.md) + - [Mago Formatter](tasks/mago/formatter.md) + - [Mago Guard](tasks/mago/guard.md) + - [Mago Linter](tasks/mago/linter.md) - [Make](tasks/make.md) - [NPM script](tasks/npm_script.md) - [Paratest](tasks/paratest.md) diff --git a/doc/tasks/mago.md b/doc/tasks/mago.md new file mode 100644 index 000000000..18780cd7c --- /dev/null +++ b/doc/tasks/mago.md @@ -0,0 +1,36 @@ +# Mago + +[Mago](https://mago.carthage.software/) is a fast PHP toolchain written in Rust. It bundles a +formatter, a linter, a static analyzer and an architectural guard. GrumPHP exposes each of these as +its own task so you can enable only what you need and configure them independently. + +## Composer + +```bash +composer require --dev carthage-software/mago +``` + +Mago is configured through a single `mago.toml` file in your project root. You can scaffold one with: + +```bash +vendor/bin/mago init +``` + +## Tasks + +| Task | Description | +| --- | --- | +| [`mago_format`](mago/formatter.md) | Format PHP code to match your configured style. | +| [`mago_lint`](mago/linter.md) | Run linting rules to catch style violations, code smells and likely bugs. | +| [`mago_analyze`](mago/analyzer.md) | Deep static analysis: type checking, control-flow and logical-error detection. | +| [`mago_guard`](mago/guard.md) | Enforce architectural rules and layer dependencies. | + +## Behavior + +`mago_format`, `mago_lint` and `mago_analyze` run read-only by default and, when they fail, GrumPHP +offers to re-run them with fixes applied. `mago_guard` has no auto-fix — architectural violations +cannot be fixed automatically, so it only reports them. + +Each task scopes its work to the relevant files per context (pre-commit vs run). The exact behavior +differs per task because of how Mago's CLI works — see each task's page below for the details and its +full set of configurable options. diff --git a/doc/tasks/mago/analyzer.md b/doc/tasks/mago/analyzer.md new file mode 100644 index 000000000..6218682f7 --- /dev/null +++ b/doc/tasks/mago/analyzer.md @@ -0,0 +1,73 @@ +# Mago Analyzer + +Perform deep static analysis on PHP code including type checking, control flow analysis, and detection of logical errors. + +## Composer + +```bash +composer require --dev carthage-software/mago +``` + +## Behavior + +The task runs `mago analyze` directly to get full diagnostic output. When running in a `git pre-commit` context, only staged files are analyzed (`--staged`). In a `run` context, all files are analyzed. + +If the task fails, GrumPHP will offer to re-run with `--fix` applied. The fix mode can be configured via `fix-mode`. + +## Config + +The task lives under the `mago_analyze` namespace and has following configurable parameters: + +```yaml +# grumphp.yml +grumphp: + tasks: + mago_analyze: + no-stubs: ~ + retain-codes: [] + ignore-baseline: ~ + sort: ~ + fix-mode: safe + minimum-report-level: ~ +``` + +**no-stubs** + +*Type: bool* + +Disable built-in PHP and library stubs for analysis. By default, the analyzer uses stubs for built-in PHP functions and popular libraries to provide accurate type information. Disabling this may result in more reported issues when external symbols can't be resolved. + +**retain-codes** + +*Type: string[] — Default: []* + +Reporting filter: only display issues matching the specified rule codes (e.g. `invalid-argument`, `semantics`). All rules still run; only the output is filtered. Can be specified multiple times. + +**ignore-baseline** + +*Type: bool* + +Ignore the baseline file and report all issues, including those currently suppressed. The baseline file must be generated manually via `mago analyze --generate-baseline`. + +**sort** + +*Type: bool* + +Sort reported issues by severity level, rule code, and file location. By default, issues are reported in the order they appear in files. + +**fix-mode** + +*Default: safe — Possible values: `safe`, `potentially-unsafe`, `unsafe`* + +Controls which fixes are applied when GrumPHP offers to auto-fix: + +- `safe` — apply only safe fixes (default) +- `potentially-unsafe` — also apply fixes that may require manual review +- `unsafe` — also apply fixes that might change code behavior + +**minimum-report-level** + +*Default: null (mago default: all levels)* + +Minimum severity level to display in the report. Issues below this level are not shown. Possible values: `note`, `help`, `warning`, `error` + diff --git a/doc/tasks/mago/formatter.md b/doc/tasks/mago/formatter.md new file mode 100644 index 000000000..5e36dc1f6 --- /dev/null +++ b/doc/tasks/mago/formatter.md @@ -0,0 +1,28 @@ +# Mago Formatter + +Automatically format PHP code to match the configured style preferences. + +## Composer + +```bash +composer require --dev carthage-software/mago +``` + +## Behavior + +The task always runs in `--dry-run` mode: it previews formatting changes without modifying any files, and fails if any file would be changed. + +In a `run` context the whole project is checked (Mago uses the paths from your `mago.toml`). In a `git pre-commit` context the staged `.php` files are passed to Mago explicitly so only those files are checked. (`mago format --staged` cannot be combined with `--dry-run`, so the staged files are passed as paths instead — note this overrides the `source`/`excludes` config in `mago.toml` for those files, the same trade-off other file-based GrumPHP tasks make.) + +If the task fails, GrumPHP will offer to re-run without `--dry-run` to apply the formatting in-place. + +## Config + +The task lives under the `mago_format` namespace and has no configurable parameters: + +```yaml +# grumphp.yml +grumphp: + tasks: + mago_format: ~ +``` diff --git a/doc/tasks/mago/guard.md b/doc/tasks/mago/guard.md new file mode 100644 index 000000000..77755b4c3 --- /dev/null +++ b/doc/tasks/mago/guard.md @@ -0,0 +1,72 @@ +# Mago Guard + +Enforce architectural rules and layer dependencies. Checks that code follows defined architectural constraints, such as ensuring that certain layers don't depend on others. + +## Composer + +```bash +composer require --dev carthage-software/mago +``` + +## Behavior + +The task runs `mago guard` and fails when an architectural violation is found. It runs on all files in both `git pre-commit` and `run` contexts (guard has no `--staged` mode, and architectural rules are evaluated against the whole project). Because every pre-commit run scans the full project, consider whether `mago_guard` is fast enough for your codebase before enabling it as a pre-commit task. + +Guard does not offer an auto-fix: architectural violations cannot be fixed automatically, so the task only reports them. + +## Config + +The task lives under the `mago_guard` namespace and has following configurable parameters: + +```yaml +# grumphp.yml +grumphp: + tasks: + mago_guard: + mode: ~ + no-stubs: ~ + retain-codes: [] + ignore-baseline: ~ + sort: ~ + minimum-report-level: ~ +``` + +**mode** + +*Default: null — Possible values: `structural`, `perimeter`* + +Selects which guard checks run. These are mutually exclusive in Mago, so a single option is used instead of separate flags: + +- `~` (not set) — run both structural and perimeter checks (Mago's default) +- `structural` — run only structural checks (naming conventions, modifiers, inheritance constraints) +- `perimeter` — run only perimeter checks (dependency boundaries, layer restrictions) + +**no-stubs** + +*Type: bool* + +Disable built-in PHP and library stubs. By default, guard uses stubs for built-in PHP functions and popular libraries to provide accurate symbol information. Disabling this may result in more warnings when external symbols can't be resolved. + +**retain-codes** + +*Type: string[] — Default: []* + +Reporting filter: only display issues matching the specified rule codes. All rules still run; only the output is filtered. Can be specified multiple times. + +**ignore-baseline** + +*Type: bool* + +Ignore the baseline file and report all issues, including those currently suppressed. The baseline file must be generated manually via `mago guard --generate-baseline`. + +**sort** + +*Type: bool* + +Sort reported issues by severity level, rule code, and file location. By default, issues are reported in the order they appear in files. + +**minimum-report-level** + +*Default: null (mago default: all levels)* + +Minimum severity level to display in the report. Issues below this level are not shown. Possible values: `note`, `help`, `warning`, `error` diff --git a/doc/tasks/mago/linter.md b/doc/tasks/mago/linter.md new file mode 100644 index 000000000..b77711cc6 --- /dev/null +++ b/doc/tasks/mago/linter.md @@ -0,0 +1,89 @@ +# Mago Linter + +Run linting rules on PHP code to identify style violations, code smells, and potential bugs. + +## Composer + +```bash +composer require --dev carthage-software/mago +``` + +## Behavior + +The task always runs in `--fix --dry-run` mode: it previews what automatic fixes would be applied without modifying any files, and fails if issues are found. When running in a `git pre-commit` context, only staged files are linted (`--staged`). In a `run` context, all files are linted. + +If the task fails, GrumPHP will offer to re-run with `--fix` applied. The fix mode can be configured via `fix-mode`. + +## Config + +The task lives under the `mago_lint` namespace and has following configurable parameters: + +```yaml +# grumphp.yml +grumphp: + tasks: + mago_lint: + semantics: ~ + pedantic: ~ + only: [] + retain-codes: [] + ignore-baseline: ~ + sort: ~ + fix-mode: safe + minimum-report-level: ~ +``` + +**semantics** + +*Type: bool* + +Skip linter rules and only perform basic syntax and semantic validation. Checks that your PHP code parses correctly and has valid semantic structure, without applying any style or quality rules. Useful for quick syntax validation. + +**pedantic** + +*Type: bool* + +Enable every available linter rule for maximum thoroughness. Overrides your configuration and enables all rules, including those disabled by default. The output will be extremely verbose and is not recommended for regular use. Useful for comprehensive code audits. + +**only** + +*Type: string[] — Default: []* + +Run only the specified rules, ignoring the configuration file. Provide a list of rule codes (e.g. `invalid-argument`, `semantics`). Overrides your `mago.toml` configuration and is useful for targeted analysis. + +**retain-codes** + +*Type: string[] — Default: []* + +Reporting filter: only display issues matching the specified rule codes (e.g. `invalid-argument`, `semantics`). All rules still run; only the output is filtered. Can be specified multiple times. + +Note: this differs from `only`, which restricts which rules are executed. + +**ignore-baseline** + +*Type: bool* + +Ignore the baseline file and report all issues, including those currently suppressed. The baseline file must be generated manually via `mago lint --generate-baseline`. + +**sort** + +*Type: bool* + +Sort reported issues by severity level, rule code, and file location. By default, issues are reported in the order they appear in files. + +**fix-mode** + +*Default: safe — Possible values: `safe`, `potentially-unsafe`, `unsafe`* + +Controls which fixes are applied when GrumPHP offers to auto-fix: + +- `safe` — apply only safe fixes (default) +- `potentially-unsafe` — also apply fixes that may require manual review +- `unsafe` — also apply fixes that might change code behavior + +**minimum-report-level** + +*Default: null (mago default: all levels)* + +Minimum severity level to display in the report. Issues below this level are not shown. Possible values: `note`, `help`, `warning`, `error` + diff --git a/resources/config/tasks.yml b/resources/config/tasks.yml index d4927541b..7861fe328 100644 --- a/resources/config/tasks.yml +++ b/resources/config/tasks.yml @@ -169,6 +169,34 @@ services: tags: - {name: grumphp.task, task: kahlan} + GrumPHP\Task\MagoAnalyzer: + arguments: + - '@process_builder' + - '@formatter.raw_process' + tags: + - {name: grumphp.task, task: mago_analyze} + + GrumPHP\Task\MagoFormatter: + arguments: + - '@process_builder' + - '@formatter.raw_process' + tags: + - {name: grumphp.task, task: mago_format} + + GrumPHP\Task\MagoGuard: + arguments: + - '@process_builder' + - '@formatter.raw_process' + tags: + - {name: grumphp.task, task: mago_guard} + + GrumPHP\Task\MagoLinter: + arguments: + - '@process_builder' + - '@formatter.raw_process' + tags: + - {name: grumphp.task, task: mago_lint} + GrumPHP\Task\Make: arguments: - '@process_builder' diff --git a/src/Task/MagoAnalyzer.php b/src/Task/MagoAnalyzer.php new file mode 100644 index 000000000..7e2891d5e --- /dev/null +++ b/src/Task/MagoAnalyzer.php @@ -0,0 +1,89 @@ + + */ +class MagoAnalyzer extends AbstractExternalTask +{ + public static function getConfigurableOptions(): ConfigOptionsResolver + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'no-stubs' => null, + 'retain-codes' => [], + 'ignore-baseline' => null, + 'sort' => null, + 'fix-mode' => 'safe', + 'minimum-report-level' => null, + ]); + + $resolver->addAllowedTypes('no-stubs', ['null', 'bool']); + $resolver->addAllowedTypes('retain-codes', ['array']); + $resolver->addAllowedTypes('ignore-baseline', ['null', 'bool']); + $resolver->addAllowedTypes('sort', ['null', 'bool']); + $resolver->addAllowedTypes('fix-mode', ['string']); + $resolver->addAllowedTypes('minimum-report-level', ['null', 'string']); + + $resolver->addAllowedValues('fix-mode', ['safe', 'potentially-unsafe', 'unsafe']); + $resolver->addAllowedValues('minimum-report-level', [null, 'note', 'help', 'warning', 'error']); + + return ConfigOptionsResolver::fromOptionsResolver($resolver); + } + + public function canRunInContext(ContextInterface $context): bool + { + return $context instanceof GitPreCommitContext || $context instanceof RunContext; + } + + public function run(ContextInterface $context): TaskResultInterface + { + $config = $this->getConfig()->getOptions(); + + $arguments = $this->processBuilder->createArgumentsForCommand('mago'); + $arguments->add('analyze'); + $arguments->addArgumentArrayWithSeparatedValue('--retain-code', $config['retain-codes']); + $arguments->addOptionalArgument('--ignore-baseline', $config['ignore-baseline']); + $arguments->addOptionalArgument('--sort', $config['sort']); + $arguments->addOptionalArgumentWithSeparatedValue('--minimum-report-level', $config['minimum-report-level']); + $arguments->addOptionalArgument('--no-stubs', $config['no-stubs']); + $arguments->addOptionalArgument('--staged', $context instanceof GitPreCommitContext); + + $process = $this->processBuilder->buildProcess($arguments); + $process->run(); + + if (!$process->isSuccessful()) { + return FixableProcessResultProvider::provide( + TaskResult::createFailed($this, $context, $this->formatter->format($process)), + function () use ($arguments, $config): Process { + // $arguments still holds --staged when running from a pre-commit context, + // so the fix is scoped to the same files that were analyzed. + $arguments->add('--fix'); + match ($config['fix-mode']) { + 'potentially-unsafe' => $arguments->add('--potentially-unsafe'), + 'unsafe' => $arguments->add('--unsafe'), + default => null, + }; + + return $this->processBuilder->buildProcess($arguments); + } + ); + } + + return TaskResult::createPassed($this, $context); + } +} diff --git a/src/Task/MagoFormatter.php b/src/Task/MagoFormatter.php new file mode 100644 index 000000000..234894164 --- /dev/null +++ b/src/Task/MagoFormatter.php @@ -0,0 +1,66 @@ + + */ +class MagoFormatter extends AbstractExternalTask +{ + public static function getConfigurableOptions(): ConfigOptionsResolver + { + return ConfigOptionsResolver::fromOptionsResolver(new OptionsResolver()); + } + + public function canRunInContext(ContextInterface $context): bool + { + return $context instanceof GitPreCommitContext || $context instanceof RunContext; + } + + public function run(ContextInterface $context): TaskResultInterface + { + $files = $context->getFiles()->extensions(['php']); + if ($context instanceof GitPreCommitContext && 0 === \count($files)) { + return TaskResult::createSkipped($this, $context); + } + + $arguments = $this->processBuilder->createArgumentsForCommand('mago'); + $arguments->add('format'); + $arguments->add('--dry-run'); + + // `mago format --staged` cannot be combined with `--dry-run`, so we scope the pre-commit + // run to the staged files explicitly instead. In a run context Mago uses its mago.toml. + if ($context instanceof GitPreCommitContext) { + $arguments->addFiles($files); + } + + $process = $this->processBuilder->buildProcess($arguments); + $process->run(); + + if (!$process->isSuccessful()) { + return FixableProcessResultProvider::provide( + TaskResult::createFailed($this, $context, $this->formatter->format($process)), + function () use ($arguments): Process { + $arguments->removeElement('--dry-run'); + + return $this->processBuilder->buildProcess($arguments); + } + ); + } + + return TaskResult::createPassed($this, $context); + } +} diff --git a/src/Task/MagoGuard.php b/src/Task/MagoGuard.php new file mode 100644 index 000000000..0afc8a162 --- /dev/null +++ b/src/Task/MagoGuard.php @@ -0,0 +1,77 @@ + + */ +class MagoGuard extends AbstractExternalTask +{ + public static function getConfigurableOptions(): ConfigOptionsResolver + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'mode' => null, + 'no-stubs' => null, + 'retain-codes' => [], + 'ignore-baseline' => null, + 'sort' => null, + 'minimum-report-level' => null, + ]); + + $resolver->addAllowedTypes('mode', ['null', 'string']); + $resolver->addAllowedTypes('no-stubs', ['null', 'bool']); + $resolver->addAllowedTypes('retain-codes', ['array']); + $resolver->addAllowedTypes('ignore-baseline', ['null', 'bool']); + $resolver->addAllowedTypes('sort', ['null', 'bool']); + $resolver->addAllowedTypes('minimum-report-level', ['null', 'string']); + + $resolver->addAllowedValues('mode', [null, 'structural', 'perimeter']); + $resolver->addAllowedValues('minimum-report-level', [null, 'note', 'help', 'warning', 'error']); + + return ConfigOptionsResolver::fromOptionsResolver($resolver); + } + + public function canRunInContext(ContextInterface $context): bool + { + return $context instanceof GitPreCommitContext || $context instanceof RunContext; + } + + public function run(ContextInterface $context): TaskResultInterface + { + $config = $this->getConfig()->getOptions(); + + $arguments = $this->processBuilder->createArgumentsForCommand('mago'); + $arguments->add('guard'); + match ($config['mode']) { + 'structural' => $arguments->add('--structural'), + 'perimeter' => $arguments->add('--perimeter'), + default => null, + }; + $arguments->addArgumentArrayWithSeparatedValue('--retain-code', $config['retain-codes']); + $arguments->addOptionalArgument('--ignore-baseline', $config['ignore-baseline']); + $arguments->addOptionalArgument('--sort', $config['sort']); + $arguments->addOptionalArgumentWithSeparatedValue('--minimum-report-level', $config['minimum-report-level']); + $arguments->addOptionalArgument('--no-stubs', $config['no-stubs']); + + $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/MagoLinter.php b/src/Task/MagoLinter.php new file mode 100644 index 000000000..1aa34b82a --- /dev/null +++ b/src/Task/MagoLinter.php @@ -0,0 +1,97 @@ + + */ +class MagoLinter extends AbstractExternalTask +{ + public static function getConfigurableOptions(): ConfigOptionsResolver + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'semantics' => null, + 'pedantic' => null, + 'only' => [], + 'retain-codes' => [], + 'ignore-baseline' => null, + 'sort' => null, + 'fix-mode' => 'safe', + 'minimum-report-level' => null, + ]); + + $resolver->addAllowedTypes('semantics', ['null', 'bool']); + $resolver->addAllowedTypes('pedantic', ['null', 'bool']); + $resolver->addAllowedTypes('only', ['array']); + $resolver->addAllowedTypes('retain-codes', ['array']); + $resolver->addAllowedTypes('ignore-baseline', ['null', 'bool']); + $resolver->addAllowedTypes('sort', ['null', 'bool']); + $resolver->addAllowedTypes('fix-mode', ['string']); + $resolver->addAllowedTypes('minimum-report-level', ['null', 'string']); + + $resolver->addAllowedValues('fix-mode', ['safe', 'potentially-unsafe', 'unsafe']); + $resolver->addAllowedValues('minimum-report-level', [null, 'note', 'help', 'warning', 'error']); + + return ConfigOptionsResolver::fromOptionsResolver($resolver); + } + + public function canRunInContext(ContextInterface $context): bool + { + return $context instanceof GitPreCommitContext || $context instanceof RunContext; + } + + public function run(ContextInterface $context): TaskResultInterface + { + $config = $this->getConfig()->getOptions(); + + $arguments = $this->processBuilder->createArgumentsForCommand('mago'); + $arguments->add('lint'); + $arguments->add('--fix'); + $arguments->add('--dry-run'); + $arguments->add('--fail-on-remaining'); + $arguments->addArgumentArrayWithSeparatedValue('--retain-code', $config['retain-codes']); + $arguments->addOptionalArgument('--ignore-baseline', $config['ignore-baseline']); + $arguments->addOptionalArgument('--sort', $config['sort']); + $arguments->addOptionalArgumentWithSeparatedValue('--minimum-report-level', $config['minimum-report-level']); + $arguments->addOptionalCommaSeparatedArgument('--only=%s', $config['only']); + $arguments->addOptionalArgument('--semantics', $config['semantics']); + $arguments->addOptionalArgument('--pedantic', $config['pedantic']); + $arguments->addOptionalArgument('--staged', $context instanceof GitPreCommitContext); + + $process = $this->processBuilder->buildProcess($arguments); + $process->run(); + + if (!$process->isSuccessful()) { + return FixableProcessResultProvider::provide( + TaskResult::createFailed($this, $context, $this->formatter->format($process)), + function () use ($arguments, $config): Process { + $arguments->removeElement('--dry-run'); + $arguments->removeElement('--fail-on-remaining'); + match ($config['fix-mode']) { + 'potentially-unsafe' => $arguments->add('--potentially-unsafe'), + 'unsafe' => $arguments->add('--unsafe'), + default => null, + }; + + return $this->processBuilder->buildProcess($arguments); + } + ); + } + + return TaskResult::createPassed($this, $context); + } +} diff --git a/test/Unit/Task/MagoAnalyzerTest.php b/test/Unit/Task/MagoAnalyzerTest.php new file mode 100644 index 000000000..ea050e157 --- /dev/null +++ b/test/Unit/Task/MagoAnalyzerTest.php @@ -0,0 +1,188 @@ +processBuilder->reveal(), + $this->formatter->reveal() + ); + } + + public static function provideConfigurableOptions(): iterable + { + yield 'defaults' => [ + [], + [ + 'no-stubs' => null, + 'retain-codes' => [], + 'ignore-baseline' => null, + 'sort' => null, + 'fix-mode' => 'safe', + 'minimum-report-level' => null, + ] + ]; + + yield 'invalid-fix-mode' => [['fix-mode' => 'invalid'], null]; + yield 'invalid-minimum-report-level' => [['minimum-report-level' => 'invalid'], 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), + function () { + $this->mockProcessBuilder('mago', $process = self::mockProcess(1)); + $this->formatter->format($process)->willReturn('nope'); + }, + 'nope', + FixableTaskResult::class, + ]; + } + + public static function providePassesOnStuff(): iterable + { + yield 'exitCode0' => [ + [], + self::mockContext(RunContext::class), + function () { + $this->mockProcessBuilder('mago', self::mockProcess(0)); + } + ]; + } + + #[Test] + #[DataProvider('provideSkipsOnStuff')] + public function it_skips_on_stuff( + array $config, + ContextInterface $context, + callable $configurator + ): void + { + self::markTestSkipped('No skip scenarios defined yet'); + } + + public static function provideSkipsOnStuff(): iterable + { + yield 'no-skip-scenarios' => [ + [], + self::mockContext(RunContext::class), + function () {} + ]; + } + + public static function provideExternalTaskRuns(): iterable + { + yield 'defaults' => [ + [], + self::mockContext(RunContext::class), + 'mago', + ['analyze'] + ]; + + yield 'pre-commit-staged' => [ + [], + self::mockContext(GitPreCommitContext::class), + 'mago', + ['analyze', '--staged'] + ]; + + yield 'no-stubs' => [ + ['no-stubs' => true], + self::mockContext(RunContext::class), + 'mago', + ['analyze', '--no-stubs'] + ]; + + yield 'retain-codes' => [ + ['retain-codes' => ['invalid-argument', 'semantics']], + self::mockContext(RunContext::class), + 'mago', + ['analyze', '--retain-code', 'invalid-argument', '--retain-code', 'semantics'] + ]; + + yield 'ignore-baseline' => [ + ['ignore-baseline' => true], + self::mockContext(RunContext::class), + 'mago', + ['analyze', '--ignore-baseline'] + ]; + + yield 'sort' => [ + ['sort' => true], + self::mockContext(RunContext::class), + 'mago', + ['analyze', '--sort'] + ]; + + yield 'minimum-report-level' => [ + ['minimum-report-level' => 'warning'], + self::mockContext(RunContext::class), + 'mago', + ['analyze', '--minimum-report-level', 'warning'] + ]; + } + + /** + * On failure GrumPHP offers a fix command: --fix is added together with the configured fix-mode, + * while the (context-driven) --staged flag is retained. The harness only asserts the detection + * command, so the fix command is verified here by inspecting the mutated arguments collection. + */ + #[Test] + #[DataProvider('provideFixCommands')] + public function it_builds_the_fix_command(string $fixMode, string $contextClass, array $expectedFixCommand): void + { + $this->processBuilder->createArgumentsForCommand('mago')->willReturn( + $arguments = new ProcessArgumentsCollection() + ); + $this->processBuilder->buildProcess($arguments)->willReturn($process = self::mockProcess(1)); + $this->formatter->format($process)->willReturn('failed'); + + $this->configureTask(['fix-mode' => $fixMode])->run(self::mockContext($contextClass)); + + self::assertSame($expectedFixCommand, $arguments->getValues()); + } + + public static function provideFixCommands(): iterable + { + yield 'safe' => ['safe', RunContext::class, ['analyze', '--fix']]; + yield 'potentially-unsafe' => ['potentially-unsafe', RunContext::class, ['analyze', '--fix', '--potentially-unsafe']]; + yield 'unsafe' => ['unsafe', RunContext::class, ['analyze', '--fix', '--unsafe']]; + yield 'pre-commit-retains-staged' => ['safe', GitPreCommitContext::class, ['analyze', '--staged', '--fix']]; + } +} diff --git a/test/Unit/Task/MagoFormatterTest.php b/test/Unit/Task/MagoFormatterTest.php new file mode 100644 index 000000000..ab5d48837 --- /dev/null +++ b/test/Unit/Task/MagoFormatterTest.php @@ -0,0 +1,148 @@ +processBuilder->reveal(), + $this->formatter->reveal() + ); + } + + public static function provideConfigurableOptions(): iterable + { + yield 'unknown-option' => [ + ['unknown' => true], + 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-run' => [ + [], + self::mockContext(RunContext::class), + function () { + $this->mockProcessBuilder('mago', $process = self::mockProcess(1)); + $this->formatter->format($process)->willReturn('nope'); + }, + 'nope', + FixableTaskResult::class, + ]; + + yield 'exitCode1-pre-commit' => [ + [], + self::mockContext(GitPreCommitContext::class, ['hello.php']), + function () { + $this->mockProcessBuilder('mago', $process = self::mockProcess(1)); + $this->formatter->format($process)->willReturn('nope'); + }, + 'nope', + FixableTaskResult::class, + ]; + } + + public static function providePassesOnStuff(): iterable + { + yield 'exitCode0' => [ + [], + self::mockContext(RunContext::class), + function () { + $this->mockProcessBuilder('mago', self::mockProcess(0)); + } + ]; + } + + public static function provideSkipsOnStuff(): iterable + { + yield 'pre-commit-without-php-files' => [ + [], + self::mockContext(GitPreCommitContext::class, ['notes.txt']), + function () {} + ]; + } + + public static function provideExternalTaskRuns(): iterable + { + yield 'defaults' => [ + [], + self::mockContext(RunContext::class), + 'mago', + ['format', '--dry-run'] + ]; + + yield 'pre-commit-staged-files' => [ + [], + self::mockContext(GitPreCommitContext::class, ['hello.php', 'hello2.php']), + 'mago', + ['format', '--dry-run', 'hello.php', 'hello2.php'] + ]; + } + + /** + * On failure GrumPHP offers a fix command that re-runs without --dry-run (applying the + * formatting in-place). The harness only asserts the detection command, so the fix command is + * verified here by inspecting the mutated arguments collection after the run. + */ + #[Test] + #[DataProvider('provideFixCommands')] + public function it_builds_the_fix_command(ContextInterface $context, array $expectedFixCommand): void + { + $this->processBuilder->createArgumentsForCommand('mago')->willReturn( + $arguments = new ProcessArgumentsCollection() + ); + $this->processBuilder->buildProcess($arguments)->willReturn($process = self::mockProcess(1)); + $this->formatter->format($process)->willReturn('failed'); + + $this->configureTask([])->run($context); + + self::assertSame($expectedFixCommand, $arguments->getValues()); + } + + public static function provideFixCommands(): iterable + { + yield 'run' => [ + self::mockContext(RunContext::class), + ['format'] + ]; + + yield 'pre-commit' => [ + self::mockContext(GitPreCommitContext::class, ['hello.php', 'hello2.php']), + ['format', 'hello.php', 'hello2.php'] + ]; + } +} diff --git a/test/Unit/Task/MagoGuardTest.php b/test/Unit/Task/MagoGuardTest.php new file mode 100644 index 000000000..120543e98 --- /dev/null +++ b/test/Unit/Task/MagoGuardTest.php @@ -0,0 +1,195 @@ +processBuilder->reveal(), + $this->formatter->reveal() + ); + } + + public static function provideConfigurableOptions(): iterable + { + yield 'defaults' => [ + [], + [ + 'mode' => null, + 'no-stubs' => null, + 'retain-codes' => [], + 'ignore-baseline' => null, + 'sort' => null, + 'minimum-report-level' => null, + ] + ]; + + yield 'mode-structural' => [ + ['mode' => 'structural'], + [ + 'mode' => 'structural', + 'no-stubs' => null, + 'retain-codes' => [], + 'ignore-baseline' => null, + 'sort' => null, + 'minimum-report-level' => null, + ] + ]; + + yield 'mode-perimeter' => [ + ['mode' => 'perimeter'], + [ + 'mode' => 'perimeter', + 'no-stubs' => null, + 'retain-codes' => [], + 'ignore-baseline' => null, + 'sort' => null, + 'minimum-report-level' => null, + ] + ]; + + yield 'invalid-mode' => [['mode' => 'invalid'], null]; + yield 'invalid-minimum-report-level' => [['minimum-report-level' => 'invalid'], 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), + function () { + $this->mockProcessBuilder('mago', $process = self::mockProcess(1)); + $this->formatter->format($process)->willReturn('nope'); + }, + 'nope', + ]; + } + + public static function providePassesOnStuff(): iterable + { + yield 'exitCode0' => [ + [], + self::mockContext(RunContext::class), + function () { + $this->mockProcessBuilder('mago', self::mockProcess(0)); + } + ]; + } + + #[Test] + #[DataProvider('provideSkipsOnStuff')] + public function it_skips_on_stuff( + array $config, + ContextInterface $context, + callable $configurator + ): void + { + self::markTestSkipped('No skip scenarios defined yet'); + } + + public static function provideSkipsOnStuff(): iterable + { + yield 'no-skip-scenarios' => [ + [], + self::mockContext(RunContext::class), + function () {} + ]; + } + + public static function provideExternalTaskRuns(): iterable + { + yield 'defaults' => [ + [], + self::mockContext(RunContext::class), + 'mago', + ['guard'] + ]; + + yield 'defaults-pre-commit' => [ + [], + self::mockContext(GitPreCommitContext::class), + 'mago', + ['guard'] + ]; + + yield 'mode-structural' => [ + ['mode' => 'structural'], + self::mockContext(RunContext::class), + 'mago', + ['guard', '--structural'] + ]; + + yield 'mode-perimeter' => [ + ['mode' => 'perimeter'], + self::mockContext(RunContext::class), + 'mago', + ['guard', '--perimeter'] + ]; + + yield 'no-stubs' => [ + ['no-stubs' => true], + self::mockContext(RunContext::class), + 'mago', + ['guard', '--no-stubs'] + ]; + + yield 'retain-codes' => [ + ['retain-codes' => ['invalid-argument', 'semantics']], + self::mockContext(RunContext::class), + 'mago', + ['guard', '--retain-code', 'invalid-argument', '--retain-code', 'semantics'] + ]; + + yield 'ignore-baseline' => [ + ['ignore-baseline' => true], + self::mockContext(RunContext::class), + 'mago', + ['guard', '--ignore-baseline'] + ]; + + yield 'sort' => [ + ['sort' => true], + self::mockContext(RunContext::class), + 'mago', + ['guard', '--sort'] + ]; + + yield 'minimum-report-level' => [ + ['minimum-report-level' => 'warning'], + self::mockContext(RunContext::class), + 'mago', + ['guard', '--minimum-report-level', 'warning'] + ]; + } +} diff --git a/test/Unit/Task/MagoLinterTest.php b/test/Unit/Task/MagoLinterTest.php new file mode 100644 index 000000000..efd4519ad --- /dev/null +++ b/test/Unit/Task/MagoLinterTest.php @@ -0,0 +1,204 @@ +processBuilder->reveal(), + $this->formatter->reveal() + ); + } + + public static function provideConfigurableOptions(): iterable + { + yield 'defaults' => [ + [], + [ + 'semantics' => null, + 'pedantic' => null, + 'only' => [], + 'retain-codes' => [], + 'ignore-baseline' => null, + 'sort' => null, + 'fix-mode' => 'safe', + 'minimum-report-level' => null, + ] + ]; + + yield 'invalid-fix-mode' => [['fix-mode' => 'invalid'], null]; + yield 'invalid-minimum-report-level' => [['minimum-report-level' => 'invalid'], 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), + function () { + $this->mockProcessBuilder('mago', $process = self::mockProcess(1)); + $this->formatter->format($process)->willReturn('nope'); + }, + 'nope', + FixableTaskResult::class, + ]; + } + + public static function providePassesOnStuff(): iterable + { + yield 'exitCode0' => [ + [], + self::mockContext(RunContext::class), + function () { + $this->mockProcessBuilder('mago', self::mockProcess(0)); + } + ]; + } + + #[Test] + #[DataProvider('provideSkipsOnStuff')] + public function it_skips_on_stuff( + array $config, + ContextInterface $context, + callable $configurator + ): void + { + self::markTestSkipped('No skip scenarios defined yet'); + } + + public static function provideSkipsOnStuff(): iterable + { + yield 'no-skip-scenarios' => [ + [], + self::mockContext(RunContext::class), + function () {} + ]; + } + + public static function provideExternalTaskRuns(): iterable + { + yield 'defaults' => [ + [], + self::mockContext(RunContext::class), + 'mago', + ['lint', '--fix', '--dry-run', '--fail-on-remaining'] + ]; + + yield 'pre-commit-staged' => [ + [], + self::mockContext(GitPreCommitContext::class), + 'mago', + ['lint', '--fix', '--dry-run', '--fail-on-remaining', '--staged'] + ]; + + yield 'semantics' => [ + ['semantics' => true], + self::mockContext(RunContext::class), + 'mago', + ['lint', '--fix', '--dry-run', '--fail-on-remaining', '--semantics'] + ]; + + yield 'pedantic' => [ + ['pedantic' => true], + self::mockContext(RunContext::class), + 'mago', + ['lint', '--fix', '--dry-run', '--fail-on-remaining', '--pedantic'] + ]; + + yield 'only' => [ + ['only' => ['invalid-argument', 'semantics']], + self::mockContext(RunContext::class), + 'mago', + ['lint', '--fix', '--dry-run', '--fail-on-remaining', '--only=invalid-argument,semantics'] + ]; + + yield 'retain-codes' => [ + ['retain-codes' => ['invalid-argument', 'semantics']], + self::mockContext(RunContext::class), + 'mago', + ['lint', '--fix', '--dry-run', '--fail-on-remaining', '--retain-code', 'invalid-argument', '--retain-code', 'semantics'] + ]; + + yield 'ignore-baseline' => [ + ['ignore-baseline' => true], + self::mockContext(RunContext::class), + 'mago', + ['lint', '--fix', '--dry-run', '--fail-on-remaining', '--ignore-baseline'] + ]; + + yield 'sort' => [ + ['sort' => true], + self::mockContext(RunContext::class), + 'mago', + ['lint', '--fix', '--dry-run', '--fail-on-remaining', '--sort'] + ]; + + yield 'minimum-report-level' => [ + ['minimum-report-level' => 'warning'], + self::mockContext(RunContext::class), + 'mago', + ['lint', '--fix', '--dry-run', '--fail-on-remaining', '--minimum-report-level', 'warning'] + ]; + + } + + /** + * When the task fails, GrumPHP offers a fix command: the dry-run flags are dropped and the + * configured fix-mode is applied. The harness only asserts the detection command, so the fix + * command is verified here by inspecting the (mutated) arguments collection after the run. + */ + #[Test] + #[DataProvider('provideFixCommands')] + public function it_builds_the_fix_command(string $fixMode, array $expectedFixCommand): void + { + $this->processBuilder->createArgumentsForCommand('mago')->willReturn( + $arguments = new ProcessArgumentsCollection() + ); + $this->processBuilder->buildProcess($arguments)->willReturn($process = self::mockProcess(1)); + $this->formatter->format($process)->willReturn('failed'); + + $this->configureTask(['fix-mode' => $fixMode])->run(self::mockContext(RunContext::class)); + + self::assertSame($expectedFixCommand, $arguments->getValues()); + } + + public static function provideFixCommands(): iterable + { + yield 'safe' => ['safe', ['lint', '--fix']]; + yield 'potentially-unsafe' => ['potentially-unsafe', ['lint', '--fix', '--potentially-unsafe']]; + yield 'unsafe' => ['unsafe', ['lint', '--fix', '--unsafe']]; + } +}