diff --git a/docs/assets/no-violation.svg b/docs/assets/no-violation.svg
index 9a4d42ab..a29441d9 100644
--- a/docs/assets/no-violation.svg
+++ b/docs/assets/no-violation.svg
@@ -14,7 +14,7 @@
➜ prj-ddd vendor/bin/structarmed analyze
-
+
===============================================
diff --git a/docs/assets/structarmed-showoff.svg b/docs/assets/structarmed-showoff.svg
index 32a86beb..57154c07 100644
--- a/docs/assets/structarmed-showoff.svg
+++ b/docs/assets/structarmed-showoff.svg
@@ -15,7 +15,7 @@
➜ prj-ddd vendor/bin/structarmed analyze
-
+
===============================================
diff --git a/docs/available-rules.md b/docs/available-rules.md
index d5f57472..49611b68 100644
--- a/docs/available-rules.md
+++ b/docs/available-rules.md
@@ -130,3 +130,5 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Usage`.
{: .rule-table }
`MayNotUseClassRule` and `MayNotUseNamespaceRule` also accept `classNamePattern` when only matching classes should be checked.
+
+`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/ClassNode.php b/src/Analyser/ClassNode.php
index 85f6d5fd..5479b338 100644
--- a/src/Analyser/ClassNode.php
+++ b/src/Analyser/ClassNode.php
@@ -147,7 +147,16 @@ public function callsFunction(string $function): bool
public function usesLanguageConstruct(string $construct): bool
{
- return in_array($construct, $this->languageConstructs, true);
+ if (in_array($construct, $this->languageConstructs, true)) {
+ return true;
+ }
+
+ // `die` is a pure alias of `exit`, so banning either spelling catches both.
+ return match ($construct) {
+ 'exit' => in_array('die', $this->languageConstructs, true),
+ 'die' => in_array('exit', $this->languageConstructs, true),
+ default => false,
+ };
}
public function accessesSuperglobals(): bool
diff --git a/tests/Analyser/ClassCollectorTest.php b/tests/Analyser/ClassCollectorTest.php
index ae346d95..5d9039ed 100644
--- a/tests/Analyser/ClassCollectorTest.php
+++ b/tests/Analyser/ClassCollectorTest.php
@@ -509,6 +509,20 @@ public function testDeduplicatesLanguageConstructs(): void
$this->assertSame(['exit'], $classNode->languageConstructs);
}
+ public function testKeepsIncludeFamilyConstructsDistinct(): void
+ {
+ $classNode = $this->collect(
+ 'assertSame(
+ ['include', 'include_once', 'require', 'require_once'],
+ $classNode->languageConstructs
+ );
+ }
+
public function testCollectsEchoAsLanguageConstruct(): void
{
$classNode = $this->collect('assertFalse($classNode->usesLanguageConstruct('eval'));
}
+ public function testUsesLanguageConstructResolvesExitDieAliases(): void
+ {
+ $usesExit = new ClassNode(
+ className: 'App\\Domain\\ExitService',
+ file: '/src/ExitService.php',
+ line: 1,
+ layer: 'Domain',
+ extends: null,
+ isAbstract: false,
+ isFinal: true,
+ isInterface: false,
+ isReadonly: false,
+ languageConstructs: ['exit'],
+ );
+
+ $usesDie = new ClassNode(
+ className: 'App\\Domain\\DieService',
+ file: '/src/DieService.php',
+ line: 1,
+ layer: 'Domain',
+ extends: null,
+ isAbstract: false,
+ isFinal: true,
+ isInterface: false,
+ isReadonly: false,
+ languageConstructs: ['die'],
+ );
+
+ // `die` is a pure alias of `exit`, so either query matches either spelling.
+ $this->assertTrue($usesExit->usesLanguageConstruct('exit'));
+ $this->assertTrue($usesExit->usesLanguageConstruct('die'));
+ $this->assertTrue($usesDie->usesLanguageConstruct('die'));
+ $this->assertTrue($usesDie->usesLanguageConstruct('exit'));
+
+ // Distinct constructs are not over-aliased.
+ $this->assertFalse($usesExit->usesLanguageConstruct('echo'));
+ $this->assertFalse($usesExit->usesLanguageConstruct('include'));
+ }
+
public function testSetRecursiveParents(): void
{
$classNode = new ClassNode(
diff --git a/tests/Rule/Usage/MayNotUseLanguageConstructRuleTest.php b/tests/Rule/Usage/MayNotUseLanguageConstructRuleTest.php
index 707d4dbf..b2d54d09 100644
--- a/tests/Rule/Usage/MayNotUseLanguageConstructRuleTest.php
+++ b/tests/Rule/Usage/MayNotUseLanguageConstructRuleTest.php
@@ -8,6 +8,7 @@
use Boundwize\StructArmed\Rule\Rules\Usage\MayNotUseLanguageConstructRule;
use Boundwize\StructArmed\Rule\RuleViolation;
use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
#[CoversClass(MayNotUseLanguageConstructRule::class)]
@@ -61,6 +62,57 @@ public function testViolatesWhenDieIsUsed(): void
$this->assertStringContainsString('die', $violation->message);
}
+ /**
+ * `die` is a pure alias of `exit`, so a rule banning either spelling must
+ * fire whichever spelling the code actually used. `exit;`/`exit(1);` are
+ * recorded as `exit`; `die;`/`die(1);` as `die` — the two recorded spellings
+ * cover the four code forms.
+ *
+ * @param array $recordedConstructs
+ */
+ #[DataProvider('exitDieAliasProvider')]
+ public function testExitAndDieAreSymmetricAliases(string $configured, array $recordedConstructs): void
+ {
+ $mayNotUseLanguageConstructRule = new MayNotUseLanguageConstructRule(layer: 'Domain', construct: $configured);
+ $classNode = $this->makeNode($recordedConstructs);
+
+ $violation = $mayNotUseLanguageConstructRule->evaluate($classNode);
+
+ $this->assertInstanceOf(RuleViolation::class, $violation);
+ $this->assertStringContainsString($configured, $violation->message);
+ }
+
+ /** @return iterable}> */
+ public static function exitDieAliasProvider(): iterable
+ {
+ yield 'ban exit, code used exit' => ['exit', ['exit']];
+ yield 'ban exit, code used die' => ['exit', ['die']];
+ yield 'ban die, code used die' => ['die', ['die']];
+ yield 'ban die, code used exit' => ['die', ['exit']];
+ }
+
+ /**
+ * The include family are genuinely distinct constructs and must not be
+ * aliased to one another.
+ */
+ #[DataProvider('includeFamilyProvider')]
+ public function testIncludeFamilyRemainsDistinct(string $configured, string $recorded): void
+ {
+ $mayNotUseLanguageConstructRule = new MayNotUseLanguageConstructRule(layer: 'Domain', construct: $configured);
+ $classNode = $this->makeNode([$recorded]);
+
+ $this->assertNotInstanceOf(RuleViolation::class, $mayNotUseLanguageConstructRule->evaluate($classNode));
+ }
+
+ /** @return iterable */
+ public static function includeFamilyProvider(): iterable
+ {
+ yield 'include vs include_once' => ['include', 'include_once'];
+ yield 'include vs require' => ['include', 'require'];
+ yield 'require vs require_once' => ['require', 'require_once'];
+ yield 'require_once vs include' => ['require_once', 'include'];
+ }
+
public function testDoesNotApplyToWrongLayer(): void
{
$mayNotUseLanguageConstructRule = new MayNotUseLanguageConstructRule(layer: 'Domain', construct: 'exit');