From faa6728aaf45cde2bdb65369b64e09463aafcc0e Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 5 Oct 2026 09:35:12 +0700 Subject: [PATCH 1/5] Add MayNotUseConstantRule and forbid STDIN, STDOUT, and STDERR in DDD preset Domain layer --- docs/available-rules.md | 1 + src/Analyser/AnalysisNodeCollector.php | 12 +- src/Cache/AnalysisResultCache.php | 2 +- src/Preset/Presets/DddPreset.php | 8 ++ .../Rules/Usage/MayNotUseConstantRule.php | 45 +++++++ tests/Analyser/AnalysisNodeCollectorTest.php | 26 ++++ tests/Preset/PresetTest.php | 1 + .../Rule/Usage/MayNotUseConstantRuleTest.php | 112 ++++++++++++++++++ 8 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 src/Rule/Rules/Usage/MayNotUseConstantRule.php create mode 100644 tests/Rule/Usage/MayNotUseConstantRuleTest.php diff --git a/docs/available-rules.md b/docs/available-rules.md index cc7cfd4b..7d255439 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -169,6 +169,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Usage`. |---|---|---| | `MayNotCallFunctionRule` | `new MayNotCallFunctionRule(layer: 'Domain', function: 'header')` | Classes in a layer do not call a forbidden function. | | `MayNotUseClassRule` | `new MayNotUseClassRule(layer: 'Domain', forbiddenClass: DateTime::class)` | Classes in a layer do not depend on a forbidden class. | +| `MayNotUseConstantRule` | `new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL')` | Classes in a layer do not use a forbidden constant. | | `MayNotUseLanguageConstructRule` | `new MayNotUseLanguageConstructRule(layer: 'Domain', construct: 'echo')` | Classes in a layer do not use a forbidden language construct. | | `MayNotUseNamespaceRule` | `new MayNotUseNamespaceRule(layer: 'Domain', forbiddenNamespace: 'Doctrine\\ORM\\')` | Classes in a layer do not depend on a forbidden namespace. | | `MayNotUseSuperglobalsRule` | `new MayNotUseSuperglobalsRule(layer: 'Controller')` | Classes in a layer do not access superglobals directly. | diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index e58af247..f6cc52dd 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -1176,8 +1176,16 @@ private function collectNodeAnalysis(Node $node): void // Entered before its name, so the FullyQualified branch above sees // the mark. if ($node instanceof ConstFetch) { - $node->name->setAttribute(self::NON_CLASS_NAME_ATTRIBUTE, true); - $this->collectKeywordConstant($node->name); + $name = $node->name; + $name->setAttribute(self::NON_CLASS_NAME_ATTRIBUTE, true); + $this->collectKeywordConstant($name); + + // An unqualified fetch in a namespace is not a FullyQualified + // node: PHP falls back to the global constant, so the global name + // is recorded as the dependency. + if (! $name instanceof FullyQualified && ! isset(self::KEYWORD_CONSTANTS[$name->toLowerString()])) { + $this->addDependency($name->toString(), false); + } return; } diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 1776884e..32c9d78e 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -65,7 +65,7 @@ final class AnalysisResultCache * their shape or naming changes: it is recorded in the metadata marker, * so a cache written by an older format is cleared on its next use. */ - public const FORMAT_VERSION = 13; + public const FORMAT_VERSION = 14; private readonly string $cacheDirectory; diff --git a/src/Preset/Presets/DddPreset.php b/src/Preset/Presets/DddPreset.php index 2ba49ded..78d8411b 100644 --- a/src/Preset/Presets/DddPreset.php +++ b/src/Preset/Presets/DddPreset.php @@ -17,6 +17,7 @@ use Boundwize\StructArmed\Rule\Rules\Method\MustHaveReturnTypeRule; use Boundwize\StructArmed\Rule\Rules\Usage\MayNotCallFunctionRule; use Boundwize\StructArmed\Rule\Rules\Usage\MayNotUseClassRule; +use Boundwize\StructArmed\Rule\Rules\Usage\MayNotUseConstantRule; use Boundwize\StructArmed\Rule\Rules\Usage\MayNotUseLanguageConstructRule; use DateTime; use Exception; @@ -270,6 +271,13 @@ private function applySafetyRules(Architecture $architecture): self new MayNotImplementInterfaceRule(layer: 'Domain', interface: JsonSerializable::class) ); + foreach (['STDIN', 'STDOUT', 'STDERR'] as $constant) { + $architecture->rule( + sprintf('ddd.safety.domain_no_%s', strtolower($constant)), + new MayNotUseConstantRule(layer: 'Domain', constant: $constant) + ); + } + foreach (['Domain', 'Application'] as $layer) { $architecture->rule( sprintf('ddd.safety.%s_max_complexity', strtolower($layer)), diff --git a/src/Rule/Rules/Usage/MayNotUseConstantRule.php b/src/Rule/Rules/Usage/MayNotUseConstantRule.php new file mode 100644 index 00000000..9a4b4bbf --- /dev/null +++ b/src/Rule/Rules/Usage/MayNotUseConstantRule.php @@ -0,0 +1,45 @@ +isInLayer($this->layer); + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + if (! $classNode->dependsOn($this->constant)) { + return null; + } + + return new RuleViolation( + message: sprintf( + '%s [%s] must not use constant [%s]', + $classNode->getType(), + $classNode->className, + $this->constant + ), + file: $classNode->file, + line: $classNode->line, + className: $classNode->className, + layer: $classNode->layer, + ); + } +} diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index f0e9a57e..e8f6c33f 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -2349,6 +2349,32 @@ public function isEnabled(): bool $this->assertContains('App\Infrastructure\Config\FEATURE_ENABLED', $classNode->dependencies); } + public function testCollectsUnqualifiedNamespacedConstantUsageAsGlobalDependency(): void + { + $classNode = $this->collect( + <<<'PHP_WRAP' + assertSame(['PHP_EOL', 'PHP_INT_MAX'], $classNode->dependencies); + $this->assertSame(['PHP_EOL', 'PHP_INT_MAX'], $classNode->nonClassDependencies); + } + public function testCollectsFullyQualifiedDependencies(): void { $classNode = $this->collect('assertArrayHasKey('ddd.safety.domain_no_dd', $rules); $this->assertArrayHasKey('ddd.safety.application_no_exit', $rules); + $this->assertArrayHasKey('ddd.safety.domain_no_stderr', $rules); } public function testDddPresetCanSkipOptionalFinalRules(): void diff --git a/tests/Rule/Usage/MayNotUseConstantRuleTest.php b/tests/Rule/Usage/MayNotUseConstantRuleTest.php new file mode 100644 index 00000000..d8178d01 --- /dev/null +++ b/tests/Rule/Usage/MayNotUseConstantRuleTest.php @@ -0,0 +1,112 @@ + $dependencies */ + private function makeNode( + array $dependencies, + string $layer = 'Domain', + bool $isTrait = false, + bool $isEnum = false, + ): ClassNode { + return new ClassNode( + className: 'App\\Domain\\OrderService', + file: '/fake.php', + line: 1, + layer: $layer, + extends: null, + isAbstract: false, + isFinal: true, + isInterface: false, + isReadonly: false, + isTrait: $isTrait, + dependencies: $dependencies, + isEnum: $isEnum, + ); + } + + public function testPassesWhenForbiddenConstantNotUsed(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); + $classNode = $this->makeNode(['PHP_INT_MAX', 'App\\Domain\\Order']); + + $this->assertNotInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); + } + + public function testViolatesWhenForbiddenConstantIsUsed(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); + $classNode = $this->makeNode(['PHP_EOL']); + + $violation = $mayNotUseConstantRule->evaluate($classNode); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame('Class [App\\Domain\\OrderService] must not use constant [PHP_EOL]', $violation->message); + } + + #[DataProvider('traitAndEnumKindProvider')] + public function testViolationMessageNamesTheClassLikeKind(string $expectedKind, bool $isTrait, bool $isEnum): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); + $classNode = $this->makeNode(['PHP_EOL'], isTrait: $isTrait, isEnum: $isEnum); + + $violation = $mayNotUseConstantRule->evaluate($classNode); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame( + $expectedKind . ' [App\\Domain\\OrderService] must not use constant [PHP_EOL]', + $violation->message + ); + } + + /** @return iterable */ + public static function traitAndEnumKindProvider(): iterable + { + yield 'trait' => ['Trait', true, false]; + yield 'enum' => ['Enum', false, true]; + } + + public function testConstantComparisonIsCaseSensitive(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); + $classNode = $this->makeNode(['php_eol']); + + $this->assertNotInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); + } + + public function testViolatesForNamespacedConstant(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'Vendor\\Config\\DEBUG'); + $classNode = $this->makeNode(['Vendor\\Config\\DEBUG']); + + $this->assertInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); + } + + public function testDoesNotApplyToWrongLayer(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); + $classNode = $this->makeNode(['PHP_EOL'], layer: 'Infrastructure'); + + $this->assertFalse($mayNotUseConstantRule->appliesTo($classNode)); + } + + public function testAppliesToCorrectLayer(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); + $classNode = $this->makeNode([]); + + $this->assertTrue($mayNotUseConstantRule->appliesTo($classNode)); + } +} From 7b6c987e889adddf9b5275a8b320e5f86629c38d Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 5 Oct 2026 09:53:29 +0700 Subject: [PATCH 2/5] fix global name --- docs/available-rules.md | 2 + src/Analyser/AnalysisNodeCollector.php | 18 ++++-- src/Analyser/ClassLikeAnalysis.php | 8 +++ src/Analyser/ClassNode.php | 34 ++++++++++ src/Cache/AnalysisResultCache.php | 4 ++ .../Rules/Usage/MayNotUseConstantRule.php | 2 +- tests/Analyser/AnalysisNodeCollectorTest.php | 21 +++++-- tests/Analyser/ClassNodeTest.php | 26 ++++++++ tests/Cache/AnalysisResultCacheTest.php | 1 + .../Rule/Usage/MayNotUseConstantRuleTest.php | 62 +++++++++++++------ 10 files changed, 148 insertions(+), 30 deletions(-) diff --git a/docs/available-rules.md b/docs/available-rules.md index 7d255439..778e414a 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -177,4 +177,6 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Usage`. `MayNotUseClassRule` and `MayNotUseNamespaceRule` also accept `classNamePattern` when only matching classes should be checked. +`MayNotUseConstantRule` takes a global or namespaced constant name, such as `'PHP_EOL'` or `'Vendor\\Config\\DEBUG'`. As in PHP, the namespace is matched case-insensitively and the constant name case-sensitively. An unqualified constant inside a namespace counts as the global constant of that name. + `MayNotUseLanguageConstructRule` accepts one of the following `construct` names: `echo`, `print`, `eval`, `isset`, `empty`, `unset`, `list`, `exit`, `die`, `include`, `include_once`, `require`, `require_once`. `die` is a pure alias of `exit`, so banning either spelling catches both. The `include` / `include_once` / `require` / `require_once` constructs are distinct and are matched exactly. diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index f6cc52dd..674de3a8 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -1180,11 +1180,16 @@ private function collectNodeAnalysis(Node $node): void $name->setAttribute(self::NON_CLASS_NAME_ATTRIBUTE, true); $this->collectKeywordConstant($name); - // An unqualified fetch in a namespace is not a FullyQualified - // node: PHP falls back to the global constant, so the global name - // is recorded as the dependency. - if (! $name instanceof FullyQualified && ! isset(self::KEYWORD_CONSTANTS[$name->toLowerString()])) { - $this->addDependency($name->toString(), false); + if ($this->activeClassLikeAnalyses !== [] && ! isset(self::KEYWORD_CONSTANTS[$name->toLowerString()])) { + // An unqualified fetch in a namespace is not a FullyQualified + // node and keeps its short name: it is recorded as the global + // constant PHP falls back to, as whether a namespaced constant + // of that name exists is not known here. + $constant = $name->toString(); + + foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { + $activeClassLikeAnalysis->constantFetches[$constant] = true; + } } return; @@ -1625,6 +1630,7 @@ enumBackingType: $classLike instanceof Enum_ && $classLike->scalarType instan ? $classLike->scalarType->toLowerString() : null, nonClassDependencies: $analysis['nonClassDependencies'], + constantFetches: $analysis['constantFetches'], ); } @@ -1768,6 +1774,7 @@ private function resolveClassName(ClassLike $classLike): string * @return array{ * dependencies: list, * nonClassDependencies: list, + * constantFetches: list, * functionCalls: string[], * superglobals: string[], * languageConstructs: string[], @@ -1795,6 +1802,7 @@ private function collectClassLikeAnalysis(ClassLikeAnalysis $classLikeAnalysis): strcasecmp(...) ) ), + 'constantFetches' => array_keys($classLikeAnalysis->constantFetches), 'functionCalls' => array_values(array_unique($functionCalls)), 'superglobals' => array_keys($classLikeAnalysis->superglobals), 'languageConstructs' => array_keys($classLikeAnalysis->languageConstructs), diff --git a/src/Analyser/ClassLikeAnalysis.php b/src/Analyser/ClassLikeAnalysis.php index 22f658a0..dc061afe 100644 --- a/src/Analyser/ClassLikeAnalysis.php +++ b/src/Analyser/ClassLikeAnalysis.php @@ -25,6 +25,14 @@ final class ClassLikeAnalysis */ public array $classDependencies = []; + /** + * The constants fetched by name, kept apart from the dependencies: a + * class-like or function of the same name is not a constant fetch. + * + * @var array + */ + public array $constantFetches = []; + /** @var list */ public array $functionCallNames = []; diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index a4f6b8d7..26e0851c 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -5,8 +5,10 @@ namespace Boundwize\StructArmed\Analyser; use function array_filter; +use function in_array; use function preg_match; use function strcasecmp; +use function strncasecmp; use function strrpos; use function substr; @@ -37,6 +39,7 @@ final class ClassNode * @param EnumCaseNode[] $enumCases Cases of this enum * @param string|null $enumBackingType Backing type for a backed enum, null otherwise * @param list $nonClassDependencies Dependencies only ever used as a function or constant name + * @param list $constantFetches Global and namespaced constants fetched within this class */ public function __construct( public readonly string $className, @@ -70,6 +73,7 @@ public function __construct( public readonly array $enumCases = [], public readonly ?string $enumBackingType = null, public readonly array $nonClassDependencies = [], + public readonly array $constantFetches = [], ) { $this->layers = $layers ?: array_filter([$this->layer]); } @@ -93,6 +97,36 @@ public function usesClass(string $class): bool return true; } + /** + * Whether the class fetches the constant $constant: a class-like or + * function of the same name does not count. + */ + public function usesConstant(string $constant): bool + { + $separatorPosition = strrpos($constant, '\\'); + + // a constant name is case-sensitive + if ($separatorPosition === false) { + return in_array($constant, $this->constantFetches, true); + } + + $namespaceLength = $separatorPosition + 1; + $name = substr($constant, $namespaceLength); + + // namespace names are case-insensitive; only the namespace is + // compared that way, the constant name after it stays case-sensitive + foreach ($this->constantFetches as $constantFetch) { + if ( + strncasecmp($constantFetch, $constant, $namespaceLength) === 0 + && substr($constantFetch, $namespaceLength) === $name + ) { + return true; + } + } + + return false; + } + public function isBackedEnum(): bool { return $this->isEnum && $this->enumBackingType !== null; diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 32c9d78e..238960da 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -970,6 +970,7 @@ private function classNodeToArray(ClassNode $classNode): array $lists = [ 'dependencies' => $classNode->dependencies, 'nonClassDependencies' => $classNode->nonClassDependencies, + 'constantFetches' => $classNode->constantFetches, 'implements' => array_values($classNode->implements), 'interfaceExtends' => array_values($classNode->interfaceExtends), 'parentClasses' => $classNode->parentClasses, @@ -1011,6 +1012,7 @@ private function classNodeFromArray(array $node, string $file): ?ClassNode $isReadonly = $node['isReadonly'] ?? null; $dependencies = $node['dependencies'] ?? []; $nonClassDependencies = $node['nonClassDependencies'] ?? []; + $constantFetches = $node['constantFetches'] ?? []; $implements = $node['implements'] ?? []; $interfaceExtends = $node['interfaceExtends'] ?? []; $parentClasses = $node['parentClasses'] ?? []; @@ -1035,6 +1037,7 @@ private function classNodeFromArray(array $node, string $file): ?ClassNode || ! is_bool($isReadonly) || ! $this->isStringArray($dependencies) || ! $this->isStringArray($nonClassDependencies) + || ! $this->isStringArray($constantFetches) || ! $this->isStringArray($implements) || ! $this->isStringArray($interfaceExtends) || ! $this->isStringArray($parentClasses) @@ -1091,6 +1094,7 @@ interfaceExtends: array_values($interfaceExtends), enumCases: $enumCases, enumBackingType: $enumBackingType, nonClassDependencies: array_values($nonClassDependencies), + constantFetches: array_values($constantFetches), ); } diff --git a/src/Rule/Rules/Usage/MayNotUseConstantRule.php b/src/Rule/Rules/Usage/MayNotUseConstantRule.php index 9a4b4bbf..ea6cba13 100644 --- a/src/Rule/Rules/Usage/MayNotUseConstantRule.php +++ b/src/Rule/Rules/Usage/MayNotUseConstantRule.php @@ -25,7 +25,7 @@ public function appliesTo(ClassNode $classNode): bool public function evaluate(ClassNode $classNode): ?RuleViolation { - if (! $classNode->dependsOn($this->constant)) { + if (! $classNode->usesConstant($this->constant)) { return null; } diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index e8f6c33f..08c142c1 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -2349,7 +2349,7 @@ public function isEnabled(): bool $this->assertContains('App\Infrastructure\Config\FEATURE_ENABLED', $classNode->dependencies); } - public function testCollectsUnqualifiedNamespacedConstantUsageAsGlobalDependency(): void + public function testCollectsConstantFetchesApartFromDependencies(): void { $classNode = $this->collect( <<<'PHP_WRAP' @@ -2357,22 +2357,31 @@ public function testCollectsUnqualifiedNamespacedConstantUsageAsGlobalDependency namespace App\Domain; + use const App\Infrastructure\Config\FEATURE_ENABLED; + class Foo { public const SEPARATOR = PHP_EOL; public function bar(?int $limit = null): bool { - return $limit === PHP_INT_MAX || true || false; + new \STDIN(); + \STDOUT(); + + return $limit === \PHP_INT_MAX || FEATURE_ENABLED || true || false; } } PHP_WRAP ); - // PHP falls back to the global constant, so the global name is the - // dependency; true, false, and null are keywords, not dependencies. - $this->assertSame(['PHP_EOL', 'PHP_INT_MAX'], $classNode->dependencies); - $this->assertSame(['PHP_EOL', 'PHP_INT_MAX'], $classNode->nonClassDependencies); + // An unqualified fetch in a namespace keeps its short name, the global + // constant PHP falls back to; true, false, and null are keywords, and + // a class-like or function named like a constant is not a fetch. + $this->assertSame( + ['PHP_EOL', 'PHP_INT_MAX', 'App\Infrastructure\Config\FEATURE_ENABLED'], + $classNode->constantFetches + ); + $this->assertNotContains('PHP_EOL', $classNode->dependencies); } public function testCollectsFullyQualifiedDependencies(): void diff --git a/tests/Analyser/ClassNodeTest.php b/tests/Analyser/ClassNodeTest.php index 2ef8aacc..5c70370c 100644 --- a/tests/Analyser/ClassNodeTest.php +++ b/tests/Analyser/ClassNodeTest.php @@ -522,6 +522,32 @@ className: 'App\\Domain\\OrderService', $this->assertTrue($classNode->dependsOn('Vendor\\helper')); } + public function testUsesConstantMatchesNamespaceCaseInsensitivelyAndNameCaseSensitively(): void + { + $classNode = new ClassNode( + className: 'App\\Domain\\OrderService', + file: '/src/OrderService.php', + line: 5, + layer: 'Domain', + extends: null, + isAbstract: false, + isFinal: false, + isInterface: false, + isReadonly: false, + dependencies: ['STDOUT'], + constantFetches: ['STDIN', 'Vendor\\Config\\DEBUG'], + ); + + $this->assertTrue($classNode->usesConstant('STDIN')); + $this->assertFalse($classNode->usesConstant('stdin')); + $this->assertTrue($classNode->usesConstant('vendor\\config\\DEBUG')); + $this->assertFalse($classNode->usesConstant('Vendor\\Config\\debug')); + $this->assertFalse($classNode->usesConstant('Vendor\\DEBUG')); + $this->assertFalse($classNode->usesConstant('DEBUG')); + // a dependency of that name is a class-like or function, not a fetch + $this->assertFalse($classNode->usesConstant('STDOUT')); + } + public function testDependsOnDoesNotMatchNamespacePrefix(): void { $classNode = new ClassNode( diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 2c0dbca8..8b04594f 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -2945,6 +2945,7 @@ className: Foo::class, ], functionCalls: ['sprintf'], superglobals: ['_SERVER'], + constantFetches: ['PHP_EOL'], ); } diff --git a/tests/Rule/Usage/MayNotUseConstantRuleTest.php b/tests/Rule/Usage/MayNotUseConstantRuleTest.php index d8178d01..52b56164 100644 --- a/tests/Rule/Usage/MayNotUseConstantRuleTest.php +++ b/tests/Rule/Usage/MayNotUseConstantRuleTest.php @@ -14,33 +14,33 @@ #[CoversClass(MayNotUseConstantRule::class)] final class MayNotUseConstantRuleTest extends TestCase { - /** @param list $dependencies */ + /** @param list $constantFetches */ private function makeNode( - array $dependencies, + array $constantFetches, string $layer = 'Domain', bool $isTrait = false, bool $isEnum = false, ): ClassNode { return new ClassNode( - className: 'App\\Domain\\OrderService', - file: '/fake.php', - line: 1, - layer: $layer, - extends: null, - isAbstract: false, - isFinal: true, - isInterface: false, - isReadonly: false, - isTrait: $isTrait, - dependencies: $dependencies, - isEnum: $isEnum, + className: 'App\\Domain\\OrderService', + file: '/fake.php', + line: 1, + layer: $layer, + extends: null, + isAbstract: false, + isFinal: true, + isInterface: false, + isReadonly: false, + isTrait: $isTrait, + isEnum: $isEnum, + constantFetches: $constantFetches, ); } public function testPassesWhenForbiddenConstantNotUsed(): void { $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'PHP_EOL'); - $classNode = $this->makeNode(['PHP_INT_MAX', 'App\\Domain\\Order']); + $classNode = $this->makeNode(['PHP_INT_MAX']); $this->assertNotInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); } @@ -86,12 +86,38 @@ public function testConstantComparisonIsCaseSensitive(): void $this->assertNotInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); } - public function testViolatesForNamespacedConstant(): void + public function testNamespaceComparisonIsCaseInsensitive(): void { $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'Vendor\\Config\\DEBUG'); - $classNode = $this->makeNode(['Vendor\\Config\\DEBUG']); - $this->assertInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); + $this->assertInstanceOf( + RuleViolation::class, + $mayNotUseConstantRule->evaluate($this->makeNode(['vendor\\config\\DEBUG'])) + ); + $this->assertNotInstanceOf( + RuleViolation::class, + $mayNotUseConstantRule->evaluate($this->makeNode(['Vendor\\Config\\debug'])) + ); + } + + public function testPassesWhenOnlyAClassOrFunctionOfThatNameIsUsed(): void + { + $mayNotUseConstantRule = new MayNotUseConstantRule(layer: 'Domain', constant: 'STDIN'); + $classNode = new ClassNode( + className: 'App\\Domain\\OrderService', + file: '/fake.php', + line: 1, + layer: 'Domain', + extends: null, + isAbstract: false, + isFinal: true, + isInterface: false, + isReadonly: false, + dependencies: ['STDIN'], + functionCalls: ['STDIN'], + ); + + $this->assertNotInstanceOf(RuleViolation::class, $mayNotUseConstantRule->evaluate($classNode)); } public function testDoesNotApplyToWrongLayer(): void From 0b0d14f5ea216fb8609984b52f6c7cbbde572508 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 5 Oct 2026 09:55:52 +0700 Subject: [PATCH 3/5] clean up doc --- docs/available-rules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/available-rules.md b/docs/available-rules.md index 778e414a..eade2310 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -177,6 +177,6 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Usage`. `MayNotUseClassRule` and `MayNotUseNamespaceRule` also accept `classNamePattern` when only matching classes should be checked. -`MayNotUseConstantRule` takes a global or namespaced constant name, such as `'PHP_EOL'` or `'Vendor\\Config\\DEBUG'`. As in PHP, the namespace is matched case-insensitively and the constant name case-sensitively. An unqualified constant inside a namespace counts as the global constant of that name. +`MayNotUseConstantRule` takes a global or namespaced constant name, such as `'PHP_EOL'` or `'Vendor\\Config\\DEBUG'`. An unqualified constant inside a namespace counts as the global constant of that name. `MayNotUseLanguageConstructRule` accepts one of the following `construct` names: `echo`, `print`, `eval`, `isset`, `empty`, `unset`, `list`, `exit`, `die`, `include`, `include_once`, `require`, `require_once`. `die` is a pure alias of `exit`, so banning either spelling catches both. The `include` / `include_once` / `require` / `require_once` constructs are distinct and are matched exactly. From 7fce4856e43b59ddd99d9cccafc396cf2b398861 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 5 Oct 2026 10:01:49 +0700 Subject: [PATCH 4/5] more test --- tests/Rule/RuleViolationTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Rule/RuleViolationTest.php b/tests/Rule/RuleViolationTest.php index 279e959f..9aeb406f 100644 --- a/tests/Rule/RuleViolationTest.php +++ b/tests/Rule/RuleViolationTest.php @@ -162,6 +162,7 @@ public function testCollectionFiltersAndSerializesViolations(): void $this->assertTrue($collection->hasViolations()); $this->assertCount(2, $collection); $this->assertSame([$app], $collection->forRule('app.rule')); + $this->assertSame([$ruleViolation->toArray(), $app->toArray()], $collection->toArray()); $this->assertSame([$ruleViolation, $app], iterator_to_array($collection)); } From e31891cae8cba75694248a40581d03414fb0e882 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Mon, 5 Oct 2026 10:04:06 +0700 Subject: [PATCH 5/5] fix inside namespace --- docs/available-rules.md | 2 +- src/Analyser/AnalysisNodeCollector.php | 13 +++++--- tests/Analyser/AnalysisNodeCollectorTest.php | 33 +++++++++++++++++--- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/docs/available-rules.md b/docs/available-rules.md index eade2310..91dfcd9f 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -177,6 +177,6 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Usage`. `MayNotUseClassRule` and `MayNotUseNamespaceRule` also accept `classNamePattern` when only matching classes should be checked. -`MayNotUseConstantRule` takes a global or namespaced constant name, such as `'PHP_EOL'` or `'Vendor\\Config\\DEBUG'`. An unqualified constant inside a namespace counts as the global constant of that name. +`MayNotUseConstantRule` takes a global or namespaced constant name, such as `'PHP_EOL'` or `'Vendor\\Config\\DEBUG'`. An unqualified constant inside a namespace counts as both the constant of that namespace and the global constant of that name, as PHP only resolves it at runtime. `MayNotUseLanguageConstructRule` accepts one of the following `construct` names: `echo`, `print`, `eval`, `isset`, `empty`, `unset`, `list`, `exit`, `die`, `include`, `include_once`, `require`, `require_once`. `die` is a pure alias of `exit`, so banning either spelling catches both. The `include` / `include_once` / `require` / `require_once` constructs are distinct and are matched exactly. diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index 674de3a8..3bd78816 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -1182,12 +1182,17 @@ private function collectNodeAnalysis(Node $node): void if ($this->activeClassLikeAnalyses !== [] && ! isset(self::KEYWORD_CONSTANTS[$name->toLowerString()])) { // An unqualified fetch in a namespace is not a FullyQualified - // node and keeps its short name: it is recorded as the global - // constant PHP falls back to, as whether a namespaced constant - // of that name exists is not known here. - $constant = $name->toString(); + // node: PHP fetches the namespaced constant when it exists and + // the global one otherwise, which is not known here, so both + // candidates are recorded. + $namespacedName = $name->getAttribute('namespacedName'); + $constant = $name->toString(); foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { + if ($namespacedName instanceof Name) { + $activeClassLikeAnalysis->constantFetches[$namespacedName->toString()] = true; + } + $activeClassLikeAnalysis->constantFetches[$constant] = true; } } diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index 08c142c1..027d6da2 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -2374,16 +2374,41 @@ public function bar(?int $limit = null): bool PHP_WRAP ); - // An unqualified fetch in a namespace keeps its short name, the global - // constant PHP falls back to; true, false, and null are keywords, and - // a class-like or function named like a constant is not a fetch. + // An unqualified fetch in a namespace is the namespaced constant when + // it exists and the global one otherwise, so both are collected; true, + // false, and null are keywords, and a class-like or function named + // like a constant is not a fetch. $this->assertSame( - ['PHP_EOL', 'PHP_INT_MAX', 'App\Infrastructure\Config\FEATURE_ENABLED'], + ['App\Domain\PHP_EOL', 'PHP_EOL', 'PHP_INT_MAX', 'App\Infrastructure\Config\FEATURE_ENABLED'], $classNode->constantFetches ); $this->assertNotContains('PHP_EOL', $classNode->dependencies); } + public function testCollectsUnqualifiedConstantFetchOfSameNamespaceConstant(): void + { + $classNode = $this->collect( + <<<'PHP' + assertTrue($classNode->usesConstant('Vendor\Config\DEBUG')); + $this->assertTrue($classNode->usesConstant('DEBUG')); + } + public function testCollectsFullyQualifiedDependencies(): void { $classNode = $this->collect('