diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index cedae08..9af909e 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -44,6 +44,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} + coverage: xdebug - name: Setup global variables id: globals @@ -62,7 +63,7 @@ jobs: ${{ github.workflow }}-PHP_${{ matrix.php-version }}- - name: Install dependencies - run: composer update --ansi --no-scripts ${{ steps.globals.outputs.EXPERIMENTAL_FLAG }} + run: composer update --ansi ${{ steps.globals.outputs.EXPERIMENTAL_FLAG }} - name: Run Unit Tests shell: bash @@ -73,6 +74,18 @@ jobs: COVERAGE_OPTION: ${{ matrix.os != 'windows-2019' && '--coverage' || '' }} TACHYCARDIA_MONITOR_GA: enabled + - name: Run Mutation Testing + shell: bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch --depth=1 origin $GITHUB_BASE_REF + composer mutation:filter + else + composer mutation:check + fi + env: + INFECTION_DASHBOARD_API_KEY: ${{ secrets.INFECTION_DASHBOARD_API_KEY }} + - name: Display structure of coverage files if: matrix.os != 'windows-2019' run: ls -la diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 4d60f87..95dfb68 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -40,6 +40,7 @@ ]) ->append([ __FILE__, + 'bin/build-infection', 'bin/parallel-phpunit', 'bin/prune-cache', ]) diff --git a/bin/build-infection b/bin/build-infection new file mode 100755 index 0000000..93f8fa2 --- /dev/null +++ b/bin/build-infection @@ -0,0 +1,15 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +require __DIR__.'/../tools/build-infection.php'; diff --git a/composer.json b/composer.json index 7ff741a..5724233 100644 --- a/composer.json +++ b/composer.json @@ -76,6 +76,8 @@ ], "cs:check": "tools/vendor/bin/php-cs-fixer check --ansi --verbose --diff", "cs:fix": "tools/vendor/bin/php-cs-fixer fix --ansi --verbose --diff", + "mutation:check": "tools/vendor/bin/infection --threads=max --ansi", + "mutation:filter": "@mutation:check --git-diff-filter=AM --git-diff-base=origin/1.x", "phpstan:baseline": "phpstan analyse --ansi --generate-baseline=phpstan-baseline.php", "phpstan:check": "phpstan analyse --ansi --verbose", "test:all": [ @@ -93,6 +95,8 @@ "scripts-descriptions": { "cs:check": "Checks for coding style violations", "cs:fix": "Fixes any coding style violations", + "mutation:check": "Runs Infection on whole codebase", + "mutation:filter": "Runs Infection on added and modified files only", "phpstan:baseline": "Runs PHPStan and dumps resulting errors to baseline", "phpstan:check": "Runs PHPStan with identifiers support", "test:all": "Runs all PHPUnit tests", diff --git a/infection.json5 b/infection.json5 new file mode 100644 index 0000000..95ba64d --- /dev/null +++ b/infection.json5 @@ -0,0 +1,182 @@ +{ + "$schema": "./tools/vendor/infection/infection/resources/schema.json", + "source": { + "directories": [ + "src/Nexus" + ], + "excludes": [ + "PHPStan" + ] + }, + "timeout": 10, + "logs": { + "text": "build/logs/infection/infection.log", + "html": "build/logs/infection/infection.html", + "stryker": { + "badge": "1.x" + } + }, + "tmpDir": "build", + "minMsi": 100, + "minCoveredMsi": 100, + "mutators": { + "ArrayItem": true, + "ArrayItemRemoval": true, + "ArrayOneItem": true, + "AssignCoalesce": true, + "Assignment": true, + "AssignmentEqual": true, + "BCMath": true, + "BitwiseAnd": true, + "BitwiseNot": true, + "BitwiseOr": true, + "BitwiseXor": true, + "Break_": true, + "CastArray": true, + "CastBool": true, + "CastFloat": true, + "CastInt": { + "ignore": [ + "Nexus\\Clock\\SystemClock" + ] + }, + "CastObject": true, + "CastString": true, + "CatchBlockRemoval": true, + "Catch_": true, + "CloneRemoval": true, + "Coalesce": true, + "ConcatOperandRemoval": true, + "Continue_": true, + "Decrement": true, + "DivEqual": true, + "Division": { + "ignore": [ + "Nexus\\Clock\\SystemClock" + ] + }, + "DoWhile": true, + "ElseIfNegation": true, + "Equal": true, + "EqualIdentical": true, + "Exponentiation": true, + "FalseValue": true, + "Finally_": true, + "FloatNegation": true, + "For_": true, + "Foreach_": true, + "FunctionCall": true, + "GreaterThanNegotiation": true, + "GreaterThanOrEqualToNegotiation": true, + "Identical": true, + "IfNegation": true, + "Increment": true, + "InstanceOf_": true, + "LessThanNegotiation": true, + "LessThanOrEqualToNegotiation": true, + "LogicalAnd": true, + "LogicalAndAllSubExprNegation": true, + "LogicalAndNegation": true, + "LogicalAndSingleSubExprNegation": true, + "LogicalLowerAnd": true, + "LogicalLowerOr": true, + "LogicalNot": true, + "LogicalOr": true, + "LogicalOrAllSubExprNegation": true, + "LogicalOrNegation": true, + "LogicalOrSingleSubExprNegation": true, + "MBString": true, + "MatchArmRemoval": true, + "MethodCallRemoval": true, + "MinusEqual": true, + "ModEqual": { + "ignore": [ + "Nexus\\Clock\\SystemClock" + ] + }, + "Modulus": true, + "MulEqual": true, + "Multiplication": true, + "NewObject": true, + "NotEqual": true, + "NotEqualNotIdentical": true, + "NullSafeMethodCall": true, + "NullSafePropertyCall": true, + "OneZeroFloat": true, + "PlusEqual": true, + "PowEqual": true, + "PregMatchMatches": true, + "PregMatchRemoveCaret": true, + "PregMatchRemoveDollar": true, + "PregMatchRemoveFlags": true, + "PregQuote": true, + "ProtectedVisibility": true, + "PublicVisibility": true, + "SharedCaseRemoval": true, + "ShiftLeft": true, + "ShiftRight": true, + "Spaceship": true, + "SpreadAssignment": true, + "SpreadOneItem": true, + "SpreadRemoval": true, + "Ternary": true, + "This": true, + "Throw_": true, + "TrueValue": true, + "UnwrapArrayChangeKeyCase": true, + "UnwrapArrayChunk": true, + "UnwrapArrayColumn": true, + "UnwrapArrayCombine": true, + "UnwrapArrayDiff": true, + "UnwrapArrayDiffAssoc": true, + "UnwrapArrayDiffKey": true, + "UnwrapArrayDiffUassoc": true, + "UnwrapArrayDiffUkey": true, + "UnwrapArrayFilter": true, + "UnwrapArrayFlip": true, + "UnwrapArrayIntersect": true, + "UnwrapArrayIntersectAssoc": true, + "UnwrapArrayIntersectKey": true, + "UnwrapArrayIntersectUassoc": true, + "UnwrapArrayIntersectUkey": true, + "UnwrapArrayKeys": true, + "UnwrapArrayMap": true, + "UnwrapArrayMerge": true, + "UnwrapArrayMergeRecursive": true, + "UnwrapArrayPad": true, + "UnwrapArrayReduce": true, + "UnwrapArrayReplace": true, + "UnwrapArrayReplaceRecursive": true, + "UnwrapArrayReverse": true, + "UnwrapArraySlice": true, + "UnwrapArraySplice": true, + "UnwrapArrayUdiff": true, + "UnwrapArrayUdiffAssoc": true, + "UnwrapArrayUdiffUassoc": true, + "UnwrapArrayUintersect": true, + "UnwrapArrayUintersectAssoc": true, + "UnwrapArrayUintersectUassoc": true, + "UnwrapArrayUnique": true, + "UnwrapArrayValues": true, + "UnwrapFinally": true, + "UnwrapLcFirst": true, + "UnwrapLtrim": true, + "UnwrapRtrim": true, + "UnwrapStrIreplace": true, + "UnwrapStrRepeat": true, + "UnwrapStrReplace": true, + "UnwrapStrRev": true, + "UnwrapStrShuffle": true, + "UnwrapStrToLower": true, + "UnwrapStrToUpper": true, + "UnwrapSubstr": true, + "UnwrapTrim": true, + "UnwrapUcFirst": true, + "UnwrapUcWords": true, + "While_": true, + "YieldValue": true, + "Yield_": true + }, + "testFramework": "phpunit", + "testFrameworkOptions": "--group=unit-test" +} diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 2f0e2b5..8ea2f03 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -17,6 +17,7 @@ parameters: analyseAndScan: - tests/**/data/** - tests/PHPStan/**/data/** + analyse: - tools/vendor/** bootstrapFiles: - vendor/autoload.php diff --git a/tests/AutoReview/InfectionConfigTest.php b/tests/AutoReview/InfectionConfigTest.php new file mode 100644 index 0000000..112c961 --- /dev/null +++ b/tests/AutoReview/InfectionConfigTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\AutoReview; + +use Nexus\Tools\InfectionConfigBuilder; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversNothing] +#[Group('auto-review')] +final class InfectionConfigTest extends TestCase +{ + public function testInfectionJsonIsUpdated(): void + { + if (is_file(__DIR__.'/../../tools/vendor/autoload.php')) { + require_once __DIR__.'/../../tools/vendor/autoload.php'; + } else { + self::markTestSkipped('Install `tools` to run this test.'); + } + + $infectionJson = file_get_contents(__DIR__.'/../../infection.json5'); + self::assertIsString($infectionJson); + + $actualConfig = json_decode($infectionJson, true); + self::assertIsArray($actualConfig); + self::assertSame( + InfectionConfigBuilder::build(), + $actualConfig, + 'The infection.json5 is not updated; run `bin/build-infection` to update.', + ); + } +} diff --git a/tests/Option/OptionTest.php b/tests/Option/OptionTest.php index 7bf27e5..a682c38 100644 --- a/tests/Option/OptionTest.php +++ b/tests/Option/OptionTest.php @@ -83,7 +83,11 @@ public function testOptionMap(): void $option = (new Some('Hello, World!'))->map($predicate); self::assertTrue($option->isSome()); self::assertSame(13, $option->unwrap()); - self::assertTrue((new None())->map($predicate)->isNone()); + + $none = new None(); + $newNone = $none->map($predicate); + self::assertTrue($newNone->isNone()); + self::assertNotSame($newNone, $none); } public function testOptionMapOr(): void @@ -105,9 +109,11 @@ public function testOptionMapOrElse(): void public function testOptionAnd(): void { - self::assertTrue((new Some(2))->and(new None())->isNone()); - self::assertTrue((new None())->and(new Some('foo'))->isNone()); - self::assertTrue((new None())->and(new None())->isNone()); + $none = new None(); + self::assertTrue((new Some(2))->and($none)->isNone()); + self::assertTrue($none->and(new Some('foo'))->isNone()); + self::assertTrue($none->and($none)->isNone()); + self::assertNotSame($none, $none->and($none)); $option = (new Some(2))->and(new Some('foo')); self::assertInstanceOf(Some::class, $option); @@ -121,16 +127,25 @@ public function testOptionAndThen(): void $option = (new Some(2))->andThen($squareThenToString); self::assertTrue($option->isSome()); self::assertSame('4', $option->unwrap()); - self::assertTrue((new None())->andThen($squareThenToString)->isNone()); + + $none = new None(); + self::assertTrue($none->andThen($squareThenToString)->isNone()); + self::assertNotSame($none, $none->andThen($squareThenToString)); } public function testOptionFilter(): void { $isEven = static fn(int $n): bool => $n % 2 === 0; - self::assertTrue((new None())->filter($isEven)->isNone()); + $none = new None(); + self::assertTrue($none->filter($isEven)->isNone()); + self::assertNotSame($none, $none->filter($isEven)); + self::assertFalse((new Some(3))->filter($isEven)->isSome()); - self::assertTrue((new Some(4))->filter($isEven)->isSome()); + + $some = new Some(4); + self::assertTrue($some->filter($isEven)->isSome()); + self::assertNotSame($some, $some->filter($isEven)); } public function testOptionOr(): void @@ -141,6 +156,7 @@ public function testOptionOr(): void self::assertTrue($some02->or($none)->isSome()); self::assertSame(2, $some02->or($none)->unwrap()); + self::assertNotSame($some02, $some02->or($none)); self::assertTrue($none->or($some100)->isSome()); self::assertSame(100, $none->or($some100)->unwrap()); @@ -156,8 +172,10 @@ public function testOptionOrElse(): void $nobody = static fn(): None => new None(); $vikings = static fn(): Some => new Some('vikings'); - $option = (new Some('barbarians'))->orElse($vikings); + $some = new Some('barbarians'); + $option = $some->orElse($vikings); self::assertTrue($option->isSome()); + self::assertNotSame($some, $option); self::assertSame('barbarians', $option->unwrap()); $option = (new None())->orElse($vikings); @@ -174,12 +192,14 @@ public function testOptionXor(): void self::assertInstanceOf(Some::class, $some->xor($none)); self::assertSame(2, $some->xor($none)->unwrap()); + self::assertNotSame($some, $some->xor($none)); self::assertInstanceOf(Some::class, $none->xor($some)); self::assertSame(2, $none->xor($some)->unwrap()); self::assertInstanceOf(None::class, $some->xor($some)); self::assertInstanceOf(None::class, $none->xor($none)); + self::assertNotSame($none, $none->xor($none)); } public function testOptionIteration(): void diff --git a/tools/build-infection.php b/tools/build-infection.php new file mode 100644 index 0000000..a6eaaa9 --- /dev/null +++ b/tools/build-infection.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use Nexus\Tools\InfectionConfigBuilder; + +require __DIR__.'/vendor/autoload.php'; + +file_put_contents( + __DIR__.'/../infection.json5', + json_encode(InfectionConfigBuilder::build(), JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)."\n", +); +printf("\033[42;30m DONE \033[0m\n"); diff --git a/tools/composer.json b/tools/composer.json index 0f70ec6..f9edb3c 100644 --- a/tools/composer.json +++ b/tools/composer.json @@ -2,6 +2,7 @@ "require": { "php": "^8.3", "friendsofphp/php-cs-fixer": "^3.60", + "infection/infection": "^0.29.6", "kubawerlos/php-cs-fixer-custom-fixers": "^3.21", "nexusphp/cs-config": "^3.24" }, @@ -11,6 +12,9 @@ } }, "config": { + "allow-plugins": { + "infection/extension-installer": true + }, "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true diff --git a/tools/src/InfectionConfigBuilder.php b/tools/src/InfectionConfigBuilder.php new file mode 100644 index 0000000..37cacec --- /dev/null +++ b/tools/src/InfectionConfigBuilder.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tools; + +use Infection\Mutator\ProfileList; +use Nexus\Clock\SystemClock; + +/** + * Inspired from https://github.com/kubawerlos/php-cs-fixer-custom-fixers/blob/main/.dev-tools/src/InfectionConfigBuilder.php. + * + * @internal + */ +final class InfectionConfigBuilder +{ + /** + * @var list + */ + public const UNWANTED_MUTATORS = [ + 'Concat', + 'DecrementInteger', + 'FunctionCallRemoval', + 'GreaterThan', + 'GreaterThanOrEqualTo', + 'IdenticalEqual', // @deprecated + 'IncrementInteger', + 'IntegerNegation', + 'LessThan', + 'LessThanOrEqualTo', + 'Minus', + 'NotIdentical', + 'NotIdenticalNotEqual', // @deprecated + 'OneZeroInteger', + 'Plus', + 'RoundingFamily', + 'SyntaxError', + ]; + + /** + * @var array> + */ + public const PER_MUTATOR_IGNORE = [ + 'CastInt' => [SystemClock::class], + 'Division' => [SystemClock::class], + 'ModEqual' => [SystemClock::class], + ]; + + /** + * @return array{ + * '$schema': string, + * 'source': array{ + * 'directories': list, + * 'excludes': list, + * }, + * 'timeout': int, + * 'logs': array>, + * 'tmpDir': string, + * 'minMsi': int, + * 'minCoveredMsi': int, + * 'mutators': array>, + * 'testFramework': string, + * 'testFrameworkOptions': string, + * } + */ + public static function build(): array + { + $config = [ + '$schema' => './tools/vendor/infection/infection/resources/schema.json', + 'source' => [ + 'directories' => ['src/Nexus'], + 'excludes' => ['PHPStan'], + ], + 'timeout' => 10, + 'logs' => [ + 'text' => 'build/logs/infection/infection.log', + 'html' => 'build/logs/infection/infection.html', + 'stryker' => ['badge' => '1.x'], + ], + 'tmpDir' => 'build', + 'minMsi' => 100, + 'minCoveredMsi' => 100, + 'mutators' => [], + 'testFramework' => 'phpunit', + 'testFrameworkOptions' => '--group=unit-test', + ]; + + $mutators = array_keys(ProfileList::ALL_MUTATORS); + sort($mutators); + + foreach ($mutators as $mutator) { + if (\in_array($mutator, self::UNWANTED_MUTATORS, true)) { + continue; + } + + if (\array_key_exists($mutator, self::PER_MUTATOR_IGNORE)) { + $config['mutators'][$mutator] = ['ignore' => self::PER_MUTATOR_IGNORE[$mutator]]; + + continue; + } + + $config['mutators'][$mutator] = true; + } + + return $config; + } +}