From 29473a368ca0a0ab7bd5944407d40b08b363d65f Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 28 Oct 2026 17:37:54 +0400 Subject: [PATCH 1/3] refactor: annotate, finalise and prune internal classes flagged by architecture rules These classes broke the rules StructArmed starts enforcing in the next commit; `BinaryException` is never thrown on its own, and the Velox `Strategy` interface has no implementation. Assisted-By: Claude Opus 5.5 --- psalm-baseline.xml | 5 ---- .../Binary/Exception/BinaryException.php | 2 +- src/Module/Binary/Internal/AbstractBinary.php | 3 ++ .../Internal/BinaryExistenceChecker.php | 2 ++ .../HttpClient/Internal/NyholmFactoryImpl.php | 2 +- src/Module/Velox/Internal/Config/Strategy.php | 29 ------------------- 6 files changed, 7 insertions(+), 36 deletions(-) delete mode 100644 src/Module/Velox/Internal/Config/Strategy.php 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 @@ - - - - - Date: Wed, 28 Oct 2026 17:37:57 +0400 Subject: [PATCH 2/3] ci: check architecture rules with StructArmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StructArmed runs isolated in `tools/structarmed` through bamarni/composer-bin-plugin, as in Testo, so its dependencies never touch the root lock beyond the plugin itself. Rules: Kernel (Common, Config, Version, Service) must not reach into modules or the CLI shell, modules must not depend on the shell or cyclically on each other, an `Internal` namespace stays private to its module and out of public signatures; the `@internal` tag grants no exemption because nearly every class in DLoad carries it. Known debt β€” the `Registry`/`Repository` cycle, `DloadResult` in Common, Velox reaching for the `DLoad` facade β€” sits in `baseline.php`, so the check blocks only new violations. Assisted-By: Claude Opus 5.5 --- .github/workflows/static-analysis.yml | 31 ++ .gitignore | 3 + AGENTS.md | 1 + composer.json | 10 + composer.lock | 59 ++- tools/structarmed/baseline.php | 167 +++++++++ tools/structarmed/composer.json | 14 + tools/structarmed/composer.lock | 341 ++++++++++++++++++ .../src/Rule/InternalClassMustBeFinalRule.php | 46 +++ ...ternalIsUsableOnlyWithinItsPackageRule.php | 97 +++++ ...rnalMustNotLeakIntoPublicSignatureRule.php | 69 ++++ ...alNamespaceMustBeAnnotatedInternalRule.php | 38 ++ .../ModulesMustNotDependCyclicallyRule.php | 172 +++++++++ .../src/Support/ClassAnnotations.php | 68 ++++ .../src/Support/ClassSignatures.php | 117 ++++++ tools/structarmed/structarmed.php | 69 ++++ 16 files changed, 1301 insertions(+), 1 deletion(-) create mode 100644 tools/structarmed/baseline.php create mode 100644 tools/structarmed/composer.json create mode 100644 tools/structarmed/composer.lock create mode 100644 tools/structarmed/src/Rule/InternalClassMustBeFinalRule.php create mode 100644 tools/structarmed/src/Rule/InternalIsUsableOnlyWithinItsPackageRule.php create mode 100644 tools/structarmed/src/Rule/InternalMustNotLeakIntoPublicSignatureRule.php create mode 100644 tools/structarmed/src/Rule/InternalNamespaceMustBeAnnotatedInternalRule.php create mode 100644 tools/structarmed/src/Rule/ModulesMustNotDependCyclicallyRule.php create mode 100644 tools/structarmed/src/Support/ClassAnnotations.php create mode 100644 tools/structarmed/src/Support/ClassSignatures.php create mode 100644 tools/structarmed/structarmed.php 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/tools/structarmed/baseline.php b/tools/structarmed/baseline.php new file mode 100644 index 00000000..c641099a --- /dev/null +++ b/tools/structarmed/baseline.php @@ -0,0 +1,167 @@ + 'kernel.must_not_depend_on_modules', + 'message' => 'Class [Internal\DLoad\Module\Common\DloadResult] in layer [Kernel] must not depend on [Internal\DLoad\Module\Binary\Binary] which belongs to layer [Modules]', + 'file' => 'src/Module/Common/DloadResult.php', + 'class' => 'Internal\DLoad\Module\Common\DloadResult', + 'layer' => 'Kernel', + ], + [ + 'rule' => '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..cba561f4 --- /dev/null +++ b/tools/structarmed/structarmed.php @@ -0,0 +1,69 @@ +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; From 5718b573fc5e3e6487ead893abad411ceddc1e86 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 28 Oct 2026 17:41:02 +0400 Subject: [PATCH 3/3] ci: count the Binary module as kernel in architecture rules `DloadResult` is public API and hands out a `Binary`, so `Binary` is part of the shared contract rather than debt to move out; it already depends on kernel modules only. Assisted-By: Claude Opus 5.5 --- tools/structarmed/baseline.php | 7 ------- tools/structarmed/structarmed.php | 3 ++- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/tools/structarmed/baseline.php b/tools/structarmed/baseline.php index c641099a..5c60cd11 100644 --- a/tools/structarmed/baseline.php +++ b/tools/structarmed/baseline.php @@ -3,13 +3,6 @@ declare(strict_types=1); return [ - [ - 'rule' => 'kernel.must_not_depend_on_modules', - 'message' => 'Class [Internal\DLoad\Module\Common\DloadResult] in layer [Kernel] must not depend on [Internal\DLoad\Module\Binary\Binary] which belongs to layer [Modules]', - 'file' => 'src/Module/Common/DloadResult.php', - 'class' => 'Internal\DLoad\Module\Common\DloadResult', - 'layer' => 'Kernel', - ], [ 'rule' => 'modules.must_not_depend_cyclically', 'message' => 'Class [Internal\DLoad\Module\Registry\Internal\StoredVersionRegistry] depends on module [Repository], closing the module cycle [Registry -> Repository -> Registry]', diff --git a/tools/structarmed/structarmed.php b/tools/structarmed/structarmed.php index cba561f4..10c65ac5 100644 --- a/tools/structarmed/structarmed.php +++ b/tools/structarmed/structarmed.php @@ -17,7 +17,8 @@ use Internal\DLoad\Tools\StructArmed\Rule\ModulesMustNotDependCyclicallyRule; // Paths resolve against the project root: `composer structarmed` runs from there. -$kernel = ['src/Module/Common', 'src/Module/Config', 'src/Module/Version', 'src/Service', 'src/Info.php']; +// Binary belongs here: the public `DloadResult` hands out a `Binary`. +$kernel = ['src/Module/Binary', 'src/Module/Common', 'src/Module/Config', 'src/Module/Version', 'src/Service', 'src/Info.php']; $architecture = Architecture::define() // Defining Source ourselves keeps StructArmed from synthesising it from composer.json,