Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f0f4189
refactor: Remove unused RuleViolationCollection::forLayer() and toJson()
samsonasik Sep 29, 2026
9fbf2dd
Merge pull request #476 from boundwize/remove-unused-method-in-rulevi…
samsonasik Sep 29, 2026
f52d23a
fix: Recognise global function fallback in namespaced top level calls
samsonasik Sep 30, 2026
fdd337c
update version screenshot
samsonasik Sep 30, 2026
985f764
Merge branch 'main' into 0.19.x
samsonasik Sep 30, 2026
375aeff
Merge branch 'main' into 0.19.x
samsonasik Oct 1, 2026
aa5963b
Merge branch 'main' into 0.19.x
samsonasik Oct 1, 2026
4a5ec4c
Merge branch 'main' into 0.19.x
samsonasik Oct 1, 2026
370322d
refactor: rename $class parameter to $dependency on ClassNode::usesCl…
samsonasik Oct 1, 2026
3523b24
refactor: rename $class parameter to $dependency on ClassNode::usesCl…
samsonasik Oct 1, 2026
ee7d84d
Merge pull request #486 from boundwize/rename-class-to-dependency
samsonasik Oct 1, 2026
3043fe1
Revert "refactor: rename $class parameter to $dependency on ClassNode…
samsonasik Oct 1, 2026
983d8b9
Merge pull request #487 from boundwize/revert-486-rename-class-to-dep…
samsonasik Oct 1, 2026
5fe3c34
refactor: rename $class parameter to $dependency on NodeQueryTrait::d…
samsonasik Oct 1, 2026
de62029
Merge pull request #488 from boundwize/use-dep
samsonasik Oct 1, 2026
32a6480
perf: Optimize ruleset expansion and simplify dependency checks
samsonasik Oct 1, 2026
c763578
keep merge behaviour
samsonasik Oct 1, 2026
0b6ff4a
update version screenshot
samsonasik Oct 1, 2026
6a2a54a
Merge branch 'main' into 0.19.x
samsonasik Oct 1, 2026
5e565c7
Bump fidry/cpu-core-counter to version ^1.4
samsonasik Oct 2, 2026
b4d6643
Merge pull request #490 from boundwize/samsonasik-patch-1
samsonasik Oct 2, 2026
b2ae979
Merge branch 'main' into 0.19.x
samsonasik Oct 2, 2026
b8d4c4f
Merge branch '0.19.x' of github.com:boundwize/structarmed into 0.19.x
samsonasik Oct 2, 2026
bfc5229
Merge branch 'main' into 0.19.x
samsonasik Oct 3, 2026
2f4e0bd
Merge branch 'main' into 0.19.x
samsonasik Oct 4, 2026
faa6728
Add MayNotUseConstantRule and forbid STDIN, STDOUT, and STDERR in DDD…
samsonasik Oct 5, 2026
7b6c987
fix global name
samsonasik Oct 5, 2026
0b0d14f
clean up doc
samsonasik Oct 5, 2026
7fce485
more test
samsonasik Oct 5, 2026
e31891c
fix inside namespace
samsonasik Oct 5, 2026
bb3ef70
Merge pull request #495 from boundwize/no-const
samsonasik Oct 5, 2026
ce652be
Add ability to setup basepath via --basepath in CLI
samsonasik Oct 7, 2026
81696cb
allow clear cache per base path
samsonasik Oct 7, 2026
eb1c5f8
add alias -d for --basepath
samsonasik Oct 7, 2026
5bcc237
Merge pull request #496 from boundwize/allow-define-basepath
samsonasik Oct 8, 2026
7b94e34
temporary disable php 8.5 on macos on github CI due seems temporary down
samsonasik Oct 8, 2026
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ jobs:
- operating-system: ubuntu-latest
php-versions: "8.2"
coverage: pcov
exclude:
# Temporarily disabled: GitHub Actions macOS runners currently fail
# to set up PHP 8.5. Re-enable once the runner image is fixed.
- operating-system: macos-latest
php-versions: "8.5"

steps:
- name: Checkout
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"php": "^8.2",
"composer-runtime-api": "^2.0",
"boundwize/jsonrecast": "^1.0",
"fidry/cpu-core-counter": "^1.3",
"fidry/cpu-core-counter": "^1.4",
"nikic/php-parser": "^5.9"
},
"require-dev": {
Expand Down
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.
36 changes: 36 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,42 @@ vendor/bin/structarmed analyse --config=path/to/structarmed.php
vendor/bin/structarmed analyze --config=path/to/structarmed.php
```

## Base Path

The project root defaults to the directory the command runs in. Pass `--basepath` when StructArmed is installed somewhere else, for example in a `tools/structarmed` directory with its own `composer.json`:

```text
composer.json
src/
tests/
tools/
└── structarmed/
├── composer.json
├── structarmed.php
└── vendor/
```

```bash
cd tools/structarmed
vendor/bin/structarmed analyse --basepath=../../
```

`-d` is a short alias for `--basepath`:

```bash
vendor/bin/structarmed analyse -d ../../
```

Everything relative to the project root now resolves against the base path: layer paths such as `->layer('Config', 'src/ConfigProvider.php')`, scan paths given on the command line, the `composer.json` read by the composer rules and PSR-4 layers, the cache directory, and baseline paths. The config file is discovered in the current directory first, then in the base path; `--config` keeps pointing to a path relative to the current directory.

`--clear-cache` accepts the same option, so the cache of a project analysed through `--basepath` is cleared with:

```bash
vendor/bin/structarmed --basepath=../../ --clear-cache
```

Options may be given before or after the command.

## Auto-Fix Violations

Use `--fix` to automatically apply fixes for violations produced by rules that implement `Boundwize\StructArmed\Rule\FixableInterface`.
Expand Down
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
8 changes: 4 additions & 4 deletions src/Analyser/NodeQueryTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ public function isInLayer(string $layer): bool
return in_array($layer, $this->layers, true);
}

public function dependsOn(string $class, bool $isCaseSensitive = true): bool
public function dependsOn(string $dependency, bool $isCaseSensitive = true): bool
{
if ($isCaseSensitive) {
return in_array($class, $this->dependencies, true);
return in_array($dependency, $this->dependencies, true);
}

foreach ($this->dependencies as $dependency) {
if (strcasecmp($dependency, $class) === 0) {
foreach ($this->dependencies as $existingDependency) {
if (strcasecmp($existingDependency, $dependency) === 0) {
return true;
}
}
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
17 changes: 16 additions & 1 deletion src/Cli/AnalyseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
* @phpstan-type CommandOptions array{
* report?: string,
* config?: string,
* basepath?: string,
* generate-baseline?: string,
* no-progress?: true,
* clear-cache?: true,
Expand All @@ -54,6 +55,8 @@
private const VALUE_OPTIONS = [
'--report' => 'report',
'--config' => 'config',
'--basepath' => 'basepath',
'-d' => 'basepath',
'--generate-baseline' => 'generate-baseline',
];

Expand Down Expand Up @@ -98,6 +101,18 @@ public function run(array $arguments, string $basePath): int
return 1;
}

$workingDirectory = $basePath;

if (isset($options['basepath'])) {
$basePath = Path::normalise(Path::resolve($options['basepath'], $workingDirectory), canonicalise: true);

if (! is_dir($basePath)) {
echo sprintf("Error: base path [%s] not found.\n", $options['basepath']);

return 1;
}
}

foreach ($scanPaths as $scanPath) {
$fullScanPath = Path::resolve($scanPath, $basePath);

Expand All @@ -123,7 +138,7 @@ public function run(array $arguments, string $basePath): int
}

try {
$configFile = $options['config'] ?? ConfigLoader::discover($basePath);
$configFile = $options['config'] ?? ConfigLoader::discover($workingDirectory, $basePath);
$architecture = ConfigLoader::load($configFile);
} catch (RuntimeException $runtimeException) {
return $this->reportError($runtimeException);
Expand Down
39 changes: 27 additions & 12 deletions src/Cli/ClearCacheCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,24 @@
use Boundwize\StructArmed\Cache\AnalysisResultCache;
use Boundwize\StructArmed\Cache\FileHashProvider;
use Boundwize\StructArmed\Config\ConfigLoader;
use Boundwize\StructArmed\Util\Path;
use RuntimeException;

use function count;
use function explode;
use function is_dir;
use function sprintf;
use function str_starts_with;
use function strlen;
use function substr;

use const PHP_EOL;

final readonly class ClearCacheCommand
{
private const VALUE_OPTIONS = [
'--config' => 'config',
'--basepath' => 'basepath',
'-d' => 'basepath',
];

/**
* @param list<string> $arguments
*/
Expand All @@ -28,15 +34,12 @@ public function run(array $arguments, string $basePath): int
$counter = count($arguments);

for ($i = 0; $i < $counter; $i++) {
$argument = $arguments[$i];

if (str_starts_with($argument, '--config=')) {
$options['config'] = substr($argument, strlen('--config='));
continue;
}
$argument = $arguments[$i];
$optionAndValue = explode('=', $argument, 2);
$option = $optionAndValue[0];

if ($argument === '--config') {
$options['config'] = $arguments[++$i] ?? '';
if (isset(self::VALUE_OPTIONS[$option])) {
$options[self::VALUE_OPTIONS[$option]] = $optionAndValue[1] ?? $arguments[++$i] ?? '';
continue;
}

Expand All @@ -46,10 +49,22 @@ public function run(array $arguments, string $basePath): int
return 1;
}

$workingDirectory = $basePath;

if (isset($options['basepath'])) {
$basePath = Path::normalise(Path::resolve($options['basepath'], $workingDirectory), canonicalise: true);

if (! is_dir($basePath)) {
echo sprintf("Error: base path [%s] not found.\n", $options['basepath']);

return 1;
}
}

$cacheDirectory = null;

try {
$configFile = $options['config'] ?? ConfigLoader::discover($basePath);
$configFile = $options['config'] ?? ConfigLoader::discover($workingDirectory, $basePath);
$cacheDirectory = ConfigLoader::load($configFile)->getCacheDirectory();
} catch (RuntimeException $runtimeException) {
if (isset($options['config'])) {
Expand Down
Loading
Loading