Skip to content

Commit 21d331c

Browse files
Goosterhofclaude
andcommitted
test: add rule-author contract tests for ADR-0021 §Doctrine source + §Identifier convention
Promotes two ADR-0021 rule-author conventions from CLAUDE.md prose (Level 4) to PHPUnit reflection-tests (Level 2): - F-6 (RuleDocblockContractTest): every class under src/Rules/ must declare a "Doctrine source:" line in its class-level docblock. Reflects on the rule class via ReflectionClass::getDocComment() and asserts the contract string is present, naming the offending class on failure. - F-7 (RuleIdentifierConventionTest): every RuleErrorBuilder->identifier(...) string under src/Rules/ must follow a cameLCase.cameLCase shape. Scans rule source via regex, names the offending file + identifier on failure, and guards against silent zero-match via a "no identifiers found" terminal assertion. Both tests pass 5/5 against the current rule set (no rule changes). They fire the Phase-2 candidate (EnforceExplicitHydrationRule) into a contract the package locks rather than narrates. Doctrine alignment: PHPUnit reflection on the package's own rule classes is rule-authoring discipline (how rules are written), not rule enforcement (what rules check). It does not contradict CLAUDE.md §"What this territory does NOT do" — that stance prevents running App\Actions-scoped PHPStan rules against src/Rules/, not PHPUnit assertions about rule-author contracts. Commander disposition resolved 2026-05-06 under Reading 2. Negative-case verification (manual, reverted before commit): - Removed "Doctrine source:" from ForbidAbortHelperRule's docblock — RuleDocblockContractTest fired with the offending class name and the ADR-0021 reference. Reverted. - Replaced 'forbidAbortHelper.abortUsed' with 'BadIdentifier' — RuleIdentifierConventionTest fired naming the file and the malformed identifier. Reverted. Origin: Quartermaster M1 first-contact F-6 + F-7. Order: orders/phpstan-warroom-rules/rule-author-contract-tests-armorer-deployment.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 87b22cf commit 21d331c

2 files changed

Lines changed: 114 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace ScriptDevelopment\PhpstanWarroomRules\Tests\Rules;
6+
7+
use PHPUnit\Framework\Attributes\Test;
8+
use PHPUnit\Framework\TestCase;
9+
use ReflectionClass;
10+
11+
/**
12+
* Locks the package's own rule-author convention: every class under `src/Rules/`
13+
* must declare a `Doctrine source:` line in its class-level docblock so the rule's
14+
* authority (ADR or war-room principle) is visible at the source.
15+
*
16+
* Doctrine source: ADR-0021 §Doctrine source in docblock.
17+
*
18+
* This test enforces *how rules are written*, not *what rules check*. It does not
19+
* contradict the package's "static-analysis library only" stance (CLAUDE.md
20+
* §"What this territory does NOT do") because PHPUnit reflection on rule classes
21+
* is rule-authoring discipline, not rule enforcement.
22+
*/
23+
final class RuleDocblockContractTest extends TestCase
24+
{
25+
#[Test]
26+
public function every_rule_class_declares_doctrine_source_in_class_docblock(): void
27+
{
28+
$ruleFiles = glob(__DIR__ . '/../../src/Rules/*.php');
29+
30+
self::assertNotEmpty($ruleFiles, 'No rule files found under src/Rules');
31+
32+
foreach ($ruleFiles as $file) {
33+
$namespace = 'ScriptDevelopment\PhpstanWarroomRules\Rules';
34+
$class = $namespace . '\\' . basename($file, '.php');
35+
36+
self::assertTrue(class_exists($class), "Rule class not autoloadable: {$class}");
37+
38+
$docblock = (new ReflectionClass($class))->getDocComment();
39+
40+
self::assertNotFalse(
41+
$docblock,
42+
"{$class} has no class-level docblock (ADR-0021 §Doctrine source in docblock).",
43+
);
44+
45+
self::assertStringContainsString(
46+
'Doctrine source:',
47+
$docblock,
48+
"{$class} class-level docblock does not name its doctrine source. ADR-0021 §Doctrine source in docblock requires every rule to cite its ADR or war-room principle.",
49+
);
50+
}
51+
}
52+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace ScriptDevelopment\PhpstanWarroomRules\Tests\Rules;
6+
7+
use PHPUnit\Framework\Attributes\Test;
8+
use PHPUnit\Framework\TestCase;
9+
10+
use function sprintf;
11+
12+
/**
13+
* Locks the package's own rule-author convention: every
14+
* `RuleErrorBuilder::message()->identifier(...)` call in `src/Rules/*.php` must
15+
* use a `cameLCase.cameLCase` identifier so consumers see a uniform shape across
16+
* every rule the package ships.
17+
*
18+
* Doctrine source: ADR-0021 §Identifier convention.
19+
*
20+
* Like RuleDocblockContractTest, this enforces rule-authoring discipline (a
21+
* lexical contract on identifier strings), not rule enforcement.
22+
*/
23+
final class RuleIdentifierConventionTest extends TestCase
24+
{
25+
private const string IDENTIFIER_PATTERN = '/->identifier\(\s*[\'"]([^\'"]+)[\'"]\s*\)/';
26+
27+
private const string CONVENTION_PATTERN = '/^[a-z][a-zA-Z0-9]*\.[a-z][a-zA-Z0-9]*$/';
28+
29+
#[Test]
30+
public function every_rule_identifier_follows_camel_dot_camel_convention(): void
31+
{
32+
$ruleFiles = glob(__DIR__ . '/../../src/Rules/*.php');
33+
34+
self::assertNotEmpty($ruleFiles, 'No rule files found under src/Rules');
35+
36+
$allIdentifiers = [];
37+
foreach ($ruleFiles as $file) {
38+
$source = file_get_contents($file);
39+
self::assertNotFalse($source, "Could not read {$file}");
40+
41+
preg_match_all(self::IDENTIFIER_PATTERN, $source, $matches);
42+
43+
foreach ($matches[1] as $identifier) {
44+
$allIdentifiers[] = [$file, $identifier];
45+
self::assertMatchesRegularExpression(
46+
self::CONVENTION_PATTERN,
47+
$identifier,
48+
sprintf(
49+
'Identifier "%s" in %s does not follow ADR-0021 §Identifier convention (cameLCase.cameLCase).',
50+
$identifier,
51+
basename($file),
52+
),
53+
);
54+
}
55+
}
56+
57+
self::assertNotEmpty(
58+
$allIdentifiers,
59+
'No identifiers found across rule files — regex broken or rules lack ->identifier() calls.',
60+
);
61+
}
62+
}

0 commit comments

Comments
 (0)