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
3 changes: 3 additions & 0 deletions docs/available-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,14 @@ 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. |
{: .rule-table }

`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 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.
25 changes: 23 additions & 2 deletions src/Analyser/AnalysisNodeCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -1176,8 +1176,26 @@ 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);

if ($this->activeClassLikeAnalyses !== [] && ! isset(self::KEYWORD_CONSTANTS[$name->toLowerString()])) {
// An unqualified fetch in a namespace is not a FullyQualified
// 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;
}
}

return;
}
Expand Down Expand Up @@ -1617,6 +1635,7 @@ enumBackingType: $classLike instanceof Enum_ && $classLike->scalarType instan
? $classLike->scalarType->toLowerString()
: null,
nonClassDependencies: $analysis['nonClassDependencies'],
constantFetches: $analysis['constantFetches'],
);
}

Expand Down Expand Up @@ -1760,6 +1779,7 @@ private function resolveClassName(ClassLike $classLike): string
* @return array{
* dependencies: list<string>,
* nonClassDependencies: list<string>,
* constantFetches: list<string>,
* functionCalls: string[],
* superglobals: string[],
* languageConstructs: string[],
Expand Down Expand Up @@ -1787,6 +1807,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),
Expand Down
8 changes: 8 additions & 0 deletions src/Analyser/ClassLikeAnalysis.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, true>
*/
public array $constantFetches = [];

/** @var list<Name> */
public array $functionCallNames = [];

Expand Down
34 changes: 34 additions & 0 deletions src/Analyser/ClassNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<string> $nonClassDependencies Dependencies only ever used as a function or constant name
* @param list<string> $constantFetches Global and namespaced constants fetched within this class
*/
public function __construct(
public readonly string $className,
Expand Down Expand Up @@ -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]);
}
Expand All @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion src/Cache/AnalysisResultCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'] ?? [];
Expand All @@ -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)
Expand Down Expand Up @@ -1091,6 +1094,7 @@ interfaceExtends: array_values($interfaceExtends),
enumCases: $enumCases,
enumBackingType: $enumBackingType,
nonClassDependencies: array_values($nonClassDependencies),
constantFetches: array_values($constantFetches),
);
}

Expand Down
8 changes: 8 additions & 0 deletions src/Preset/Presets/DddPreset.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)),
Expand Down
45 changes: 45 additions & 0 deletions src/Rule/Rules/Usage/MayNotUseConstantRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Boundwize\StructArmed\Rule\Rules\Usage;

use Boundwize\StructArmed\Analyser\ClassNode;
use Boundwize\StructArmed\Rule\RuleInterface;
use Boundwize\StructArmed\Rule\RuleViolation;

use function sprintf;

final readonly class MayNotUseConstantRule implements RuleInterface
{
public function __construct(
private string $layer,
private string $constant,
) {
}

public function appliesTo(ClassNode $classNode): bool
{
return $classNode->isInLayer($this->layer);
}

public function evaluate(ClassNode $classNode): ?RuleViolation
{
if (! $classNode->usesConstant($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,
);
}
}
60 changes: 60 additions & 0 deletions tests/Analyser/AnalysisNodeCollectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2349,6 +2349,66 @@ public function isEnabled(): bool
$this->assertContains('App\Infrastructure\Config\FEATURE_ENABLED', $classNode->dependencies);
}

public function testCollectsConstantFetchesApartFromDependencies(): void
{
$classNode = $this->collect(
<<<'PHP_WRAP'
<?php

namespace App\Domain;

use const App\Infrastructure\Config\FEATURE_ENABLED;

class Foo
{
public const SEPARATOR = PHP_EOL;

public function bar(?int $limit = null): bool
{
new \STDIN();
\STDOUT();

return $limit === \PHP_INT_MAX || FEATURE_ENABLED || true || false;
}
}
PHP_WRAP
);

// 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(
['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'
<?php

namespace Vendor\Config;

const DEBUG = true;

class Foo
{
public function run(): bool
{
return DEBUG;
}
}
PHP
);

$this->assertTrue($classNode->usesConstant('Vendor\Config\DEBUG'));
$this->assertTrue($classNode->usesConstant('DEBUG'));
}

public function testCollectsFullyQualifiedDependencies(): void
{
$classNode = $this->collect('<?php class Foo { public function bar(): void { new \DateTimeImmutable(); } }');
Expand Down
26 changes: 26 additions & 0 deletions tests/Analyser/ClassNodeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions tests/Cache/AnalysisResultCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2945,6 +2945,7 @@ className: Foo::class,
],
functionCalls: ['sprintf'],
superglobals: ['_SERVER'],
constantFetches: ['PHP_EOL'],
);
}

Expand Down
1 change: 1 addition & 0 deletions tests/Preset/PresetTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ public function testDddPresetRegistersAllDefaultRules(): void
);
$this->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
Expand Down
1 change: 1 addition & 0 deletions tests/Rule/RuleViolationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down
Loading
Loading