diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index e68bfbad..a8e18632 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -150,11 +150,29 @@ jobs: with: name: docker-image-api-dev-amd path: /tmp/docker - - name: Mutant Test + - name: Leak Test run: | docker load < /tmp/docker/api-dev-amd.tar.gz docker run -v $(pwd):/var/www/html api:dev-amd sh -c "composer install --quiet && composer test:leak" + test-mess-detector: + runs-on: ubuntu-latest + name: 'Mess Detector' + needs: + - build-docker-image + continue-on-error: true + steps: + - uses: actions/checkout@v3 + - run: mkdir -p /tmp/docker + - uses: actions/download-artifact@v3 + with: + name: docker-image-api-dev-amd + path: /tmp/docker + - name: Mess Detector + run: | + docker load < /tmp/docker/api-dev-amd.tar.gz + docker run -v $(pwd):/var/www/html api:dev-amd sh -c "composer install --quiet && composer mess" + test-feature: runs-on: buildjet-4vcpu-ubuntu-2204 name: 'Feature Test' @@ -216,7 +234,7 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Feature Test + - name: Example Generation Test run: | chmod 777 test-feature-prepare docker load < /tmp/docker/api-dev-amd.tar.gz diff --git a/CHANGELOG.md b/CHANGELOG.md index ffc0724e..74777942 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add WebDAV HTTP methods to the allowed HTTP method lists. - Add 'Example Generation Test' to CI. - Enable phpstan rule for enforcing [safe functions](https://github.com/thecodingmachine/safe), closes #55. +- Add PHP Mess Detector, closes #114. ## 0.0.28 - 2023-09-14 ### Added diff --git a/composer.json b/composer.json index bb465425..d8ae1a8d 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,7 @@ "guzzlehttp/guzzle": "^7.5", "infection/infection": "^0.27", "phpbench/phpbench": "^1.2", + "phpmd/phpmd": "^2.13", "phpspec/prophecy-phpunit": "^2.0", "phpstan/phpstan": "^1.6", "phpunit/php-code-coverage": "^9.2", @@ -105,6 +106,7 @@ "psalm:fix-dry": "php vendor/bin/psalm --alter --issues=MissingParamType,MissingReturnType,InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType --dry-run", "psalm:fix-apply": "php vendor/bin/psalm --alter --issues=MissingParamType,MissingReturnType,InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType", "phpstan": "vendor/bin/phpstan", + "mess": "vendor/bin/phpmd src,lib,tests text phpmd.xml", "benchmark": "vendor/bin/phpbench run --report=aggregate --progress dots", "benchmark:csv": "vendor/bin/phpbench run --report=bare --output=csv-file", "benchmark:plot": "python benchmark/plot/plot.py", diff --git a/composer.lock b/composer.lock old mode 100755 new mode 100644 index 6de7a7e3..0de51e9d --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "041369a5be9e1ee2257f08c816d464a2", + "content-hash": "c71f528e920aba088fe20431fc258432", "packages": [ { "name": "aws/aws-crt-php", @@ -7358,16 +7358,16 @@ }, { "name": "infection/infection", - "version": "0.27.0", + "version": "0.27.2", "source": { "type": "git", "url": "https://github.com/infection/infection.git", - "reference": "a9ff8171577d98b887d7f16428edd81ff69ce887" + "reference": "5019cdc8d707ddacffbe991a1c12c8e8e348845f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/a9ff8171577d98b887d7f16428edd81ff69ce887", - "reference": "a9ff8171577d98b887d7f16428edd81ff69ce887", + "url": "https://api.github.com/repos/infection/infection/zipball/5019cdc8d707ddacffbe991a1c12c8e8e348845f", + "reference": "5019cdc8d707ddacffbe991a1c12c8e8e348845f", "shasum": "" }, "require": { @@ -7415,7 +7415,7 @@ "phpstan/phpstan-webmozart-assert": "^1.0.2", "phpunit/phpunit": "^9.5.5", "rector/rector": "^0.16.0", - "sidz/phpstan-rules": "^0.2.1", + "sidz/phpstan-rules": "^0.3.0", "symfony/phpunit-bridge": "^5.4 || ^6.0", "symfony/yaml": "^5.4 || ^6.0", "thecodingmachine/phpstan-safe-rule": "^1.2.0" @@ -7474,7 +7474,7 @@ ], "support": { "issues": "https://github.com/infection/infection/issues", - "source": "https://github.com/infection/infection/tree/0.27.0" + "source": "https://github.com/infection/infection/tree/0.27.2" }, "funding": [ { @@ -7486,7 +7486,7 @@ "type": "open_collective" } ], - "time": "2023-05-16T05:28:04+00:00" + "time": "2023-09-16T16:39:29+00:00" }, { "name": "justinrainbow/json-schema", @@ -7802,6 +7802,69 @@ }, "time": "2021-04-14T09:16:52+00:00" }, + { + "name": "pdepend/pdepend", + "version": "2.14.0", + "source": { + "type": "git", + "url": "https://github.com/pdepend/pdepend.git", + "reference": "1121d4b04af06e33e9659bac3a6741b91cab1de1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pdepend/pdepend/zipball/1121d4b04af06e33e9659bac3a6741b91cab1de1", + "reference": "1121d4b04af06e33e9659bac3a6741b91cab1de1", + "shasum": "" + }, + "require": { + "php": ">=5.3.7", + "symfony/config": "^2.3.0|^3|^4|^5|^6.0", + "symfony/dependency-injection": "^2.3.0|^3|^4|^5|^6.0", + "symfony/filesystem": "^2.3.0|^3|^4|^5|^6.0" + }, + "require-dev": { + "easy-doc/easy-doc": "0.0.0|^1.2.3", + "gregwar/rst": "^1.0", + "phpunit/phpunit": "^4.8.36|^5.7.27", + "squizlabs/php_codesniffer": "^2.0.0" + }, + "bin": [ + "src/bin/pdepend" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "PDepend\\": "src/main/php/PDepend" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Official version of pdepend to be handled with Composer", + "keywords": [ + "PHP Depend", + "PHP_Depend", + "dev", + "pdepend" + ], + "support": { + "issues": "https://github.com/pdepend/pdepend/issues", + "source": "https://github.com/pdepend/pdepend/tree/2.14.0" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/pdepend/pdepend", + "type": "tidelift" + } + ], + "time": "2023-05-26T13:15:18+00:00" + }, { "name": "phar-io/manifest", "version": "2.0.3", @@ -8273,6 +8336,89 @@ }, "time": "2023-08-12T11:01:26+00:00" }, + { + "name": "phpmd/phpmd", + "version": "2.13.0", + "source": { + "type": "git", + "url": "https://github.com/phpmd/phpmd.git", + "reference": "dad0228156856b3ad959992f9748514fa943f3e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpmd/phpmd/zipball/dad0228156856b3ad959992f9748514fa943f3e3", + "reference": "dad0228156856b3ad959992f9748514fa943f3e3", + "shasum": "" + }, + "require": { + "composer/xdebug-handler": "^1.0 || ^2.0 || ^3.0", + "ext-xml": "*", + "pdepend/pdepend": "^2.12.1", + "php": ">=5.3.9" + }, + "require-dev": { + "easy-doc/easy-doc": "0.0.0 || ^1.3.2", + "ext-json": "*", + "ext-simplexml": "*", + "gregwar/rst": "^1.0", + "mikey179/vfsstream": "^1.6.8", + "phpunit/phpunit": "^4.8.36 || ^5.7.27", + "squizlabs/php_codesniffer": "^2.0" + }, + "bin": [ + "src/bin/phpmd" + ], + "type": "library", + "autoload": { + "psr-0": { + "PHPMD\\": "src/main/php" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Manuel Pichler", + "email": "github@manuel-pichler.de", + "homepage": "https://github.com/manuelpichler", + "role": "Project Founder" + }, + { + "name": "Marc Würth", + "email": "ravage@bluewin.ch", + "homepage": "https://github.com/ravage84", + "role": "Project Maintainer" + }, + { + "name": "Other contributors", + "homepage": "https://github.com/phpmd/phpmd/graphs/contributors", + "role": "Contributors" + } + ], + "description": "PHPMD is a spin-off project of PHP Depend and aims to be a PHP equivalent of the well known Java tool PMD.", + "homepage": "https://phpmd.org/", + "keywords": [ + "mess detection", + "mess detector", + "pdepend", + "phpmd", + "pmd" + ], + "support": { + "irc": "irc://irc.freenode.org/phpmd", + "issues": "https://github.com/phpmd/phpmd/issues", + "source": "https://github.com/phpmd/phpmd/tree/2.13.0" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/phpmd/phpmd", + "type": "tidelift" + } + ], + "time": "2022-09-10T08:44:15+00:00" + }, { "name": "phpspec/prophecy", "version": "v1.17.0", diff --git a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php index f503c8fc..8aeccb5f 100644 --- a/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php +++ b/lib/EmberNexusBundle/src/Service/EmberNexusConfiguration.php @@ -4,6 +4,10 @@ use Exception; +/** + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ class EmberNexusConfiguration { public const PAGE_SIZE = 'pageSize'; @@ -42,7 +46,7 @@ class EmberNexusConfiguration private static function getValueFromConfig(array $configuration, array $keyParts): mixed { $currentKeyParts = []; - foreach ($keyParts as $i => $keyPart) { + foreach ($keyParts as $keyPart) { $currentKeyParts[] = $keyPart; if (!array_key_exists($keyPart, $configuration)) { throw new Exception(sprintf("Configuration must contain key '%s'.", implode('.', $currentKeyParts))); diff --git a/phpmd.xml b/phpmd.xml new file mode 100644 index 00000000..23eaa41b --- /dev/null +++ b/phpmd.xml @@ -0,0 +1,35 @@ + + + Created with the PHP Coding Standard Generator. http://edorian.github.com/php-coding-standard-generator/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Command/BackupFetchCommand.php b/src/Command/BackupFetchCommand.php index 8dc89ffd..952d10c6 100644 --- a/src/Command/BackupFetchCommand.php +++ b/src/Command/BackupFetchCommand.php @@ -218,6 +218,9 @@ public function copyFile(FilesystemOperator $source, string $sourcePath, Filesys ); } + /** + * @SuppressWarnings(PHPMD.CountInLoopExpression) + */ private function findBackupRootFolder(Filesystem $filesystem): ?string { $stack = ['/']; diff --git a/src/Command/TestCommand.php b/src/Command/TestCommand.php index 734d1601..2d8c1ea2 100755 --- a/src/Command/TestCommand.php +++ b/src/Command/TestCommand.php @@ -19,6 +19,9 @@ public function __construct( parent::__construct(); } + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ protected function execute(InputInterface $input, OutputInterface $output): int { $dataUuid = UuidV4::fromString('3a3c2f8b-d1bd-40fd-b381-82de60539c9f'); diff --git a/src/Controller/Element/PostIndexController.php b/src/Controller/Element/PostIndexController.php index b701d4fa..3b7da2d9 100644 --- a/src/Controller/Element/PostIndexController.php +++ b/src/Controller/Element/PostIndexController.php @@ -22,6 +22,10 @@ use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +/** + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ class PostIndexController extends AbstractController { public function __construct( diff --git a/src/Controller/Element/PutElementController.php b/src/Controller/Element/PutElementController.php index 60d72112..9e49d963 100644 --- a/src/Controller/Element/PutElementController.php +++ b/src/Controller/Element/PutElementController.php @@ -58,7 +58,7 @@ public function putElement(string $uuid, Request $request): Response */ $data = \Safe\json_decode($request->getContent(), true); - foreach ($element->getProperties() as $name => $value) { + foreach (array_keys($element->getProperties()) as $name) { if ('id' === $name) { continue; } diff --git a/src/Controller/File/DeleteElementFileController.php b/src/Controller/File/DeleteElementFileController.php index 96a91190..3be2554b 100644 --- a/src/Controller/File/DeleteElementFileController.php +++ b/src/Controller/File/DeleteElementFileController.php @@ -9,6 +9,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +/** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ class DeleteElementFileController extends AbstractController { public function __construct( diff --git a/src/Controller/File/GetElementFileController.php b/src/Controller/File/GetElementFileController.php index d97850d4..ad8cb5cc 100644 --- a/src/Controller/File/GetElementFileController.php +++ b/src/Controller/File/GetElementFileController.php @@ -9,6 +9,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +/** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ class GetElementFileController extends AbstractController { public function __construct( diff --git a/src/Controller/File/PatchElementFileController.php b/src/Controller/File/PatchElementFileController.php index da8a02f4..f0f9399c 100644 --- a/src/Controller/File/PatchElementFileController.php +++ b/src/Controller/File/PatchElementFileController.php @@ -9,6 +9,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +/** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ class PatchElementFileController extends AbstractController { public function __construct( diff --git a/src/Controller/File/PostElementFileController.php b/src/Controller/File/PostElementFileController.php index 7be4d35f..808e0f30 100644 --- a/src/Controller/File/PostElementFileController.php +++ b/src/Controller/File/PostElementFileController.php @@ -9,6 +9,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +/** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ class PostElementFileController extends AbstractController { public function __construct( diff --git a/src/Controller/File/PutElementFileController.php b/src/Controller/File/PutElementFileController.php index 15ea2213..6fe525cc 100644 --- a/src/Controller/File/PutElementFileController.php +++ b/src/Controller/File/PutElementFileController.php @@ -9,6 +9,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +/** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ class PutElementFileController extends AbstractController { public function __construct( diff --git a/src/Controller/Search/PostSearchController.php b/src/Controller/Search/PostSearchController.php index 3818d7cc..64358b11 100644 --- a/src/Controller/Search/PostSearchController.php +++ b/src/Controller/Search/PostSearchController.php @@ -17,6 +17,10 @@ use Symfony\Component\Routing\Annotation\Route; use Syndesi\ElasticEntityManager\Type\EntityManager as ElasticEntityManager; +/** + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ class PostSearchController extends AbstractController { public function __construct( diff --git a/src/Controller/User/PostTokenController.php b/src/Controller/User/PostTokenController.php index ae65f593..3bda1c09 100644 --- a/src/Controller/User/PostTokenController.php +++ b/src/Controller/User/PostTokenController.php @@ -19,6 +19,10 @@ use Symfony\Component\Routing\Annotation\Route; use Syndesi\CypherEntityManager\Type\EntityManager as CypherEntityManager; +/** + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ class PostTokenController extends AbstractController { public function __construct( diff --git a/src/Controller/WebDAV/CopyElementController.php b/src/Controller/WebDAV/CopyElementController.php index 80ff07c7..b9cd6113 100644 --- a/src/Controller/WebDAV/CopyElementController.php +++ b/src/Controller/WebDAV/CopyElementController.php @@ -9,6 +9,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +/** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ class CopyElementController extends AbstractController { public function __construct( diff --git a/src/Controller/WebDAV/LockElementController.php b/src/Controller/WebDAV/LockElementController.php index bd76dbb4..889877fa 100644 --- a/src/Controller/WebDAV/LockElementController.php +++ b/src/Controller/WebDAV/LockElementController.php @@ -9,6 +9,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +/** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ class LockElementController extends AbstractController { public function __construct( diff --git a/src/Controller/WebDAV/MkcolElementController.php b/src/Controller/WebDAV/MkcolElementController.php index d80beb1e..f6b4b2bd 100644 --- a/src/Controller/WebDAV/MkcolElementController.php +++ b/src/Controller/WebDAV/MkcolElementController.php @@ -9,6 +9,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +/** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ class MkcolElementController extends AbstractController { public function __construct( diff --git a/src/Controller/WebDAV/MoveElementController.php b/src/Controller/WebDAV/MoveElementController.php index 8760c6e4..d31ea206 100644 --- a/src/Controller/WebDAV/MoveElementController.php +++ b/src/Controller/WebDAV/MoveElementController.php @@ -9,6 +9,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +/** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ class MoveElementController extends AbstractController { public function __construct( diff --git a/src/Controller/WebDAV/PropfindElementController.php b/src/Controller/WebDAV/PropfindElementController.php index 0b95ce49..2bc5e2e9 100644 --- a/src/Controller/WebDAV/PropfindElementController.php +++ b/src/Controller/WebDAV/PropfindElementController.php @@ -9,6 +9,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +/** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ class PropfindElementController extends AbstractController { public function __construct( diff --git a/src/Controller/WebDAV/ProppatchElementController.php b/src/Controller/WebDAV/ProppatchElementController.php index e8c6b1a7..b63bf345 100644 --- a/src/Controller/WebDAV/ProppatchElementController.php +++ b/src/Controller/WebDAV/ProppatchElementController.php @@ -9,6 +9,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +/** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ class ProppatchElementController extends AbstractController { public function __construct( diff --git a/src/Controller/WebDAV/UnlockElementController.php b/src/Controller/WebDAV/UnlockElementController.php index 3157203f..3412f909 100644 --- a/src/Controller/WebDAV/UnlockElementController.php +++ b/src/Controller/WebDAV/UnlockElementController.php @@ -9,6 +9,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +/** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ class UnlockElementController extends AbstractController { public function __construct( diff --git a/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php b/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php index c8e156e0..21be110d 100644 --- a/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php +++ b/src/EventSystem/ElementFragmentize/EventListener/GenericPropertyElementFragmentizeEventListener.php @@ -8,6 +8,10 @@ use DateTimeInterface; use Laudis\Neo4j\Types\DateTimeZoneId; +/** + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ class GenericPropertyElementFragmentizeEventListener { public function __construct( diff --git a/src/EventSystem/Exception/EventListener/ExceptionEventListener.php b/src/EventSystem/Exception/EventListener/ExceptionEventListener.php index e3cd9aec..4c43b093 100644 --- a/src/EventSystem/Exception/EventListener/ExceptionEventListener.php +++ b/src/EventSystem/Exception/EventListener/ExceptionEventListener.php @@ -23,6 +23,8 @@ public function __construct( /** * @psalm-suppress UndefinedInterfaceMethod + * + * @SuppressWarnings(PHPMD.EmptyCatchBlock) */ public function onKernelException(ExceptionEvent $event): void { diff --git a/src/Service/CollectionService.php b/src/Service/CollectionService.php index 5ba1de00..d93f704d 100755 --- a/src/Service/CollectionService.php +++ b/src/Service/CollectionService.php @@ -10,6 +10,10 @@ use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\RequestStack; +/** + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ class CollectionService { public function __construct( diff --git a/tests/FeatureTests/Security/Scenario02BasicPositiveTests/_02_01_ImmediateNodeOwnershipTest.php b/tests/FeatureTests/Security/Scenario02BasicPositiveTests/_02_01_ImmediateNodeOwnershipTest.php index bf66bba6..88b532ad 100644 --- a/tests/FeatureTests/Security/Scenario02BasicPositiveTests/_02_01_ImmediateNodeOwnershipTest.php +++ b/tests/FeatureTests/Security/Scenario02BasicPositiveTests/_02_01_ImmediateNodeOwnershipTest.php @@ -4,6 +4,9 @@ use App\Tests\FeatureTests\BaseRequestTestCase; +/** + * @SuppressWarnings(PHPMD.UnusedLocalVariable) + */ class _02_01_ImmediateNodeOwnershipTest extends BaseRequestTestCase { public const TOKEN = 'secret-token:P4VWKNQ2A6UaoaQgGSQXRB';