diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5d66bc4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,27 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +[*.{php,phpt}] +indent_style = tab +indent_size = 4 + +[*.xml] +indent_style = tab +indent_size = 4 + +[*.neon] +indent_style = tab +indent_size = 4 + +[*.{yaml,yml}] +indent_style = space +indent_size = 2 + +[composer.json] +indent_style = tab +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..45a67c4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +*.php text eol=lf + +.github export-ignore +tests export-ignore +tmp export-ignore +.gitattributes export-ignore +.gitignore export-ignore +Makefile export-ignore +phpstan.neon export-ignore +phpunit.xml export-ignore diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..d3f5961 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,19 @@ +{ + "extends": [ + "config:base", + "schedule:weekly" + ], + "rangeStrategy": "update-lockfile", + "packageRules": [ + { + "matchPaths": ["+(composer.json)"], + "enabled": true, + "groupName": "root-composer" + }, + { + "matchPaths": [".github/**"], + "enabled": true, + "groupName": "github-actions" + } + ] +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2624592 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,149 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +name: "Build" + +on: + pull_request: + push: + branches: + - "1.0.x" + +jobs: + lint: + name: "Lint" + runs-on: "ubuntu-latest" + + strategy: + fail-fast: false + matrix: + php-version: + - "8.2" + - "8.3" + - "8.4" + + steps: + - name: "Checkout" + uses: actions/checkout@v5 + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "${{ matrix.php-version }}" + ini-file: development + extensions: "mongodb" + + - name: "Validate Composer" + run: "composer validate" + + - name: "Allow installing on PHP 8.4" + if: matrix.php-version == '8.4' + run: "composer config platform.php 8.3.99" + + - name: "Install dependencies" + run: "composer install --no-interaction --no-progress" + + - name: "Lint" + run: "make lint" + + coding-standard: + name: "Coding Standard" + + runs-on: "ubuntu-latest" + + steps: + - name: "Checkout" + uses: actions/checkout@v5 + + - name: "Checkout build-cs" + uses: actions/checkout@v5 + with: + repository: "phpstan/build-cs" + path: "build-cs" + ref: "2.x" + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "8.2" + ini-file: development + + - name: "Validate Composer" + run: "composer validate" + + - name: "Install dependencies" + run: "composer install --no-interaction --no-progress" + + - name: "Install build-cs dependencies" + working-directory: "build-cs" + run: "composer install --no-interaction --no-progress" + + - name: "Lint" + run: "make lint" + + - name: "Coding Standard" + run: "make cs" + + tests: + name: "Tests" + runs-on: "ubuntu-latest" + + strategy: + fail-fast: false + matrix: + php-version: + - "8.2" + - "8.3" + - "8.4" + + steps: + - name: "Checkout" + uses: actions/checkout@v5 + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "xdebug" + php-version: "${{ matrix.php-version }}" + ini-file: development + extensions: "mongodb" + + - name: "Allow installing on PHP 8.4" + if: matrix.php-version == '8.4' + run: "composer config platform.php 8.3.99" + + - name: "Install dependencies" + run: "composer install --no-interaction --no-progress" + + - name: "Tests" + run: "make tests" + + static-analysis: + name: "PHPStan" + runs-on: "ubuntu-latest" + + strategy: + fail-fast: false + matrix: + php-version: + - "8.2" + - "8.3" + - "8.4" + + steps: + - name: "Checkout" + uses: actions/checkout@v5 + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "${{ matrix.php-version }}" + ini-file: development + + - name: "Install dependencies" + run: "composer install --no-interaction --no-progress" + + - name: "PHPStan" + run: "make phpstan" diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml new file mode 100644 index 0000000..fd91816 --- /dev/null +++ b/.github/workflows/create-tag.yml @@ -0,0 +1,53 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +name: "Create tag" + +on: + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch + workflow_dispatch: + inputs: + version: + description: 'Next version' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + +jobs: + create-tag: + name: "Create tag" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout" + uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ secrets.PHPSTAN_BOT_TOKEN }} + + - name: 'Get Previous tag' + id: previoustag + uses: "WyriHaximus/github-action-get-previous-tag@v1" + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + + - name: 'Get next versions' + id: semvers + uses: "WyriHaximus/github-action-next-semvers@v1" + with: + version: ${{ steps.previoustag.outputs.tag }} + + - name: "Create new minor tag" + uses: rickstaa/action-create-tag@v1 + if: inputs.version == 'minor' + with: + tag: ${{ steps.semvers.outputs.minor }} + message: ${{ steps.semvers.outputs.minor }} + + - name: "Create new patch tag" + uses: rickstaa/action-create-tag@v1 + if: inputs.version == 'patch' + with: + tag: ${{ steps.semvers.outputs.patch }} + message: ${{ steps.semvers.outputs.patch }} diff --git a/.github/workflows/release-toot.yml b/.github/workflows/release-toot.yml new file mode 100644 index 0000000..1ba4fd7 --- /dev/null +++ b/.github/workflows/release-toot.yml @@ -0,0 +1,21 @@ +name: Toot release + +# More triggers +# https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#release +on: + release: + types: [published] + +jobs: + toot: + runs-on: ubuntu-latest + steps: + - uses: cbrgm/mastodon-github-action@v2 + if: ${{ !github.event.repository.private }} + with: + # GitHub event payload + # https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#release + message: "New release: ${{ github.event.repository.name }} ${{ github.event.release.tag_name }} ${{ github.event.release.html_url }} #phpstan" + env: + MASTODON_URL: https://phpc.social + MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }} diff --git a/.github/workflows/release-tweet.yml b/.github/workflows/release-tweet.yml new file mode 100644 index 0000000..d81f34c --- /dev/null +++ b/.github/workflows/release-tweet.yml @@ -0,0 +1,24 @@ +name: Tweet release + +# More triggers +# https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#release +on: + release: + types: [published] + +jobs: + tweet: + runs-on: ubuntu-latest + steps: + - uses: Eomm/why-don-t-you-tweet@v2 + if: ${{ !github.event.repository.private }} + with: + # GitHub event payload + # https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#release + tweet-message: "New release: ${{ github.event.repository.name }} ${{ github.event.release.tag_name }} ${{ github.event.release.html_url }} #phpstan" + env: + # Get your tokens from https://developer.twitter.com/apps + TWITTER_CONSUMER_API_KEY: ${{ secrets.TWITTER_CONSUMER_API_KEY }} + TWITTER_CONSUMER_API_SECRET: ${{ secrets.TWITTER_CONSUMER_API_SECRET }} + TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }} + TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ed7e51a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +name: "Create release" + +on: + push: + tags: + - '*' + +jobs: + deploy: + name: "Deploy" + runs-on: "ubuntu-latest" + + steps: + - name: "Checkout" + uses: actions/checkout@v5 + + - name: Generate changelog + id: changelog + uses: metcalfc/changelog-generator@v4.6.2 + with: + myToken: ${{ secrets.PHPSTAN_BOT_TOKEN }} + + - name: "Create release" + id: create-release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: ${{ github.ref }} + body: ${{ steps.changelog.outputs.changelog }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3d740a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/tests/tmp +/build-cs +/vendor +/composer.lock +/.env +.phpunit.result.cache diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e5f34e6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2016 Ondřej Mirtes +Copyright (c) 2025 PHPStan s.r.o. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8c11203 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: check +check: lint cs tests phpstan + +.PHONY: tests +tests: + php vendor/bin/phpunit + +.PHONY: lint +lint: + php vendor/bin/parallel-lint --colors \ + --exclude tests/Rules/DeadCode/data/bug-383.php \ + src tests + +.PHONY: cs-install +cs-install: + git clone https://github.com/phpstan/build-cs.git || true + git -C build-cs fetch origin && git -C build-cs reset --hard origin/2.x + composer install --working-dir build-cs + +.PHONY: cs +cs: + php build-cs/vendor/bin/phpcs --standard=build-cs/phpcs.xml src tests + +.PHONY: cs-fix +cs-fix: + php build-cs/vendor/bin/phpcbf --standard=build-cs/phpcs.xml src tests + +.PHONY: phpstan +phpstan: + php vendor/bin/phpstan analyse -c phpstan.neon + +.PHONY: phpstan-generate-baseline +phpstan-generate-baseline: + php vendor/bin/phpstan analyse -c phpstan.neon -b phpstan-baseline.neon diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b89d689 --- /dev/null +++ b/composer.json @@ -0,0 +1,37 @@ +{ + "name": "phpstan/build-infection", + "description": "Infection extensions for PHPStan", + "license": [ + "MIT" + ], + "require": { + "php": "^7.4 || ^8.0", + "infection/infection": "^0.31.6" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan": "^2.1.13", + "phpstan/phpstan-deprecation-rules": "^2.0.2", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.23" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "autoload-dev": { + "classmap": [ + "tests/" + ] + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/infection.json5 b/infection.json5 new file mode 100644 index 0000000..7afd723 --- /dev/null +++ b/infection.json5 @@ -0,0 +1,17 @@ +{ + "$schema": "vendor/infection/infection/resources/schema.json", + "timeout": 30, + "source": { + "directories": [ + "src" + ] + }, + "staticAnalysisTool": "phpstan", + "logs": { + "text": "tmp/infection.log" + }, + "mutators": { + "@default": false, + "PHPStan\\Infection\\TrinaryLogicMutator": true + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..6d7c0fd --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: 8 + paths: + - src + - tests + + resultCachePath: tmp/resultCache.php diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..4674938 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,19 @@ + + + + + tests + + + + + diff --git a/src/Infection/TrinaryLogicMutator.php b/src/Infection/TrinaryLogicMutator.php new file mode 100644 index 0000000..eb527a2 --- /dev/null +++ b/src/Infection/TrinaryLogicMutator.php @@ -0,0 +1,69 @@ + + */ +final class TrinaryLogicMutator implements Mutator +{ + + public static function getDefinition(): Definition + { + return new Definition( + <<<'TXT' + Replaces TrinaryLogic->yes() with !TrinaryLogic->no() and vice versa. + TXT + , + MutatorCategory::ORTHOGONAL_REPLACEMENT, + null, + <<<'DIFF' + - $type->isBoolean()->yes(); + + !$type->isBoolean()->no(); + DIFF, + ); + } + + public function getName(): string + { + return self::class; + } + + public function canMutate(Node $node): bool + { + if (!$node instanceof Node\Expr\MethodCall) { + return false; + } + + if (!$node->name instanceof Node\Identifier) { + return false; + } + + if (!in_array($node->name->name, ['yes', 'no'], true)) { + return false; + } + + return true; + } + + public function mutate(Node $node): iterable + { + if (!$node->name instanceof Node\Identifier) { + throw new LogicException(); + } + + if ($node->name->name === 'yes') { + yield new Node\Expr\BooleanNot(new Node\Expr\MethodCall($node->var, 'no')); + } else { + yield new Node\Expr\BooleanNot(new Node\Expr\MethodCall($node->var, 'yes')); + } + } + +} diff --git a/tests/Infection/TrinaryLogicMutatorTest.php b/tests/Infection/TrinaryLogicMutatorTest.php new file mode 100644 index 0000000..8f1e734 --- /dev/null +++ b/tests/Infection/TrinaryLogicMutatorTest.php @@ -0,0 +1,74 @@ +assertMutatesInput($input, $expected); + } + + /** + * @return iterable + */ + public static function mutationsProvider(): iterable + { + yield 'It mutates trinary yes' => [ + <<<'PHP' + yes(); + PHP +, + <<<'PHP' + no(); + PHP +, + ]; + + yield 'It mutates trinary no' => [ + <<<'PHP' + no(); + PHP +, + <<<'PHP' + yes(); + PHP +, + ]; + + yield 'It skips maybe' => [ + <<<'PHP' + maybe(); + PHP +, + ]; + } + + protected function getTestedMutatorClassName(): string + { + return TrinaryLogicMutator::class; + } + +} diff --git a/tmp/.gitignore b/tmp/.gitignore new file mode 100644 index 0000000..37890ca --- /dev/null +++ b/tmp/.gitignore @@ -0,0 +1,3 @@ +* +!cache +!.*