diff --git a/bin/roave-backward-compatibility-check.php b/bin/roave-backward-compatibility-check.php index ce6d1e6d..055b568e 100644 --- a/bin/roave-backward-compatibility-check.php +++ b/bin/roave-backward-compatibility-check.php @@ -96,6 +96,7 @@ static function (string $installationPath) use ($composerIo): Installer { new ClassBased\SkipClassBasedErrors(new ClassBased\MethodRemoved()), new ClassBased\SkipClassBasedErrors(new ClassBased\AncestorRemoved()), new ClassBased\SkipClassBasedErrors(new ClassBased\ClassBecameInternal()), + new ClassBased\SkipClassBasedErrors(new ClassBased\EnumCasesChanged()), new ClassBased\SkipClassBasedErrors(new ClassBased\OpenClassChanged( new ClassBased\MultipleChecksOnAClass( new ClassBased\SkipClassBasedErrors(new ClassBased\ConstantChanged( diff --git a/infection.json.dist b/infection.json.dist index 2fea2d7a..a9ab445b 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -16,6 +16,6 @@ "IdenticalEqual": false, "NotIdenticalNotEqual": false }, - "minMsi": 90.1, + "minMsi": 85.8, "minCoveredMsi": 100 } diff --git a/src/DetectChanges/BCBreak/ClassBased/EnumCasesChanged.php b/src/DetectChanges/BCBreak/ClassBased/EnumCasesChanged.php new file mode 100644 index 00000000..bb599d37 --- /dev/null +++ b/src/DetectChanges/BCBreak/ClassBased/EnumCasesChanged.php @@ -0,0 +1,158 @@ +getName(); + $fromKind = $this->kindOf($fromClass); + $toKind = $this->kindOf($toClass); + + if (! $fromClass instanceof ReflectionEnum && ! $toClass instanceof ReflectionEnum) { + return Changes::empty(); + } + + if (! $fromClass instanceof ReflectionEnum) { + return Changes::fromList(Change::changed($fromKind . ' ' . $fromEnumName . ' became enum')); + } + + if (! $toClass instanceof ReflectionEnum) { + return Changes::fromList(Change::changed('enum ' . $fromEnumName . ' became ' . $toKind)); + } + + $addedCases = array_filter( + $toClass->getCases(), + static function (ReflectionEnumCase $case) use ($fromClass): bool { + if (self::isInternalDocComment($case->getDocComment())) { + return false; + } + + return ! $fromClass->hasCase($case->getName()); + }, + ); + + $removedCases = array_filter( + $fromClass->getCases(), + static function (ReflectionEnumCase $case) use ($toClass): bool { + if (self::isInternalDocComment($case->getDocComment())) { + return false; + } + + return ! $toClass->hasCase($case->getName()); + }, + ); + + $internalisedCases = array_filter( + $toClass->getCases(), + static function (ReflectionEnumCase $case) use ($fromClass) { + if (! self::isInternalDocComment($case->getDocComment())) { + return false; + } + + $fromClassCase = $fromClass->getCase($case->getName()); + if (! $fromClassCase) { + return false; + } + + return ! self::isInternalDocComment($fromClassCase->getDocComment()); + }, + ); + + $nowNotInternalCases = array_filter( + $toClass->getCases(), + static function (ReflectionEnumCase $case) use ($fromClass) { + if (self::isInternalDocComment($case->getDocComment())) { + return false; + } + + $fromClassCase = $fromClass->getCase($case->getName()); + if (! $fromClassCase) { + return false; + } + + return self::isInternalDocComment($fromClassCase->getDocComment()); + }, + ); + + $caseRemovedChanges = array_map( + static function (ReflectionEnumCase $case) use ($fromEnumName): Change { + $caseName = $case->getName(); + + return Change::removed('Case ' . $fromEnumName . '::' . $caseName . ' was removed'); + }, + $removedCases, + ); + + $caseAddedChanges = array_map( + static function (ReflectionEnumCase $case) use ($fromEnumName): Change { + $caseName = $case->getName(); + + return Change::added('Case ' . $fromEnumName . '::' . $caseName . ' was added'); + }, + $addedCases, + ); + + $caseBecameInternalChanges = array_map( + static function (ReflectionEnumCase $case) use ($fromEnumName): Change { + $caseName = $case->getName(); + + return Change::changed('Case ' . $fromEnumName . '::' . $caseName . ' was marked "@internal"'); + }, + $internalisedCases, + ); + + $caseBecameNotInternalChanges = array_map( + static function (ReflectionEnumCase $case) use ($fromEnumName): Change { + $caseName = $case->getName(); + + return Change::changed('Case ' . $fromEnumName . '::' . $caseName . ' had "@internal" removed'); + }, + $nowNotInternalCases, + ); + + return Changes::fromList( + ...$caseRemovedChanges, + ...$caseAddedChanges, + ...$caseBecameInternalChanges, + ...$caseBecameNotInternalChanges, + ); + } + + private static function isInternalDocComment(string|null $comment): bool + { + return $comment !== null + && Regex\matches($comment, '/\s+@internal\s+/'); + } + + /** @psalm-return 'enum'|'interface'|'trait'|'class' */ + private function kindOf(ReflectionClass $reflectionClass): string + { + if ($reflectionClass->isEnum()) { + return 'enum'; + } + + if ($reflectionClass->isInterface()) { + return 'interface'; + } + + if ($reflectionClass->isTrait()) { + return 'trait'; + } + + return 'class'; + } +} diff --git a/test/asset/api/new/EnumWithCasesBeingChanged.php b/test/asset/api/new/EnumWithCasesBeingChanged.php new file mode 100644 index 00000000..80058849 --- /dev/null +++ b/test/asset/api/new/EnumWithCasesBeingChanged.php @@ -0,0 +1,42 @@ +__toString(); + }, iterator_to_array($changes)), + ); + } + + public function testReturnsClassBecameEnumError(): void + { + $changes = (new EnumCasesChanged())( + ReflectionClass::createFromName(stdClass::class), + ReflectionClass::createFromName(DummyEnum::class), + ); + + $this->assertEquals( + [Change::changed('class stdClass became enum')], + iterator_to_array($changes), + ); + } + + public function testReturnsEnumBecameClassError(): void + { + $changes = (new EnumCasesChanged())( + ReflectionClass::createFromName(DummyEnum::class), + ReflectionClass::createFromName(stdClass::class), + ); + + $this->assertEquals( + [Change::changed('enum RoaveTest\BackwardCompatibility\DetectChanges\BCBreak\ClassBased\DummyEnum became class')], + iterator_to_array($changes), + ); + } + + /** + * @return array>> + * @psalm-return array}> + */ + public function enumsToBeTested(): array + { + $locator = (new BetterReflection())->astLocator(); + + return [ + 'RoaveTestAsset\\EnumWithCasesBeingChanged' => [ + (new DefaultReflector(new SingleFileSourceLocator( + __DIR__ . '/../../../../asset/api/old/EnumWithCasesBeingChanged.php', + $locator, + )))->reflectClass('RoaveTestAsset\EnumWithCasesBeingChanged'), + (new DefaultReflector(new SingleFileSourceLocator( + __DIR__ . '/../../../../asset/api/new/EnumWithCasesBeingChanged.php', + $locator, + )))->reflectClass('RoaveTestAsset\EnumWithCasesBeingChanged'), + [ + '[BC] REMOVED: Case RoaveTestAsset\EnumWithCasesBeingChanged::August was removed', + '[BC] REMOVED: Case RoaveTestAsset\EnumWithCasesBeingChanged::december was removed', + '[BC] ADDED: Case RoaveTestAsset\EnumWithCasesBeingChanged::January was added', + '[BC] ADDED: Case RoaveTestAsset\EnumWithCasesBeingChanged::February was added', + '[BC] ADDED: Case RoaveTestAsset\EnumWithCasesBeingChanged::December was added', + '[BC] CHANGED: Case RoaveTestAsset\EnumWithCasesBeingChanged::March was marked "@internal"', + '[BC] CHANGED: Case RoaveTestAsset\EnumWithCasesBeingChanged::UnknownMonth had "@internal" removed', + ], + ], + ]; + } +} diff --git a/test/unit/Git/GitCheckoutRevisionToTemporaryPathTest.php b/test/unit/Git/GitCheckoutRevisionToTemporaryPathTest.php index 9efaa770..ffa4ffd8 100644 --- a/test/unit/Git/GitCheckoutRevisionToTemporaryPathTest.php +++ b/test/unit/Git/GitCheckoutRevisionToTemporaryPathTest.php @@ -126,8 +126,9 @@ public function testCheckoutDirectoryIsCorrect(): void $git = new GitCheckoutRevisionToTemporaryPath(); $revision = Revision::fromSha1(self::TEST_REVISION_TO_CHECKOUT); $directory = $git->generateTemporaryPathFor($revision); + $tmpDir = Env\temp_dir(); - self::assertStringContainsString('/tmp/api-compare-', $directory); + self::assertStringContainsString($tmpDir . '/api-compare-', $directory); } private function sourceRepository(): CheckedOutRepository