diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml
index e3f22286..e664b60f 100644
--- a/.github/workflows/static-analysis.yml
+++ b/.github/workflows/static-analysis.yml
@@ -8,6 +8,7 @@ on: # yamllint disable-line rule:truthy
- '.php-cs-fixer.dist.php'
- 'psalm*'
- 'composer.*'
+ - 'tools/structarmed/**'
push:
paths:
- 'src/**'
@@ -15,6 +16,7 @@ on: # yamllint disable-line rule:truthy
- '.php-cs-fixer.dist.php'
- 'psalm*'
- 'composer.*'
+ - 'tools/structarmed/**'
name: π Static analysis
@@ -58,3 +60,32 @@ jobs:
- name: π Run static analysis using vimeo/psalm
run: composer psalm:ci
+
+ structarmed:
+ timeout-minutes: 4
+ runs-on: ubuntu-latest
+ concurrency:
+ cancel-in-progress: true
+ group: structarmed-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ steps:
+ - name: π¦ Check out the codebase
+ uses: actions/checkout@v7
+
+ - name: π οΈ Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ ini-values: error_reporting=E_ALL
+ coverage: none
+
+ - name: π₯ Install dependencies with composer
+ uses: ramsey/composer-install@v3
+ with:
+ dependency-versions: locked
+
+ # StructArmed lives in its own tools/structarmed namespace, which the root install skips.
+ - name: π₯ Install StructArmed
+ run: composer bin structarmed install --ansi
+
+ - name: ποΈ Check architecture rules using boundwize/structarmed
+ run: composer structarmed:ci
diff --git a/.gitignore b/.gitignore
index 88afc22d..6368b027 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,6 @@
/.env
*.log
*.exe
+/tools/*/vendor/
+/tools/*/.agents/
+/tools/*/.claude/
diff --git a/AGENTS.md b/AGENTS.md
index 11eab9d4..4ab7b784 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,6 +6,7 @@ A PHP CLI (`vendor/bin/dload`, also shipped as a PHAR) that downloads binaries a
- `resources/software.json` is the built-in software registry; `dload.xsd` is the schema of `dload.xml`.
- `1.x` is the development and release branch: branch from it and target pull requests at it.
- Tests run on Testo: `vendor/bin/testo --json`. Static analysis: `composer psalm`. Code style: `composer cs:fix`.
+- Architecture rules run on StructArmed: `composer structarmed`, after a one-time `composer bin structarmed install` (it lives isolated in `tools/structarmed`). `structarmed.php` there defines the layers and rules; `baseline.php` is known debt β after cutting some, shrink it with `composer structarmed:baseline`.
## Docs and skills follow behaviour
diff --git a/composer.json b/composer.json
index 1c50040e..3e66ce73 100644
--- a/composer.json
+++ b/composer.json
@@ -34,6 +34,7 @@
"symfony/http-client": "^6.4 || ^7 || ^8"
},
"require-dev": {
+ "bamarni/composer-bin-plugin": "^1.9",
"buggregator/trap": "^1.15",
"composer/semver": "^3.4",
"dereuromark/composer-prefer-lowest": "^0.1.10",
@@ -65,6 +66,7 @@
],
"config": {
"allow-plugins": {
+ "bamarni/composer-bin-plugin": true,
"ergebnis/composer-normalize": true,
"infection/extension-installer": true,
"llm/skills": true
@@ -75,6 +77,11 @@
"sort-packages": true
},
"extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false,
+ "target-directory": "tools"
+ },
"skills": {
"source": "skills"
}
@@ -95,6 +102,9 @@
"psalm:ci": "psalm --output-format=github --shepherd --show-info=false --stats --threads=4",
"refactor": "rector process --config=rector.php",
"refactor:ci": "rector process --config=rector.php --dry-run --ansi",
+ "structarmed": "structarmed analyse --config=tools/structarmed/structarmed.php",
+ "structarmed:baseline": "structarmed analyse --config=tools/structarmed/structarmed.php --generate-baseline=tools/structarmed/baseline.php",
+ "structarmed:ci": "structarmed analyse --config=tools/structarmed/structarmed.php --no-progress --report=github",
"test": [
"testo --ansi --no-coverage"
],
diff --git a/composer.lock b/composer.lock
index e3ca6011..6bf3a55e 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "f04fa5e31f0a6538da2ab1cf1e81de59",
+ "content-hash": "191315420ac028ae46592413a6755d20",
"packages": [
{
"name": "internal/container",
@@ -2665,6 +2665,63 @@
],
"time": "2024-08-03T19:31:26+00:00"
},
+ {
+ "name": "bamarni/composer-bin-plugin",
+ "version": "1.9.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/bamarni/composer-bin-plugin.git",
+ "reference": "641d0663f5ac270b1aeec4337b7856f76204df47"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/641d0663f5ac270b1aeec4337b7856f76204df47",
+ "reference": "641d0663f5ac270b1aeec4337b7856f76204df47",
+ "shasum": ""
+ },
+ "require": {
+ "composer-plugin-api": "^2.0",
+ "php": "^7.2.5 || ^8.0"
+ },
+ "require-dev": {
+ "composer/composer": "^2.2.26",
+ "ext-json": "*",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.8 || ^2.0",
+ "phpstan/phpstan-phpunit": "^1.1 || ^2.0",
+ "phpunit/phpunit": "^8.5 || ^9.6 || ^10.0",
+ "symfony/console": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0",
+ "symfony/finder": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0",
+ "symfony/process": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "class": "Bamarni\\Composer\\Bin\\BamarniBinPlugin"
+ },
+ "autoload": {
+ "psr-4": {
+ "Bamarni\\Composer\\Bin\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "No conflicts for your bin dependencies",
+ "keywords": [
+ "composer",
+ "conflict",
+ "dependency",
+ "executable",
+ "isolation",
+ "tool"
+ ],
+ "support": {
+ "issues": "https://github.com/bamarni/composer-bin-plugin/issues",
+ "source": "https://github.com/bamarni/composer-bin-plugin/tree/1.9.1"
+ },
+ "time": "2026-02-04T10:18:12+00:00"
+ },
{
"name": "buggregator/trap",
"version": "1.16.1",
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 0dabe4f3..da52755d 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -180,11 +180,6 @@
-
-
-
-
-
'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Registry\Internal\StoredVersionRegistry] depends on module [Repository], closing the module cycle [Registry -> Repository -> Registry]',
+ 'file' => 'src/Module/Registry/Internal/StoredVersionRegistry.php',
+ 'class' => 'Internal\DLoad\Module\Registry\Internal\StoredVersionRegistry',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Interface [Internal\DLoad\Module\Registry\ReleaseSource] depends on module [Repository], closing the module cycle [Registry -> Repository -> Registry]',
+ 'file' => 'src/Module/Registry/ReleaseSource.php',
+ 'class' => 'Internal\DLoad\Module\Registry\ReleaseSource',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Interface [Internal\DLoad\Module\Registry\VersionRegistry] depends on module [Repository], closing the module cycle [Registry -> Repository -> Registry]',
+ 'file' => 'src/Module/Registry/VersionRegistry.php',
+ 'class' => 'Internal\DLoad\Module\Registry\VersionRegistry',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Collection\CompositeRepository] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Collection/CompositeRepository.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Collection\CompositeRepository',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitHub\Api\RepositoryApi] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitHub/Api/RepositoryApi.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitHub\Api\RepositoryApi',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitHub\Api\Response\AssetInfo] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitHub/Api/Response/AssetInfo.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitHub\Api\Response\AssetInfo',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitHub\Api\Response\ReleaseInfo] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitHub/Api/Response/ReleaseInfo.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitHub\Api\Response\ReleaseInfo',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitHub\Factory] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitHub/Factory.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitHub\Factory',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitHub\GitHubAsset] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitHub/GitHubAsset.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitHub\GitHubAsset',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitHub\GitHubRelease] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitHub/GitHubRelease.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitHub\GitHubRelease',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitHub\GitHubRepository] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitHub/GitHubRepository.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitHub\GitHubRepository',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitLab\Api\RepositoryApi] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitLab\Api\RepositoryApi',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitLab\Api\Response\AssetInfo] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitLab/Api/Response/AssetInfo.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitLab\Api\Response\AssetInfo',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitLab\Api\Response\ReleaseInfo] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitLab/Api/Response/ReleaseInfo.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitLab\Api\Response\ReleaseInfo',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitLab\Factory] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitLab/Factory.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitLab\Factory',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitLab\GitLabAsset] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitLab/GitLabAsset.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitLab\GitLabAsset',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitLab\GitLabRelease] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitLab/GitLabRelease.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitLab\GitLabRelease',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\GitLab\GitLabRepository] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/GitLab/GitLabRepository.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\GitLab\GitLabRepository',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Class [Internal\DLoad\Module\Repository\Internal\PagedReleaseSource] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Internal/PagedReleaseSource.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Internal\PagedReleaseSource',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_cyclically',
+ 'message' => 'Interface [Internal\DLoad\Module\Repository\Repository] depends on module [Registry], closing the module cycle [Repository -> Registry -> Repository]',
+ 'file' => 'src/Module/Repository/Repository.php',
+ 'class' => 'Internal\DLoad\Module\Repository\Repository',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'internal.is_usable_only_within_its_module',
+ 'message' => 'Class [Internal\DLoad\Module\Software\ComposerPackages] must not use internal [Internal\DLoad\Module\Common\Internal\Injection\ConfigInflector] β it is private to package [Internal\DLoad\Module\Common]',
+ 'file' => 'src/Module/Software/ComposerPackages.php',
+ 'class' => 'Internal\DLoad\Module\Software\ComposerPackages',
+ 'layer' => 'Modules',
+ ],
+ [
+ 'rule' => 'modules.must_not_depend_on_app',
+ 'message' => 'Class [Internal\DLoad\Module\Velox\Internal\DependencyChecker] in layer [Modules] must not depend on [Internal\DLoad\DLoad] which belongs to layer [App]',
+ 'file' => 'src/Module/Velox/Internal/DependencyChecker.php',
+ 'class' => 'Internal\DLoad\Module\Velox\Internal\DependencyChecker',
+ 'layer' => 'Modules',
+ ],
+];
diff --git a/tools/structarmed/composer.json b/tools/structarmed/composer.json
new file mode 100644
index 00000000..f2c5f32b
--- /dev/null
+++ b/tools/structarmed/composer.json
@@ -0,0 +1,14 @@
+{
+ "require": {
+ "boundwize/structarmed": "^0.19.10",
+ "roxblnfk/unpoly": "^1.8.2"
+ },
+ "autoload": {
+ "psr-4": {
+ "Internal\\DLoad\\Tools\\StructArmed\\": "src/"
+ }
+ },
+ "config": {
+ "platform-check": false
+ }
+}
diff --git a/tools/structarmed/composer.lock b/tools/structarmed/composer.lock
new file mode 100644
index 00000000..917bf45f
--- /dev/null
+++ b/tools/structarmed/composer.lock
@@ -0,0 +1,341 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "bbbd4875e8923a2dbe40b05a18f95055",
+ "packages": [
+ {
+ "name": "boundwize/jsonrecast",
+ "version": "1.0.37",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/boundwize/jsonrecast.git",
+ "reference": "d37e0d808cd5c0a8f6e5d6f36ea363202950e399"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/boundwize/jsonrecast/zipball/d37e0d808cd5c0a8f6e5d6f36ea363202950e399",
+ "reference": "d37e0d808cd5c0a8f6e5d6f36ea363202950e399",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.2"
+ },
+ "require-dev": {
+ "boundwize/pyrameter": "^0.5",
+ "boundwize/structarmed": "^0.19",
+ "laminas/laminas-coding-standard": "^3.1",
+ "phpstan/phpstan": "^2.2",
+ "phpunit/phpunit": "^11.5",
+ "rector/rector": "^2.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Boundwize\\JsonRecast\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Abdul Malik Ikhsan",
+ "email": "samsonasik@gmail.com",
+ "homepage": "http://samsonasik.wordpress.com",
+ "role": "Developer"
+ }
+ ],
+ "description": "Editable JSON AST with visitor traversal and formatting-preserving printing.",
+ "homepage": "https://github.com/boundwize/jsonrecast",
+ "keywords": [
+ "ast",
+ "formatting",
+ "json",
+ "parser",
+ "refactoring"
+ ],
+ "support": {
+ "issues": "https://github.com/boundwize/jsonrecast/issues",
+ "source": "https://github.com/boundwize/jsonrecast/tree/1.0.37"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/samsonasik",
+ "type": "github"
+ }
+ ],
+ "time": "2026-10-10T08:53:11+00:00"
+ },
+ {
+ "name": "boundwize/structarmed",
+ "version": "0.19.10",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/boundwize/structarmed.git",
+ "reference": "2733bae2c04254cb36f08cf96c3cd56bc4cb62a8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/boundwize/structarmed/zipball/2733bae2c04254cb36f08cf96c3cd56bc4cb62a8",
+ "reference": "2733bae2c04254cb36f08cf96c3cd56bc4cb62a8",
+ "shasum": ""
+ },
+ "require": {
+ "boundwize/jsonrecast": "^1.0",
+ "composer-runtime-api": "^2.0",
+ "fidry/cpu-core-counter": "^1.4",
+ "nikic/php-parser": "^5.9",
+ "php": "^8.2"
+ },
+ "require-dev": {
+ "boundwize/pyrameter": "^0.5",
+ "laminas/laminas-coding-standard": "^3.1",
+ "phpstan/phpstan": "^2.2",
+ "phpunit/phpunit": "^11.5",
+ "rector/rector": "dev-main"
+ },
+ "bin": [
+ "bin/structarmed"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Boundwize\\StructArmed\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Abdul Malik Ikhsan",
+ "email": "samsonasik@gmail.com",
+ "homepage": "http://samsonasik.wordpress.com",
+ "role": "Developer"
+ }
+ ],
+ "description": "Configurable PHP architecture guards β define your layers and rules, then keep them enforced",
+ "homepage": "https://github.com/boundwize/structarmed",
+ "keywords": [
+ "Enforcement",
+ "analysis",
+ "architecture",
+ "ddd",
+ "dev",
+ "layer",
+ "mvc",
+ "psr1",
+ "psr12",
+ "psr15",
+ "psr4",
+ "static analysis",
+ "yagni"
+ ],
+ "support": {
+ "issues": "https://github.com/boundwize/structarmed/issues",
+ "source": "https://github.com/boundwize/structarmed/tree/0.19.10"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/samsonasik",
+ "type": "github"
+ }
+ ],
+ "time": "2026-10-26T15:02:09+00:00"
+ },
+ {
+ "name": "fidry/cpu-core-counter",
+ "version": "1.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theofidry/cpu-core-counter.git",
+ "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/d384a7d0d6e67aa497327260ca95023e92dc2032",
+ "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "fidry/makefile": "^0.2.0 || ^1.1.2",
+ "fidry/php-cs-fixer-config": "^1.1.2",
+ "phpstan/extension-installer": "^1.2.0",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-deprecation-rules": "^2.0.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^8.5.31 || ^9.5.26",
+ "symfony/polyfill-mbstring": "^1.33",
+ "webmozarts/strict-phpunit": "^7.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Fidry\\CpuCoreCounter\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "ThΓ©o FIDRY",
+ "email": "theo.fidry@gmail.com"
+ }
+ ],
+ "description": "Tiny utility to get the number of CPU cores.",
+ "keywords": [
+ "CPU",
+ "core"
+ ],
+ "support": {
+ "issues": "https://github.com/theofidry/cpu-core-counter/issues",
+ "source": "https://github.com/theofidry/cpu-core-counter/tree/1.4.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theofidry",
+ "type": "github"
+ }
+ ],
+ "time": "2026-09-29T22:11:39+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.9.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def",
+ "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0"
+ },
+ "time": "2026-09-13T18:51:52+00:00"
+ },
+ {
+ "name": "roxblnfk/unpoly",
+ "version": "1.8.4.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/roxblnfk/unpoly.git",
+ "reference": "ad31e0109d3c74f320532f1150929aa4a58f7876"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/roxblnfk/unpoly/zipball/ad31e0109d3c74f320532f1150929aa4a58f7876",
+ "reference": "ad31e0109d3c74f320532f1150929aa4a58f7876",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "replace": {
+ "symfony/polyfill-php73": "*",
+ "symfony/polyfill-php74": "*",
+ "symfony/polyfill-php80": "*",
+ "symfony/polyfill-php81": "*",
+ "symfony/polyfill-php82": "*",
+ "symfony/polyfill-php83": "*",
+ "symfony/polyfill-php84": "*"
+ },
+ "type": "metapackage",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aleksei Gagarin (roxblnfk)",
+ "homepage": "https://github.com/roxblnfk"
+ }
+ ],
+ "description": "Remove unnecessary PHP polyfills",
+ "keywords": [
+ "cleaning",
+ "php",
+ "polyfill",
+ "polyfill-php"
+ ],
+ "support": {
+ "issues": "https://github.com/roxblnfk/unpoly/issues",
+ "source": "https://github.com/roxblnfk/unpoly/tree/1.8.4.6"
+ },
+ "funding": [
+ {
+ "url": "https://patreon.com/roxblnfk",
+ "type": "patreon"
+ }
+ ],
+ "time": "2025-11-20T19:51:54+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {},
+ "platform-dev": {},
+ "plugin-api-version": "2.9.0"
+}
diff --git a/tools/structarmed/src/Rule/InternalClassMustBeFinalRule.php b/tools/structarmed/src/Rule/InternalClassMustBeFinalRule.php
new file mode 100644
index 00000000..acc22c77
--- /dev/null
+++ b/tools/structarmed/src/Rule/InternalClassMustBeFinalRule.php
@@ -0,0 +1,46 @@
+isClass()
+ && ! $classNode->isAbstract
+ && $classNode->isInLayer($this->layer)
+ && ClassAnnotations::hasInternal($classNode->file, $classNode->shortName());
+ }
+
+ public function evaluate(ClassNode $classNode): ?RuleViolation
+ {
+ // A class another scanned class extends cannot be made final without breaking the child.
+ if ($classNode->isFinal || $classNode->isExtended) {
+ return null;
+ }
+
+ return new RuleViolation(
+ message: \sprintf('Internal class [%s] must be declared final', $classNode->className),
+ file: $classNode->file,
+ line: $classNode->line,
+ className: $classNode->className,
+ layer: $classNode->layer,
+ );
+ }
+}
diff --git a/tools/structarmed/src/Rule/InternalIsUsableOnlyWithinItsPackageRule.php b/tools/structarmed/src/Rule/InternalIsUsableOnlyWithinItsPackageRule.php
new file mode 100644
index 00000000..fe108f50
--- /dev/null
+++ b/tools/structarmed/src/Rule/InternalIsUsableOnlyWithinItsPackageRule.php
@@ -0,0 +1,97 @@
+> $allowed consumer class or namespace prefix => internal
+ * namespace prefixes it may use anyway, for consumers whose job is to reach inside
+ * (a composition root wiring implementations)
+ */
+ public function __construct(
+ private string $layer,
+ private array $allowed = [],
+ ) {}
+
+ public function appliesTo(ClassNode $classNode): bool
+ {
+ return $classNode->isInLayer($this->layer);
+ }
+
+ public function evaluate(ClassNode $classNode): ?RuleViolation
+ {
+ return $this->evaluateAll($classNode)[0] ?? null;
+ }
+
+ /**
+ * @return list
+ */
+ public function evaluateAll(ClassNode $classNode): array
+ {
+ $violations = [];
+
+ foreach ($classNode->dependencies as $dependency) {
+ $position = \strpos($dependency, self::SEGMENT);
+
+ if ($position === false) {
+ continue;
+ }
+
+ $owner = \substr($dependency, 0, $position);
+
+ if (\str_starts_with($classNode->className, $owner . '\\') || $this->isAllowed($classNode->className, $dependency)) {
+ continue;
+ }
+
+ $violations[] = new RuleViolation(
+ message: \sprintf(
+ '%s [%s] must not use internal [%s] β it is private to package [%s]',
+ $classNode->getType(),
+ $classNode->className,
+ $dependency,
+ $owner,
+ ),
+ file: $classNode->file,
+ line: $classNode->line,
+ className: $classNode->className,
+ layer: $classNode->layer,
+ );
+ }
+
+ return $violations;
+ }
+
+ private function isAllowed(string $consumer, string $dependency): bool
+ {
+ foreach ($this->allowed as $consumerPrefix => $internalPrefixes) {
+ if ($consumer !== $consumerPrefix && !\str_starts_with($consumer, $consumerPrefix . '\\')) {
+ continue;
+ }
+
+ foreach ($internalPrefixes as $internalPrefix) {
+ if ($dependency === $internalPrefix || \str_starts_with($dependency, $internalPrefix . '\\')) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/tools/structarmed/src/Rule/InternalMustNotLeakIntoPublicSignatureRule.php b/tools/structarmed/src/Rule/InternalMustNotLeakIntoPublicSignatureRule.php
new file mode 100644
index 00000000..a3814b26
--- /dev/null
+++ b/tools/structarmed/src/Rule/InternalMustNotLeakIntoPublicSignatureRule.php
@@ -0,0 +1,69 @@
+isInLayer($this->layer) && !\str_contains($classNode->className, self::SEGMENT);
+ }
+
+ public function evaluate(ClassNode $classNode): ?RuleViolation
+ {
+ return $this->evaluateAll($classNode)[0] ?? null;
+ }
+
+ /**
+ * @return list
+ */
+ public function evaluateAll(ClassNode $classNode): array
+ {
+ $violations = [];
+
+ foreach (ClassSignatures::publicTypes($classNode->file, $classNode->shortName()) as $type) {
+ if (! \str_contains($type, self::SEGMENT)) {
+ continue;
+ }
+
+ $violations[] = new RuleViolation(
+ message: \sprintf(
+ '%s [%s] exposes internal type [%s] in its public signature',
+ $classNode->getType(),
+ $classNode->className,
+ $type,
+ ),
+ file: $classNode->file,
+ line: $classNode->line,
+ className: $classNode->className,
+ layer: $classNode->layer,
+ );
+ }
+
+ return $violations;
+ }
+}
diff --git a/tools/structarmed/src/Rule/InternalNamespaceMustBeAnnotatedInternalRule.php b/tools/structarmed/src/Rule/InternalNamespaceMustBeAnnotatedInternalRule.php
new file mode 100644
index 00000000..1b2d111a
--- /dev/null
+++ b/tools/structarmed/src/Rule/InternalNamespaceMustBeAnnotatedInternalRule.php
@@ -0,0 +1,38 @@
+isInLayer($this->layer)
+ && \str_contains($classNode->className, '\\Internal\\');
+ }
+
+ public function evaluate(ClassNode $classNode): ?RuleViolation
+ {
+ return ClassAnnotations::hasInternal($classNode->file, $classNode->shortName()) ? null : new RuleViolation(
+ message: \sprintf('%s [%s] lives under an Internal namespace and must be annotated @internal', $classNode->getType(), $classNode->className),
+ file: $classNode->file,
+ line: $classNode->line,
+ className: $classNode->className,
+ layer: $classNode->layer,
+ );
+ }
+}
diff --git a/tools/structarmed/src/Rule/ModulesMustNotDependCyclicallyRule.php b/tools/structarmed/src/Rule/ModulesMustNotDependCyclicallyRule.php
new file mode 100644
index 00000000..c23259c8
--- /dev/null
+++ b/tools/structarmed/src/Rule/ModulesMustNotDependCyclicallyRule.php
@@ -0,0 +1,172 @@
+>|null module => modules it depends on */
+ private ?array $graph = null;
+
+ public function __construct(
+ private readonly string $layer,
+ private readonly string $modulesNamespace,
+ ) {}
+
+ public function appliesTo(ClassNode $classNode): bool
+ {
+ return $classNode->isInLayer($this->layer) && $this->module($classNode->className) !== null;
+ }
+
+ public function evaluate(ClassNode $classNode): ?RuleViolation
+ {
+ return $this->evaluateAll($classNode)[0] ?? null;
+ }
+
+ /**
+ * @return list
+ */
+ public function evaluateAll(ClassNode $classNode): array
+ {
+ $from = (string) $this->module($classNode->className);
+ $violations = [];
+
+ foreach ($this->dependencyModules($classNode, $from) as $to) {
+ $back = $this->path($to, $from);
+
+ if ($back === null) {
+ continue;
+ }
+
+ $violations[] = new RuleViolation(
+ message: \sprintf(
+ '%s [%s] depends on module [%s], closing the module cycle [%s]',
+ $classNode->getType(),
+ $classNode->className,
+ $to,
+ \implode(' -> ', [$from, ...$back]),
+ ),
+ file: $classNode->file,
+ line: $classNode->line,
+ className: $classNode->className,
+ layer: $classNode->layer,
+ );
+ }
+
+ return $violations;
+ }
+
+ private function module(string $className): ?string
+ {
+ $prefix = $this->modulesNamespace . '\\';
+
+ if (!\str_starts_with($className, $prefix)) {
+ return null;
+ }
+
+ $module = \strstr(\substr($className, \strlen($prefix)), '\\', true);
+
+ return $module === false || $module === '' ? null : $module;
+ }
+
+ /**
+ * Other modules of the layer the class depends on, sorted.
+ *
+ * @return list
+ */
+ private function dependencyModules(ClassNode $classNode, string $from): array
+ {
+ $modules = [];
+
+ foreach ($classNode->dependencies as $dependency) {
+ $node = $this->getDependencyNode($dependency);
+
+ if ($node === null || !$node->isInLayer($this->layer)) {
+ continue;
+ }
+
+ $to = $this->module($node->className);
+ $to === null || $to === $from or $modules[$to] = true;
+ }
+
+ $modules = \array_keys($modules);
+ \sort($modules);
+
+ return $modules;
+ }
+
+ /**
+ * Shortest module path from $start to $goal, breadth-first, both ends included.
+ *
+ * @return list|null
+ */
+ private function path(string $start, string $goal): ?array
+ {
+ $graph = $this->graph ??= $this->buildGraph();
+ $previous = [$start => null];
+ $queue = [$start];
+
+ while ($queue !== []) {
+ $current = \array_shift($queue);
+
+ if ($current === $goal) {
+ $path = [];
+ for ($node = $goal; $node !== null; $node = $previous[$node]) {
+ $path[] = $node;
+ }
+
+ return \array_reverse($path);
+ }
+
+ foreach (\array_keys($graph[$current] ?? []) as $next) {
+ if (!\array_key_exists($next, $previous)) {
+ $previous[$next] = $current;
+ $queue[] = $next;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Built from the whole scanned class map on first use, so it sees every module edge
+ * no matter which class is evaluated first.
+ *
+ * @return array>
+ */
+ private function buildGraph(): array
+ {
+ $graph = [];
+
+ foreach ($this->classNodeMap as $node) {
+ if (!$this->appliesTo($node)) {
+ continue;
+ }
+
+ $from = (string) $this->module($node->className);
+
+ foreach ($this->dependencyModules($node, $from) as $to) {
+ $graph[$from][$to] = true;
+ }
+ }
+
+ return $graph;
+ }
+}
diff --git a/tools/structarmed/src/Support/ClassAnnotations.php b/tools/structarmed/src/Support/ClassAnnotations.php
new file mode 100644
index 00000000..db3d09eb
--- /dev/null
+++ b/tools/structarmed/src/Support/ClassAnnotations.php
@@ -0,0 +1,68 @@
+> file => short class name => docblock text */
+ private static array $cache = [];
+
+ public static function hasApi(string $file, string $shortName): bool
+ {
+ return self::hasTag(self::docblock($file, $shortName), 'api');
+ }
+
+ public static function hasInternal(string $file, string $shortName): bool
+ {
+ return self::hasTag(self::docblock($file, $shortName), 'internal');
+ }
+
+ private static function hasTag(string $docblock, string $tag): bool
+ {
+ return \preg_match('/@' . $tag . '\b/', $docblock) === 1;
+ }
+
+ private static function docblock(string $file, string $shortName): string
+ {
+ return (self::$cache[$file] ??= self::parse($file))[$shortName] ?? '';
+ }
+
+ /**
+ * @return array
+ */
+ private static function parse(string $file): array
+ {
+ $code = @\file_get_contents($file);
+
+ if ($code === false) {
+ return [];
+ }
+
+ try {
+ $ast = (new ParserFactory())->createForNewestSupportedVersion()->parse($code) ?? [];
+ } catch (\Throwable) {
+ return [];
+ }
+
+ $docblocks = [];
+
+ foreach ((new NodeFinder())->findInstanceOf($ast, ClassLike::class) as $node) {
+ $name = $node->name?->toString();
+
+ $name === null or $docblocks[$name] = $node->getDocComment()?->getText() ?? '';
+ }
+
+ return $docblocks;
+ }
+}
diff --git a/tools/structarmed/src/Support/ClassSignatures.php b/tools/structarmed/src/Support/ClassSignatures.php
new file mode 100644
index 00000000..d8ecfc98
--- /dev/null
+++ b/tools/structarmed/src/Support/ClassSignatures.php
@@ -0,0 +1,117 @@
+>> file => short class name => FQ type names on its public surface */
+ private static array $cache = [];
+
+ /**
+ * @return list
+ */
+ public static function publicTypes(string $file, string $shortName): array
+ {
+ return (self::$cache[$file] ??= self::parse($file))[$shortName] ?? [];
+ }
+
+ /**
+ * @return array>
+ */
+ private static function parse(string $file): array
+ {
+ $code = @\file_get_contents($file);
+
+ if ($code === false) {
+ return [];
+ }
+
+ try {
+ $ast = (new ParserFactory())->createForNewestSupportedVersion()->parse($code) ?? [];
+
+ $traverser = new NodeTraverser(new NameResolver());
+ $ast = $traverser->traverse($ast);
+ } catch (\Throwable) {
+ return [];
+ }
+
+ $signatures = [];
+
+ foreach ((new NodeFinder())->findInstanceOf($ast, ClassLike::class) as $class) {
+ $name = $class->name?->toString();
+
+ if ($name === null) {
+ continue;
+ }
+
+ $types = [];
+
+ foreach ($class->getMethods() as $method) {
+ if ($method->isPrivate()) {
+ continue;
+ }
+
+ foreach ($method->params as $param) {
+ // A privately promoted constructor param is a private property, not surface.
+ ($param->flags & Modifiers::PRIVATE) === 0 and self::collect($param->type, $types);
+ }
+
+ self::collect($method->returnType, $types);
+ }
+
+ foreach ($class->getProperties() as $property) {
+ $property->isPrivate() or self::collect($property->type, $types);
+ }
+
+ $signatures[$name] = \array_values(\array_unique($types));
+ }
+
+ return $signatures;
+ }
+
+ /**
+ * @param list $types
+ */
+ private static function collect(?Node $type, array &$types): void
+ {
+ switch (true) {
+ case $type instanceof Name:
+ $types[] = $type->toString();
+ break;
+ case $type instanceof NullableType:
+ self::collect($type->type, $types);
+ break;
+ case $type instanceof UnionType:
+ case $type instanceof IntersectionType:
+ foreach ($type->types as $inner) {
+ self::collect($inner, $types);
+ }
+ break;
+ // A plain Identifier (int, string, void, self, static, ...) carries no namespace: ignore.
+ }
+ }
+}
diff --git a/tools/structarmed/structarmed.php b/tools/structarmed/structarmed.php
new file mode 100644
index 00000000..10c65ac5
--- /dev/null
+++ b/tools/structarmed/structarmed.php
@@ -0,0 +1,70 @@
+layer('Source', ['src'])
+ // Shared building blocks every module may use: they must not reach into a module.
+ ->layer('Kernel', $kernel)
+ ->layer('Modules', ['src/Module'], $kernel)
+ // The CLI shell: commands, the facade, and the container wiring.
+ ->layer('App', ['src/Command', 'src/Bootstrap.php', 'src/DLoad.php'])
+ ->withPresets(
+ Preset::PSR4(sourcePaths: ['src']),
+ Preset::PER(sourcePaths: ['src']),
+ Preset::YAGNI(sourcePaths: ['src']),
+ Preset::CODEQUALITY(sourcePaths: ['src']),
+ )
+ ->rule('kernel.must_not_depend_on_modules', new MayNotDependOnRule('Kernel', 'Modules'))
+ ->rule('kernel.must_not_depend_on_app', new MayNotDependOnRule('Kernel', 'App'))
+ ->rule('modules.must_not_depend_on_app', new MayNotDependOnRule('Modules', 'App'))
+ ->rule('modules.must_not_depend_cyclically', new ModulesMustNotDependCyclicallyRule('Modules', 'Internal\DLoad\Module'))
+ ->rule('internal.namespace_must_be_annotated_internal', new InternalNamespaceMustBeAnnotatedInternalRule('Source'))
+ ->rule('internal.class_must_be_final', new InternalClassMustBeFinalRule('Source'))
+ ->rule('internal.must_not_leak_into_public_signature', new InternalMustNotLeakIntoPublicSignatureRule('Source'))
+ ->rule('internal.is_usable_only_within_its_module', new InternalIsUsableOnlyWithinItsPackageRule('Source', [
+ // The composition root binds module interfaces to their internal implementations.
+ 'Internal\DLoad\Bootstrap' => ['Internal\DLoad\Module'],
+ // The schema declares its XML/env/CLI mapping with Common's inflection attributes.
+ 'Internal\DLoad\Module\Config\Schema' => ['Internal\DLoad\Module\Common\Internal\Attribute'],
+ ]))
+ ->rule('source.must_not_use_superglobals', new MayNotUseSuperglobalsRule('Source'))
+ ->skip([
+ // Case names spell the architecture identifiers used in release asset names.
+ PerPreset::ENUM_CASES_MUST_BE_PASCAL_CASE => ['src/Module/Common/Architecture.php'],
+ // A concrete fallback for hosts without a dedicated subclass; tests throw it directly.
+ YagniPreset::EXTENDED_CLASS_MUST_BE_ABSTRACT_OR_INSTANTIATED => ['src/Module/Repository/Exception/RateLimitException.php'],
+ ])
+ // Known debt: module cycles and upward dependencies that need a refactoring of their own.
+ ->baseline('tools/structarmed/baseline.php');
+
+foreach (['exit', 'eval'] as $construct) {
+ $architecture->rule("source.must_not_use_{$construct}", new MayNotUseLanguageConstructRule('Source', $construct));
+}
+
+// Debug helpers of symfony/var-dumper and buggregator/trap must not ship.
+foreach (['dump', 'dd', 'var_dump', 'trap', 'tr', 'td'] as $function) {
+ $architecture->rule("source.must_not_call_{$function}", new MayNotCallFunctionRule('Source', $function));
+}
+
+return $architecture;