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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/vendor/
/.idea/
/.phpunit.result.cache
/.phpunit.cache/
32 changes: 32 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# AGENTS.md

Custom PHPStan rules package (`simtel/phpstan-rules`). Adds static-analysis rules as a PHPStan extension distributed via `rules.neon`. Namespace: `Simtel\PHPStanRules\` → `src/`, `Simtel\PHPStanRules\Tests\` → `tests/`. PHP >=8.3, PHPStan ^2.0, PHPUnit ^12.

## Commands

- Tests: `vendor/bin/phpunit` — all tests pass (8). Single test: `vendor/bin/phpunit tests/Rules/CommandClassShouldHaveCommandHandlerSeeTagTest.php`
- Code style: `vendor/bin/ecs check` (or `ecs check --fix`; there is no `ecs fix`). Uses PSR-12 + symplify + spaces + `NoUnusedImports`. Run after changes.
- Static analysis: `vendor/bin/phpstan analyse` — self-analysis of `src/` at level max, must stay at 0 errors.
- CI (`.github/workflows/php.yml`) only runs `composer validate --strict`, `composer install`, and `vendor/bin/phpunit`.

## Architecture

- `AbstractPhpDocRule` (in `src/Rule/`) is the shared base for rules that parse PHPDoc: it injects `PhpDocParser` + `Lexer` and exposes `parsePhpDoc(string $doc): PhpDocNode`. Subclasses implement `Rule` themselves.
- `EventListenerShouldHaveAsEventListenerAttribute` inspects `$node->attrGroups` directly (no reflection, no base class).
- `ShouldNotPhpDocReturnWhenTypeHintExists` works on `ClassMethod` nodes and reads the native type from `$node->returnType` (no reflection needed). Only `Identifier`/`Name` native types and `IdentifierTypeNode` PHPDoc types are compared — union/nullable/generic types are skipped, not errors.
- Errors are reported via `RuleErrorBuilder::message(...)->identifier('rule.group')` (never throw). PHPStan 2.x requires identifiers.

## Adding a rule

1. Create `src/Rule/<Name>.php` implementing `PHPStan\Rules\Rule` with `@implements Rule<NodeType>` and `getNodeType()` + `processNode()`. Extend `AbstractPhpDocRule` if it parses PHPDoc.
2. Register it in `rules.neon` (this is what consumers include).
3. Add a test extending `PHPStan\Testing\RuleTestCase` in `tests/Rules/`. In `getRule()`, fetch parser/lexer from the container: `self::getContainer()->getByType(PhpDocParser::class)` / `getByType(Lexer::class)` — do not build them manually.
4. Add fixture/data PHP files under `tests/Fixture/` or `tests/data/`. Command-rule data files live in `tests/data/command_handler_data*.php`.
5. Update the README feature list.

## Gotchas

- Rule classes are `final`, extend `AbstractPhpDocRule` when parsing PHPDoc, `declare(strict_types=1)`, PSR-12 style, no comments.
- `rules.neon` and the README list the 3 distributed rules — keep in sync when adding/removing.
- Tests use PHPStan's `RuleTestCase::analyse()`; expectations include exact line numbers, so changing a fixture's layout breaks tests. Append new methods to a fixture rather than inserting before existing ones.
- `phpunit.xml.dist` uses the migrated PHPUnit 12 schema (`<source>` instead of `<coverage>`); `.phpunit.cache/` is gitignored.
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ A collection of custom PHPStan rules that enforce coding standards and improve c
This package includes three powerful rules that help maintain high code quality:

### 1. Command-Handler Relationship Rule
**Rule**: `CommandClassShouldBeHelpCommandHandlerClass`
**Rule**: `CommandClassShouldHaveCommandHandlerSeeTag`

Enforces that classes ending with "Command" must have a `@see` PHPDoc tag pointing to their corresponding CommandHandler class.

Expand All @@ -29,7 +29,7 @@ class CreateUserCommand
**Exception**: Classes with an `__invoke` method are exempt from this rule.

### 2. Event Listener Attribute Rule
**Rule**: `EventListenerClassShouldBeIncludeAsListenerAttribute`
**Rule**: `EventListenerShouldHaveAsEventListenerAttribute`

Ensures that classes ending with "EventListener" are properly annotated with the `#[AsEventListener]` attribute.

Expand All @@ -45,7 +45,7 @@ class UserRegisteredEventListener
```

### 3. Redundant PHPDoc Return Type Rule
**Rule**: `NotShouldPhpdocReturnIfExistTypeHint`
**Rule**: `ShouldNotPhpDocReturnWhenTypeHintExists`

Prevents redundant or conflicting `@return` PHPDoc annotations when native return type hints are already declared.

Expand Down Expand Up @@ -102,9 +102,9 @@ For granular control, register specific rules:
```neon
parameters:
rules:
- Simtel\PHPStanRules\Rule\CommandClassShouldBeHelpCommandHandlerClass
- Simtel\PHPStanRules\Rule\EventListenerClassShouldBeIncludeAsListenerAttribute
- Simtel\PHPStanRules\Rule\NotShouldPhpdocReturnIfExistTypeHint
- Simtel\PHPStanRules\Rule\CommandClassShouldHaveCommandHandlerSeeTag
- Simtel\PHPStanRules\Rule\EventListenerShouldHaveAsEventListenerAttribute
- Simtel\PHPStanRules\Rule\ShouldNotPhpDocReturnWhenTypeHintExists
```

### Complete Configuration Example
Expand All @@ -123,6 +123,9 @@ parameters:
# Exclude specific patterns if needed
- '#Command class should be include phpDoc with @see attribute#'
path: src/Deprecated/
# Rules now report error identifiers, e.g.:
# - identifier: commandClass.missingPhpDoc
# path: src/Deprecated/
```

## 🔧 Development
Expand Down Expand Up @@ -171,9 +174,10 @@ vendor/bin/phpstan analyse
```
.
├── src/Rule/ # Rule implementations
│ ├── CommandClassShouldBeHelpCommandHandlerClass.php
│ ├── EventListenerClassShouldBeIncludeAsListenerAttribute.php
│ └── NotShouldPhpdocReturnIfExistTypeHint.php
│ ├── CommandClassShouldHaveCommandHandlerSeeTag.php
│ ├── EventListenerShouldHaveAsEventListenerAttribute.php
│ ├── ShouldNotPhpDocReturnWhenTypeHintExists.php
│ └── AbstractPhpDocRule.php # Shared PHPDoc parsing base
├── tests/
│ ├── Fixture/ # Test code samples
│ │ ├── EventListener/
Expand Down
6 changes: 4 additions & 2 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
rules:
- Simtel\PHPStanRules\Rule\CommandClassShouldBeHelpCommandHandlerClass
- Simtel\PHPStanRules\Rule\CommandClassShouldHaveCommandHandlerSeeTag
- Simtel\PHPStanRules\Rule\EventListenerShouldHaveAsEventListenerAttribute
- Simtel\PHPStanRules\Rule\ShouldNotPhpDocReturnWhenTypeHintExists
parameters:
paths:
- src\Examples
- src
level: max

42 changes: 17 additions & 25 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,28 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>

<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
colors="true"
convertDeprecationsToExceptions="false"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
</php>

<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>

<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" backupGlobals="false" colors="true" cacheDirectory=".phpunit.cache">
<php>
<ini name="display_errors" value="1"/>
<ini name="error_reporting" value="-1"/>
<server name="APP_ENV" value="test" force="true"/>
<server name="SHELL_VERBOSITY" value="-1"/>
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>
6 changes: 3 additions & 3 deletions rules.neon
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
rules:
- Simtel\PHPStanRules\Rule\CommandClassShouldBeHelpCommandHandlerClass
- Simtel\PHPStanRules\Rule\EventListenerClassShouldBeIncludeAsListenerAttribute
- Simtel\PHPStanRules\Rule\NotShouldPhpdocReturnIfExistTypeHint
- Simtel\PHPStanRules\Rule\CommandClassShouldHaveCommandHandlerSeeTag
- Simtel\PHPStanRules\Rule\EventListenerShouldHaveAsEventListenerAttribute
- Simtel\PHPStanRules\Rule\ShouldNotPhpDocReturnWhenTypeHintExists
24 changes: 24 additions & 0 deletions src/Rule/AbstractPhpDocRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Simtel\PHPStanRules\Rule;

use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use PHPStan\PhpDocParser\Lexer\Lexer;
use PHPStan\PhpDocParser\Parser\PhpDocParser;
use PHPStan\PhpDocParser\Parser\TokenIterator;

abstract class AbstractPhpDocRule
{
public function __construct(
protected readonly PhpDocParser $phpDocParser,
protected readonly Lexer $phpDocLexer,
) {
}

protected function parsePhpDoc(string $doc): PhpDocNode
{
return $this->phpDocParser->parse(new TokenIterator($this->phpDocLexer->tokenize($doc)));
}
}
94 changes: 0 additions & 94 deletions src/Rule/CommandClassShouldBeHelpCommandHandlerClass.php

This file was deleted.

84 changes: 84 additions & 0 deletions src/Rule/CommandClassShouldHaveCommandHandlerSeeTag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace Simtel\PHPStanRules\Rule;

use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\PhpDocParser\Ast\PhpDoc\GenericTagValueNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
* @implements Rule<Class_>
*/
final class CommandClassShouldHaveCommandHandlerSeeTag extends AbstractPhpDocRule implements Rule
{
public function getNodeType(): string
{
return Class_::class;
}

public function processNode(Node $node, Scope $scope): array
{
if ($node->name === null) {
return [];
}

if (! str_ends_with($node->name->name, 'Command')) {
return [];
}

foreach ($node->getMethods() as $method) {
if ($method->name->name === '__invoke') {
return [];
}
}

$doc = $node->getDocComment()?->getText() ?? '';
if ($doc === '') {
return [
RuleErrorBuilder::message('Command class should be include phpDoc with @see attribute')
->identifier('commandClass.missingPhpDoc')
->build(),
];
}

$hasSeeTag = false;
foreach ($this->parsePhpDoc($doc)->getTags() as $tag) {
if ($tag->name !== '@see') {
continue;
}
if (! $tag->value instanceof GenericTagValueNode) {
continue;
}
$hasSeeTag = true;
if (! str_ends_with($tag->value->value, 'CommandHandler')) {
return [
RuleErrorBuilder::message(
sprintf(
'PhpDoc command class should be include @see attribute with CommandHandler class name, but include %s',
$tag->value->value
)
)
->identifier('commandClass.invalidSeeValue')
->build(),
];
}
}

if (! $hasSeeTag) {
return [
RuleErrorBuilder::message(
'PhpDoc command class should be include @see attribute with CommandHandler class name'
)
->identifier('commandClass.missingSee')
->build(),
];
}

return [];
}
}
Loading
Loading