Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6c9e3f5
Add --no-cache option to analyse command
samsonasik Oct 18, 2026
8e31398
avoid use --clear-cache along with --no-cache
samsonasik Oct 18, 2026
6371fad
Merge pull request #507 from boundwize/feat-no-cache
samsonasik Oct 18, 2026
45f907d
update docs on --no-cache
samsonasik Oct 19, 2026
0071a7b
Merge branch 'main' into 0.20.x
samsonasik Oct 19, 2026
fc0d73a
Merge branch 'main' into 0.20.x
samsonasik Oct 21, 2026
afc14cb
fix: Remove the empty temp cache directory after a parallel --no-cach…
samsonasik Oct 22, 2026
8e52af9
cs fix
samsonasik Oct 22, 2026
6a4f61f
skip MergePhpstanDocTagIntoNativeRector
samsonasik Oct 22, 2026
41f83e8
fix comma
samsonasik Oct 22, 2026
3e7f053
Merge pull request #513 from boundwize/temp-dir
samsonasik Oct 22, 2026
560a671
Merge branch 'main' into 0.20.x
samsonasik Oct 23, 2026
ed85747
Merge branch 'main' into 0.20.x
samsonasik Oct 23, 2026
2ccd1f7
Merge branch 'main' into 0.20.x
samsonasik Oct 24, 2026
95f4d12
fix: Stop treating .class.php files as PSR-4 aliases in Psr4Namespace…
samsonasik Oct 24, 2026
1eaa114
Merge pull request #516 from boundwize/class-php
samsonasik Oct 24, 2026
12e5296
fix: Report files that map to invalid PSR-4 class names in Psr4Namesp…
samsonasik Oct 24, 2026
9312728
Merge pull request #517 from boundwize/fix-report
samsonasik Oct 24, 2026
fc638ea
Merge branch 'main' into 0.20.x
samsonasik Oct 24, 2026
53d47d4
Merge branch 'main' into 0.20.x
samsonasik Oct 26, 2026
6fdbf83
feat: Add Architecture::import() to compose config from multiple files
samsonasik Oct 29, 2026
97b23f8
fix test on windows
samsonasik Oct 29, 2026
9eb662a
guard of circular import
samsonasik Oct 29, 2026
2a73e63
move require
samsonasik Oct 29, 2026
af37501
add test warm cache
samsonasik Oct 29, 2026
58fde7b
Merge pull request #522 from boundwize/add-import
samsonasik Oct 29, 2026
bbab325
feat: Detect @api and @internal docblock tags on ClassNode
samsonasik Oct 20, 2026
3df62d1
update doc
samsonasik Oct 20, 2026
367a640
Add ApiMayNotBeInternalRule and ApiMayNotExtendInternalRule and regis…
samsonasik Oct 29, 2026
ad7f321
add check on class and interface
samsonasik Oct 29, 2026
9ee9b81
case fix
samsonasik Oct 29, 2026
9304653
Merge pull request #510 from boundwize/add-api-internal
samsonasik Oct 29, 2026
01c2a38
Fix cache directory removal race between concurrent runs with best ef…
samsonasik Oct 29, 2026
9c5d73d
Merge pull request #523 from boundwize/race-cache
samsonasik Oct 29, 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
2 changes: 2 additions & 0 deletions docs/available-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`.
| Rule | Constructor | Checks |
|---|---|---|
| `AnonymousClassMayNotHaveEmptyParenthesesRule` | `new AnonymousClassMayNotHaveEmptyParenthesesRule(layer: 'Source')` | Anonymous classes that pass no constructor argument omit the parentheses after `class` (`new class {}`, not `new class () {}`), per [PER Coding Style](https://www.php-fig.org/per/coding-style/#8-anonymous-classes). Supports `--fix` by removing the empty parentheses. |
| `ApiMayNotBeInternalRule` | `new ApiMayNotBeInternalRule(layer: 'Source')` | Class-likes in a layer are not tagged both `@api` and `@internal`, which contradict each other. |
| `ApiMayNotExtendInternalRule` | `new ApiMayNotExtendInternalRule(layer: 'Source')` | Classes and interfaces tagged `@api` in a layer do not extend a scanned class or interface tagged `@internal`, directly or through any parent, which would expose its members through the public API. Unscanned parents are skipped, as their docblock is unknown. |
| `ClassConstantNameMustBeUpperCaseRule` | `new ClassConstantNameMustBeUpperCaseRule(layer: 'Domain')` | Class, interface, and trait constants use upper case with underscore separators. Enums are skipped (PER Coding Style recommends PascalCase enum constants). |
| `ClassImplementingInterfaceMustHaveSuffixRule` | `new ClassImplementingInterfaceMustHaveSuffixRule(layer: 'HTTP', interface: MiddlewareInterface::class, suffix: 'Middleware')` | Classes implementing a specific interface use the required suffix. |
| `ClassNameMustBeStudlyCapsRule` | `new ClassNameMustBeStudlyCapsRule(layer: 'Source')` | Class names use StudlyCaps. |
Expand Down
13 changes: 13 additions & 0 deletions docs/cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,16 @@ You can also clear cache directly:
```bash
vendor/bin/structarmed --clear-cache
```

## Disabling Cache

Use `--no-cache` to run analysis without caching. StructArmed skips file hashing and neither reads nor writes cache entries. Skipping this work can make analysis faster on very huge codebases.

```bash
vendor/bin/structarmed analyse --no-cache
```

{: .important }
Keep in mind that every run with `--no-cache` repeats the full analysis, so similar runs will take roughly the same amount of time. With caching enabled, later runs can reuse cached results and finish faster.

The existing cache is left untouched. `--no-cache` cannot be combined with `--clear-cache`; clear the cache separately with `vendor/bin/structarmed --clear-cache`.
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ vendor/bin/structarmed init --preset=mvc
vendor/bin/structarmed init --preset=ddd
vendor/bin/structarmed init --preset=yagni
vendor/bin/structarmed init --preset=codequality
vendor/bin/structarmed init --preset=stability
vendor/bin/structarmed init --preset=all
```

Expand Down
29 changes: 29 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,35 @@ Use `replaceRule()` to swap a preset rule's configuration:

`replaceRule()` throws `RuleNotFoundException` if the target key does not exist, so typos are caught immediately.

## Importing Config Files

Use `import()` to split a large config into per-module files:

```php
<?php

use Boundwize\StructArmed\Architecture;

return Architecture::define()
->import(__DIR__ . '/module/Foo/structarmed.php')
->import(__DIR__ . '/module/Bar/structarmed.php');
```

Each imported file returns a callable that receives the root `Architecture`:

```php
<?php

// module/Foo/structarmed.php
use Boundwize\StructArmed\Architecture;

return static function (Architecture $architecture): void {
$architecture->layer('Foo', 'module/Foo/src/');
};
```

Paths in imported files are still resolved from the project root. Imported files can import other files. Changing any imported file invalidates the analysis cache, just like changing the main config.

## Custom Extensions

Use [Custom Rules And Presets](../custom-rules-and-presets/) when you want to add project-specific rules or package reusable rule sets.
Expand Down
50 changes: 49 additions & 1 deletion docs/custom-rules-and-presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,54 @@ return Architecture::define()
);
```

## Reading @api And @internal Tags In A Custom Rule

Each `ClassNode` records whether the docblock of its class, interface, trait, or enum declaration carries an `@api` or `@internal` tag:

| Flag | Meaning |
| --- | --- |
| `$classNode->isApi` | The class-like docblock has an `@api` tag |
| `$classNode->isInternal` | The class-like docblock has an `@internal` tag |

A tag counts when it starts a docblock line, also when a description follows, such as `@internal only used by the analyser`. A tag name mentioned within the description does not count, and neither do longer tag names such as `@apiVersion` and `@api-version` or the phpDocumentor inline `{@internal ...}}` form, which marks part of the description as internal rather than the class-like itself. Member docblocks are not read.

The built-in `ApiMayNotBeInternalRule` and `ApiMayNotExtendInternalRule` read both flags. The example below enforces that every class-like in a layer declares one of the two tags:

```php
<?php

namespace App\Architecture\Rules;

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

use function sprintf;

final readonly class ClassMustDeclareApiOrInternalRule implements RuleInterface
{
public function appliesTo(ClassNode $classNode): bool
{
return $classNode->isInLayer('Core');
}

public function evaluate(ClassNode $classNode): ?RuleViolation
{
if ($classNode->isApi || $classNode->isInternal) {
return null;
}

return new RuleViolation(
message: sprintf('%s [%s] must be annotated @api or @internal', $classNode->getType(), $classNode->className),
file: $classNode->file,
line: $classNode->line,
className: $classNode->className,
layer: $classNode->layer,
);
}
}
```

## Reading Usage Flags In A Custom Rule

Usage flags are opt-in because collecting them costs extra analysis time. Before reading a usage flag, a custom rule must declare that it needs the data by implementing the corresponding marker interface instead of the plain `Boundwize\StructArmed\Rule\RuleInterface`. Each marker extends `RuleInterface`, so no other change is needed:
Expand Down Expand Up @@ -205,7 +253,7 @@ Trade-off: only usage within the scanned paths is known. A class-like used solel

## Reading Other Classes' Layers In A Custom Rule

A rule only receives the node it is evaluating. When a check depends on the layer of *another* class — the layer a dependency belongs to, for instance — extend `Boundwize\StructArmed\Rule\AbstractLayerAwareRule`. Before any class is evaluated, the analyser injects the map of every scanned class into its protected `$classNodeMap` property, keyed by fully qualified class name, and `getDependencyNode()` looks a class up in it:
A rule only receives the node it is evaluating. When a check depends on the layer of *another* class — the layer a dependency belongs to, for instance — extend `Boundwize\StructArmed\Rule\AbstractLayerAwareRule`. Before any class is evaluated, the analyser injects the map of every scanned class into its protected `$classNodeMap` property, keyed by fully qualified class name, and `getDependencyNode()` looks a class up in it, ignoring case as PHP does for class names:

```php
<?php
Expand Down
3 changes: 3 additions & 0 deletions docs/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ StructArmed ships with presets for common PHP standards and architecture styles.
| `Preset::MVC()` | Layer isolation, thin controllers, model/view/service rules, return types for helper functions |
| `Preset::YAGNI()` | Speculative-abstraction cleanup: interfaces must be implemented by a class or extended by another interface, abstract classes must be extended, traits must be used, extended classes that are never instantiated must be abstract, and named functions must be called — a dependency reference (type hint, `instanceof`, `::class`, static call, a class-name string, ...) also counts as usage within the scanned paths, while only instantiation (`new X`, `new self`/`static`/`parent`, or a constant class expression such as `new (X::class)`) keeps an extended class concrete. All rules support `--fix`, removing the unused declaration or adding the `abstract` modifier |
| `Preset::CODEQUALITY()` | General readability conventions independent of any architecture style: closures and arrow functions that do not read `$this` must be declared `static`, and plain decimal numeric literals of `1_000_000` or more must group their digits with `_` separators (`1000500` becomes `1_000_500`). Both rules support `--fix`. Tune the literal threshold with `replaceRule(CodeQualityPreset::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR, new LargeNumericLiteralMustUseSeparatorRule(minimum: 1_000))` |
| `Preset::STABILITY()` | Keeps the stable `@api` surface apart from `@internal` code: a class-like must not be tagged both `@api` and `@internal`, and an `@api` class or interface must not extend a scanned `@internal` one, directly or through any parent. Class-likes without either tag are not checked |

## Initialize Presets

Expand All @@ -41,6 +42,7 @@ vendor/bin/structarmed init --preset=mvc
vendor/bin/structarmed init --preset=ddd
vendor/bin/structarmed init --preset=yagni
vendor/bin/structarmed init --preset=codequality
vendor/bin/structarmed init --preset=stability
vendor/bin/structarmed init --preset=all
```

Expand All @@ -58,6 +60,7 @@ return Architecture::define()
Preset::DDD(),
Preset::YAGNI(),
Preset::CODEQUALITY(),
Preset::STABILITY(),
);
```

Expand Down
4 changes: 4 additions & 0 deletions docs/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ vendor/bin/structarmed init --preset=yagni
# Static closures and digit separators in large numeric literals
vendor/bin/structarmed init --preset=codequality

# @api class-likes kept apart from @internal ones
vendor/bin/structarmed init --preset=stability

# Enable every preset at once
vendor/bin/structarmed init --preset=all
```
Expand Down Expand Up @@ -112,5 +115,6 @@ return Architecture::define()
Preset::DDD(),
Preset::CODEQUALITY(),
Preset::YAGNI(),
Preset::STABILITY(),
)
```
17 changes: 17 additions & 0 deletions src/Analyser/AnalysisNodeCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Boundwize\StructArmed\Util\PhpParser\ObjectBoundAnonymousFunction;
use Boundwize\StructArmed\Util\PhpParser\UnconditionallyDeclaredFunctions;
use Boundwize\StructArmed\Util\PhpParser\VisibilityFlagChecker;
use PhpParser\Comment\Doc;
use PhpParser\ConstExprEvaluationException;
use PhpParser\ConstExprEvaluator;
use PhpParser\Node;
Expand Down Expand Up @@ -1689,6 +1690,8 @@ enumBackingType: $classLike instanceof Enum_ && $classLike->scalarType instan
: null,
nonClassDependencies: $analysis['nonClassDependencies'],
constantFetches: $analysis['constantFetches'],
isApi: $this->hasDocBlockTag($classLike, 'api'),
isInternal: $this->hasDocBlockTag($classLike, 'internal'),
);
}

Expand Down Expand Up @@ -1829,6 +1832,20 @@ private function resolveClassName(ClassLike $classLike): string
: (string) $classLike->name;
}

/**
* Whether the docblock carries the $tag tag, e.g. `@api`. A tag starts a
* docblock line, so the tag name mentioned in the description, a longer
* tag such as `@api-version`, or an inline `{@internal ...}` comment does
* not count.
*/
private function hasDocBlockTag(ClassLike $classLike, string $tag): bool
{
$docComment = $classLike->getDocComment();

return $docComment instanceof Doc
&& preg_match('#^[/*\s]*@' . $tag . '(?![\w-])#m', $docComment->getText()) === 1;
}

/**
* @return array{
* dependencies: list<string>,
Expand Down
4 changes: 4 additions & 0 deletions src/Analyser/ClassNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ final class ClassNode
* @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
* @param bool $isApi Whether the docblock marks it as @api
* @param bool $isInternal Whether the docblock marks it as @internal
*/
public function __construct(
public readonly string $className,
Expand Down Expand Up @@ -75,6 +77,8 @@ public function __construct(
public readonly ?string $enumBackingType = null,
public readonly array $nonClassDependencies = [],
public readonly array $constantFetches = [],
public readonly bool $isApi = false,
public readonly bool $isInternal = false,
) {
$this->layers = $layers ?: array_filter([$this->layer]);
}
Expand Down
8 changes: 8 additions & 0 deletions src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
use function min;
use function mkdir;
use function proc_close;
use function rmdir;
use function serialize;
use function sprintf;
use function stream_set_blocking;
Expand Down Expand Up @@ -334,6 +335,13 @@ public function extract(
}
}

// when --no-cache is used, the directory only held worker temporary files:
// remove it best-effort, rmdir() refuses a non-empty directory atomically,
// so a concurrent run's files or an existing cache keep it in place
if (! $this->analysisResultCache instanceof AnalysisResultCache) {
@rmdir($cacheDirectory);
}

if ($failures !== []) {
throw new RuntimeException(implode("\n", $failures));
}
Expand Down
61 changes: 61 additions & 0 deletions src/Architecture.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
use function array_merge;
use function array_unique;
use function array_values;
use function is_callable;
use function is_int;
use function realpath;
use function sprintf;

/**
Expand Down Expand Up @@ -112,6 +114,12 @@ final class Architecture

private ?string $baseline = null;

/** @var list<string> config files applied through import(), nested imports included */
private array $importedFiles = [];

/** @var array<string, true> config files whose import() is still running */
private array $importing = [];

private function __construct()
{
}
Expand All @@ -121,6 +129,53 @@ public static function define(): self
return new self();
}

/**
* Apply another config file, which returns a callable that receives this
* Architecture:
*
* return Architecture::define()
* ->import(__DIR__ . '/module/Foo/structarmed.php')
* ->import(__DIR__ . '/module/Bar/structarmed.php');
*
* // module/Foo/structarmed.php
* return static function (Architecture $architecture): void {
* $architecture->layer('Foo', 'module/Foo/src/');
* };
*
* Imported files are part of the config cache key, so changing one
* invalidates the analysis cache.
*/
public function import(string $file): self
{
$importedFile = realpath($file) ?: $file;

// only imports still in progress are tracked, so a file imported twice
// through separate branches is applied again rather than rejected
if (isset($this->importing[$importedFile])) {
throw new InvalidArgumentException(sprintf('Circular import of configuration [%s].', $file));
}

$configure = require $file;

if (! is_callable($configure)) {
throw new InvalidArgumentException(
sprintf('Imported configuration [%s] must return a callable.', $file)
);
}

$this->importing[$importedFile] = true;

try {
$configure($this);
} finally {
unset($this->importing[$importedFile]);
}

$this->importedFiles[] = $importedFile;

return $this;
}

// -------------------------------------------------------------------------
// Layer definition
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -492,6 +547,12 @@ public function getBaseline(): ?string
return $this->baseline;
}

/** @return list<string> */
public function getImportedFiles(): array
{
return $this->importedFiles;
}

private function registerPendingSkip(string $skip): void
{
if (isset($this->rules[$skip])) {
Expand Down
11 changes: 11 additions & 0 deletions src/Cache/AnalysisCacheMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ public function fileHash(string $path): string
return $this->fileHashProvider->hash($path);
}

/**
* Files merged through Architecture::import() shape the config as much as
* the config file itself, so a change to any of them must change the hash.
*
* @param list<string> $importedFiles
*/
public function configHash(string $configPath, array $importedFiles): string
{
return $this->filesHash([$configPath, ...$importedFiles]);
}

/**
* Cached analysis nodes store resolved layer assignments, which depend on the
* composer.json PSR-4 mappings as well as the config, so both hashes must
Expand Down
Loading
Loading