diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdf9457..7ae53c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,7 +116,7 @@ jobs: with: name: coverage-report - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@v7 + uses: SonarSource/sonarqube-scan-action@v8 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }} diff --git a/CLAUDE.md b/CLAUDE.md index d4d443b..20e6580 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,14 +9,16 @@ Custom PHPStan rules library (`iwf-web/phpstan-rules`) enforcing coding standard ## Commands ```bash -bin/test.sh # Run PHPStan + PHPUnit (local PHP or Docker fallback) -bin/test.sh 8.4 # Target specific PHP version -bin/lint.sh # Auto-fix code style (PHP CS Fixer) -composer lint:check # Check style without modifying (CI mode) -composer phpstan # PHPStan only -composer phpunit # PHPUnit only -composer test # Both PHPStan + PHPUnit -XDEBUG_TRIGGER=1 bin/test.sh # Debug with Xdebug +bin/test.sh # Run PHPStan + PHPUnit (Docker, all PHP versions) +bin/test.sh 8.4 # Target specific PHP version +bin/build.sh # Rebuild all Docker test images (run after Dockerfile changes) +bin/build.sh 8.4 # Rebuild single PHP version image +bin/lint.sh # Auto-fix code style (PHP CS Fixer) +bin/composer.sh lint:check # Check style without modifying (CI mode) +bin/composer.sh phpstan # PHPStan only +bin/composer.sh phpunit # PHPUnit only +bin/composer.sh test # Both PHPStan + PHPUnit +XDEBUG_TRIGGER=1 bin/test.sh # Debug with Xdebug ``` ## Architecture diff --git a/README.md b/README.md index b20fe59..d3debdb 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,10 @@ parameters: requires: - 'OpenApi\Attributes\Tag' - 'Symfony\Component\Security\Http\Attribute\IsGranted' + excludedClasses: + - 'App\Controller\Api\Security\LoginController' + excludedClasses: + - 'App\Controller\Api\Security\LoginController' ``` #### Force DateProvider (requires `coala/date-provider-bundle`) diff --git a/bin/build.sh b/bin/build.sh new file mode 100755 index 0000000..e29d1b2 --- /dev/null +++ b/bin/build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Usage: bin/build.sh [VERSION] +# +# Rebuilds Docker images for test services. +# VERSION Optional PHP version (e.g. "8.3" or "83"). Omit to rebuild all. +set -euo pipefail + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +# shellcheck source=_env.sh +source "$SCRIPT_DIR/_env.sh" + +VERSION="${1:-}" + +if [[ -n "$VERSION" ]]; then + svc="php${VERSION//./}" + printf '===== build %s =====\n' "$svc" + docker compose --ansi never -f "$COMPOSE_FILE" build "$svc" +else + printf '===== build all =====\n' + docker compose --ansi never -f "$COMPOSE_FILE" build +fi diff --git a/bin/test.sh b/bin/test.sh index 40e1abd..edd6f85 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -15,6 +15,11 @@ source "$SCRIPT_DIR/_env.sh" COVERAGE=false VERSION="" +# Promote Xdebug to debug mode when the user opted in via XDEBUG_TRIGGER/XDEBUG_SESSION. +if [[ -n "${XDEBUG_TRIGGER:-}" || -n "${XDEBUG_SESSION:-}" ]]; then + export XDEBUG_MODE="${XDEBUG_MODE:-debug}" +fi + for arg in "$@"; do case "$arg" in --coverage) COVERAGE=true ;; @@ -25,8 +30,16 @@ done run_docker() { local service="${1:-}" if [[ -n "$service" ]]; then - echo "==> PHP ${service} (Docker)" - docker compose -f "$COMPOSE_FILE" up "php${service//./}" --remove-orphans + local svc="php${service//./}" + printf '\n===== %s =====\n' "$svc" + local rc=0 + docker compose --ansi never -f "$COMPOSE_FILE" run --rm -T "$svc" || rc=$? + if [[ $rc -eq 0 ]]; then + printf '===== %s OK =====\n' "$svc" + else + printf '===== %s FAIL (exit %d) =====\n' "$svc" "$rc" + fi + return $rc else exec "$PROJECT_DIR/docker/test.sh" fi diff --git a/composer.json b/composer.json index 48ba4cb..b2e8ad3 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,7 @@ "lint": "@composer exec -- php-cs-fixer fix", "lint:check": [ "find . -name '*.php' -not -path './vendor/*' -not -path './tests/data/*' | xargs -n1 php -l", - "@composer exec -- php-cs-fixer fix --dry-run --diff" + "@lint --dry-run --diff" ], "test": [ "@phpstan:smoke", @@ -86,10 +86,10 @@ "@phpunit" ], "phpunit": "@composer exec -- phpunit", - "phpunit:coverage": "@composer exec -- phpunit --coverage-clover coverage.xml", + "phpunit:coverage": "@phpunit --coverage-clover coverage.xml", "phpstan": "@composer exec -- phpstan --memory-limit=256M", - "phpstan:ci": "@composer exec -- phpstan --memory-limit=256M --no-progress --error-format=github", - "phpstan:smoke": "@composer exec -- phpstan --memory-limit=256M --no-progress --configuration=tests/smoke/phpstan-smoke.neon.dist", + "phpstan:ci": "@phpstan --no-progress --error-format=github", + "phpstan:smoke": "@phpstan --no-progress --configuration=tests/smoke/phpstan-smoke.neon.dist", "rector": [ "@composer exec -- rector", "@lint" diff --git a/config/common.neon b/config/common.neon index 03ba430..d8bec18 100644 --- a/config/common.neon +++ b/config/common.neon @@ -9,14 +9,15 @@ parameters: - { namespace: 'Symfony\Component\Validator\Constraints', alias: 'Assert' } - { namespace: 'Symfony\Component\Serializer\Attribute', alias: 'Serializer' } attributeRequirements: - excludedClasses: - - 'App\Controller\Api\Security\LoginController' + excludedClasses: [] attributeDefinitions: - attribute: 'Symfony\Component\Routing\Attribute\Route' requires: - 'OpenApi\Attributes\Tag' - 'Symfony\Component\Security\Http\Attribute\IsGranted' + excludedClasses: + - 'App\Controller\Api\Security\LoginController' services: - diff --git a/docker/run/compose.yml b/docker/run/compose.yml index 035e072..cdfd859 100644 --- a/docker/run/compose.yml +++ b/docker/run/compose.yml @@ -6,6 +6,9 @@ x-base: &base - "host.docker.internal:host-gateway" environment: PHP_IDE_CONFIG: serverName=iwf-phpstan-rules # for PHPStorm debugging + XDEBUG_MODE: "${XDEBUG_MODE:-off}" + XDEBUG_TRIGGER: "${XDEBUG_TRIGGER:-}" + XDEBUG_SESSION: "${XDEBUG_SESSION:-}" command: > sh -c "cp /composer-lock/composer.lock /app/composer.lock 2>/dev/null; composer update && diff --git a/docker/test.sh b/docker/test.sh index e6bcc51..17ca4c8 100755 --- a/docker/test.sh +++ b/docker/test.sh @@ -2,11 +2,28 @@ set -uo pipefail SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +COMPOSE_FILE="$SCRIPT_DIR/run/compose.yml" EXIT_CODE=0 +declare -a RESULTS=() -for service in $(docker compose -f "$SCRIPT_DIR/run/compose.yml" config --services | sort); do - docker compose -f "$SCRIPT_DIR/run/compose.yml" up "$service" --remove-orphans || EXIT_CODE=$? +for service in $(docker compose --ansi never -f "$COMPOSE_FILE" config --services | sort); do + printf '\n===== %s =====\n' "$service" + rc=0 + docker compose --ansi never -f "$COMPOSE_FILE" run --rm -T "$service" || rc=$? + if [[ $rc -eq 0 ]]; then + printf '===== %s OK =====\n' "$service" + RESULTS+=("$service OK") + else + printf '===== %s FAIL (exit %d) =====\n' "$service" "$rc" + RESULTS+=("$service FAIL($rc)") + EXIT_CODE=$rc + fi +done + +printf '\n===== SUMMARY =====\n' +for line in "${RESULTS[@]}"; do + printf '%s\n' "$line" done exit "$EXIT_CODE" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 1a4ed04..ed14cec 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -8,4 +8,5 @@ parameters: - tests excludePaths: - tests/data + - tests/smoke errorFormat: table diff --git a/rules.neon b/rules.neon index 94c45c9..a5eca53 100644 --- a/rules.neon +++ b/rules.neon @@ -11,6 +11,7 @@ parametersSchema: attributeDefinitions: listOf(structure([ attribute: string() requires: listOf(string()) + ?excludedClasses: listOf(string()) ])) ]) controller: structure([ diff --git a/src/Common/AttributeRequirementsRule.php b/src/Common/AttributeRequirementsRule.php index fb3d060..3d5f052 100644 --- a/src/Common/AttributeRequirementsRule.php +++ b/src/Common/AttributeRequirementsRule.php @@ -30,8 +30,12 @@ public const string IDENTIFIER = 'iwfWeb.attributeRequirements'; /** - * @param list}> $attributeDefinitions - * @param list $excludedClasses Fully-qualified class names to skip + * @param list, // List of fully-qualified attributes to require be present upon target discovery + * excludedClasses?: list, // Fully-qualified class names to skip + * }> $attributeDefinitions + * @param list $excludedClasses Fully-qualified class names to skip */ public function __construct( private array $attributeDefinitions = [], @@ -73,6 +77,8 @@ public function processNode(Node $node, Scope $scope): array $errors = []; + $className = $classReflection?->getName(); + foreach ($this->attributeDefinitions as $requirement) { $triggerAttribute = $requirement['attribute']; @@ -80,6 +86,10 @@ public function processNode(Node $node, Scope $scope): array continue; } + if ($className !== null && \in_array($className, $requirement['excludedClasses'] ?? [], true)) { + continue; + } + foreach ($requirement['requires'] as $requiredAttribute) { if (!\in_array($requiredAttribute, $presentAttributes, true)) { $message = \sprintf( diff --git a/src/Common/RequiredUseAliasGroupUseRule.php b/src/Common/RequiredUseAliasGroupUseRule.php index 1fb2b9c..e9cc09e 100644 --- a/src/Common/RequiredUseAliasGroupUseRule.php +++ b/src/Common/RequiredUseAliasGroupUseRule.php @@ -30,7 +30,10 @@ final class RequiredUseAliasGroupUseRule implements Rule public const string IDENTIFIER = 'iwfWeb.requiredUseAliasGroupUse'; /** - * @param list $aliasDefinitions + * @param list $aliasDefinitions */ public function __construct(array $aliasDefinitions) { diff --git a/src/Common/RequiredUseAliasRule.php b/src/Common/RequiredUseAliasRule.php index f070788..2d8eb30 100644 --- a/src/Common/RequiredUseAliasRule.php +++ b/src/Common/RequiredUseAliasRule.php @@ -30,7 +30,10 @@ final class RequiredUseAliasRule implements Rule public const string IDENTIFIER = 'iwfWeb.requiredUseAlias'; /** - * @param list $aliasDefinitions + * @param list $aliasDefinitions */ public function __construct(array $aliasDefinitions) { diff --git a/src/Controller/ControllerIsGrantedRule.php b/src/Controller/ControllerIsGrantedRule.php index 3f6750b..4d31fcf 100644 --- a/src/Controller/ControllerIsGrantedRule.php +++ b/src/Controller/ControllerIsGrantedRule.php @@ -22,6 +22,7 @@ use PHPStan\Rules\RuleErrorBuilder; use PHPStan\ShouldNotHappenException; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; /** * Ensures that every controller method with a #[Route] attribute also has a @@ -36,7 +37,7 @@ public const string IDENTIFIER = 'iwfWeb.controllerMissingIsGranted'; private const string ROUTE_ATTRIBUTE = Route::class; - private const string IS_GRANTED_ATTRIBUTE = 'Symfony\Component\Security\Http\Attribute\IsGranted'; + private const string IS_GRANTED_ATTRIBUTE = IsGranted::class; /** * @param list $excludedNamespaces Namespace prefixes to skip diff --git a/tests/AttributeRequirementsRuleCombinedExclusionTest.php b/tests/AttributeRequirementsRuleCombinedExclusionTest.php new file mode 100644 index 0000000..2a014f1 --- /dev/null +++ b/tests/AttributeRequirementsRuleCombinedExclusionTest.php @@ -0,0 +1,53 @@ + + * @copyright Copyright (c) 2025-2026 IWF Web Solutions + * @license https://github.com/iwf-web/phpstan-rules/blob/main/LICENSE.txt MIT License + * @link https://github.com/iwf-web/phpstan-rules + */ + +namespace IWFWeb\PhpstanRules\Tests; + +use App\Controller\Api\Combined\GloballyExcludedController; +use App\Controller\Api\Combined\PerDefinitionExcludedController; +use IWFWeb\PhpstanRules\Common\AttributeRequirementsRule; +use OpenApi\Attributes\Tag; +use PHPStan\Rules\Rule; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; + +/** + * @extends AbstractRuleTestCase + * + * @internal + */ +final class AttributeRequirementsRuleCombinedExclusionTest extends AbstractRuleTestCase +{ + protected function getRule(): Rule + { + return new AttributeRequirementsRule( + attributeDefinitions: [ + [ + 'attribute' => Route::class, + 'requires' => [ + Tag::class, + IsGranted::class, + ], + 'excludedClasses' => [PerDefinitionExcludedController::class], + ], + ], + excludedClasses: [GloballyExcludedController::class], + ); + } + + public function testGlobalAndPerDefinitionExclusionsBothApply(): void + { + $files = [__DIR__.'/data/attribute-requirements-combined-excluded.php']; + $errors = $this->gatherAnalyserErrors($files); + self::assertRuleErrorsByAnnotation($errors, $files); + } +} diff --git a/tests/AttributeRequirementsRuleExcludedClassesTest.php b/tests/AttributeRequirementsRuleExcludedClassesTest.php index 343cdd7..bc2d2e8 100644 --- a/tests/AttributeRequirementsRuleExcludedClassesTest.php +++ b/tests/AttributeRequirementsRuleExcludedClassesTest.php @@ -14,8 +14,10 @@ use App\Controller\Api\Security\LoginController; use IWFWeb\PhpstanRules\Common\AttributeRequirementsRule; +use OpenApi\Attributes\Tag; use PHPStan\Rules\Rule; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; /** * @extends AbstractRuleTestCase @@ -31,8 +33,8 @@ protected function getRule(): Rule [ 'attribute' => Route::class, 'requires' => [ - 'OpenApi\Attributes\Tag', - 'Symfony\Component\Security\Http\Attribute\IsGranted', + Tag::class, + IsGranted::class, ], ], ], diff --git a/tests/AttributeRequirementsRulePerDefinitionExcludedTest.php b/tests/AttributeRequirementsRulePerDefinitionExcludedTest.php new file mode 100644 index 0000000..c555f6a --- /dev/null +++ b/tests/AttributeRequirementsRulePerDefinitionExcludedTest.php @@ -0,0 +1,51 @@ + + * @copyright Copyright (c) 2025-2026 IWF Web Solutions + * @license https://github.com/iwf-web/phpstan-rules/blob/main/LICENSE.txt MIT License + * @link https://github.com/iwf-web/phpstan-rules + */ + +namespace IWFWeb\PhpstanRules\Tests; + +use App\Controller\Api\PerDefinition\ExcludedForRouteController; +use IWFWeb\PhpstanRules\Common\AttributeRequirementsRule; +use OpenApi\Attributes\Tag; +use PHPStan\Rules\Rule; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; + +/** + * @extends AbstractRuleTestCase + * + * @internal + */ +final class AttributeRequirementsRulePerDefinitionExcludedTest extends AbstractRuleTestCase +{ + protected function getRule(): Rule + { + return new AttributeRequirementsRule( + attributeDefinitions: [ + [ + 'attribute' => Route::class, + 'requires' => [ + Tag::class, + IsGranted::class, + ], + 'excludedClasses' => [ExcludedForRouteController::class], + ], + ], + ); + } + + public function testPerDefinitionExclusionSkipsOnlyTheExcludedClass(): void + { + $files = [__DIR__.'/data/attribute-requirements-per-definition-excluded.php']; + $errors = $this->gatherAnalyserErrors($files); + self::assertRuleErrorsByAnnotation($errors, $files); + } +} diff --git a/tests/AttributeRequirementsRuleTest.php b/tests/AttributeRequirementsRuleTest.php index d7db384..4cc64a8 100644 --- a/tests/AttributeRequirementsRuleTest.php +++ b/tests/AttributeRequirementsRuleTest.php @@ -13,8 +13,10 @@ namespace IWFWeb\PhpstanRules\Tests; use IWFWeb\PhpstanRules\Common\AttributeRequirementsRule; +use OpenApi\Attributes\Tag; use PHPStan\Rules\Rule; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; /** * @extends AbstractRuleTestCase @@ -30,8 +32,8 @@ protected function getRule(): Rule [ 'attribute' => Route::class, 'requires' => [ - 'OpenApi\Attributes\Tag', - 'Symfony\Component\Security\Http\Attribute\IsGranted', + Tag::class, + IsGranted::class, ], ], ], diff --git a/tests/data/attribute-requirements-combined-excluded.php b/tests/data/attribute-requirements-combined-excluded.php new file mode 100644 index 0000000..d1d7d7a --- /dev/null +++ b/tests/data/attribute-requirements-combined-excluded.php @@ -0,0 +1,29 @@ +