Skip to content

Commit 781172a

Browse files
author
Florian Krämer
committed
Refactoring the doc block rule
1 parent 7b7ae77 commit 781172a

12 files changed

+234
-33
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\SpecificationDocblock;
6+
7+
class InvalidMethodDocblockClass
8+
{
9+
/**
10+
* Specification:
11+
* This is missing the blank line and list items.
12+
*/
13+
public function testMethod(): void
14+
{
15+
}
16+
17+
public function otherMethod(): void
18+
{
19+
}
20+
}
21+
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\SpecificationDocblock;
6+
7+
class MissingMethodDocblockClass
8+
{
9+
public function testMethod(): void
10+
{
11+
}
12+
13+
public function otherMethod(): void
14+
{
15+
}
16+
}
17+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\SpecificationDocblock;
6+
7+
class ValidMethodDocblockClass
8+
{
9+
/**
10+
* Specification:
11+
*
12+
* - This is a test method with valid specification.
13+
* - It has proper formatting.
14+
*/
15+
public function testMethod(): void
16+
{
17+
}
18+
19+
public function otherMethod(): void
20+
{
21+
}
22+
}
23+

docs/rules/Class-Must-Have-Specification-Docblock-Rule.md

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,62 @@
11
# Class Must Have Specification Docblock Rule
22

3-
Ensures that classes matching specified patterns have a properly formatted docblock with a "Specification:" section. The specification must contain a list of items, and optionally allows annotations and additional text.
3+
Ensures that classes and/or methods matching specified patterns have a properly formatted docblock with a "Specification:" section. The specification must contain a list of items, and optionally allows annotations and additional text.
44

55
## Configuration Example
66

7+
### Validate Classes
8+
79
```neon
810
-
911
class: Phauthentic\PHPStanRules\Architecture\ClassMustHaveSpecificationDocblockRule
1012
arguments:
11-
patterns:
13+
classPatterns:
1214
- '/.*Facade$/' # All classes ending with "Facade"
1315
- '/.*Command$/' # All classes ending with "Command"
1416
- '/.*Handler$/' # All classes ending with "Handler"
17+
methodPatterns: [] # No method validation
1518
specificationHeader: 'Specification:' # Optional: customize the header text
1619
requireBlankLineAfterHeader: true # Optional: require blank line after header (default: true)
1720
requireListItemsEndWithPeriod: false # Optional: require list items to end with period (default: false)
1821
tags:
1922
- phpstan.rules.rule
2023
```
2124

25+
### Validate Methods
26+
27+
```neon
28+
-
29+
class: Phauthentic\PHPStanRules\Architecture\ClassMustHaveSpecificationDocblockRule
30+
arguments:
31+
classPatterns: [] # No class validation
32+
methodPatterns:
33+
- '/.*Repository::find.*/' # All find* methods in Repository classes
34+
- '/.*Service::execute$/' # execute methods in Service classes
35+
- '/App\\.*Handler::handle$/' # handle methods in Handler classes
36+
specificationHeader: 'Specification:'
37+
requireBlankLineAfterHeader: true
38+
requireListItemsEndWithPeriod: false
39+
tags:
40+
- phpstan.rules.rule
41+
```
42+
43+
### Validate Both Classes and Methods
44+
45+
```neon
46+
-
47+
class: Phauthentic\PHPStanRules\Architecture\ClassMustHaveSpecificationDocblockRule
48+
arguments:
49+
classPatterns:
50+
- '/.*Facade$/'
51+
methodPatterns:
52+
- '/.*Repository::find.*/'
53+
specificationHeader: 'Specification:'
54+
requireBlankLineAfterHeader: true
55+
requireListItemsEndWithPeriod: false
56+
tags:
57+
- phpstan.rules.rule
58+
```
59+
2260
## Valid Docblock Formats
2361

2462
### Default Format
@@ -112,9 +150,31 @@ class MyFacade {}
112150
class MyFacade {}
113151
```
114152

153+
### Method Docblock
154+
155+
The same format applies to methods when using `methodPatterns`:
156+
157+
```php
158+
class UserRepository
159+
{
160+
/**
161+
* Specification:
162+
*
163+
* - Searches for users matching the given criteria.
164+
* - Returns an array of User objects.
165+
* - Throws an exception if the query fails.
166+
*/
167+
public function findByEmail(string $email): array
168+
{
169+
// implementation
170+
}
171+
}
172+
```
173+
115174
## Parameters
116175

117-
- `patterns`: Array of regex patterns to match against class names (with full namespace).
176+
- `classPatterns`: Array of regex patterns to match against fully qualified class names (default: `[]`). If empty, no classes are validated.
177+
- `methodPatterns`: Array of regex patterns to match against methods in the format `FQCN::methodName` (default: `[]`). If empty, no methods are validated. Supports full regex matching on the entire string, allowing patterns like `/.*Repository::find.*/` or `/App\\Service\\.*::execute$/`.
118178
- `specificationHeader`: The header text to look for in the docblock (default: `'Specification:'`). You can use any custom text like `'Requirements:'`, `'Behavior:'`, etc.
119179
- `requireBlankLineAfterHeader`: Whether a blank line is required after the header before the list items (default: `true`).
120180
- `requireListItemsEndWithPeriod`: Whether all list items must end with a period to form proper sentences (default: `false`). This works correctly with multi-line list items.

src/Architecture/ClassMustHaveSpecificationDocblockRule.php

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
* Specification:
1717
*
1818
* - Enforces that matched classes have a docblock with a "Specification:" section.
19+
* - Enforces that matched methods have a docblock with a "Specification:" section.
20+
* - Methods are matched using FQCN::methodName format with regex patterns.
1921
* - The specification section must contain a list of items starting with "-".
2022
* - Optionally allows @ annotations after a blank line.
2123
* - Optionally allows additional text between the list and annotations.
@@ -24,18 +26,22 @@
2426
*/
2527
class ClassMustHaveSpecificationDocblockRule implements Rule
2628
{
27-
private const ERROR_MESSAGE_MISSING = 'Class %s must have a docblock with a "%s" section.';
29+
private const ERROR_MESSAGE_MISSING = '%s %s must have a docblock with a "%s" section.';
30+
private const ERROR_MESSAGE_INVALID = '%s %s has an invalid specification docblock. %s';
2831
private const IDENTIFIER = 'phauthentic.architecture.classMustHaveSpecificationDocblock';
2932

3033
/**
31-
* @param array<string> $patterns An array of regex patterns to match against class names.
34+
* @param array<string> $classPatterns An array of regex patterns to match against class names.
35+
* Each pattern should be a valid PCRE regex.
36+
* @param array<string> $methodPatterns An array of regex patterns to match against FQCN::methodName.
3237
* Each pattern should be a valid PCRE regex.
3338
* @param string $specificationHeader The header text to look for (default: "Specification:")
3439
* @param bool $requireBlankLineAfterHeader Whether to require a blank line after the header (default: true)
3540
* @param bool $requireListItemsEndWithPeriod Whether list items must end with a period (default: false)
3641
*/
3742
public function __construct(
38-
protected array $patterns,
43+
protected array $classPatterns = [],
44+
protected array $methodPatterns = [],
3945
protected string $specificationHeader = 'Specification:',
4046
protected bool $requireBlankLineAfterHeader = true,
4147
protected bool $requireListItemsEndWithPeriod = false
@@ -76,30 +82,46 @@ public function processNode(Node $node, Scope $scope): array
7682
return [];
7783
}
7884

85+
$errors = [];
7986
$className = $node->name->toString();
8087
$namespaceName = $scope->getNamespace() ?? '';
8188
$fullClassName = $namespaceName . '\\' . $className;
8289

83-
if (!$this->classNameMatchesPattern($fullClassName)) {
84-
return [];
85-
}
86-
87-
$docComment = $node->getDocComment();
88-
if ($docComment === null) {
89-
return [$this->buildMissingDocblockError($fullClassName, $node)];
90+
// Check class docblock
91+
if ($this->matchesPatterns($fullClassName, $this->classPatterns)) {
92+
$docComment = $node->getDocComment();
93+
if ($docComment === null) {
94+
$errors[] = $this->buildMissingDocblockError('Class', $fullClassName, $node);
95+
} elseif (!$this->isValidSpecificationDocblock($docComment)) {
96+
$errors[] = $this->buildInvalidDocblockError('Class', $fullClassName, $node);
97+
}
9098
}
9199

92-
if (!$this->isValidSpecificationDocblock($docComment)) {
93-
return [$this->buildInvalidDocblockError($fullClassName, $node)];
100+
// Check method docblocks
101+
foreach ($node->getMethods() as $method) {
102+
$methodName = $method->name->toString();
103+
$fullMethodName = $fullClassName . '::' . $methodName;
104+
105+
if ($this->matchesPatterns($fullMethodName, $this->methodPatterns)) {
106+
$docComment = $method->getDocComment();
107+
if ($docComment === null) {
108+
$errors[] = $this->buildMissingDocblockError('Method', $fullMethodName, $method);
109+
} elseif (!$this->isValidSpecificationDocblock($docComment)) {
110+
$errors[] = $this->buildInvalidDocblockError('Method', $fullMethodName, $method);
111+
}
112+
}
94113
}
95114

96-
return [];
115+
return $errors;
97116
}
98117

99-
private function classNameMatchesPattern(string $fullClassName): bool
118+
/**
119+
* @param array<string> $patterns
120+
*/
121+
private function matchesPatterns(string $target, array $patterns): bool
100122
{
101-
foreach ($this->patterns as $pattern) {
102-
if (preg_match($pattern, $fullClassName)) {
123+
foreach ($patterns as $pattern) {
124+
if (preg_match($pattern, $target)) {
103125
return true;
104126
}
105127
}
@@ -303,9 +325,9 @@ private function listItemEndsWithPeriod(string $listItem): bool
303325
* @throws ShouldNotHappenException
304326
* @return \PHPStan\Rules\IdentifierRuleError
305327
*/
306-
private function buildMissingDocblockError(string $fullClassName, Class_ $node)
328+
private function buildMissingDocblockError(string $type, string $fullName, Node $node)
307329
{
308-
return RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE_MISSING, $fullClassName, $this->specificationHeader))
330+
return RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE_MISSING, $type, $fullName, $this->specificationHeader))
309331
->identifier(self::IDENTIFIER)
310332
->line($node->getLine())
311333
->build();
@@ -315,9 +337,9 @@ private function buildMissingDocblockError(string $fullClassName, Class_ $node)
315337
* @throws ShouldNotHappenException
316338
* @return \PHPStan\Rules\IdentifierRuleError
317339
*/
318-
private function buildInvalidDocblockError(string $fullClassName, Class_ $node)
340+
private function buildInvalidDocblockError(string $type, string $fullName, Node $node)
319341
{
320-
return RuleErrorBuilder::message(sprintf('Class %s has an invalid specification docblock. %s', $fullClassName, $this->buildInvalidFormatMessage()))
342+
return RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE_INVALID, $type, $fullName, $this->buildInvalidFormatMessage()))
321343
->identifier(self::IDENTIFIER)
322344
->line($node->getLine())
323345
->build();

tests/TestCases/Architecture/ClassMustHaveSpecificationDocblockRuleCustomHeaderTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ class ClassMustHaveSpecificationDocblockRuleCustomHeaderTest extends RuleTestCas
1515
protected function getRule(): \PHPStan\Rules\Rule
1616
{
1717
return new ClassMustHaveSpecificationDocblockRule(
18-
patterns: ['/.*/'], // Match all classes
18+
classPatterns: ['/.*/'], // Match all classes
19+
methodPatterns: [],
1920
specificationHeader: 'Requirements:',
2021
requireBlankLineAfterHeader: true
2122
);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Phauthentic\PHPStanRules\Tests\TestCases\Architecture;
6+
7+
use Phauthentic\PHPStanRules\Architecture\ClassMustHaveSpecificationDocblockRule;
8+
use PHPStan\Testing\RuleTestCase;
9+
10+
/**
11+
* @extends RuleTestCase<ClassMustHaveSpecificationDocblockRule>
12+
*/
13+
class ClassMustHaveSpecificationDocblockRuleMethodTest extends RuleTestCase
14+
{
15+
protected function getRule(): \PHPStan\Rules\Rule
16+
{
17+
return new ClassMustHaveSpecificationDocblockRule(
18+
classPatterns: [],
19+
methodPatterns: [
20+
'/.*::testMethod$/', // Match testMethod in any class
21+
]
22+
);
23+
}
24+
25+
public function testValidMethodDocblock(): void
26+
{
27+
$this->analyse([__DIR__ . '/../../../data/SpecificationDocblock/ValidMethodDocblockClass.php'], []);
28+
}
29+
30+
public function testMissingMethodDocblock(): void
31+
{
32+
$this->analyse([__DIR__ . '/../../../data/SpecificationDocblock/MissingMethodDocblockClass.php'], [
33+
[
34+
'Method App\SpecificationDocblock\MissingMethodDocblockClass::testMethod must have a docblock with a "Specification:" section.',
35+
9,
36+
],
37+
]);
38+
}
39+
40+
public function testInvalidMethodDocblock(): void
41+
{
42+
$this->analyse([__DIR__ . '/../../../data/SpecificationDocblock/InvalidMethodDocblockClass.php'], [
43+
[
44+
'Method App\SpecificationDocblock\InvalidMethodDocblockClass::testMethod has an invalid specification docblock. Expected format: "Specification:" header, blank line, then list items starting with "-".',
45+
13,
46+
],
47+
]);
48+
}
49+
}
50+

tests/TestCases/Architecture/ClassMustHaveSpecificationDocblockRuleMultiLineTest.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ class ClassMustHaveSpecificationDocblockRuleMultiLineTest extends RuleTestCase
1414
{
1515
protected function getRule(): \PHPStan\Rules\Rule
1616
{
17-
return new ClassMustHaveSpecificationDocblockRule([
18-
'/.*/', // Match all classes
19-
]);
17+
return new ClassMustHaveSpecificationDocblockRule(
18+
classPatterns: [
19+
'/.*/', // Match all classes
20+
]
21+
);
2022
}
2123

2224
public function testMultiLineListItems(): void

tests/TestCases/Architecture/ClassMustHaveSpecificationDocblockRuleMultiLineWithPeriodsTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ class ClassMustHaveSpecificationDocblockRuleMultiLineWithPeriodsTest extends Rul
1515
protected function getRule(): \PHPStan\Rules\Rule
1616
{
1717
return new ClassMustHaveSpecificationDocblockRule(
18-
patterns: ['/.*/'],
18+
classPatterns: ['/.*/'],
19+
methodPatterns: [],
1920
specificationHeader: 'Specification:',
2021
requireBlankLineAfterHeader: true,
2122
requireListItemsEndWithPeriod: true

tests/TestCases/Architecture/ClassMustHaveSpecificationDocblockRuleNoBlankLineTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ class ClassMustHaveSpecificationDocblockRuleNoBlankLineTest extends RuleTestCase
1515
protected function getRule(): \PHPStan\Rules\Rule
1616
{
1717
return new ClassMustHaveSpecificationDocblockRule(
18-
patterns: ['/.*/' // Match all classes
18+
classPatterns: ['/.*/' // Match all classes
1919
],
20+
methodPatterns: [],
2021
specificationHeader: 'Specification:',
2122
requireBlankLineAfterHeader: false
2223
);

0 commit comments

Comments
 (0)