Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
18 changes: 10 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
21 changes: 21 additions & 0 deletions bin/build.sh
Original file line number Diff line number Diff line change
@@ -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
17 changes: 15 additions & 2 deletions bin/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ;;
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions config/common.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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:
-
Expand Down
3 changes: 3 additions & 0 deletions docker/run/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
21 changes: 19 additions & 2 deletions docker/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ parameters:
- tests
excludePaths:
- tests/data
- tests/smoke
errorFormat: table
1 change: 1 addition & 0 deletions rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ parametersSchema:
attributeDefinitions: listOf(structure([
attribute: string()
requires: listOf(string())
?excludedClasses: listOf(string())
]))
])
controller: structure([
Expand Down
14 changes: 12 additions & 2 deletions src/Common/AttributeRequirementsRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@
public const string IDENTIFIER = 'iwfWeb.attributeRequirements';

/**
* @param list<array{attribute: string, requires: list<string>}> $attributeDefinitions
* @param list<string> $excludedClasses Fully-qualified class names to skip
* @param list<array{
* attribute: class-string, // Fully-qualified target attribute
* requires: list<class-string>, // List of fully-qualified attributes to require be present upon target discovery
* excludedClasses?: list<class-string>, // Fully-qualified class names to skip
* }> $attributeDefinitions
* @param list<class-string> $excludedClasses Fully-qualified class names to skip
*/
public function __construct(
private array $attributeDefinitions = [],
Expand Down Expand Up @@ -73,13 +77,19 @@ public function processNode(Node $node, Scope $scope): array

$errors = [];

$className = $classReflection?->getName();

foreach ($this->attributeDefinitions as $requirement) {
$triggerAttribute = $requirement['attribute'];

if (!\in_array($triggerAttribute, $presentAttributes, true)) {
continue;
}

if ($className !== null && \in_array($className, $requirement['excludedClasses'] ?? [], true)) {
continue;
}

foreach ($requirement['requires'] as $requiredAttribute) {
if (!\in_array($requiredAttribute, $presentAttributes, true)) {
$message = \sprintf(
Expand Down
5 changes: 4 additions & 1 deletion src/Common/RequiredUseAliasGroupUseRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ final class RequiredUseAliasGroupUseRule implements Rule
public const string IDENTIFIER = 'iwfWeb.requiredUseAliasGroupUse';

/**
* @param list<array{namespace: string, alias: string}> $aliasDefinitions
* @param list<array{
* namespace: string, // Namespace definition to act as target
* alias: string,
* }> $aliasDefinitions
*/
public function __construct(array $aliasDefinitions)
{
Expand Down
5 changes: 4 additions & 1 deletion src/Common/RequiredUseAliasRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ final class RequiredUseAliasRule implements Rule
public const string IDENTIFIER = 'iwfWeb.requiredUseAlias';

/**
* @param list<array{namespace: string, alias: string}> $aliasDefinitions
* @param list<array{
* namespace: string,
* alias: string,
* }> $aliasDefinitions
*/
public function __construct(array $aliasDefinitions)
{
Expand Down
3 changes: 2 additions & 1 deletion src/Controller/ControllerIsGrantedRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string> $excludedNamespaces Namespace prefixes to skip
Expand Down
53 changes: 53 additions & 0 deletions tests/AttributeRequirementsRuleCombinedExclusionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php declare(strict_types=1);

/**
* PHPStan Rules
*
* @package PHPStan Rules
* @author IWF Web Solutions <web-solutions@iwf.ch>
* @copyright Copyright (c) 2025-2026 IWF Web Solutions <web-solutions@iwf.ch>
* @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<AttributeRequirementsRule>
*
* @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);
}
}
6 changes: 4 additions & 2 deletions tests/AttributeRequirementsRuleExcludedClassesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<AttributeRequirementsRule>
Expand All @@ -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,
],
],
],
Expand Down
51 changes: 51 additions & 0 deletions tests/AttributeRequirementsRulePerDefinitionExcludedTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php declare(strict_types=1);

/**
* PHPStan Rules
*
* @package PHPStan Rules
* @author IWF Web Solutions <web-solutions@iwf.ch>
* @copyright Copyright (c) 2025-2026 IWF Web Solutions <web-solutions@iwf.ch>
* @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<AttributeRequirementsRule>
*
* @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);
}
}
Loading