diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 3b5e0d2..0858ca9 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -1,10 +1,8 @@ -name: PHP Composer - +name: ci on: - push: - branches: [ master ] pull_request: - branches: [ master ] + push: + branches: [main] jobs: build: @@ -37,7 +35,19 @@ jobs: - name: Install dependencies run: ./composer.phar install --prefer-dist --no-progress - - name: Run test suite + - name: Check pattern structure + run: ./composer.phar structure:check + + - name: Run test suite with coverage env: XDEBUG_MODE: coverage - run: ./composer.phar test + run: ./composer.phar test:coverage + + - name: Check coverage threshold + run: ./composer.phar coverage:check + + - name: Run coding standards + run: ./composer.phar cs + + - name: Run static analysis + run: ./composer.phar stan diff --git a/README.md b/README.md index 590349b..3bdc07e 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,30 @@ ## Just another collection of design patterns implementations in PHP ## Requirements -- PHP 8.3+ +- PHP 8.4.1+ ## Setup - Run `./composer.phar install` ## Run Tests - Run `./composer.phar test` +- Run `./composer.phar test:coverage` ## Run Coding Standards - Run `./composer.phar cs` +- Run `./composer.phar cs:fix` + +## Run Static Analysis +- Run `./composer.phar stan` + +## CI Pipeline (local) +- Run `./composer.phar ci` + +## Contributing +- Create feature branches from `master`. +- Keep pattern folders mirrored in `src//` and `test//`. +- Include/maintain `README.md` in each pattern folder. +- Run `./composer.phar ci` before opening a PR. + +## Project Conventions +- See `docs/PATTERN_STRUCTURE.md`. diff --git a/composer.json b/composer.json index 789fe7f..9c96326 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ ], "license": "MIT", "require": { - "php": ">=8.3" + "php": ">=8.4.1" }, "autoload": { "psr-4": { @@ -23,14 +23,23 @@ }, "require-dev": { "phpunit/phpunit": "^13.0", + "phpstan/phpstan": "^2.1", "squizlabs/php_codesniffer": "^4.0" }, "scripts": { - "test": "vendor/bin/phpunit test/", + "test": "vendor/bin/phpunit --no-coverage test/", + "test:coverage": "vendor/bin/phpunit --coverage-clover coverage/clover.xml test/", "cs": "vendor/bin/phpcs src test", + "cs:fix": "vendor/bin/phpcbf src test", + "stan": "vendor/bin/phpstan analyse --configuration=phpstan.neon --no-progress", + "structure:check": "bash scripts/check-pattern-structure.sh", + "coverage:check": "bash scripts/check-coverage.sh 70 coverage/clover.xml", "ci": [ - "@test", - "@cs" + "@structure:check", + "@test:coverage", + "@coverage:check", + "@cs", + "@stan" ] } } diff --git a/composer.lock b/composer.lock index 4637d3f..53b9085 100644 --- 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": "a62f16ccdf9745c28fc45b98872891d0", + "content-hash": "8f425deeeab3642b367510798d7a1c16", "packages": [], "packages-dev": [ { @@ -243,6 +243,59 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.1.39", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", + "reference": "c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-02-11T14:48:56+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "13.0.1", @@ -1888,7 +1941,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.3" + "php": ">=8.4.1" }, "platform-dev": [], "plugin-api-version": "2.6.0" diff --git a/docs/PATTERN_STRUCTURE.md b/docs/PATTERN_STRUCTURE.md new file mode 100644 index 0000000..b37e8b6 --- /dev/null +++ b/docs/PATTERN_STRUCTURE.md @@ -0,0 +1,19 @@ +# Pattern Structure Conventions + +Each pattern should follow the same layout to keep navigation predictable. + +## Source layout + +- `src///` contains implementation classes. +- `src///README.md` explains intent and usage. + +## Test layout + +- `test///` contains tests for that pattern. +- Test classes should map to source classes when possible. + +## Categories + +- Creational +- Structural +- Behavioral diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..0e974a1 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 1 + paths: + - src + - test diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh new file mode 100755 index 0000000..a2f6248 --- /dev/null +++ b/scripts/check-coverage.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +set -euo pipefail + +MIN_COVERAGE="${1:-70}" +CLOVER_FILE="${2:-coverage/clover.xml}" + +if [[ ! -f "$CLOVER_FILE" ]]; then + echo "Coverage file not found: $CLOVER_FILE" + exit 1 +fi + +read -r COVERED STATEMENTS < <( + php -r ' + $file = $argv[1]; + $xml = @simplexml_load_file($file); + if ($xml === false) { + fwrite(STDERR, "Could not parse coverage XML: $file\n"); + exit(1); + } + + $metrics = $xml->xpath("//metrics[@statements and @coveredstatements]"); + if ($metrics === false || count($metrics) === 0) { + fwrite(STDERR, "No coverage metrics found in $file\n"); + exit(1); + } + + $covered = -1; + $statements = -1; + + foreach ($metrics as $node) { + $nodeStatements = (int) $node["statements"]; + $nodeCovered = (int) $node["coveredstatements"]; + + if ($nodeStatements > $statements) { + $statements = $nodeStatements; + $covered = $nodeCovered; + } + } + + if ($statements <= 0) { + fwrite(STDERR, "Invalid coverage statement count in $file\n"); + exit(1); + } + + echo $covered . " " . $statements . PHP_EOL; + ' "$CLOVER_FILE" +) + +if [[ -z "$STATEMENTS" || -z "$COVERED" || "$STATEMENTS" -eq 0 ]]; then + echo "Invalid coverage metrics in $CLOVER_FILE" + exit 1 +fi + +PERCENT="$(awk -v c="$COVERED" -v s="$STATEMENTS" 'BEGIN { printf "%.2f", (c / s) * 100 }')" + +echo "Coverage: ${PERCENT}% (min ${MIN_COVERAGE}%)" + +if awk -v p="$PERCENT" -v m="$MIN_COVERAGE" 'BEGIN { exit !(p < m) }'; then + echo "Coverage below threshold." + exit 1 +fi diff --git a/scripts/check-pattern-structure.sh b/scripts/check-pattern-structure.sh new file mode 100755 index 0000000..a2029b8 --- /dev/null +++ b/scripts/check-pattern-structure.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "$ROOT_DIR" + +status=0 + +while IFS= read -r pattern_dir; do + category="$(basename "$(dirname "$pattern_dir")")" + pattern="$(basename "$pattern_dir")" + + if [[ ! -f "${pattern_dir}/README.md" ]]; then + echo "Missing README: ${pattern_dir}/README.md" + status=1 + fi + + if [[ ! -d "test/${category}/${pattern}" ]]; then + echo "Missing test directory: test/${category}/${pattern}" + status=1 + fi +done < <(find src -mindepth 2 -maxdepth 2 -type d | sort) + +exit "$status" diff --git a/src/Structural/Facade/CoffeeMakerFacade.php b/src/Structural/Facade/CoffeeMakerFacade.php index d959d8e..4b36a12 100644 --- a/src/Structural/Facade/CoffeeMakerFacade.php +++ b/src/Structural/Facade/CoffeeMakerFacade.php @@ -24,9 +24,9 @@ public function makeCups(int $numberOfCupsToMake): array $numberOfCupsToMake = $this->checkCupsToMake($numberOfCupsToMake); $coffeeCups = []; - for ($i=1; $i<$numberOfCupsToMake+1; $i++) { + for ($i = 1; $i < $numberOfCupsToMake + 1; $i++) { $products = implode(',', $this->getProducts()); - $coffeeCups["coffee #".$i] = $products; + $coffeeCups["coffee #" . $i] = $products; } return $coffeeCups; } diff --git a/src/Structural/Proxy/ApiProxy.php b/src/Structural/Proxy/ApiProxy.php index cda0fc3..4d4b56e 100644 --- a/src/Structural/Proxy/ApiProxy.php +++ b/src/Structural/Proxy/ApiProxy.php @@ -4,7 +4,9 @@ class ApiProxy extends Api { - public function __construct(private ?Api $wrapper) {} + public function __construct(private ?Api $wrapper) + { + } public function doApiCall(string $url, array $data, string $method): array|null { diff --git a/test/Creational/AbstractFactory/BmwCarFactoryTest.php b/test/Creational/AbstractFactory/BmwCarFactoryTest.php index 3879831..b396a3b 100644 --- a/test/Creational/AbstractFactory/BmwCarFactoryTest.php +++ b/test/Creational/AbstractFactory/BmwCarFactoryTest.php @@ -63,6 +63,9 @@ public function testShouldCreateABmwFamilyCar() private function createCarDescription(string $type): string { - return "BMW $type car!" . PHP_EOL . "Name:" . (self::GENERIC_CAR_NAME) . "" . PHP_EOL . "Color:" . (self::GENERIC_CAR_COLOR) . "" . PHP_EOL . "Engine:" . (self::GENERIC_CAR_ENGINE_SPECS) . ""; + return "BMW $type car!" + . PHP_EOL . "Name:" . self::GENERIC_CAR_NAME + . PHP_EOL . "Color:" . self::GENERIC_CAR_COLOR + . PHP_EOL . "Engine:" . self::GENERIC_CAR_ENGINE_SPECS; } } diff --git a/test/Creational/Builder/ClassicWatchBuilderTest.php b/test/Creational/Builder/ClassicWatchBuilderTest.php index f9c8859..832ecf9 100644 --- a/test/Creational/Builder/ClassicWatchBuilderTest.php +++ b/test/Creational/Builder/ClassicWatchBuilderTest.php @@ -8,7 +8,6 @@ class ClassicWatchBuilderTest extends TestCase { - public function testShouldCreateAClassicWatch() { $classicWatch = (new ClassicWatchBuilder()) diff --git a/test/Creational/Builder/ClassicWatchTest.php b/test/Creational/Builder/ClassicWatchTest.php index 87e70bc..0a979ce 100644 --- a/test/Creational/Builder/ClassicWatchTest.php +++ b/test/Creational/Builder/ClassicWatchTest.php @@ -8,7 +8,6 @@ class ClassicWatchTest extends TestCase { - public function testShouldAddAComponentToAWatch() { $watch = new ClassicWatch(); diff --git a/test/Creational/Builder/DirectorTest.php b/test/Creational/Builder/DirectorTest.php index 7117126..33f735f 100644 --- a/test/Creational/Builder/DirectorTest.php +++ b/test/Creational/Builder/DirectorTest.php @@ -10,7 +10,6 @@ class DirectorTest extends TestCase { - public function testShouldCreateASportWatch() { $sportWatch = (new Director())->build(new SportWatchBuilder()); diff --git a/test/Creational/Builder/SportWatchBuilderTest.php b/test/Creational/Builder/SportWatchBuilderTest.php index 9dceffb..858ffc5 100644 --- a/test/Creational/Builder/SportWatchBuilderTest.php +++ b/test/Creational/Builder/SportWatchBuilderTest.php @@ -8,7 +8,6 @@ class SportWatchBuilderTest extends TestCase { - public function testShouldCreateASportWatch() { $watch = (new SportWatchBuilder()) diff --git a/test/Creational/Builder/SportWatchTest.php b/test/Creational/Builder/SportWatchTest.php index 1900260..31f57ba 100644 --- a/test/Creational/Builder/SportWatchTest.php +++ b/test/Creational/Builder/SportWatchTest.php @@ -8,7 +8,6 @@ class SportWatchTest extends TestCase { - public function testShouldAddAComponentToASportWatch() { $watch = new SportWatch(); diff --git a/test/Structural/Composite/PhoneTest.php b/test/Structural/Composite/PhoneTest.php index ca2796f..ad3d24a 100644 --- a/test/Structural/Composite/PhoneTest.php +++ b/test/Structural/Composite/PhoneTest.php @@ -9,7 +9,6 @@ class PhoneTest extends TestCase { - public function testShouldGetPhonePrice() { $phone = new Phone( diff --git a/test/Structural/Decorator/ApiResponseTest.php b/test/Structural/Decorator/ApiResponseTest.php index 0e28612..3083e98 100644 --- a/test/Structural/Decorator/ApiResponseTest.php +++ b/test/Structural/Decorator/ApiResponseTest.php @@ -7,7 +7,6 @@ class ApiResponseTest extends TestCase { - public function testShouldReturnARawApiResponse() { $expected = [ diff --git a/test/Structural/Decorator/JsonResponseTest.php b/test/Structural/Decorator/JsonResponseTest.php index 2896006..720c3f5 100644 --- a/test/Structural/Decorator/JsonResponseTest.php +++ b/test/Structural/Decorator/JsonResponseTest.php @@ -8,7 +8,6 @@ class JsonResponseTest extends TestCase { - public function testShouldBeAbleToConvertResponseToJsonString() { $expected = '{"message":"api response to json"}'; diff --git a/test/Structural/Decorator/XmlResponseTest.php b/test/Structural/Decorator/XmlResponseTest.php index 6c7c2a2..0adfa94 100644 --- a/test/Structural/Decorator/XmlResponseTest.php +++ b/test/Structural/Decorator/XmlResponseTest.php @@ -8,7 +8,6 @@ class XmlResponseTest extends TestCase { - public function testShouldBeAbleToConvertResponseToXml() { $expected = <<assertEquals(0, (new FlyweightFactory())->count()); diff --git a/test/Structural/Proxy/ApiTest.php b/test/Structural/Proxy/ApiTest.php index ce7473c..d3e6998 100644 --- a/test/Structural/Proxy/ApiTest.php +++ b/test/Structural/Proxy/ApiTest.php @@ -7,7 +7,6 @@ class ApiTest extends TestCase { - public function testShouldPerformAnApiCallSuccessfully() { $expects = [