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
+!.*