diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b32f64c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto eol=lf + +/.github export-ignore +/docs export-ignore +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml export-ignore +/phpstan.neon.dist export-ignore +/pint.json export-ignore +/AGENTS.md export-ignore +/docs-site export-ignore diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..99e41bd --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,48 @@ +name: docs + +on: + push: + branches: [main] + paths: ['docs-site/**'] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: docs-site + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: docs-site/package-lock.json + + - run: npm ci + - run: npm run build + + - uses: actions/upload-pages-artifact@v3 + with: + path: docs-site/.vitepress/dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c68bf51 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,71 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.3', '8.4'] + laravel: ['12.*', '13.*'] + include: + - laravel: '12.*' + testbench: '10.*' + - laravel: '13.*' + testbench: '11.*' + + name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }} + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Install dependencies + run: | + composer require "illuminate/support:${{ matrix.laravel }}" "illuminate/console:${{ matrix.laravel }}" "illuminate/contracts:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --prefer-dist --no-interaction + + - name: Tests + run: vendor/bin/pest + + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + + - run: composer update --prefer-dist --no-interaction + + - name: Code style + run: vendor/bin/pint --test + + - name: Static analysis + run: vendor/bin/phpstan analyse + + benchmark: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + + - run: composer update --prefer-dist --no-interaction + + - name: 1,000 files in less than 10 seconds + run: composer bench diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48ec0e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor +composer.lock +.phpunit.result.cache +.phpunit.cache +.idea +.vscode +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8aa1260 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Instructions for AI coding agents + +This file is for AI agents (and agent swarms) working on the Cordon Modulith codebase. + +## Project in one paragraph + +Cordon Modulith is a Laravel dev-dependency that verifies boundaries between modules. It statically parses PHP files (nikic/php-parser, never loading code), maps class references to modules, and runs rules (`internal_access`, `undeclared_dependency`, `cycles`). Entry points: `php artisan cordon:verify` (and `cordon:docs`), the Pest expectation `toRespectBoundaries()` and the PHPStan rule. Pipeline: `ModuleResolver` → `FileCollector` → `DependencyExtractor` → `Analyzer` (cross-module edges) → `Rule`s → `Baseline` → `Reporter`. + +## Hard rules + +1. **Contracts are frozen.** Do not change interfaces in `src/Contracts`, value objects in `src/Analysis` and `src/Module`, the config shape, the baseline JSON format or command options without an accepted ADR in `docs/adr`. If you believe a change is needed, stop and write the ADR proposal instead. +2. **Tests first.** Write or update the acceptance test before the implementation. Every detection change needs a fixture in `tests/Fixtures`. +3. **Never load analysed code.** No `class_exists`, `require`, reflection or autoloading of user code in `src/`. +4. **Zero false positives.** When unsure whether something is a dependency, don't report it and document the limitation. +5. **Do not edit `tests/Fixtures` line layout** without updating the tests that assert line numbers. +6. **Stay in your lane.** Swarms only touch the files listed in their brief (`docs/plan/enjambres/`). Integration across swarms is done by the maintainer. +7. **Quality gate:** `composer check` (Pint, PHPStan, Pest) must pass before you open a pull request. + +## Conventions + +- PHP 8.3, `declare(strict_types=1)`, `final` classes, `readonly` value objects. +- The analysis core (`src/Analysis`, `src/Module`, `src/Rules`, `src/Resolvers`, `src/Reporters`, `src/Baseline`, `src/Support`, `src/Documentation`) must stay framework-agnostic. Only the integration layers (`src/Laravel`, `src/Testing`, `src/PHPStan`) may use Laravel. +- User-facing messages: one sentence stating the problem plus one stating the fix. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8088e09 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to Cordon Modulith are documented here. The project follows [Semantic Versioning](https://semver.org) from 1.0.0; before that, minor versions may contain breaking changes. + +## [Unreleased] - 0.1.0 + +### Added +- `cordon:verify` command with `text`, `json` and `github` output formats. +- `cordon:modules` command to inspect the detected modules. +- Module resolvers for plain namespaces, nwidart/laravel-modules and InterNACHI/modular, with auto-detection. +- Static dependency extraction with nikic/php-parser (the analysed code is never loaded). +- Rules: `internal_access`, `undeclared_dependency`, `cycles`. +- Public API model: public namespaces, `#[PublicApi]`, `#[Internal]`, per-module `public` list and `open` modules. +- Baseline file for gradual adoption (`--generate-baseline`, `--no-baseline`). +- Laravel Boost guideline for AI coding agents. +- `--module` option for `cordon:verify` to report the violations of one or more modules. +- Pest expectation `toRespectBoundaries()` and `Cordon\Testing\Cordon::modules()`. +- PHPStan rule `cordon.internalAccess` (`extension.neon`), reading `config/cordon.php` without booting Laravel. +- Reusable GitHub Action (`action.yml`). +- `cordon:docs` command: Mermaid dependency diagrams, a canvas per module and an event inventory. +- `cordon:verify` and `cordon:docs` warn on stderr when Xdebug is active, since it makes the analysis several times slower. +- Custom rules: classes implementing `Cordon\Contracts\Rule` in the `rules` config. +- Laravel Boost skill `cordon-fix-violations`. +- Documentation site (`docs-site/`), benchmark (`composer bench`) and real-project fixtures. + +### Fixed +- Commands read the config through the repository contract (PHPStan level 8). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2ce7d6c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing + +Thanks for helping make module boundaries in Laravel verifiable. + +## Setup + +```bash +git clone https://github.com/ChrisAbner/Cordon-Modulith.git cordon-modulith && cd cordon-modulith +composer install +composer check # pint --test, phpstan, pest +composer bench # analyses a synthetic project of 1,000 files; fails above 10 s +``` + +`php tests/Benchmark/run.php 5000 50` runs a larger benchmark (files, modules, optional time budget in seconds). + +## Pull requests + +- One topic per pull request, with tests. Bug fixes start with a failing test. +- New detection behaviour needs a fixture under `tests/Fixtures` that reproduces a real project layout. +- Run `composer format` before pushing. `tests/Fixtures` is excluded from Pint on purpose, because tests assert line numbers. +- Changes to anything in `src/Contracts`, the config file shape, the baseline format or command options are public API changes: open a discussion or an ADR in `docs/adr` first. +- Keep false positives at zero. A rule that reports correct code is a bug. + +## Reporting bugs + +Please open a pull request with a failing test or fixture, even if you don't have the fix. Pull requests written with the help of a coding agent are welcome; see [AGENTS.md](AGENTS.md). + +## Security + +Please report security issues privately to the maintainers instead of opening a public issue. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d6a7532 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2026 Cordon Modulith contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 08fe5e2..8411d27 100644 --- a/README.md +++ b/README.md @@ -1 +1,264 @@ -# Cordon-Modulith \ No newline at end of file +# Cordon Modulith + +**Cordon off your modules.** Verifiable boundaries between the modules of a Laravel application, checked in CI. + +> **Status: 0.1 (alpha).** The public API may change before 1.0. + +Modular monoliths in Laravel usually rely on nwidart/laravel-modules, InterNACHI/modular or a hand-made `app/Modules` folder. All of them organise code, but none of them stops the `Billing` module from reaching into `Catalog`'s models. Six months later the "modules" are just folders. + +Cordon Modulith reads your code statically, builds the dependency graph between modules and fails the build when a module: + +- uses **internal classes** of another module instead of its public API, +- depends on a module it **did not declare**, or +- takes part in a **dependency cycle**. + +It is inspired by [Spring Modulith](https://spring.io/projects/spring-modulith) and Shopify's Packwerk, adapted to Laravel conventions. + +```text +$ php artisan cordon:verify + +Cordon analysed 212 files in 6 modules (148 cross-module references). + +x [internal_access] Modules/Billing/app/Services/CheckoutService.php:13 + Module [Billing] uses Modules\Catalog\Models\Product, which is internal to module [Catalog]. Depend on its public API instead (a class in a public namespace such as Contracts, or one marked #[PublicApi]). + +x [cycles] + Modules [Billing, Catalog] form a dependency cycle: Billing -> Catalog -> Billing. Break it by inverting one dependency, for example with an event or a contract. + +2 boundary violations. +``` + +## Requirements + +- PHP 8.3+ +- Laravel 12 or 13 + +## Installation + +```bash +composer require --dev chrisabner/cordon-modulith +``` + +Cordon Modulith detects your module layout automatically. Check what it found: + +```bash +php artisan cordon:modules +``` + +Then verify the boundaries: + +```bash +php artisan cordon:verify +``` + +Optionally publish the config file: + +```bash +php artisan vendor:publish --tag=cordon-config +``` + +## Adopting Cordon Modulith in an existing project + +Existing codebases usually start with many violations. Record them in a baseline so the build only fails on **new** ones, then burn the baseline down over time: + +```bash +php artisan cordon:verify --generate-baseline # writes cordon-baseline.json +php artisan cordon:verify # passes; new violations fail +php artisan cordon:verify --no-baseline # shows everything again +``` + +Commit `cordon-baseline.json`. Entries are keyed by rule, file and target class (not line numbers), so unrelated edits don't invalidate them. + +## The public API of a module + +Other modules may only use classes that belong to a module's public API. A class is public when, in order of precedence: + +1. it is marked `#[Cordon\Attributes\Internal]` → **internal**, always; +2. it is marked `#[Cordon\Attributes\PublicApi]` → public; +3. its module is configured as `open` → public (handy for a shared kernel); +4. it lives under one of the public namespaces, relative to the module: `Contracts`, `Events`, `Data`, `Enums`, `Exceptions` by default, plus the module's own `public` list → public; +5. otherwise → **internal**. + +`public_namespaces` match the **first namespace segment relative to the module root** only. `Modules\Billing\Contracts\Gateway` is public, but `Modules\Billing\Invoices\Enums\Status` is **not**: its relative name starts with `Invoices`, not `Enums`. Field tests showed that nested enums and events cause a large share of violations in some apps. To expose them, do one of these: + +- list the nested namespace in the module's `public` list, e.g. `'Billing' => ['public' => ['Invoices\\Enums', 'Invoices\\Events']]`; +- mark the class with `#[PublicApi]`; +- move it to a top-level public namespace. + +`DTOs` is not in the defaults (only `Data`). If you use it, add it to `public_namespaces`, e.g. `['Contracts', 'Events', 'Data', 'DTOs', 'Enums', 'Exceptions']` (setting the option replaces the default list). + +```php +namespace Modules\Catalog\Support; + +use Cordon\Attributes\PublicApi; + +#[PublicApi] +final class PriceFormatter +{ + // ... +} +``` + +The recommended way for modules to talk to each other is through contracts (interfaces bound in the module's service provider), events and data objects. + +## Configuration + +```php +// config/cordon.php +return [ + 'resolver' => 'auto', // auto, namespace, nwidart, internachi + + 'public_namespaces' => ['Contracts', 'Events', 'Data', 'Enums', 'Exceptions'], + + 'modules' => [ + 'Billing' => [ + 'depends_on' => ['Catalog', 'Shared'], // anything else is reported + 'public' => ['Services\\BillingService'], // extra public classes/namespaces + ], + 'Shared' => ['open' => true], // every class is public + ], + + 'rules' => [ + 'internal_access' => true, + 'undeclared_dependency' => true, + 'cycles' => true, + ], + + 'exclude' => ['vendor', 'node_modules', 'tests', 'Tests'], + + 'baseline' => 'cordon-baseline.json', +]; +``` + +`depends_on` is opt-in per module: modules without it are not checked by `undeclared_dependency`. + +### Module layouts + +| Resolver | Detected when | Module | Namespace | +|---|---|---|---| +| `nwidart` | `Modules/*/module.json` exists | `Modules/Blog` → `Blog` | `Modules\Blog` (from `config/modules.php`) | +| `internachi` | `app-modules/` exists | `app-modules/billing` → `billing` | from the module's `composer.json` PSR-4 entry | +| `namespace` | fallback | `app/Modules/Billing` → `Billing` | `App\Modules\Billing` | + +Paths and namespaces can be overridden under `resolvers` in the config file. + +## Rules + +| Rule | Reports | +|---|---| +| `internal_access` | A module using a class that is not part of another module's public API. One report per file and target class. | +| `undeclared_dependency` | A module with `depends_on` using a module that is not listed. One report per file and target module. | +| `cycles` | Modules that depend on each other in a cycle, with the shortest cycle path. | + +You can add your own rules: list classes implementing `Cordon\Contracts\Rule` under `rules` in the config. See [docs/custom-rules.md](docs/custom-rules.md). + +## Continuous integration + +Use the reusable GitHub Action to get annotations on pull requests: + +```yaml +# .github/workflows/cordon.yml +name: cordon +on: [pull_request] +jobs: + cordon: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ChrisAbner/Cordon-Modulith@v0.1.0 +``` + +Or add `php artisan cordon:verify --format=github` to an existing job. `--format=json` prints a machine readable report. The command exits with `1` when there are violations, `0` otherwise and `2` on invalid options. See [docs/ci.md](docs/ci.md) for the action inputs and a job per module. + +### One module at a time + +```bash +php artisan cordon:verify --module=Billing +``` + +`--module` (repeatable) reports only the violations a module causes and the dependency cycles it takes part in. + +## Pest + +Check boundaries from your test suite with the `toRespectBoundaries()` expectation. It needs a booted application, so use it in tests that extend your `Tests\TestCase`. A fresh Laravel app with Pest doesn't bind it yet: run `composer require --dev pestphp/pest pestphp/pest-plugin-laravel` and `./vendor/bin/pest --init`, or make sure `tests/Pest.php` contains `pest()->extend(Tests\TestCase::class)->in('Feature');`. + +```php +use Cordon\Testing\Cordon; + +it('keeps Billing inside its boundaries', function () { + expect('Billing')->toRespectBoundaries(); +}); + +it('keeps every module inside its boundaries', function () { + expect(Cordon::modules())->each->toRespectBoundaries(); +}); +``` + +The analysis runs once per test process and honours the baseline, like `cordon:verify`. The `each` form stops at the first failing module (Pest behaviour). + +## PHPStan + +Cordon Modulith ships a PHPStan rule that reports `internal_access` in your editor. With [phpstan/extension-installer](https://github.com/phpstan/extension-installer) it is enabled automatically. A fresh Laravel app doesn't ship it, so either `composer require --dev phpstan/extension-installer` or include the extension by hand in a minimal `phpstan.neon`: + +```neon +# phpstan.neon +includes: + - vendor/chrisabner/cordon-modulith/extension.neon + +parameters: + level: 5 + paths: + - app + - Modules +``` + +The rule reads `config/cordon.php` without booting Laravel, so keep that file a plain array. Set `parameters.cordon.basePath` if PHPStan does not run from the project root. Dependency cycles and `depends_on` are only checked by `cordon:verify`. + +## Living documentation + +Generate Markdown documentation of the real architecture, straight from the code: + +```bash +php artisan cordon:docs # writes docs/architecture/ +php artisan cordon:docs --output=docs/modules +``` + +- `README.md`: every module and a Mermaid diagram of their dependencies (internal access in red); +- `modules/.md`: a canvas per module with its public API, what it uses and who uses it, its events and its violations; +- `events.md`: every module event with who publishes and who listens to it. + +The output folder is created if it doesn't exist. GitHub renders the Mermaid diagrams, and the output is deterministic, so commit it and review architecture changes in pull requests. + +## AI coding agents + +Cordon Modulith ships [Laravel Boost](https://laravel.com/docs/boost) resources that Boost picks up when you run `php artisan boost:install`: + +- a guideline (`resources/boost/guidelines/core.blade.php`) so agents go through a module's public API and run `cordon:verify`; +- the `cordon-fix-violations` skill (`resources/boost/skills/`), which teaches agents to read `cordon:verify --format=json` and fix each kind of violation without touching the baseline. + +## How it works + +Cordon Modulith parses every PHP file inside your modules with [nikic/php-parser](https://github.com/nikic/PHP-Parser). Your code is never loaded or executed, so it works on code that doesn't boot and on classes that don't exist yet. It records every class reference (`new`, static calls, type declarations, `extends`/`implements`, traits, attributes, `instanceof`, `catch`...) and maps each one to its module by namespace. Import statements alone, function calls and constants don't count. Speed is around 1 ms per file on a typical CI runner (about a second for 1,000 files, Linux, no Xdebug). Run with Xdebug off (`XDEBUG_MODE=off php artisan cordon:verify`): Xdebug makes it 3-4x slower, and the first run on Windows can be slower because of cold file reads and antivirus scanning. See [docs/architecture.md](docs/architecture.md). + +**Known limitations in 0.1:** docblock-only types (`@var`, generics), string class names (`'App\\Foo'`, `app('...')`) and dynamic references are not detected. Code outside modules (for example `app/Http`) is not analysed. When Cordon runs standalone (the PHPStan rule), project config files are evaluated without booting Laravel. If one can't be evaluated, the rule reports a `cordon.configuration` error and falls back to defaults; see [PHPStan](https://chrisabner.github.io/Cordon-Modulith/guide/phpstan). + +## Roadmap + +What's next: + +- Filament plugin with the module graph +- 1.0 with a frozen public API and a SemVer policy +- File-hash cache for large projects +- C4 diagrams + +Ideas and bugs are welcome in [GitHub issues](https://github.com/ChrisAbner/Cordon-Modulith/issues). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). AI agents: read [AGENTS.md](AGENTS.md) first. + +## License + +MIT. See [LICENSE.md](LICENSE.md). + +Cordon Modulith is a community project and is not affiliated with or endorsed by Laravel, or by the Spring team, VMware or Broadcom (Spring Modulith inspired the name and the approach). diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..ae8d28b --- /dev/null +++ b/action.yml @@ -0,0 +1,50 @@ +name: Cordon Modulith +description: Verify the boundaries between the modules of a Laravel application. +author: ChrisAbner +branding: + icon: shield + color: purple + +inputs: + php-version: + description: PHP version used to run the verification. + required: false + default: '8.4' + working-directory: + description: Directory of the Laravel application (where artisan is). + required: false + default: '.' + format: + description: Report format (github, text or json). "github" adds annotations to pull requests. + required: false + default: github + args: + description: Extra arguments for cordon:verify, for example "--module=Billing" or "--no-baseline". + required: false + default: '' + install-dependencies: + description: Run composer install before verifying. Set to "false" if an earlier step already did. + required: false + default: 'true' + +runs: + using: composite + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + coverage: none + + - name: Install dependencies + if: ${{ inputs.install-dependencies == 'true' }} + shell: bash + working-directory: ${{ inputs.working-directory }} + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Verify module boundaries + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + CORDON_FORMAT: ${{ inputs.format }} + CORDON_ARGS: ${{ inputs.args }} + run: php artisan cordon:verify --format="$CORDON_FORMAT" $CORDON_ARGS diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c8a4651 --- /dev/null +++ b/composer.json @@ -0,0 +1,63 @@ +{ + "name": "chrisabner/cordon-modulith", + "description": "Cordon off your modules: verifiable boundaries between modules in Laravel applications.", + "keywords": ["laravel", "modules", "modular-monolith", "architecture", "static-analysis", "boundaries"], + "license": "MIT", + "type": "library", + "homepage": "https://chrisabner.github.io/Cordon-Modulith/", + "support": { + "issues": "https://github.com/ChrisAbner/Cordon-Modulith/issues", + "source": "https://github.com/ChrisAbner/Cordon-Modulith", + "docs": "https://chrisabner.github.io/Cordon-Modulith/" + }, + "require": { + "php": "^8.3", + "illuminate/console": "^12.0|^13.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", + "nikic/php-parser": "^5.0" + }, + "require-dev": { + "larastan/larastan": "^3.0", + "laravel/pint": "^1.18", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^4.0" + }, + "autoload": { + "psr-4": { + "Cordon\\": "src/" + }, + "files": ["src/Testing/expectations.php"] + }, + "autoload-dev": { + "psr-4": { + "Cordon\\Tests\\": "tests/" + }, + "classmap": ["tests/Fixtures/namespace-app/app"] + }, + "extra": { + "phpstan": { + "includes": ["extension.neon"] + }, + "laravel": { + "providers": [ + "Cordon\\Laravel\\CordonServiceProvider" + ] + } + }, + "scripts": { + "test": "pest", + "analyse": "phpstan analyse", + "format": "pint", + "check": ["pint --test", "phpstan analyse", "pest"], + "bench": "php tests/Benchmark/run.php 1000 20 10" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/config/cordon.php b/config/cordon.php new file mode 100644 index 0000000..9791a18 --- /dev/null +++ b/config/cordon.php @@ -0,0 +1,108 @@ + env('CORDON_RESOLVER', 'auto'), + + 'resolvers' => [ + + // Every direct sub-directory of "path" is a module under "namespace". + 'namespace' => [ + 'path' => 'app/Modules', + 'namespace' => 'App\\Modules', + ], + + // null = read from config/modules.php, falling back to "Modules". + 'nwidart' => [ + 'path' => null, + 'namespace' => null, + ], + + // null = read from config/app-modules.php, falling back to "app-modules". + // Namespaces are read from each module's composer.json. + 'internachi' => [ + 'path' => null, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Public API + |-------------------------------------------------------------------------- + | + | Other modules may only use classes that belong to a module's public API: + | classes under one of these namespaces (relative to the module namespace) + | or classes marked with #[Cordon\Attributes\PublicApi]. + | #[Cordon\Attributes\Internal] always wins. + | + */ + + 'public_namespaces' => ['Contracts', 'Events', 'Data', 'Enums', 'Exceptions'], + + /* + |-------------------------------------------------------------------------- + | Per-module settings + |-------------------------------------------------------------------------- + | + | depends_on: modules this module may depend on. When set, any other + | dependency is reported. Omit it to leave dependencies open. + | public: extra namespaces or classes (relative) exposed as public API. + | open: every class is public (useful for a shared kernel module). + | + */ + + 'modules' => [ + // 'Billing' => [ + // 'depends_on' => ['Catalog', 'Shared'], + // 'public' => ['Services\\BillingService'], + // ], + // 'Shared' => ['open' => true], + ], + + /* + |-------------------------------------------------------------------------- + | Rules + |-------------------------------------------------------------------------- + | + | Switch built-in rules on or off, and add your own: class names that + | implement Cordon\Contracts\Rule (see docs/custom-rules.md). + | + */ + + 'rules' => [ + 'internal_access' => true, + 'undeclared_dependency' => true, + 'cycles' => true, + // App\Architecture\MyRule::class, + ], + + /* + |-------------------------------------------------------------------------- + | Excluded directories and baseline + |-------------------------------------------------------------------------- + | + | Directory names skipped anywhere inside a module. The baseline file + | stores known violations so existing projects can adopt Cordon Modulith + | gradually (php artisan cordon:verify --generate-baseline). + | + */ + + 'exclude' => ['vendor', 'node_modules', 'tests', 'Tests'], + + 'baseline' => 'cordon-baseline.json', + +]; diff --git a/docs-site/.gitignore b/docs-site/.gitignore new file mode 100644 index 0000000..5506568 --- /dev/null +++ b/docs-site/.gitignore @@ -0,0 +1,3 @@ +node_modules +.vitepress/cache +.vitepress/dist diff --git a/docs-site/.vitepress/config.mts b/docs-site/.vitepress/config.mts new file mode 100644 index 0000000..60fbd76 --- /dev/null +++ b/docs-site/.vitepress/config.mts @@ -0,0 +1,74 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + title: 'Cordon Modulith', + description: 'Verifiable boundaries between the modules of a Laravel application.', + base: '/Cordon-Modulith/', + cleanUrls: true, + lastUpdated: true, + themeConfig: { + nav: [ + { text: 'Guide', link: '/guide/introduction' }, + { text: 'Recipes', link: '/recipes/expose-a-service' }, + { text: 'Comparison', link: '/comparison' }, + { text: 'Changelog', link: 'https://github.com/ChrisAbner/Cordon-Modulith/blob/main/CHANGELOG.md' }, + ], + sidebar: [ + { + text: 'Getting started', + items: [ + { text: 'Introduction', link: '/guide/introduction' }, + { text: 'Installation', link: '/guide/installation' }, + { text: 'Adopting in an existing project', link: '/guide/baseline' }, + ], + }, + { + text: 'Concepts', + items: [ + { text: 'The public API of a module', link: '/guide/public-api' }, + { text: 'Rules', link: '/guide/rules' }, + { text: 'Configuration', link: '/guide/configuration' }, + ], + }, + { + text: 'Integrations', + items: [ + { text: 'Continuous integration', link: '/guide/ci' }, + { text: 'Pest', link: '/guide/pest' }, + { text: 'PHPStan', link: '/guide/phpstan' }, + { text: 'AI coding agents', link: '/guide/ai-agents' }, + ], + }, + { + text: 'Going further', + items: [ + { text: 'Living documentation', link: '/guide/living-documentation' }, + { text: 'Custom rules', link: '/guide/custom-rules' }, + ], + }, + { + text: 'Recipes', + items: [ + { text: 'Expose a service to other modules', link: '/recipes/expose-a-service' }, + { text: 'Break a cycle with an event', link: '/recipes/break-a-cycle' }, + { text: 'Replace a cross-module Eloquent relation', link: '/recipes/cross-module-relations' }, + ], + }, + { + text: 'More', + items: [ + { text: 'Comparison', link: '/comparison' }, + { text: 'FAQ', link: '/faq' }, + ], + }, + ], + socialLinks: [{ icon: 'github', link: 'https://github.com/ChrisAbner/Cordon-Modulith' }], + editLink: { + pattern: 'https://github.com/ChrisAbner/Cordon-Modulith/edit/main/docs-site/:path', + }, + search: { provider: 'local' }, + footer: { + message: 'MIT licensed. A community project, not affiliated with or endorsed by Laravel or Spring.', + }, + }, +}) diff --git a/docs-site/comparison.md b/docs-site/comparison.md new file mode 100644 index 0000000..9a5ca78 --- /dev/null +++ b/docs-site/comparison.md @@ -0,0 +1,31 @@ +# Comparison + +Cordon Modulith is not the only way to keep a Laravel codebase in shape. Here is an honest comparison to help you choose, or combine. + +## Deptrac + +[Deptrac](https://github.com/deptrac/deptrac) is the reference static architecture checker for PHP. You define layers with collectors (by namespace, directory, class name, inheritance...) and which layers may depend on which. + +- **What it does well:** framework-agnostic, very flexible, mature, fast, with caching, a baseline and several output formats. It can express layered rules *inside* modules. +- **What Cordon Modulith adds:** module discovery from nwidart, InterNACHI or `app/Modules` with no layer definitions; a public API model per module (public namespaces, `#[PublicApi]`/`#[Internal]`, open modules) instead of "may depend on the whole layer"; cycle detection with the shortest path; Laravel integration (Artisan, Pest expectation, Boost skill, living documentation). +- **Choose Deptrac** when you need layered rules or you are not on Laravel. You can use both: Deptrac for layers inside modules, Cordon Modulith for the edges between them. + +## Pest `arch()` + +[Pest's architecture testing](https://pestphp.com/docs/arch-testing) ships with Laravel's default test setup. + +- **What it does well:** zero setup, readable expectations (`expect('App\Models')->toExtend(Model::class)`), presets, great for conventions *inside* a module. +- **Limits for boundaries:** expectations rely on reflection, so they only see classes that exist and can be autoloaded. They express namespace-to-namespace rules, so the notion of a module's public API, per-module `depends_on`, cycles and a baseline are left to you. +- **Use both:** `arch()` for conventions, `expect('Billing')->toRespectBoundaries()` for module boundaries. + +## laravel-true-modular + +[happenv-com/laravel-true-modular](https://github.com/happenv-com/laravel-true-modular) is a modular framework for Laravel: every module is a Composer package with declared dependencies, a deterministic boot order and extra lifecycle phases. Its companion PHPStan package detects undeclared cross-module references and cycles, and it provides commands such as `module:graph`, `module:why` and `module:impact`. + +- **What it does well:** it owns the whole module lifecycle, so dependencies are declared once in `composer.json` and enforced at boot and in static analysis. Good fit for greenfield projects that want strong structure. +- **Differences:** Cordon Modulith does not create, register or boot modules: it is a dev dependency that works on top of the layout you already have (nwidart, InterNACHI, folders). It adds a public API model inside each module, a baseline for gradual adoption in existing projects, a Pest expectation and generated documentation. +- **Choose laravel-true-modular** when you want a framework for modules, not only verification. Check its documentation for its current feature set. + +## Packwerk and Spring Modulith + +Cordon Modulith borrows from both: Packwerk's per-package dependencies and `package_todo.yml` (our `depends_on` and baseline), and Spring Modulith's named interfaces, verification in tests and generated documentation. diff --git a/docs-site/faq.md b/docs-site/faq.md new file mode 100644 index 0000000..eeb456e --- /dev/null +++ b/docs-site/faq.md @@ -0,0 +1,47 @@ +# FAQ + +## Does it slow down my application? + +No. It is a dev dependency that only runs when you call `cordon:verify`, `cordon:docs`, your tests or PHPStan. Nothing runs at request time. + +## How fast is it? + +Around 1 ms per file on a typical CI runner (Linux, no Xdebug): about a second for 1,000 files, five for 5,000. The analysis is linear in the number of files. + +Run it with Xdebug off (`XDEBUG_MODE=off php artisan cordon:verify`): Xdebug makes it 3-4x slower. On Windows the first run can be slower because of cold file reads and antivirus scanning; measured there with Xdebug off, 1,000 synthetic files took 5-7 s and a real 584-file project about 3 s once warm. + +## Does it load or execute my code? + +No. Files are parsed with nikic/php-parser; nothing is required, autoloaded or reflected. It works on code that doesn't boot and on classes that don't exist yet. Only [custom rules](./guide/custom-rules) are loaded, because they are extension code you wrote. + +## Why is a class I only `use` not reported? + +An import alone is not a dependency. As soon as the class is used (type, `new`, static call, `::class`...), it counts. + +## Why is `app(Product::class)` reported but `app('App\\Models\\Product')` is not? + +`Product::class` is a class reference. A string could be anything, so it is not reported: false positives are treated as bugs. The same applies to docblock types. + +## What about code outside modules, like `app/Http`? + +It is not analysed: Cordon Modulith checks the boundaries between modules. Controllers in `app/Http` may use any module; if they should not, move them into modules. + +## What are the known limitations? + +Docblock-only types (`@var`, generics), string class names (`'App\\Foo'`, `app('...')`) and dynamic references are not detected, and code outside modules is not analysed. When Cordon runs standalone (the PHPStan rule), project config files are evaluated without booting Laravel. If one can't be evaluated, the rule reports a `cordon.configuration` error and falls back to defaults; see [PHPStan](./guide/phpstan#configuration-errors). + +## Are tests analysed? + +Directories named `tests` or `Tests` are skipped by default (tests often reach into internals on purpose). Change `exclude` in the config if you want them checked. + +## Does listening to another module's event count as a dependency? + +Yes: your listener references the event class. Events are public API, so it is not an `internal_access`, but it counts for `depends_on` and cycles. + +## Can I use it without Laravel? + +The core (`src/Analysis`, `src/Rules`, `src/Resolvers`...) is framework-agnostic, and the PHPStan rule runs without booting Laravel, but the commands and the Pest expectation need a Laravel application. + +## Is it affiliated with Laravel? + +No. Cordon Modulith is a community project. diff --git a/docs-site/guide/ai-agents.md b/docs-site/guide/ai-agents.md new file mode 100644 index 0000000..43517c1 --- /dev/null +++ b/docs-site/guide/ai-agents.md @@ -0,0 +1,26 @@ +# AI coding agents + +Agents happily import whatever class solves the task in front of them, including another module's models. Cordon Modulith ships [Laravel Boost](https://laravel.com/docs/boost) resources so they don't: + +- a **guideline** (`resources/boost/guidelines/core.blade.php`): go through a module's public API, prefer contracts and events, never grow the baseline, run `cordon:verify` after changes that touch several modules; +- the **`cordon-fix-violations` skill** (`resources/boost/skills/`): how to read `cordon:verify --format=json` and fix each rule with before/after examples, without touching the baseline or `depends_on`. + +Boost picks both up when you run: + +```bash +php artisan boost:install +``` + +## Without Boost + +Add this to your agent instructions (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`...): + +```markdown +- Modules may only use another module's public API: Contracts, Events, Data, Enums, + Exceptions or classes marked #[PublicApi]. Never use another module's models or services. +- After changing code in more than one module, run `php artisan cordon:verify --format=json` + and fix every violation. Never regenerate the baseline and never add a module to + depends_on just to silence a violation. +``` + +The JSON report is designed to be read by tools: every violation has `rule`, `message`, `file`, `line`, `source_module`, `target_module` and `target`. diff --git a/docs-site/guide/baseline.md b/docs-site/guide/baseline.md new file mode 100644 index 0000000..5020097 --- /dev/null +++ b/docs-site/guide/baseline.md @@ -0,0 +1,43 @@ +# Adopting in an existing project + +Existing codebases usually start with many violations. Record them in a baseline so the build only fails on **new** ones, then burn the baseline down over time: + +```bash +php artisan cordon:verify --generate-baseline # writes cordon-baseline.json +php artisan cordon:verify # passes; new violations fail +php artisan cordon:verify --no-baseline # shows everything again +``` + +Commit `cordon-baseline.json`. + +## How entries are matched + +Entries are keyed by **rule, file and target** (the class, module or cycle), with an occurrence count. Line numbers are left out, so unrelated edits don't invalidate them. + +- Moving a violation to another file reports it again. This is intended: new code should not inherit old exceptions. +- Only as many occurrences as recorded are suppressed. +- Fixed violations simply stop matching. Regenerate the baseline from time to time to shrink it. + +```json +{ + "version": 1, + "violations": [ + { + "rule": "internal_access", + "file": "Modules/Orders/app/Models/Order.php", + "target": "Modules\\Catalog\\Models\\Product", + "count": 1 + } + ] +} +``` + +## A practical adoption plan + +1. Install, check `cordon:modules`, and fix the resolver if needed. +2. Mark your shared kernel as `open`, and add `public` entries for classes that are really meant to be shared. +3. Generate the baseline and add `cordon:verify` to CI. +4. Pick one module per sprint and fix its entries (`cordon:verify --no-baseline --module=Billing`). +5. Add `depends_on` to modules once their dependencies are clean. + +Keep AI agents from growing the baseline: the [Boost skill](./ai-agents) tells them never to do it. diff --git a/docs-site/guide/ci.md b/docs-site/guide/ci.md new file mode 100644 index 0000000..606df7b --- /dev/null +++ b/docs-site/guide/ci.md @@ -0,0 +1,56 @@ +# Continuous integration + +## GitHub Actions (reusable action) + +```yaml +# .github/workflows/cordon.yml +name: cordon +on: [pull_request] +jobs: + cordon: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ChrisAbner/Cordon-Modulith@v0.1.0 +``` + +The action installs PHP and your Composer dependencies, then runs `php artisan cordon:verify --format=github`, which adds an annotation to the pull request for every violation. + +| Input | Default | Description | +|---|---|---| +| `php-version` | `8.4` | PHP version | +| `working-directory` | `.` | Directory of the Laravel application | +| `format` | `github` | `github`, `text` or `json` | +| `args` | | Extra arguments, for example `--module=Billing` | +| `install-dependencies` | `true` | Set to `false` when an earlier step already ran `composer install` | + +If your workflow already sets up PHP and installs dependencies (for example in a test job), add a step instead: + +```yaml + - run: php artisan cordon:verify --format=github +``` + +## Other CI systems + +`cordon:verify` exits with `1` when there are violations, `0` otherwise and `2` on invalid options. Use `--format=json` for a machine readable report: + +```bash +php artisan cordon:verify --format=json > cordon-report.json +``` + +## One job per module + +Teams that own modules can check only theirs: + +```yaml + strategy: + matrix: + module: [Billing, Catalog, Orders] + steps: + - uses: actions/checkout@v4 + - uses: ChrisAbner/Cordon-Modulith@v0.1.0 + with: + args: --module=${{ matrix.module }} +``` + +`--module` reports the violations a module causes and the dependency cycles it takes part in. The analysis still covers the whole application. diff --git a/docs-site/guide/configuration.md b/docs-site/guide/configuration.md new file mode 100644 index 0000000..61566ba --- /dev/null +++ b/docs-site/guide/configuration.md @@ -0,0 +1,71 @@ +# Configuration + +Publish the config file with `php artisan vendor:publish --tag=cordon-config`. Every key is optional. + +```php +// config/cordon.php +return [ + 'resolver' => 'auto', // auto, namespace, nwidart, internachi + + 'resolvers' => [ + 'namespace' => ['path' => 'app/Modules', 'namespace' => 'App\\Modules'], + 'nwidart' => ['path' => null, 'namespace' => null], // null: read config/modules.php + 'internachi' => ['path' => null], // null: read config/app-modules.php + ], + + 'public_namespaces' => ['Contracts', 'Events', 'Data', 'Enums', 'Exceptions'], + + 'modules' => [ + 'Billing' => [ + 'depends_on' => ['Catalog', 'Shared'], // anything else is reported + 'public' => ['Services\\BillingService'], // extra public classes/namespaces + ], + 'Shared' => ['open' => true], // every class is public + ], + + 'rules' => [ + 'internal_access' => true, + 'undeclared_dependency' => true, + 'cycles' => true, + // App\Architecture\MyRule::class, // custom rules + ], + + 'exclude' => ['vendor', 'node_modules', 'tests', 'Tests'], + + 'baseline' => 'cordon-baseline.json', +]; +``` + +## resolver + +`auto` picks `nwidart` when it finds `module.json` files in the nwidart modules path, `internachi` when an `app-modules` directory exists, and `namespace` otherwise. Force one with `CORDON_RESOLVER` or the `resolver` key. + +## resolvers + +- `namespace`: every direct sub-directory of `path` is a module named after it, under `namespace`. Use it for DDD layouts too: `'path' => 'src/Domain', 'namespace' => 'Domain'`. +- `nwidart`: every directory with a `module.json`. Path and namespace default to `modules.paths.modules` and `modules.namespace` from nwidart's config. +- `internachi`: every directory with a `composer.json`; the namespace comes from its PSR-4 entry (the one mapped to `src/` when there are several). + +## modules + +Per-module settings, keyed by module name as shown by `cordon:modules`: + +| Key | Meaning | +|---|---| +| `depends_on` | Modules this module may depend on. Any other dependency is an `undeclared_dependency`. Omit it to leave dependencies open; `[]` means "no dependencies". | +| `public` | Extra public namespaces or classes, relative to the module namespace. | +| `open` | Every class of the module is public. | + +`cordon:verify` warns about unknown module names in this section. + +## exclude + +Directory names skipped at any depth inside a module. + +## baseline + +Path of the baseline file, relative to the project root. See [adopting in an existing project](./baseline). + +## Using the config outside Laravel + +The [PHPStan rule](./phpstan) reads `config/cordon.php` without booting Laravel. Keep it a plain array; `env()` is fine. diff --git a/docs-site/guide/custom-rules.md b/docs-site/guide/custom-rules.md new file mode 100644 index 0000000..afde84d --- /dev/null +++ b/docs-site/guide/custom-rules.md @@ -0,0 +1,108 @@ +# Custom rules + +Cordon Modulith ships three rules (`internal_access`, `undeclared_dependency`, `cycles`). You can add your own architecture rules that run with them in `cordon:verify`, the Pest expectation and the baseline. + +## 1. Write the rule + +A rule implements `Cordon\Contracts\Rule`: a stable `id()` and a `check()` method that yields `Cordon\Analysis\Violation`s. + +```php +namespace App\Architecture; + +use Cordon\Analysis\AnalysisContext; +use Cordon\Analysis\Violation; +use Cordon\Contracts\Rule; + +/** + * Only the Shared module may be used by every other module. + */ +final class OnlySharedIsEverywhere implements Rule +{ + public function id(): string + { + return 'only_shared_everywhere'; + } + + public function check(AnalysisContext $context): iterable + { + $users = []; + + foreach ($context->edges as $edge) { + $users[$edge->to->name][$edge->from->name] = true; + } + + $others = count($context->modules) - 1; + + foreach ($users as $module => $from) { + if ($module !== 'Shared' && count($from) === $others) { + yield new Violation( + $this->id(), + sprintf('Every module depends on [%s]. Move what they share to the Shared module.', $module), + targetModule: $module, + target: $module, + ); + } + } + } +} +``` + +## 2. Register it + +List the class in `config/cordon.php`. Built-in rules keep their `true`/`false` switches: + +```php +'rules' => [ + 'internal_access' => true, + 'undeclared_dependency' => true, + 'cycles' => true, + App\Architecture\OnlySharedIsEverywhere::class, +], +``` + +Rules are built through the Laravel container, so their constructors can receive dependencies. The id must not clash with another rule. + +## What a rule receives + +`AnalysisContext` is read-only: + +| Property | Content | +|---|---| +| `modules` | `ModuleMap` with every module (`name`, `namespace`, `path`, `dependsOn`, `publicApi`, `open`) | +| `edges` | Every reference that crosses a module boundary: `from` and `to` modules and the `reference` (`sourceFile`, `sourceClass`, `target` class, `line`) | +| `symbols` | `SymbolTable`: every class declared in the modules (`get($fqcn)` returns its file, line and `#[PublicApi]`/`#[Internal]` marker) | +| `policy` | `PublicApiPolicy`: `isPublic($fqcn, $module, $declaration)` | + +References inside a module are not edges. Code is never loaded: rules work on the static analysis only. + +## Writing good violations + +- One sentence stating the problem, one stating the fix (the same style as the built-in rules). +- Fill `file` and `line` when the violation points to code, so the GitHub annotations and the baseline work. The baseline keys entries by rule, file and `target`, so choose a `target` that stays stable when unrelated code changes. +- Set `sourceModule` to the module that must change: `cordon:verify --module` and the Pest expectation filter by it. +- Report nothing when unsure. False positives make teams switch the rule off. + +## Testing a rule + +Build a small fixture with a module layout and run the analyzer on it: + +```php +use Cordon\Analysis\Analyzer; +use Cordon\Analysis\FileCollector; +use Cordon\Analysis\PhpParserExtractor; +use Cordon\Analysis\PublicApiPolicy; +use Cordon\Resolvers\NamespaceResolver; +use Cordon\Rules\RuleSet; + +it('reports modules everybody depends on', function () { + $modules = (new NamespaceResolver(__DIR__.'/fixtures/app/Modules', 'App\\Modules'))->resolve(); + $analyzer = new Analyzer(new PhpParserExtractor, new FileCollector, new PublicApiPolicy, RuleSet::fromConfig([ + 'internal_access' => false, + 'undeclared_dependency' => false, + 'cycles' => false, + OnlySharedIsEverywhere::class, + ])); + + expect($analyzer->analyze($modules)->violations)->toHaveCount(1); +}); +``` diff --git a/docs-site/guide/installation.md b/docs-site/guide/installation.md new file mode 100644 index 0000000..81b8513 --- /dev/null +++ b/docs-site/guide/installation.md @@ -0,0 +1,61 @@ +# Installation + +Requirements: PHP 8.3+ and Laravel 12 or 13. + +```bash +composer require --dev chrisabner/cordon-modulith +``` + +## Check what was detected + +Cordon Modulith detects your module layout automatically: + +```bash +php artisan cordon:modules +``` + +```text +Resolver: namespace ++---------+---------------------+---------------------+----------------+------+ +| Module | Namespace | Path | Depends on | Open | ++---------+---------------------+---------------------+----------------+------+ +| Billing | App\Modules\Billing | app/Modules/Billing | (not declared) | no | +| Catalog | App\Modules\Catalog | app/Modules/Catalog | (not declared) | no | ++---------+---------------------+---------------------+----------------+------+ +``` + +With a plain `app/Modules` folder you see `Resolver: namespace`, as above. With nwidart/laravel-modules the same command prints `Resolver: nwidart`: + +```text +Resolver: nwidart ++----------+------------------+-----------------+----------------+------+ +| Module | Namespace | Path | Depends on | Open | ++----------+------------------+-----------------+----------------+------+ +| Billing | Modules\Billing | Modules/Billing | (not declared) | no | +| Catalog | Modules\Catalog | Modules/Catalog | (not declared) | no | ++----------+------------------+-----------------+----------------+------+ +``` + +| Resolver | Detected when | Module | Namespace | +|---|---|---|---| +| `nwidart` | `Modules/*/module.json` exists | `Modules/Blog` → `Blog` | `Modules\Blog` (from `config/modules.php`) | +| `internachi` | `app-modules/` exists | `app-modules/billing` → `billing` | from the module's `composer.json` PSR-4 entry | +| `namespace` | fallback | `app/Modules/Billing` → `Billing` | `App\Modules\Billing` | + +If nothing is found, publish the config and set the resolver and its path (see [Configuration](./configuration)). + +## Verify + +```bash +php artisan cordon:verify +``` + +It exits with `1` when there are violations, `0` otherwise and `2` on invalid options. + +## Publish the config (optional) + +```bash +php artisan vendor:publish --tag=cordon-config +``` + +Next: understand [the public API of a module](./public-api), or [adopt it in an existing project](./baseline) if the first run reports many violations. diff --git a/docs-site/guide/introduction.md b/docs-site/guide/introduction.md new file mode 100644 index 0000000..7280b7e --- /dev/null +++ b/docs-site/guide/introduction.md @@ -0,0 +1,27 @@ +# Introduction + +Modular monoliths in Laravel usually rely on [nwidart/laravel-modules](https://github.com/nWidart/laravel-modules), [InterNACHI/modular](https://github.com/InterNACHI/modular) or a hand-made `app/Modules` folder. All of them organise code, but none of them stops the `Billing` module from reaching into `Catalog`'s models. Six months later the "modules" are just folders. + +Cordon Modulith reads your code statically, builds the dependency graph between modules and fails the build when a module: + +- uses **internal classes** of another module instead of its public API (`internal_access`), +- depends on a module it **did not declare** (`undeclared_dependency`), or +- takes part in a **dependency cycle** (`cycles`). + +It is inspired by [Spring Modulith](https://spring.io/projects/spring-modulith) and Shopify's [Packwerk](https://github.com/Shopify/packwerk), adapted to Laravel conventions. + +## What it is not + +- It does not create, register or load modules. Keep using nwidart, InterNACHI or your own folders. +- It does not run at request time. It is a dev dependency for your terminal, CI, tests and editor. +- It does not check rules inside a module (for example "controllers must be final"). Pest's `arch()` and PHPStan are great at that. + +## How it works + +1. A **resolver** finds your modules (name, namespace, path). +2. Every PHP file inside a module is parsed with [nikic/php-parser](https://github.com/nikic/PHP-Parser). Your code is never loaded or executed. +3. Every class reference (`new`, static calls, type declarations, `extends`/`implements`, traits, attributes, `instanceof`, `catch`, `::class`...) is mapped to the module that owns it by namespace. Import statements alone, function calls and constants don't count. +4. References that cross a module boundary are checked by the **rules**. +5. Known violations can be ignored with a **baseline**. + +Next: [install it](./installation). diff --git a/docs-site/guide/living-documentation.md b/docs-site/guide/living-documentation.md new file mode 100644 index 0000000..9ba0643 --- /dev/null +++ b/docs-site/guide/living-documentation.md @@ -0,0 +1,46 @@ +# Living documentation + +Generate Markdown documentation of the real architecture, straight from the code: + +```bash +php artisan cordon:docs # writes docs/architecture/ +php artisan cordon:docs --output=docs/modules +``` + +The output folder (`docs/architecture` by default, relative to the project root) is created if it doesn't exist. Commit it: the diffs show how the architecture changes. + +| File | Content | +|---|---| +| `README.md` | Every module with its namespace, dependencies, users and violation count, and a Mermaid diagram of the dependencies. Edge labels count the classes used; edges with internal access are red. | +| `modules/.md` | A canvas per module: namespace, path, `depends_on`, a diagram of the module and its neighbours, its public API, the classes it uses from other modules and the ones other modules use from it (internal ones flagged), its events and its violations. | +| `events.md` | Every module event with the classes that publish and listen to it. | + +GitHub and GitLab render the Mermaid diagrams. The output is deterministic (sorted, no timestamps), so you can commit it and see architecture changes in pull request diffs. + +```mermaid +flowchart LR + m0["Billing"] + m1["Orders"] + m2["Shipping"] + m0 -->|1| m1 + m1 -->|1| m0 + m2 -->|1| m0 + m2 -->|2| m1 + linkStyle 3 stroke:#d33,stroke-width:2px +``` + +## Events + +Events are detected statically: + +- classes under a module's `Events` namespace; +- classes dispatched with `event(new X)`, `broadcast(new X)`, `$dispatcher->dispatch(new X)` or `Event::dispatch(new X)`; +- classes registered with `Event::listen(X::class, ...)`. + +Publishers also include `X::dispatch()` and listeners include `handle(X $event)` / `__invoke(X $event)`, but only for classes that are already events, because jobs use the same method names. + +String event names and wildcard listeners are not detected. + +## Violations + +The documentation shows every violation, ignoring the baseline: it describes the code as it is. diff --git a/docs-site/guide/pest.md b/docs-site/guide/pest.md new file mode 100644 index 0000000..e241cd4 --- /dev/null +++ b/docs-site/guide/pest.md @@ -0,0 +1,52 @@ +# Pest + +Check boundaries from your test suite with the `toRespectBoundaries()` expectation. It is registered automatically when Pest is installed. + +```php +use Cordon\Testing\Cordon; + +it('keeps Billing inside its boundaries', function () { + expect('Billing')->toRespectBoundaries(); +}); + +it('keeps every module inside its boundaries', function () { + expect(Cordon::modules())->each->toRespectBoundaries(); +}); +``` + +A failure lists every violation of the module: + +```text +Module [Billing] does not respect its boundaries (3 violations): + - [internal_access] app/Modules/Billing/Services/CheckoutService.php:13 Module [Billing] uses App\Modules\Catalog\Models\Product, which is internal to module [Catalog]. ... + - [internal_access] app/Modules/Billing/Services/InvoiceRenderer.php:10 Module [Billing] uses App\Modules\Catalog\Contracts\LegacyCatalog, which is internal to module [Catalog]. ... + - [cycles] Modules [Billing, Catalog] form a dependency cycle: Billing -> Catalog -> Billing. ... +``` + +## Setup + +A fresh Laravel app with Pest has no binding between your tests and the framework, so the expectation fails with "needs a booted Laravel application". Install Pest with its Laravel plugin and initialise it: + +```bash +composer require --dev pestphp/pest pestphp/pest-plugin-laravel +./vendor/bin/pest --init +``` + +`pest --init` creates `tests/Pest.php`. If you already have that file, make sure it extends your `TestCase` for the folder where the test lives: + +```php +// tests/Pest.php +pest()->extend(Tests\TestCase::class)->in('Feature'); +``` + +## Details + +- The expectation needs a booted application: use it in tests that extend your `Tests\TestCase`. Pest only does that for the folders bound in `tests/Pest.php` (see [Setup](#setup)). +- `expect(Cordon::modules())->each->toRespectBoundaries()` stops at the first module that fails (that is how Pest's `each` works). Fix it and run again, or write one `it()` per module to see every failing module at once. +- A module's violations are the ones it causes plus the dependency cycles it takes part in, like `cordon:verify --module`. +- The baseline is applied, so tests and CI give the same verdict. +- The analysis runs once per test process and is reused while the config and the baseline file don't change. Call `Cordon::flush()` if a test changes files on disk. + +## Pest `arch()` and Cordon Modulith + +They complement each other. Pest's `arch()` expectations are great for rules inside a module ("models extend Model", "no `dd()`"), but they rely on reflection, so they only see classes that exist and autoload. `toRespectBoundaries()` is about the edges between modules, knows each module's public API and uses static analysis. diff --git a/docs-site/guide/phpstan.md b/docs-site/guide/phpstan.md new file mode 100644 index 0000000..a145a10 --- /dev/null +++ b/docs-site/guide/phpstan.md @@ -0,0 +1,60 @@ +# PHPStan + +Cordon Modulith ships a PHPStan rule that reports `internal_access`, so you see violations in your editor while you type. + +A fresh Laravel app doesn't ship [phpstan/extension-installer](https://github.com/phpstan/extension-installer). Either require it (`composer require --dev phpstan/extension-installer`) and the rule is enabled automatically, or include the extension by hand. A complete minimal `phpstan.neon`: + +```yaml +# phpstan.neon +includes: + - vendor/chrisabner/cordon-modulith/extension.neon + +parameters: + level: 5 + paths: + - app + - Modules +``` + +Point `paths` at the folders that contain your modules (`app-modules` for InterNACHI). + +```text + ------ ------------------------------------------------------------------------------------------ + Line app/Modules/Billing/Services/CheckoutService.php + ------ ------------------------------------------------------------------------------------------ + 13 Module [Billing] uses App\Modules\Catalog\Models\Product, which is internal to module [Catalog]. + 🪪 cordon.internalAccess + 💡 Depend on its public API instead: a class in a public namespace such as Contracts, or one marked #[PublicApi]. + ------ ------------------------------------------------------------------------------------------ +``` + +## How it works + +- It uses the same extractor and public API rules as `cordon:verify`. `#[Internal]` and `#[PublicApi]` are read through PHPStan's static reflection. +- PHPStan does not boot Laravel, so the rule reads `config/cordon.php` directly (and `config/modules.php` / `config/app-modules.php` when they load without the framework). Keep `config/cordon.php` a plain array. +- The project root is PHPStan's working directory. If you run PHPStan from elsewhere, set it: + +```yaml +parameters: + cordon: + basePath: /path/to/project +``` + +## Configuration errors + +Project config files (`config/cordon.php`, `config/modules.php`, `config/app-modules.php`) are evaluated without booting Laravel. Helpers such as `base_path()`, `app_path()` and `config_path()` resolve against the project root, but anything that needs the framework (facades, the container, `env()` values that live in a booted app) can't be evaluated. In that case the rule reports one error with the identifier `cordon.configuration` and the resolvers fall back to their defaults. + +Fix it by setting the values explicitly in `config/cordon.php` (for example `resolvers.nwidart.path`). To silence it, ignore the identifier: + +```yaml +parameters: + ignoreErrors: + - identifier: cordon.configuration +``` + +With parallel workers the error may appear once per worker. + +## Limits + +- Only `internal_access` is reported: `undeclared_dependency` and `cycles` need the whole graph, so they stay in `cordon:verify`. +- Cordon's baseline is not applied. Use PHPStan's own baseline if you want the same exceptions in the editor, or ignore the identifier `cordon.internalAccess` for specific paths. diff --git a/docs-site/guide/public-api.md b/docs-site/guide/public-api.md new file mode 100644 index 0000000..7accbd3 --- /dev/null +++ b/docs-site/guide/public-api.md @@ -0,0 +1,75 @@ +# The public API of a module + +Other modules may only use classes that belong to a module's public API. A class is public when, in order of precedence: + +1. it is marked `#[Cordon\Attributes\Internal]` → **internal**, always; +2. it is marked `#[Cordon\Attributes\PublicApi]` → public; +3. its module is configured as `open` → public (handy for a shared kernel); +4. it lives under one of the public namespaces, relative to the module: `Contracts`, `Events`, `Data`, `Enums`, `Exceptions` by default, plus the module's own `public` list → public; +5. otherwise → **internal**. + +`public_namespaces` match the **first namespace segment relative to the module root** only. `Modules\Billing\Contracts\Gateway` is public, but `Modules\Billing\Invoices\Enums\Status` is **not**: its relative name starts with `Invoices`, not `Enums`. Field tests showed that nested enums and events cause a large share of violations in some apps. To expose them, do one of these: + +- list the nested namespace in the module's `public` list, e.g. `'Billing' => ['public' => ['Invoices\\Enums', 'Invoices\\Events']]`; +- mark the class with `#[PublicApi]`; +- move it to a top-level public namespace. + +`DTOs` is not in the defaults (only `Data`). If you use it, add it to `public_namespaces`, e.g. `['Contracts', 'Events', 'Data', 'DTOs', 'Enums', 'Exceptions']` (setting the option replaces the default list). + +For a module `Modules\Catalog`: + +| Class | Public? | Why | +|---|---|---| +| `Modules\Catalog\Contracts\ProductCatalog` | yes | `Contracts` namespace | +| `Modules\Catalog\Data\ProductData` | yes | `Data` namespace | +| `Modules\Catalog\Events\ProductDiscontinued` | yes | `Events` namespace | +| `Modules\Catalog\Models\Product` | no | internal by default | +| `Modules\Catalog\Support\PriceFormatter` with `#[PublicApi]` | yes | attribute | +| `Modules\Catalog\Contracts\LegacyCatalog` with `#[Internal]` | no | `#[Internal]` always wins | + +## Marking classes + +```php +namespace Modules\Catalog\Support; + +use Cordon\Attributes\PublicApi; + +#[PublicApi] +final class PriceFormatter +{ + // ... +} +``` + +The attributes have no runtime behaviour; Cordon Modulith reads them statically. + +## Extra public namespaces per module + +```php +// config/cordon.php +'modules' => [ + 'Sales' => ['public' => ['Application\\Commands', 'Domain\\Events']], +], +``` + +Entries are relative to the module namespace and can be a namespace or a single class. + +## Open modules + +A shared kernel (value objects like `Money`, base exceptions...) can be `open`: every class in it is public. + +```php +'modules' => [ + 'Shared' => ['open' => true], +], +``` + +Keep open modules small: everything in them becomes a dependency of the whole application. + +## How modules should talk + +- **Contracts**: interfaces in `Contracts`, bound to an internal implementation in the module's service provider. +- **Data objects**: immutable classes in `Data` that cross the boundary instead of Eloquent models. +- **Events**: classes in `Events` that other modules listen to. + +See the recipes: [expose a service](../recipes/expose-a-service), [break a cycle with an event](../recipes/break-a-cycle), [replace a cross-module relation](../recipes/cross-module-relations). diff --git a/docs-site/guide/rules.md b/docs-site/guide/rules.md new file mode 100644 index 0000000..abe9a04 --- /dev/null +++ b/docs-site/guide/rules.md @@ -0,0 +1,75 @@ +# Rules + +| Rule | Reports | +|---|---| +| `internal_access` | A module using a class that is not part of another module's [public API](./public-api). One report per file and target class. | +| `undeclared_dependency` | A module with `depends_on` using a module that is not listed. One report per file and target module. | +| `cycles` | Modules that depend on each other in a cycle, with the shortest cycle path. | + +All three are enabled by default. Switch them off in the config: + +```php +'rules' => [ + 'internal_access' => true, + 'undeclared_dependency' => true, + 'cycles' => false, +], +``` + +## internal_access + +```text +x [internal_access] Modules/Orders/app/Models/Order.php:13 + Module [Orders] uses Modules\Catalog\Models\Product, which is internal to module [Catalog]. Depend on its public API instead (a class in a public namespace such as Contracts, or one marked #[PublicApi]). +``` + +Fix it by depending on a contract or data object of the target module. See [expose a service](../recipes/expose-a-service). + +## undeclared_dependency + +`depends_on` is opt-in per module: modules without it are not checked. + +```php +'modules' => [ + 'notifications' => ['depends_on' => ['accounts']], +], +``` + +```text +x [undeclared_dependency] app-modules/notifications/src/Listeners/SendInvoiceEmail.php:12 + Module [notifications] depends on module [invoicing] (via Modules\Invoicing\Events\InvoiceIssued), but [invoicing] is not listed in its depends_on. +``` + +Add the module to `depends_on` only when the dependency is intended. + +Using a module's **public** class without listing that module reports only `undeclared_dependency`, never `internal_access`. With `'Orders' => ['depends_on' => ['Catalog']]`: + +| Orders uses | Reported | +|---|---| +| `Modules\Catalog\Contracts\ProductCatalog` (public, `Catalog` listed) | nothing | +| `Modules\Catalog\Contracts\ProductCatalog` (public, `Catalog` not listed) | `undeclared_dependency` only | +| `Modules\Catalog\Models\Product` (internal, `Catalog` listed) | `internal_access` only | +| `Modules\Catalog\Models\Product` (internal, `Catalog` not listed) | both | + +## cycles + +```text +x [cycles] + Modules [Orders, Payments] form a dependency cycle: Orders -> Payments -> Orders. Break it by inverting one dependency, for example with an event or a contract. +``` + +Listening to another module's event is a dependency on that module too. See [break a cycle with an event](../recipes/break-a-cycle). + +Every cross-module reference is an edge, including references to public classes such as events. Two modules that listen to each other's events still form a cycle. Break it by moving one direction: publish from one side only, or introduce a shared or orchestrating module that both depend on. + +## What counts as a dependency + +Counted: `new`, static calls and properties, class constants, `::class`, type declarations (parameters, returns, properties, union, intersection and DNF types), `extends`, `implements`, traits, attributes, `instanceof` and `catch`. + +Not counted: `use` imports on their own, function calls, constants, docblock types (`@var`, generics), class names in strings (`'App\\Foo'`, `app('...')`) and dynamic references. When unsure, Cordon Modulith reports nothing: false positives are treated as bugs. + +Files in `vendor`, `node_modules`, `tests` and `Tests` directories and Blade views are skipped. Code outside modules (for example `app/Http`) is not analysed. + +## Your own rules + +See [custom rules](./custom-rules). diff --git a/docs-site/index.md b/docs-site/index.md new file mode 100644 index 0000000..2583154 --- /dev/null +++ b/docs-site/index.md @@ -0,0 +1,43 @@ +--- +layout: home + +hero: + name: Cordon Modulith + text: Cordon off your modules + tagline: Verifiable boundaries between the modules of a Laravel application, checked in CI. + actions: + - theme: brand + text: Get started + link: /guide/introduction + - theme: alt + text: View on GitHub + link: https://github.com/ChrisAbner/Cordon-Modulith + +features: + - title: Public API per module + details: Other modules may only use a module's contracts, events, data objects, enums and exceptions, or classes you mark with #[PublicApi]. + - title: Works with your layout + details: nwidart/laravel-modules, InterNACHI/modular or a plain app/Modules folder, detected automatically. Nothing to scaffold. + - title: Static, never runs your code + details: Parses files with nikic/php-parser. Works on code that doesn't boot and classes that don't exist yet. Around a second for 1,000 files on a CI runner. + - title: Adopt gradually + details: Record existing violations in a baseline and fail the build only on new ones. + - title: Where you already work + details: GitHub annotations, a Pest expectation, a PHPStan rule for your editor and a Laravel Boost skill for AI agents. + - title: Living documentation + details: Mermaid diagrams, a canvas per module and an event inventory, generated from the code. +--- + +```text +$ php artisan cordon:verify + +Cordon analysed 212 files in 6 modules (148 cross-module references). + +x [internal_access] Modules/Billing/app/Services/CheckoutService.php:13 + Module [Billing] uses Modules\Catalog\Models\Product, which is internal to module [Catalog]. Depend on its public API instead (a class in a public namespace such as Contracts, or one marked #[PublicApi]). + +x [cycles] + Modules [Billing, Catalog] form a dependency cycle: Billing -> Catalog -> Billing. Break it by inverting one dependency, for example with an event or a contract. + +2 boundary violations. +``` diff --git a/docs-site/package-lock.json b/docs-site/package-lock.json new file mode 100644 index 0000000..73f1574 --- /dev/null +++ b/docs-site/package-lock.json @@ -0,0 +1,2519 @@ +{ + "name": "cordon-modulith-docs", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cordon-modulith-docs", + "devDependencies": { + "vitepress": "^1.6.4" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.25.0.tgz", + "integrity": "sha512-rSTin9Uta23uaewYVQEp8XI9T3iA/zrg0/1G2vhf8oFFDxFL5vybnZ5IQwsVAg4JpKxPX4/WYNKdcfWrZymk7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.59.0.tgz", + "integrity": "sha512-bm2XN0hCSMYwStSsCBT0/PUB2BDxoyR1Lnub3c392HMEy9bi8PUSW8vR6zltVEKWG2t4PQFWMD5C07bmJxGgPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.59.0.tgz", + "integrity": "sha512-XOFPOTa69WuqHR6c5tMgnUUwwqQgNSzMpxmhrgA9KmxRf8WIqEa0cokHJvohk5CYb7CZx0xeSL6Bk2IUJ7Lv7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.59.0.tgz", + "integrity": "sha512-PC8ipLOYFKRTfIUY1J3FJxS6ryzWziaXmIX9/sNMoUR8L+XhF7hX2QAeUI87YVl5zujvlYTSOb+HAIqKXDjyHQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.59.0.tgz", + "integrity": "sha512-yFNcCMM5fHiyoR0HuxMrzy+VjDcmhFUZTm2IJ2DHwGsVH3B5SEob4zTmeEZ3j/AZqNnmrJOCKl/tJnBg7+84bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.59.0.tgz", + "integrity": "sha512-GYja6HkDt2VrQhWmB2cLx3Z5fDwI9no7q+xwCWcFrTPm5CLH9QZvK0XLO9co1FcNIDxxkMaP3AXM39/XcecEOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.59.0.tgz", + "integrity": "sha512-Wofg7bMpWh8N5qDDZs0wy6whc+KMmdNsxgrIGp9Ug2s1Bka0uq7AjyckdxReKPHLA0Q1qH8X/SNh0wn2t2X2Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.59.0.tgz", + "integrity": "sha512-fHnALZfbEnODczGk14Y/1YBRApp6UEpZUTexGcMUPzY7RDc7q4HN2Y6jh0KUG8Jo9B+q+wOoQEc3ziR0ErbuFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.59.0.tgz", + "integrity": "sha512-Fa38s1mHgoaLCT117sfJ6P78rtxUt93CYxBYpq1VOIcslaF+cH+1h5uikAPsKfxLnsuEDZHiBiaI6eNPTsQMRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.59.0.tgz", + "integrity": "sha512-NyNsRSqM2tF1MX7ZGw/j4rduoEJiQ5wfvbSo/CFVdEzYCjyxbGFRPOZyS/GETJG9rk1TdHOq8NZqKqk/u1fm4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.59.0.tgz", + "integrity": "sha512-nXBK2uygWtvbCOffMWqqfjjcEtp9enDKY5/2Pw/Hhw8VVaOyK2ThbGK04bNX/Le+qoK1VHYkodWGVTKfw0Otkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.59.0.tgz", + "integrity": "sha512-yb+4afX/zja8QwX0KmV4/ae2kyYkRknsE79CRusYS2U5D8qwn2wq0cn1x12f3tm3F3+5FgmDdbuGdQTuCLSKMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.59.0.tgz", + "integrity": "sha512-Lp52TmpA1QtNmdHzs505Xwf4tgII7RAapkDSF9AAtWIBETcGaTIaXci4Rd9nS6SdaWK1BWTSqAPp5wzh6UN3Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.59.0.tgz", + "integrity": "sha512-YYHLEs5rC6oRTFwT7bJaKBR9NSFzikeVHGAT1ffATmUQzR8XqyhHXjoieAUoR7fBU6I5cfy1FVeh5GoRgriuzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.98", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.98.tgz", + "integrity": "sha512-35CEBG1H/3nsohVgJaRWtOZ/P5syRTL++ZXTISdQSC76o1mAFXngFoSm9EOnnBiDApKPu8WN/DY+z69yCycL9A==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", + "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.10" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", + "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.10", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", + "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/algoliasearch": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.59.0.tgz", + "integrity": "sha512-wUXzaeI7B526W4y1gFg3lcxgDZ67XSgRJIiellYWOas/pLpO7rOtxm9Gr2E/C8aiSDXIgx1q5TdSsvK67Uakqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.25.0", + "@algolia/client-abtesting": "5.59.0", + "@algolia/client-analytics": "5.59.0", + "@algolia/client-common": "5.59.0", + "@algolia/client-insights": "5.59.0", + "@algolia/client-personalization": "5.59.0", + "@algolia/client-query-suggestions": "5.59.0", + "@algolia/client-search": "5.59.0", + "@algolia/ingestion": "1.59.0", + "@algolia/monitoring": "1.59.0", + "@algolia/recommend": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/copy-anything": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.4.tgz", + "integrity": "sha512-/LLeW0CtsoSybVkEpYjifB2Pis8DUSuwY7QD9FiR98fWkdbdrACw3ilckfnAHMecQ+RFEdZlRt6eCXhYHJbl/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs-site/package.json b/docs-site/package.json new file mode 100644 index 0000000..e2fdf52 --- /dev/null +++ b/docs-site/package.json @@ -0,0 +1,13 @@ +{ + "name": "cordon-modulith-docs", + "private": true, + "type": "module", + "scripts": { + "dev": "vitepress dev", + "build": "vitepress build", + "preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "^1.6.4" + } +} diff --git a/docs-site/recipes/break-a-cycle.md b/docs-site/recipes/break-a-cycle.md new file mode 100644 index 0000000..a8d7338 --- /dev/null +++ b/docs-site/recipes/break-a-cycle.md @@ -0,0 +1,94 @@ +# Break a cycle with an event + +**Problem:** `Orders` uses `Payments` to charge the customer, and `Payments` calls back into `Orders` to mark the order as paid: + +```text +x [cycles] + Modules [Orders, Payments] form a dependency cycle: Orders -> Payments -> Orders. Break it by inverting one dependency, for example with an event or a contract. +``` + +With a cycle, neither module can change, be tested or be extracted on its own. + +## Before + +```php +// Modules/Payments/app/Services/Gateway.php +namespace Modules\Payments\Services; + +use Modules\Orders\Contracts\OrderRepository; + +final class Gateway +{ + public function __construct(private OrderRepository $orders) {} + + public function capture(int $orderId, int $amount): void + { + // ... charge the card + $this->orders->markAsPaid($orderId); // Payments -> Orders + } +} +``` + +## After + +`Payments` announces what happened in its own event and knows nothing about orders: + +```php +// Modules/Payments/app/Events/PaymentCaptured.php +namespace Modules\Payments\Events; + +final readonly class PaymentCaptured +{ + public function __construct(public int $orderId, public int $amount) {} +} +``` + +```php +// Modules/Payments/app/Services/Gateway.php +namespace Modules\Payments\Services; + +use Modules\Payments\Events\PaymentCaptured; + +final class Gateway +{ + public function capture(int $orderId, int $amount): void + { + // ... charge the card + event(new PaymentCaptured($orderId, $amount)); + } +} +``` + +`Orders` listens: + +```php +// Modules/Orders/app/Listeners/MarkOrderAsPaid.php +namespace Modules\Orders\Listeners; + +use Modules\Orders\Contracts\OrderRepository; +use Modules\Payments\Events\PaymentCaptured; + +final class MarkOrderAsPaid +{ + public function __construct(private OrderRepository $orders) {} + + public function handle(PaymentCaptured $event): void + { + $this->orders->markAsPaid($event->orderId); + } +} +``` + +Only `Orders -> Payments` remains: `Orders` uses the gateway contract and listens to the event. + +::: tip Listening is a dependency too +Referencing `PaymentCaptured` in `Orders` is a dependency on `Payments`. The cycle is gone because `Payments` no longer references `Orders`, not because events are invisible. Check with `php artisan cordon:verify`. +::: + +::: warning Events in both directions still form a cycle +If `Payments` also listened to an `Orders` event, `Orders -> Payments -> Orders` would be back: public events count as edges too. Move one direction: publish from one side only, or introduce a shared or orchestrating module that both depend on. +::: + +## Alternative: dependency inversion + +When the call must stay synchronous, let the lower-level module own the interface. `Payments` defines `Contracts\PaymentListener`, `Orders` implements it and binds it in its service provider. The arrow now points from `Orders` to `Payments` only. diff --git a/docs-site/recipes/cross-module-relations.md b/docs-site/recipes/cross-module-relations.md new file mode 100644 index 0000000..0a83108 --- /dev/null +++ b/docs-site/recipes/cross-module-relations.md @@ -0,0 +1,73 @@ +# Replace a cross-module Eloquent relation + +**Problem:** Eloquent makes it easy to relate models across modules: + +```php +namespace Modules\Invoicing\Models; + +use Modules\Accounts\Models\Team; + +class Invoice extends Model +{ + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); // internal_access + } +} +``` + +Every `$invoice->team->...` in Invoicing now depends on the `teams` table and the `Team` model. Renaming a column in Accounts breaks Invoicing. + +## Keep the foreign key, ask the owner + +Keep `team_id` on the invoice, remove the relation and load what you need through the owning module's public API: + +```php +namespace Modules\Accounts\Contracts; + +use Modules\Accounts\Data\TeamData; + +interface FindsTeams +{ + public function find(int $id): TeamData; + + /** + * @param list $ids + * @return array keyed by id + */ + public function findMany(array $ids): array; +} +``` + +```php +namespace Modules\Invoicing\Actions; + +use Modules\Accounts\Contracts\FindsTeams; +use Modules\Invoicing\Models\Invoice; + +final class ListInvoices +{ + public function __construct(private FindsTeams $teams) {} + + public function __invoke(): array + { + $invoices = Invoice::latest()->limit(50)->get(); + $teams = $this->teams->findMany($invoices->pluck('team_id')->unique()->all()); + + return $invoices->map(fn (Invoice $invoice) => [ + 'number' => $invoice->number, + 'team' => $teams[$invoice->team_id]->name ?? null, + ])->all(); + } +} +``` + +`findMany()` avoids N+1 queries without a relation. + +## What about joins and reports? + +Reporting queries that join tables of several modules are a legitimate need. Options: + +- a read model owned by a reporting module and fed by events; +- a query in the owning module that returns data objects; +- accepting the dependency for a reporting module and declaring it explicitly in `depends_on`, with the model marked `#[PublicApi]` only if the owner agrees to keep it stable. diff --git a/docs-site/recipes/expose-a-service.md b/docs-site/recipes/expose-a-service.md new file mode 100644 index 0000000..360d944 --- /dev/null +++ b/docs-site/recipes/expose-a-service.md @@ -0,0 +1,104 @@ +# Expose a service to other modules + +**Problem:** `Billing` needs product prices and uses `Catalog`'s Eloquent model: + +```php +namespace Modules\Billing\Services; + +use Modules\Catalog\Models\Product; + +final class CheckoutService +{ + public function total(int $productId, int $quantity): int + { + return Product::findOrFail($productId)->price_in_cents * $quantity; + } +} +``` + +```text +x [internal_access] Modules/Billing/app/Services/CheckoutService.php:11 + Module [Billing] uses Modules\Catalog\Models\Product, which is internal to module [Catalog]. ... +``` + +Now `Billing` depends on `Catalog`'s table structure, its model events and every attribute of `Product`. + +## 1. Define what Catalog offers + +A contract and a data object in public namespaces: + +```php +namespace Modules\Catalog\Contracts; + +use Modules\Catalog\Data\ProductData; + +interface ProductCatalog +{ + public function find(int $id): ProductData; +} +``` + +```php +namespace Modules\Catalog\Data; + +final readonly class ProductData +{ + public function __construct( + public int $id, + public string $name, + public int $priceInCents, + ) {} +} +``` + +## 2. Implement it inside Catalog + +```php +namespace Modules\Catalog\Services; + +use Modules\Catalog\Contracts\ProductCatalog; +use Modules\Catalog\Data\ProductData; +use Modules\Catalog\Models\Product; + +final class EloquentProductCatalog implements ProductCatalog +{ + public function find(int $id): ProductData + { + $product = Product::findOrFail($id); + + return new ProductData($product->id, $product->name, $product->price_in_cents); + } +} +``` + +```php +// Modules/Catalog/app/Providers/CatalogServiceProvider.php +public function register(): void +{ + $this->app->bind(ProductCatalog::class, EloquentProductCatalog::class); +} +``` + +## 3. Depend on the contract + +```php +namespace Modules\Billing\Services; + +use Modules\Catalog\Contracts\ProductCatalog; + +final class CheckoutService +{ + public function __construct(private ProductCatalog $catalog) {} + + public function total(int $productId, int $quantity): int + { + return $this->catalog->find($productId)->priceInCents * $quantity; + } +} +``` + +`Billing` still depends on `Catalog`, but only on two small, stable types that `Catalog` chose to publish. It can be tested with a fake `ProductCatalog`. + +## When a contract is overkill + +If the class is a stable value object designed to be shared (a `PriceFormatter`, a `Money`), mark it `#[PublicApi]` or move it to an `open` shared kernel module instead. diff --git a/docs/adr/0001-static-analysis.md b/docs/adr/0001-static-analysis.md new file mode 100644 index 0000000..83909de --- /dev/null +++ b/docs/adr/0001-static-analysis.md @@ -0,0 +1,13 @@ +# ADR 0001: Static analysis with nikic/php-parser + +**Status:** accepted + +## Context +Boundary checks must run in CI on any project, including code that does not boot, and must not execute user code. Pest's `arch()` expectations rely on reflection and only see classes that exist and autoload. + +## Decision +Parse files with nikic/php-parser v5 and `NameResolver`. A dependency is any fully qualified class name in a class position after name resolution. Function and constant names are excluded. `use` imports alone are not dependencies. + +## Consequences +- Works without booting the application; fast and deterministic. +- Docblock-only types, string class names and dynamic references are not detected (documented limitation, zero false positives preferred). diff --git a/docs/adr/0002-adapters.md b/docs/adr/0002-adapters.md new file mode 100644 index 0000000..c1d652f --- /dev/null +++ b/docs/adr/0002-adapters.md @@ -0,0 +1,13 @@ +# ADR 0002: Adapt to existing module layouts + +**Status:** accepted + +## Context +nwidart/laravel-modules and InterNACHI/modular already dominate module organisation in Laravel, and many teams use a plain `app/Modules` folder. None of them enforces boundaries. + +## Decision +Cordon Modulith never scaffolds or registers modules. It reads the existing layout through `ModuleResolver` implementations (`namespace`, `nwidart`, `internachi`) selected by config or auto-detected. Module membership of a file is decided by path; of a class, by namespace. + +## Consequences +- Cordon Modulith complements those packages instead of competing with them. +- New layouts only need a new resolver. diff --git a/docs/adr/0003-public-api-model.md b/docs/adr/0003-public-api-model.md new file mode 100644 index 0000000..d974117 --- /dev/null +++ b/docs/adr/0003-public-api-model.md @@ -0,0 +1,13 @@ +# ADR 0003: Public API model + +**Status:** accepted + +## Context +To report internal access, Cordon Modulith must know which classes form a module's public API, with low configuration and escape hatches. + +## Decision +Precedence: `#[Internal]` > `#[PublicApi]` > `open` module > public namespaces (global `public_namespaces` plus the module's `public` list) > internal. Default public namespaces: `Contracts`, `Events`, `Data`, `Enums`, `Exceptions`. + +## Consequences +- Convention first: most projects need no attributes. +- Internal by default is strict; the baseline makes adoption possible. diff --git a/docs/adr/0004-baseline.md b/docs/adr/0004-baseline.md new file mode 100644 index 0000000..c2a0778 --- /dev/null +++ b/docs/adr/0004-baseline.md @@ -0,0 +1,13 @@ +# ADR 0004: Baseline for gradual adoption + +**Status:** accepted + +## Context +Existing projects will start with many violations. Without a way to accept them, nobody can add Cordon Modulith to CI. + +## Decision +`cordon:verify --generate-baseline` writes a JSON file of known violations keyed by rule, relative file and target, with an occurrence count. Line numbers are excluded so unrelated edits don't invalidate entries. Only as many occurrences as recorded are suppressed. + +## Consequences +- The build fails only on new violations. +- A violation moved to another file is reported again (intended). diff --git a/docs/adr/0005-module-option.md b/docs/adr/0005-module-option.md new file mode 100644 index 0000000..9b4fb6f --- /dev/null +++ b/docs/adr/0005-module-option.md @@ -0,0 +1,16 @@ +# ADR 0005: `--module` option and shared verification flow + +**Status:** accepted + +## Context +Teams want to check one module at a time: while working on it, in a module-owned CI job, or from a test. Brief 02D asks for `cordon:verify --module=Billing`, and the Pest expectation (ADR 0006) needs the same flow as the command without going through Artisan. + +## Decision +- `cordon:verify` gains a repeatable `--module=NAME` option. It narrows the report to the violations **of** those modules: the ones whose source module is one of them, plus the dependency cycles that include them. A cycle belongs to every module on its path. +- The analysis still covers every module, because cycles and the public API of target modules need the whole graph. Only the report is filtered. +- An unknown module name exits with `2` (invalid option). `--module` cannot be combined with `--generate-baseline`, because a partial baseline would silently drop the other modules' entries. +- The flow resolve → configure → analyse → baseline moves from the command into `Cordon\Laravel\Verifier`, bound in the container. `Cordon\Analysis\ModuleFilter` implements the filtering without changing `Result`. + +## Consequences +- No change to contracts, value objects, config shape or the baseline format. +- Exit codes are unchanged: `1` when the filtered report has violations, `0` otherwise. diff --git a/docs/adr/0006-pest-expectation.md b/docs/adr/0006-pest-expectation.md new file mode 100644 index 0000000..e4a1dfa --- /dev/null +++ b/docs/adr/0006-pest-expectation.md @@ -0,0 +1,17 @@ +# ADR 0006: Pest expectation `toRespectBoundaries()` + +**Status:** accepted + +## Context +Most Laravel teams already run Pest. Brief 02A asks for `expect('Billing')->toRespectBoundaries()` and `expect(Cordon::modules())->each->toRespectBoundaries()`, reusing the analyzer. + +## Decision +- `Cordon\Testing\Cordon` exposes `modules()`, `result()`, `violationsOf($module)` and `flush()`. It resolves `Cordon\Laravel\Verifier` from the running Laravel container, so the expectation needs a booted application (a test that extends the app's `Tests\TestCase`). Outside one it throws a `LogicException` with that advice. +- The result is computed once per process and reused while the `cordon` config and the baseline file (modification time and size) do not change. Expecting on every module therefore costs one analysis. +- The baseline is applied, exactly as `cordon:verify` does, so adopting projects get the same verdict in CI and in tests. +- A module's violations are those of `cordon:verify --module` (ADR 0005): the ones it causes and the cycles it takes part in. +- The expectation is registered from `src/Testing/expectations.php`, loaded through Composer's `files` autoload. It only calls `Pest\Expectation::extend()` when Pest is installed, so production code paths are unaffected. + +## Consequences +- New public API: `Cordon\Testing\Cordon` and the `toRespectBoundaries` expectation name. +- Pest's own `arch()` expectations remain the tool for rules inside a module; this one covers boundaries between modules. diff --git a/docs/adr/0007-phpstan-rule.md b/docs/adr/0007-phpstan-rule.md new file mode 100644 index 0000000..2cdbb2a --- /dev/null +++ b/docs/adr/0007-phpstan-rule.md @@ -0,0 +1,18 @@ +# ADR 0007: PHPStan rule and configuration outside Laravel + +**Status:** accepted + +## Context +Developers see PHPStan errors in their editor while they type; `cordon:verify` only runs on demand or in CI. Brief 02B asks for a PHPStan rule that reports `internal_access`. PHPStan does not boot the Laravel application, so the rule cannot read `config()`. + +## Decision +- `Cordon\PHPStan\InternalAccessRule` is a `Rule`. For each analysed file inside a module it runs the same extractor and `PublicApiPolicy` as `cordon:verify`, and reports each internal target once per file with the identifier `cordon.internalAccess`. +- Target classes are looked up with PHPStan's static reflection to honour `#[Internal]` and `#[PublicApi]`; nothing is autoloaded. +- `Cordon\Laravel\StandaloneConfig` reads `config/cordon.php` from the project (merged over the package defaults, like `mergeConfigFrom`), plus `config/modules.php` and `config/app-modules.php` when they load without the framework. Files that throw (for example because they call `base_path()`) are ignored and the resolvers use their defaults. +- The project root defaults to PHPStan's `%currentWorkingDirectory%` and can be set with the `cordon.basePath` parameter. +- `extension.neon` registers the rule and is listed under `extra.phpstan.includes`, so `phpstan/extension-installer` enables it automatically. +- Only `internal_access` is reported. `undeclared_dependency` and `cycles` need the whole graph and stay in `cordon:verify`. Excluded directories and `rules.internal_access = false` are honoured. The baseline is not applied: PHPStan has its own baseline. + +## Consequences +- `config/cordon.php` should stay a plain array (it may call `env()`), otherwise the PHPStan rule falls back to the defaults. +- The editor shows the same `internal_access` findings as CI, minus those in `cordon-baseline.json`, which should go into PHPStan's baseline if the team wants them silenced there too. diff --git a/docs/adr/0008-living-documentation.md b/docs/adr/0008-living-documentation.md new file mode 100644 index 0000000..c965c2c --- /dev/null +++ b/docs/adr/0008-living-documentation.md @@ -0,0 +1,20 @@ +# ADR 0008: Living documentation (`cordon:docs`) + +**Status:** accepted + +## Context +Spring Modulith generates architecture documentation from the code. Brief 05 asks for the same in Laravel: dependency diagrams, a canvas per module and an event inventory. + +## Decision +- New command `php artisan cordon:docs {--output=docs/architecture}` writes Markdown: + - `README.md`: overview table and a Mermaid diagram of all module dependencies. Edge labels count the distinct classes used; edges that include internal access are red. + - `modules/.md`: namespace, path, `depends_on`, `open`, a Mermaid diagram of the module and its direct neighbours, its public API, the classes it uses from other modules and the ones other modules use from it (internal ones are flagged), its events and its current violations. + - `events.md`: every module event with the classes that publish and listen to it. +- Output is deterministic (sorted, no timestamps) so it can be committed and reviewed in pull requests. +- Violations are shown **without** the baseline: the documentation describes the real state of the code. +- Events are detected statically (`Cordon\Documentation\EventExtractor`): classes under a module's `Events` namespace, or used explicitly with `event(new X)`, `broadcast(new X)`, `->dispatch(new X)`, `Event::dispatch(new X)` or `Event::listen(X::class)`. `X::dispatch()` and `handle(X $x)` only count for classes that are already events, because jobs use the same methods. +- `Analyzer` gains `snapshot(ModuleMap): Snapshot` and `check(Snapshot): Result`; `analyze()` is now the composition of both, with the same behaviour. `Snapshot` is a new value object (context, file analyses, parse errors) so the generator can reuse the edges and declarations without changing `Result`. + +## Consequences +- New public API: the command, its `--output` option and the file layout above. +- Event listeners registered only through event discovery on `handle()` of classes outside the `Events` convention are not linked; string event names and wildcard listeners are not detected. diff --git a/docs/adr/0009-custom-rules.md b/docs/adr/0009-custom-rules.md new file mode 100644 index 0000000..f740afc --- /dev/null +++ b/docs/adr/0009-custom-rules.md @@ -0,0 +1,17 @@ +# ADR 0009: Custom rules + +**Status:** accepted + +## Context +Teams have architecture rules beyond module boundaries (a shared kernel that must stay small, modules that must declare their dependencies...). Brief 06A asks for the `rules` config to accept classes implementing `Cordon\Contracts\Rule`. + +## Decision +- The `rules` config keeps the built-in switches (`'cycles' => false`) and also accepts class names as values, with or without a key: `[App\Architecture\MyRule::class]` or `['mine' => MyRule::class]`. +- Custom rules run after the built-in ones. Their `id()` must be unique; a duplicated id or a class that does not exist or does not implement `Rule` throws an `InvalidArgumentException` with the fix. +- `RuleSet::fromConfig()` takes an optional factory; the service provider passes the Laravel container so rules can have constructor dependencies. Without a factory (tests, core usage) rules are created with `new`. +- `Rule` and `AnalysisContext` become the extension API: from now on they follow SemVer like the rest of the public API. +- Loading rule classes is not loading analysed code: rules are extension code chosen by the project, like a PHPStan extension. Analysed code is still never loaded. + +## Consequences +- Custom violations work with the baseline, the reporters, `--module` and the Pest expectation, because they are regular `Violation`s. +- The PHPStan rule (ADR 0007) does not run custom rules. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..96e9c39 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,55 @@ +# Architecture + +## Goals + +- Make module boundaries in Laravel applications explicit and verifiable in CI. +- Work on top of any module layout (nwidart, InterNACHI, plain namespaces) instead of replacing it. +- Never load or execute the analysed code. +- Zero false positives: every report must be a real boundary crossing. + +## Pipeline + +```mermaid +flowchart LR + R[ModuleResolver] -->|ModuleMap| C[FileCollector] + C -->|PHP files| E[DependencyExtractor] + E -->|FileAnalysis: declarations + references| A[Analyzer] + A -->|AnalysisContext: edges, symbols, policy| Ru[Rules] + Ru -->|Violations| B[Baseline] + B -->|Result| Rep[Reporter: text / json / github] +``` + +1. **ModuleResolver** discovers modules: name, namespace, path. Per-module config (`depends_on`, `public`, `open`) is applied with `ModuleMap::configure()`. +2. **FileCollector** lists `*.php` files in each module, skipping excluded directory names and Blade views. +3. **DependencyExtractor** (`PhpParserExtractor`) parses each file with nikic/php-parser, runs `NameResolver`, and collects: + - declarations (class, interface, trait, enum) with `#[PublicApi]` / `#[Internal]`; + - references: every `Name\FullyQualified` node after name resolution, except function and constant names. Imports alone are not references. +4. **Analyzer** maps each reference to a target module (longest namespace prefix) and each file to a source module (longest path prefix). References that cross modules become **edges**. +5. **Rules** receive an immutable `AnalysisContext` and yield `Violation`s. +6. **Baseline** removes known violations (keyed by rule + relative file + target, without line numbers). +7. **Reporter** renders the `Result`. + +`Analyzer::snapshot()` runs steps 2 to 4 and returns a `Snapshot` (context, file analyses, parse errors); `Analyzer::check()` runs the rules on it. `cordon:docs` reuses the snapshot to document modules and events. + +## Contracts (frozen) + +| Contract | Responsibility | +|---|---| +| `Contracts\ModuleResolver::resolve(): ModuleMap` | Discover modules | +| `Contracts\DependencyExtractor::extract(string $file): FileAnalysis` | Static extraction, never loads code | +| `Contracts\Rule::id(): string`, `check(AnalysisContext): iterable` | One boundary rule | +| `Contracts\Reporter::render(Result): string`, `formatted(): bool` | Output format | + +Value objects: `Module`, `ModuleMap`, `ClassDeclaration`, `Reference`, `FileAnalysis`, `Edge`, `AnalysisContext`, `Violation`, `Result`. + +## Layers + +- `src/Analysis`, `src/Module`, `src/Rules`, `src/Resolvers`, `src/Reporters`, `src/Baseline`, `src/Support`: framework-agnostic core. It may use Symfony Console's `OutputFormatter` for escaping, nothing from Laravel. +- `src/Documentation`: event extraction and the Markdown generator behind `cordon:docs` (framework-agnostic). +- `src/Laravel`: service provider, `ResolverFactory` (reads Laravel config), `StandaloneConfig` (config outside Laravel), `Verifier` (shared verification flow), Artisan commands. +- `src/Testing`: the Pest expectation `toRespectBoundaries()`. +- `src/PHPStan`: the PHPStan rule for `internal_access`. + +## Decisions + +See `docs/adr/`. diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..606df7b --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,56 @@ +# Continuous integration + +## GitHub Actions (reusable action) + +```yaml +# .github/workflows/cordon.yml +name: cordon +on: [pull_request] +jobs: + cordon: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ChrisAbner/Cordon-Modulith@v0.1.0 +``` + +The action installs PHP and your Composer dependencies, then runs `php artisan cordon:verify --format=github`, which adds an annotation to the pull request for every violation. + +| Input | Default | Description | +|---|---|---| +| `php-version` | `8.4` | PHP version | +| `working-directory` | `.` | Directory of the Laravel application | +| `format` | `github` | `github`, `text` or `json` | +| `args` | | Extra arguments, for example `--module=Billing` | +| `install-dependencies` | `true` | Set to `false` when an earlier step already ran `composer install` | + +If your workflow already sets up PHP and installs dependencies (for example in a test job), add a step instead: + +```yaml + - run: php artisan cordon:verify --format=github +``` + +## Other CI systems + +`cordon:verify` exits with `1` when there are violations, `0` otherwise and `2` on invalid options. Use `--format=json` for a machine readable report: + +```bash +php artisan cordon:verify --format=json > cordon-report.json +``` + +## One job per module + +Teams that own modules can check only theirs: + +```yaml + strategy: + matrix: + module: [Billing, Catalog, Orders] + steps: + - uses: actions/checkout@v4 + - uses: ChrisAbner/Cordon-Modulith@v0.1.0 + with: + args: --module=${{ matrix.module }} +``` + +`--module` reports the violations a module causes and the dependency cycles it takes part in. The analysis still covers the whole application. diff --git a/docs/custom-rules.md b/docs/custom-rules.md new file mode 100644 index 0000000..afde84d --- /dev/null +++ b/docs/custom-rules.md @@ -0,0 +1,108 @@ +# Custom rules + +Cordon Modulith ships three rules (`internal_access`, `undeclared_dependency`, `cycles`). You can add your own architecture rules that run with them in `cordon:verify`, the Pest expectation and the baseline. + +## 1. Write the rule + +A rule implements `Cordon\Contracts\Rule`: a stable `id()` and a `check()` method that yields `Cordon\Analysis\Violation`s. + +```php +namespace App\Architecture; + +use Cordon\Analysis\AnalysisContext; +use Cordon\Analysis\Violation; +use Cordon\Contracts\Rule; + +/** + * Only the Shared module may be used by every other module. + */ +final class OnlySharedIsEverywhere implements Rule +{ + public function id(): string + { + return 'only_shared_everywhere'; + } + + public function check(AnalysisContext $context): iterable + { + $users = []; + + foreach ($context->edges as $edge) { + $users[$edge->to->name][$edge->from->name] = true; + } + + $others = count($context->modules) - 1; + + foreach ($users as $module => $from) { + if ($module !== 'Shared' && count($from) === $others) { + yield new Violation( + $this->id(), + sprintf('Every module depends on [%s]. Move what they share to the Shared module.', $module), + targetModule: $module, + target: $module, + ); + } + } + } +} +``` + +## 2. Register it + +List the class in `config/cordon.php`. Built-in rules keep their `true`/`false` switches: + +```php +'rules' => [ + 'internal_access' => true, + 'undeclared_dependency' => true, + 'cycles' => true, + App\Architecture\OnlySharedIsEverywhere::class, +], +``` + +Rules are built through the Laravel container, so their constructors can receive dependencies. The id must not clash with another rule. + +## What a rule receives + +`AnalysisContext` is read-only: + +| Property | Content | +|---|---| +| `modules` | `ModuleMap` with every module (`name`, `namespace`, `path`, `dependsOn`, `publicApi`, `open`) | +| `edges` | Every reference that crosses a module boundary: `from` and `to` modules and the `reference` (`sourceFile`, `sourceClass`, `target` class, `line`) | +| `symbols` | `SymbolTable`: every class declared in the modules (`get($fqcn)` returns its file, line and `#[PublicApi]`/`#[Internal]` marker) | +| `policy` | `PublicApiPolicy`: `isPublic($fqcn, $module, $declaration)` | + +References inside a module are not edges. Code is never loaded: rules work on the static analysis only. + +## Writing good violations + +- One sentence stating the problem, one stating the fix (the same style as the built-in rules). +- Fill `file` and `line` when the violation points to code, so the GitHub annotations and the baseline work. The baseline keys entries by rule, file and `target`, so choose a `target` that stays stable when unrelated code changes. +- Set `sourceModule` to the module that must change: `cordon:verify --module` and the Pest expectation filter by it. +- Report nothing when unsure. False positives make teams switch the rule off. + +## Testing a rule + +Build a small fixture with a module layout and run the analyzer on it: + +```php +use Cordon\Analysis\Analyzer; +use Cordon\Analysis\FileCollector; +use Cordon\Analysis\PhpParserExtractor; +use Cordon\Analysis\PublicApiPolicy; +use Cordon\Resolvers\NamespaceResolver; +use Cordon\Rules\RuleSet; + +it('reports modules everybody depends on', function () { + $modules = (new NamespaceResolver(__DIR__.'/fixtures/app/Modules', 'App\\Modules'))->resolve(); + $analyzer = new Analyzer(new PhpParserExtractor, new FileCollector, new PublicApiPolicy, RuleSet::fromConfig([ + 'internal_access' => false, + 'undeclared_dependency' => false, + 'cycles' => false, + OnlySharedIsEverywhere::class, + ])); + + expect($analyzer->analyze($modules)->violations)->toHaveCount(1); +}); +``` diff --git a/docs/plan/README.md b/docs/plan/README.md new file mode 100644 index 0000000..971310e --- /dev/null +++ b/docs/plan/README.md @@ -0,0 +1,52 @@ +# Plan del proyecto Cordon Modulith + +Documentación interna de planificación (en español). La documentación pública del paquete está en inglés en la raíz del repo. + +## Estado actual (MVP 0.1) + +| Pieza | Estado | +|---|---| +| Estructura del repo, CI, Pint, PHPStan, Pest | Hecho | +| Contratos del núcleo (`src/Contracts`) | Hecho, congelados | +| Resolvers: namespace, nwidart, InterNACHI + autodetección | Hecho | +| Extracción estática con nikic/php-parser | Hecho, con fixtures de casos límite | +| Reglas: `internal_access`, `undeclared_dependency`, `cycles` | Hecho | +| Baseline | Hecho | +| Reporters: text, json, github | Hecho | +| Comandos `cordon:verify` (con `--module`), `cordon:modules`, `cordon:docs` | Hecho | +| Guideline y skill de Laravel Boost | Hecho | +| Tests unitarios y de feature | **Ejecutados: 91 en verde** (PHP 8.4, Laravel 13 y 12), `composer check` en verde | +| Fixtures de 5 proyectos reales | Hecho (`tests/Fixtures/real-*`) | +| Rendimiento | 1.000 archivos en ~1,1 s; 5.000 en ~5,5 s (`composer bench`) | +| Integraciones: Pest, PHPStan, GitHub Action | Hecho (ADR 0005–0007 aceptados) | +| Sitio de documentación (VitePress) | Hecho (`docs-site/`), falta publicarlo en GitHub Pages | +| Documentación viva (`cordon:docs`) | Hecho (ADR 0008 aceptado) | +| API de reglas propias | Hecho (ADR 0009 aceptado) | +| Materiales de lanzamiento y guion de vídeo | Borradores en `lanzamiento/` y `video.md` | +| Nombre final y vendor de Composer | Decidido: **Cordon Modulith**, paquete `chrisabner/cordon-modulith`. Falta verificar marcas (Fase 0) | + +**Pendiente de ti** (detalle paso a paso en [siguientes-pasos.md](siguientes-pasos.md)): verificar marcas, probar a mano en 3 proyectos reales, crear el repo `cordon-demo` (brief 03B), publicar en Packagist y los materiales de lanzamiento. El plugin de Filament (6B) y la v1.0 (6C) quedan para después de la v0.x. + +## Cómo trabajar con enjambres de agentes + +1. Cada fase tiene un brief en `enjambres/` con objetivo, archivos permitidos, tareas, tests de aceptación, definición de terminado y un prompt listo para pegar. +2. Los agentes leen primero `AGENTS.md`, `docs/architecture.md` y los proyectos de `referencias.md` marcados para su brief. +3. Un enjambre no toca archivos fuera de su alcance. Tú integras y mergeas. +4. Cualquier cambio a contratos, formato de config, baseline u opciones de comandos requiere un ADR aprobado por ti. +5. Una fase se cierra solo cuando `composer check` pasa en CI y se cumple su definición de terminado. + +## Índice + +- [siguientes-pasos.md](siguientes-pasos.md): checklist para publicar y lanzar la v0.1.0. +- [roadmap.md](roadmap.md): fases, calendario y criterios de salida. +- [referencias.md](referencias.md): proyectos de referencia y qué estudiar de cada uno por brief. +- [enjambres/00-fundacion.md](enjambres/00-fundacion.md) +- [enjambres/01-verificacion-nucleo.md](enjambres/01-verificacion-nucleo.md) +- [enjambres/02-integraciones.md](enjambres/02-integraciones.md) +- [enjambres/03-docs-e-ia.md](enjambres/03-docs-e-ia.md) +- [enjambres/04-lanzamiento.md](enjambres/04-lanzamiento.md) +- [enjambres/05-documentacion-viva.md](enjambres/05-documentacion-viva.md) +- [enjambres/06-ecosistema.md](enjambres/06-ecosistema.md) +- [lanzamiento/](lanzamiento/README.md): borradores de lanzamiento (brief 04). +- [video.md](video.md): guion de vídeo (brief 03D). +- [originales/](originales/): archivos originales entregados al inicio (`cordon.zip`, `README.md`, `PLAN.md`, `referencias.md`), sin modificar. diff --git a/docs/plan/enjambres/00-fundacion.md b/docs/plan/enjambres/00-fundacion.md new file mode 100644 index 0000000..89b3bf0 --- /dev/null +++ b/docs/plan/enjambres/00-fundacion.md @@ -0,0 +1,32 @@ +# Enjambre 00 · Fundación + +**Fase:** 0 · **Duración:** 1 semana · **Dependencias:** ninguna + +**Referencias:** ver los proyectos marcados con el brief 00 en [referencias.md](../referencias.md). + +## Objetivo +Dejar el proyecto listo para publicar: nombre verificado, vendor definido, repo en GitHub con CI ejecutándose. + +## Alcance (archivos permitidos) +`composer.json` (solo `name`), `README.md` (solo placeholders de nombre/vendor), `LICENSE.md`, `.github/`, `docs/plan/`. + +## Tareas +1. **Investigación de nombre** (agente de investigación): buscar "Cordon Modulith" en Packagist, GitHub, npm, dominios (cordon.dev, cordonphp.dev, getcordon.dev), USPTO y EUIPO en clase 9/42. Entregar tabla de conflictos. Plan B: "Bulwark". +2. **Tú:** decidir nombre y vendor, crear la organización en GitHub y reservar el dominio. +3. ~~Reemplazar el placeholder de vendor por el real en `composer.json` y `README.md`.~~ Hecho: `chrisabner/cordon-modulith`. +4. Subir el repo, activar GitHub Actions y comprobar que la matriz arranca. +5. Activar GitHub Discussions y publicar el RFC (texto en el brief 04). + +## Definición de terminado +- Tabla de conflictos de nombre revisada por ti. +- Repo público, CI ejecutándose (aunque falle: eso lo arregla el brief 01). +- Ningún placeholder de vendor restante en `composer.json` ni `README.md`. + +## Prompt para pegar +``` +Eres un agente de investigación. Lee docs/plan/enjambres/00-fundacion.md. +Tarea 1: investiga la disponibilidad del nombre "Cordon Modulith" para una librería PHP/Laravel +(Packagist, GitHub, npm, dominios .dev/.io/.com, USPTO y EUIPO clases 9 y 42). +Entrega una tabla: recurso | disponible sí/no | conflicto | riesgo (bajo/medio/alto). +No modifiques archivos del repo. +``` diff --git a/docs/plan/enjambres/01-verificacion-nucleo.md b/docs/plan/enjambres/01-verificacion-nucleo.md new file mode 100644 index 0000000..07b8bf3 --- /dev/null +++ b/docs/plan/enjambres/01-verificacion-nucleo.md @@ -0,0 +1,45 @@ +# Enjambre 01 · Verificación y endurecimiento del núcleo + +**Fase:** 1 · **Duración:** semanas 2–5 · **Dependencias:** brief 00 (repo con CI) + +**Referencias:** ver los proyectos marcados con el brief 01 en [referencias.md](../referencias.md). + +## Objetivo +Llevar el núcleo del MVP a calidad de release: todos los tests en verde, sin falsos positivos en proyectos reales y con buen rendimiento. + +## Contexto obligatorio +`AGENTS.md`, `docs/architecture.md`, `docs/adr/*`. El código se escribió sin ejecutar PHP: esperar errores menores. + +## Sub-enjambres (paralelos) + +### 1A · Verde en CI +- Alcance: `src/`, `tests/`, `phpstan.neon.dist`. +- Ejecutar `composer install && composer check`, corregir fallos de Pest, PHPStan (nivel 8) y Pint sin cambiar contratos. +- Hecho cuando: la matriz completa de CI pasa. + +### 1B · Casos límite de extracción +- Alcance: `src/Analysis/ReferenceCollector.php`, `src/Analysis/PhpParserExtractor.php`, `tests/Unit/ExtractorTest.php`, `tests/Fixtures/edge-cases/`. +- Cubrir con fixtures: enums, traits, clases anónimas, first-class callables (`Foo::bar(...)`), `match`, atributos con argumentos de clase, `instanceof`, `catch` múltiple, tipos unión/intersección/DNF, constantes de clase (`Foo::class`), closures estáticas, promoted properties. +- Hecho cuando: cada caso tiene test y ningún falso positivo. + +### 1C · Fixtures de proyectos reales +- Alcance: `tests/Fixtures/real-*`, `tests/Unit/RealProjectsTest.php`. +- Recrear 5–10 estructuras reales (nwidart v11+ con `app/`, InterNACHI, `app/Modules`, DDD con `src/Domain`), anonimizadas. Documentar violaciones esperadas. +- Hecho cuando: los resultados coinciden con lo esperado revisado por ti. + +### 1D · Rendimiento +- Alcance: `tests/Benchmark/`, optimizaciones en `src/Analysis/` sin cambiar contratos. +- Generar un proyecto sintético de 1.000 y 5.000 archivos; medir. Si hace falta, caché por hash de archivo (proponer ADR antes). +- Hecho cuando: 1.000 archivos < 10 s en CI. + +## Tests de aceptación de la fase +- `composer check` verde en PHP 8.3/8.4 × Laravel 12/13. +- `php artisan cordon:verify` probado a mano en 3 proyectos reales (uno por resolver). + +## Prompt para pegar (1A) +``` +Lee AGENTS.md, docs/architecture.md y docs/plan/enjambres/01-verificacion-nucleo.md (sección 1A). +Ejecuta `composer install && composer check`. Corrige los fallos en src/ y tests/ sin cambiar +las firmas de src/Contracts ni los value objects. Si un test está mal y no el código, explica por qué +antes de cambiarlo. Entrega: lista de cambios y salida final de composer check. +``` diff --git a/docs/plan/enjambres/02-integraciones.md b/docs/plan/enjambres/02-integraciones.md new file mode 100644 index 0000000..fb902ea --- /dev/null +++ b/docs/plan/enjambres/02-integraciones.md @@ -0,0 +1,39 @@ +# Enjambre 02 · Integraciones de desarrollo + +**Fase:** 2 · **Duración:** semanas 6–7 · **Dependencias:** fase 1 cerrada + +**Referencias:** ver los proyectos marcados con el brief 02 en [referencias.md](../referencias.md). + +## Objetivo +Que Cordon Modulith se use donde el equipo ya trabaja: tests de Pest, PHPStan y GitHub Actions. + +## Sub-enjambres (paralelos) + +### 2A · Expectativa Pest +- Alcance: `src/Testing/`, `tests/Unit/PestExpectationTest.php`. +- API: `expect('Billing')->toRespectBoundaries();` y `expect(Cordon::modules())->each->toRespectBoundaries();` +- Reutiliza `Analyzer` y filtra violaciones por módulo origen. Requiere ADR para cualquier cambio en `Result`. + +### 2B · Regla PHPStan +- Alcance: `src/PHPStan/`, `extension.neon`, `tests/PHPStan/`. +- Regla que reporta `internal_access` en el editor. Leer config desde un archivo `cordon.php` independiente de Laravel (ADR necesario: config fuera de Laravel). + +### 2C · GitHub Action reutilizable +- Alcance: `action.yml` (acción compuesta), `docs/ci.md`. +- Entradas: `php-version`, `working-directory`, `format`. Documentar uso en 5 líneas. + +### 2D · Opción `--module` +- Alcance: `src/Laravel/Commands/VerifyCommand.php`, tests de feature. +- `cordon:verify --module=Billing` limita el reporte a un módulo origen. + +## Definición de terminado +- Cada integración con tests y documentación en README. +- App demo (brief 03) usando las tres integraciones. + +## Prompt para pegar (2A) +``` +Lee AGENTS.md, docs/architecture.md y docs/plan/enjambres/02-integraciones.md (2A). +Implementa una expectativa de Pest `toRespectBoundaries()` en src/Testing/ que reutilice +Cordon\Analysis\Analyzer. Escribe primero los tests usando tests/Fixtures/namespace-app. +No cambies src/Contracts. Si necesitas cambiar Result, detente y redacta un ADR en docs/adr/. +``` diff --git a/docs/plan/enjambres/03-docs-e-ia.md b/docs/plan/enjambres/03-docs-e-ia.md new file mode 100644 index 0000000..45b3d22 --- /dev/null +++ b/docs/plan/enjambres/03-docs-e-ia.md @@ -0,0 +1,37 @@ +# Enjambre 03 · Documentación e IA + +**Fase:** 3 · **Duración:** semanas 7–8 · **Dependencias:** fase 1 (2 recomendable) + +**Referencias:** ver los proyectos marcados con el brief 03 en [referencias.md](../referencias.md). + +## Objetivo +Que alguien ajeno instale y configure Cordon Modulith en menos de 15 minutos, y que los agentes de IA respeten los límites automáticamente. + +## Sub-enjambres + +### 3A · Sitio de documentación +- Alcance: `docs-site/` (VitePress o similar). +- Páginas: introducción, instalación, modelo de API pública, configuración, reglas, baseline, CI, recetas ("cómo exponer un servicio", "cómo romper un ciclo con eventos"), FAQ, comparativa honesta con Deptrac, Pest `arch()` y laravel-true-modular. + +### 3B · App demo +- Alcance: repo aparte `cordon-demo`. +- Laravel 13 con nwidart, 4 módulos, violaciones intencionales, CI en rojo y rama `fixed` en verde. + +### 3C · Skill de Laravel Boost +- Alcance: `resources/boost/skills/cordon-fix-violations/SKILL.md`. +- Enseña al agente a leer la salida de `cordon:verify --format=json` y corregir cada tipo de violación (extraer contrato, publicar evento, declarar dependencia). + +### 3D · Guion de vídeo (3 min) +- Alcance: `docs/plan/video.md`. Problema → instalación → violación en CI → corrección → baseline. + +## Definición de terminado +- Prueba con una persona ajena: instalación y primera verificación en < 15 min. +- Tú revisas y apruebas el README y la página de inicio. + +## Prompt para pegar (3C) +``` +Lee AGENTS.md, README.md y docs/plan/enjambres/03-docs-e-ia.md (3C). +Escribe resources/boost/skills/cordon-fix-violations/SKILL.md siguiendo el formato de skills +de Laravel Boost. Debe explicar cómo corregir internal_access, undeclared_dependency y cycles +con ejemplos de código antes/después. Nunca debe recomendar añadir violaciones al baseline. +``` diff --git a/docs/plan/enjambres/04-lanzamiento.md b/docs/plan/enjambres/04-lanzamiento.md new file mode 100644 index 0000000..7c7b85b --- /dev/null +++ b/docs/plan/enjambres/04-lanzamiento.md @@ -0,0 +1,32 @@ +# Enjambre 04 · Lanzamiento v0.1 + +**Fase:** 4 · **Duración:** semanas 9–10 · **Dependencias:** MVP publicado en Packagist + +**Referencias:** ver los proyectos marcados con el brief 04 en [referencias.md](../referencias.md). + +## Objetivo +Preparar todos los materiales de lanzamiento. **La publicación y el contacto con personas los haces tú**, no los agentes. + +## Entregables (los agentes redactan, tú publicas) +1. RFC para GitHub Discussions: problema, propuesta, preguntas abiertas ("¿cómo verificáis hoy los límites?"). +2. Borrador de artículo para Laravel News (envío por su formulario de paquetes). +3. Post para r/laravel: problema real + demo, sin tono de anuncio. +4. Hilo de X de 5–7 posts con GIF de la terminal. +5. Artículo técnico para dev.to en inglés y español: "Cómo impedir que tu módulo Billing use los modelos de Catalog". +6. Mensaje a los mantenedores de nwidart e InterNACHI proponiendo enlazar Cordon Modulith como complemento. +7. Propuesta de charla (CFP) para Laracon o meetups locales. + +## Reglas +- Nada de afirmar afiliación con Laravel. Usar siempre "Cordon Modulith for Laravel". +- Comparativas honestas: reconocer lo que hacen bien Deptrac y Pest `arch()`. + +## Definición de terminado +- Materiales revisados por ti y calendario de publicación fijado. + +## Prompt para pegar +``` +Lee README.md, docs/plan/roadmap.md y docs/plan/enjambres/04-lanzamiento.md. +Redacta los entregables 1 a 5 en docs/plan/lanzamiento/. Tono técnico y humilde, +sin superlativos, sin afirmar afiliación con Laravel. Incluye ejemplos de código reales +de tests/Fixtures/namespace-app. +``` diff --git a/docs/plan/enjambres/05-documentacion-viva.md b/docs/plan/enjambres/05-documentacion-viva.md new file mode 100644 index 0000000..ba374a2 --- /dev/null +++ b/docs/plan/enjambres/05-documentacion-viva.md @@ -0,0 +1,24 @@ +# Enjambre 05 · Documentación viva + +**Fase:** 5 · **Duración:** meses 3–4 · **Dependencias:** v0.1 publicada + +**Referencias:** ver los proyectos marcados con el brief 05 en [referencias.md](../referencias.md). + +## Objetivo +Que Cordon Modulith documente la arquitectura real a partir del código, como Spring Modulith. + +## Sub-enjambres + +### 5A · Diagramas +- Comando `cordon:docs` que genera en `docs/modules/`: un diagrama Mermaid global de dependencias entre módulos y uno por módulo. +- Requiere ADR: nuevo comando y formato de salida. + +### 5B · Canvas de módulo +- Un Markdown por módulo: namespace, API pública (clases), dependencias entrantes y salientes, violaciones actuales. + +### 5C · Inventario de eventos +- Detectar clases de eventos públicos, dónde se despachan (`event(new X)`, `X::dispatch()`) y quién escucha (tipos de `handle()` en listeners). Tabla publicador → evento → oyentes. + +## Definición de terminado +- `cordon:docs` probado en la app demo y en 2 proyectos reales. +- Documentado en el sitio. diff --git a/docs/plan/enjambres/06-ecosistema.md b/docs/plan/enjambres/06-ecosistema.md new file mode 100644 index 0000000..2344fe8 --- /dev/null +++ b/docs/plan/enjambres/06-ecosistema.md @@ -0,0 +1,25 @@ +# Enjambre 06 · Ecosistema y v1.0 + +**Fase:** 6 · **Duración:** meses 5–6+ · **Dependencias:** tracción de v0.x + +**Referencias:** ver los proyectos marcados con el brief 06 en [referencias.md](../referencias.md). + +## Objetivo +Crear el foso competitivo: extensiones, UI y estabilidad. + +## Sub-enjambres + +### 6A · API de reglas propias +- Config `rules` acepta clases que implementen `Cordon\Contracts\Rule`. Documentar cómo escribir una regla. ADR obligatorio. + +### 6B · Plugin de Filament +- Paquete aparte: página con el grafo de módulos y la lista de violaciones. + +### 6C · v1.0 +- Congelar API pública, política de SemVer, guía de actualización desde 0.x. + +### 6D · Sostenibilidad +- GitHub Sponsors. Evaluar una capa Pro solo si hay tracción (informes de arquitectura para equipos). Revisar la política de marca de Laravel antes de cualquier uso comercial. + +## Definición de terminado +- v1.0.0 publicada con changelog y guía de actualización. diff --git a/docs/plan/lanzamiento/01-rfc-discussions.md b/docs/plan/lanzamiento/01-rfc-discussions.md new file mode 100644 index 0000000..f688991 --- /dev/null +++ b/docs/plan/lanzamiento/01-rfc-discussions.md @@ -0,0 +1,38 @@ +# RFC para GitHub Discussions (borrador) + +Categoría: Ideas. Publicar en inglés. + +--- + +**Title:** RFC: verifiable module boundaries for Laravel modular monoliths + +## The problem + +Many Laravel teams split their application into modules with nwidart/laravel-modules, InterNACHI/modular or a plain `app/Modules` folder. The folders help, but nothing stops one module from using another module's models, jobs or services. After a few months, every module depends on every other one and the boundaries only exist in the folder tree. + +## The proposal + +Cordon Modulith is a dev dependency that checks those boundaries statically and fails CI when a module: + +- uses classes that are internal to another module (anything outside its public API: `Contracts`, `Events`, `Data`, `Enums`, `Exceptions`, or classes marked `#[PublicApi]`); +- depends on a module it did not declare in `depends_on`; +- takes part in a dependency cycle. + +It never loads your code (nikic/php-parser), works on top of your current layout, and has a baseline so existing projects can adopt it without fixing everything first. There is also a Pest expectation, a PHPStan rule, a Laravel Boost skill and a `cordon:docs` command that generates Mermaid diagrams and an event inventory. + +```text +x [internal_access] app/Modules/Billing/Services/CheckoutService.php:13 + Module [Billing] uses App\Modules\Catalog\Models\Product, which is internal to module [Catalog]. Depend on its public API instead (a class in a public namespace such as Contracts, or one marked #[PublicApi]). +``` + +## Open questions + +1. How do you verify module boundaries today? Deptrac, Pest `arch()`, code review, nothing? +2. Are the default public namespaces (`Contracts`, `Events`, `Data`, `Enums`, `Exceptions`) the right ones for your projects? +3. Should listening to another module's event count as a dependency for `depends_on`? (Today it does.) +4. Which layouts are we missing? (DDD with `src/Domain`, packages in `packages/`, ...) +5. Would you use the PHPStan rule, the Pest expectation or only the Artisan command in CI? + +Feedback, especially "this would not work for us because...", is very welcome. + +Repository: https://github.com/ChrisAbner/Cordon-Modulith · Docs: https://chrisabner.github.io/Cordon-Modulith/ diff --git a/docs/plan/lanzamiento/02-laravel-news.md b/docs/plan/lanzamiento/02-laravel-news.md new file mode 100644 index 0000000..beff424 --- /dev/null +++ b/docs/plan/lanzamiento/02-laravel-news.md @@ -0,0 +1,40 @@ +# Envío a Laravel News (borrador) + +Enviar por el formulario de paquetes de Laravel News. Texto en inglés, en tercera persona, sin superlativos. + +--- + +**Package name:** Cordon Modulith + +**Composer:** `chrisabner/cordon-modulith` + +**Short description (one line):** Verify the boundaries between the modules of a Laravel application in CI. + +**Description:** + +Cordon Modulith is a development package for Laravel applications organised in modules, whether they use nwidart/laravel-modules, InterNACHI/modular or a plain `app/Modules` folder. It reads the code statically, builds the dependency graph between modules and reports when a module uses another module's internal classes, depends on a module it did not declare, or takes part in a dependency cycle. + +Each module has a public API: by default, classes under `Contracts`, `Events`, `Data`, `Enums` and `Exceptions`, plus classes marked with `#[PublicApi]`. Everything else is internal. + +```bash +composer require --dev chrisabner/cordon-modulith +php artisan cordon:verify +``` + +Existing projects can record current violations in a baseline and only fail on new ones. The package also includes: + +- a reusable GitHub Action with pull request annotations and a `--module` option for per-module jobs; +- a Pest expectation: `expect('Billing')->toRespectBoundaries()`; +- a PHPStan rule that shows internal access in the editor; +- `php artisan cordon:docs`, which generates Mermaid diagrams, a page per module and an event inventory; +- a Laravel Boost guideline and skill for AI coding agents. + +It is inspired by Spring Modulith and Shopify's Packwerk. Requires PHP 8.3+ and Laravel 12 or 13. MIT licensed. + +**Links:** + +- GitHub repository: https://github.com/ChrisAbner/Cordon-Modulith +- Documentation: https://chrisabner.github.io/Cordon-Modulith/ +- Packagist: https://packagist.org/packages/chrisabner/cordon-modulith + +Cordon Modulith is a community project and is not affiliated with or endorsed by Laravel. diff --git a/docs/plan/lanzamiento/03-reddit.md b/docs/plan/lanzamiento/03-reddit.md new file mode 100644 index 0000000..287a08b --- /dev/null +++ b/docs/plan/lanzamiento/03-reddit.md @@ -0,0 +1,30 @@ +# Post para r/laravel (borrador) + +Tono: problema real primero, sin tono de anuncio. Responder a todos los comentarios durante las primeras horas. + +--- + +**Title:** How do you stop modules from using each other's models? + +We split a Laravel app into modules (nwidart, but the same happens with InterNACHI or plain folders). A year later, `Orders` had `belongsTo(\Modules\Catalog\Models\Product::class)`, `Payments` called back into `Orders`, and renaming a column in Catalog broke three other modules. Code review didn't catch it because each change looked reasonable on its own. + +Pest's `arch()` helped for conventions, but for boundaries we wanted something that: + +- knows what each module exposes (contracts, events, data objects) vs what is internal; +- doesn't need to load or autoload the code; +- lets a big existing app adopt it without fixing 300 violations first. + +So I built a small dev package for it: it parses the code, maps every class reference to a module and reports internal access, undeclared dependencies and cycles. + +```text +x [internal_access] Modules/Orders/app/Models/Order.php:13 + Module [Orders] uses Modules\Catalog\Models\Product, which is internal to module [Catalog]. ... + +x [cycles] + Modules [Orders, Payments] form a dependency cycle: Orders -> Payments -> Orders. ... +``` + +It has a baseline (only new violations fail), a Pest expectation, a PHPStan rule and can generate Mermaid diagrams of the real dependencies. Repo: https://github.com/ChrisAbner/Cordon-Modulith (docs: https://chrisabner.github.io/Cordon-Modulith/). It's early (0.1), so I'd love to hear: + +- how you handle this today (Deptrac? discipline? nothing?); +- layouts it would get wrong in your projects. diff --git a/docs/plan/lanzamiento/04-hilo-x.md b/docs/plan/lanzamiento/04-hilo-x.md new file mode 100644 index 0000000..1dd473e --- /dev/null +++ b/docs/plan/lanzamiento/04-hilo-x.md @@ -0,0 +1,20 @@ +# Hilo de X (borrador, 6 posts) + +Adjuntar el GIF de la terminal (escena 0:40–1:30 del guion de vídeo) en el post 3. + +1. Your Laravel app has `Modules/Billing` and `Modules/Catalog`. Nice folders. + Nothing stops Billing from `use Modules\Catalog\Models\Product;` though. 🧵 + +2. After a few months every module uses every other module's models, and the "modular monolith" is a monolith with extra folders. + +3. Cordon Modulith checks module boundaries in CI: + - internal classes used across modules + - undeclared dependencies + - dependency cycles + `php artisan cordon:verify` [GIF] + +4. Each module has a public API: Contracts, Events, Data, Enums, Exceptions, or `#[PublicApi]`. Everything else is internal. Works with nwidart, InterNACHI or plain `app/Modules`. + +5. Existing app with hundreds of violations? Generate a baseline: CI only fails on new ones. Then burn it down module by module. + +6. Also: `expect('Billing')->toRespectBoundaries()` in Pest, a PHPStan rule for your editor, Mermaid diagrams with `cordon:docs`, and a Boost skill so AI agents respect boundaries. Early days, feedback welcome: https://github.com/ChrisAbner/Cordon-Modulith diff --git a/docs/plan/lanzamiento/05-articulo-en.md b/docs/plan/lanzamiento/05-articulo-en.md new file mode 100644 index 0000000..bf4467f --- /dev/null +++ b/docs/plan/lanzamiento/05-articulo-en.md @@ -0,0 +1,137 @@ +# How to stop your Billing module from using Catalog's models + +*Draft for dev.to. Code from `tests/Fixtures/namespace-app` in the Cordon Modulith repository.* + +You split your Laravel application into modules. `app/Modules/Billing`, `app/Modules/Catalog`, `app/Modules/Shared`. Each has its own models, services and events. Then this lands in a pull request: + +```php +namespace App\Modules\Billing\Services; + +use App\Modules\Catalog\Contracts\ProductCatalog; +use App\Modules\Catalog\Models\Product; +use App\Modules\Shared\Money; + +final class CheckoutService +{ + public function __construct(private ProductCatalog $catalog) {} + + public function total(Product $product): Money + { + return new Money($product->price); + } +} +``` + +It works. It passes review. And Billing now depends on Catalog's database table through its Eloquent model. + +## Why it matters + +A module boundary is a promise: "you can change anything inside, as long as the public API stays the same". Once other modules use your models, you can't rename a column, split a table or change how prices are stored without touching them. The folders still say "modules", but the code no longer does. + +## Make the public API explicit + +A simple convention covers most cases. Other modules may use: + +- `Contracts`: interfaces, bound to internal implementations in the module's service provider; +- `Data`: immutable objects that cross the boundary instead of models; +- `Events`: what happened in the module, for others to react to; +- `Enums` and `Exceptions`. + +Everything else (models, services, jobs, actions) is internal. Exceptions to the convention can be marked with an attribute: + +```php +namespace App\Modules\Catalog\Support; + +use Cordon\Attributes\PublicApi; + +#[PublicApi] +final class PriceFormatter +{ + public static function format(int $cents): string + { + return number_format($cents / 100, 2); + } +} +``` + +## Check it on every pull request + +Conventions only work if something checks them. [Cordon Modulith](https://github.com/ChrisAbner/Cordon-Modulith) is a dev package that parses your modules statically (nothing is loaded or executed) and reports boundary violations: + +```bash +composer require --dev chrisabner/cordon-modulith +php artisan cordon:verify +``` + +```text +Cordon analysed 9 files in 3 modules (7 cross-module references). + +x [internal_access] app/Modules/Billing/Services/CheckoutService.php:13 + Module [Billing] uses App\Modules\Catalog\Models\Product, which is internal to module [Catalog]. Depend on its public API instead (a class in a public namespace such as Contracts, or one marked #[PublicApi]). + +x [internal_access] app/Modules/Billing/Services/InvoiceRenderer.php:10 + Module [Billing] uses App\Modules\Catalog\Contracts\LegacyCatalog, which is internal to module [Catalog]. Depend on its public API instead (a class in a public namespace such as Contracts, or one marked #[PublicApi]). + +x [cycles] + Modules [Billing, Catalog] form a dependency cycle: Billing -> Catalog -> Billing. Break it by inverting one dependency, for example with an event or a contract. + +3 boundary violations. +``` + +`LegacyCatalog` lives in `Contracts`, but Catalog marked it `#[Internal]` because it is on its way out: the attribute always wins. + +The `Money` value object is fine because `Shared` is configured as an open module (a shared kernel): + +```php +// config/cordon.php +'modules' => [ + 'Shared' => ['open' => true], +], +``` + +## Fix it + +Billing needs a price, not a model. Catalog already publishes a contract; give it a `find()` method that returns a data object (for example `ProductData`, in `Catalog\Data`) and use it: + +```php +namespace App\Modules\Billing\Services; + +use App\Modules\Catalog\Contracts\ProductCatalog; +use App\Modules\Shared\Money; + +final class CheckoutService +{ + public function __construct(private ProductCatalog $catalog) {} + + public function total(int $productId): Money + { + return new Money($this->catalog->find($productId)->priceInCents); + } +} +``` + +The cycle is a different kind of problem. Catalog listens to Billing's `InvoicePaid` event to update stock, while Billing uses Catalog's contract to price products. Listening to an event is a dependency too, so the two modules depend on each other. One edge has to go: for example, Billing receives prices from the caller instead of asking Catalog, or Catalog exposes a `ReservesStock` contract that the checkout calls. The tool tells you the cycle exists and its shortest path; which edge to invert is a design decision. + +## Adopting it in a real project + +Nobody fixes 300 violations in one pull request. Record them in a baseline, commit it, and only new violations will fail: + +```bash +php artisan cordon:verify --generate-baseline +``` + +Then pick one module per sprint: `php artisan cordon:verify --no-baseline --module=Billing`. + +## Where else it runs + +- Pest: `expect('Billing')->toRespectBoundaries();` +- PHPStan: the `internal_access` rule shows up in your editor. +- Docs: `php artisan cordon:docs` generates Mermaid diagrams of the real dependencies and an event inventory. + +## What it is not + +It doesn't replace Deptrac (great for layers) or Pest `arch()` (great for conventions inside a module); it focuses on the edges between modules. Static analysis also has limits: class names in strings and docblock-only types are not detected, by design, to avoid false positives. + +If you run a modular monolith in Laravel, I'd like to know how you keep boundaries today and where this approach would not fit. + +Links: [repository](https://github.com/ChrisAbner/Cordon-Modulith) · [documentation](https://chrisabner.github.io/Cordon-Modulith/) · [Packagist](https://packagist.org/packages/chrisabner/cordon-modulith). Cordon Modulith is a community project, not affiliated with or endorsed by Laravel. diff --git a/docs/plan/lanzamiento/05-articulo-es.md b/docs/plan/lanzamiento/05-articulo-es.md new file mode 100644 index 0000000..14efca0 --- /dev/null +++ b/docs/plan/lanzamiento/05-articulo-es.md @@ -0,0 +1,137 @@ +# Cómo impedir que tu módulo Billing use los modelos de Catalog + +*Borrador para dev.to. Código de `tests/Fixtures/namespace-app` del repositorio de Cordon Modulith.* + +Dividiste tu aplicación Laravel en módulos: `app/Modules/Billing`, `app/Modules/Catalog`, `app/Modules/Shared`. Cada uno con sus modelos, servicios y eventos. Hasta que llega esto en un pull request: + +```php +namespace App\Modules\Billing\Services; + +use App\Modules\Catalog\Contracts\ProductCatalog; +use App\Modules\Catalog\Models\Product; +use App\Modules\Shared\Money; + +final class CheckoutService +{ + public function __construct(private ProductCatalog $catalog) {} + + public function total(Product $product): Money + { + return new Money($product->price); + } +} +``` + +Funciona. Pasa la revisión. Y ahora Billing depende de la tabla de Catalog a través de su modelo Eloquent. + +## Por qué importa + +Un límite de módulo es una promesa: "puedes cambiar cualquier cosa dentro mientras la API pública no cambie". En cuanto otros módulos usan tus modelos, ya no puedes renombrar una columna, dividir una tabla o cambiar cómo se guardan los precios sin tocarlos. Las carpetas siguen diciendo "módulos", pero el código ya no. + +## Haz explícita la API pública + +Una convención sencilla cubre la mayoría de casos. Otros módulos pueden usar: + +- `Contracts`: interfaces, enlazadas a implementaciones internas en el service provider del módulo; +- `Data`: objetos inmutables que cruzan el límite en lugar de los modelos; +- `Events`: lo que pasó en el módulo, para que otros reaccionen; +- `Enums` y `Exceptions`. + +Todo lo demás (modelos, servicios, jobs, acciones) es interno. Las excepciones a la convención se marcan con un atributo: + +```php +namespace App\Modules\Catalog\Support; + +use Cordon\Attributes\PublicApi; + +#[PublicApi] +final class PriceFormatter +{ + public static function format(int $cents): string + { + return number_format($cents / 100, 2); + } +} +``` + +## Compruébalo en cada pull request + +Las convenciones solo funcionan si algo las verifica. [Cordon Modulith](https://github.com/ChrisAbner/Cordon-Modulith) es un paquete de desarrollo que analiza tus módulos de forma estática (no carga ni ejecuta nada) e informa de las violaciones de límites: + +```bash +composer require --dev chrisabner/cordon-modulith +php artisan cordon:verify +``` + +```text +Cordon analysed 9 files in 3 modules (7 cross-module references). + +x [internal_access] app/Modules/Billing/Services/CheckoutService.php:13 + Module [Billing] uses App\Modules\Catalog\Models\Product, which is internal to module [Catalog]. Depend on its public API instead (a class in a public namespace such as Contracts, or one marked #[PublicApi]). + +x [internal_access] app/Modules/Billing/Services/InvoiceRenderer.php:10 + Module [Billing] uses App\Modules\Catalog\Contracts\LegacyCatalog, which is internal to module [Catalog]. Depend on its public API instead (a class in a public namespace such as Contracts, or one marked #[PublicApi]). + +x [cycles] + Modules [Billing, Catalog] form a dependency cycle: Billing -> Catalog -> Billing. Break it by inverting one dependency, for example with an event or a contract. + +3 boundary violations. +``` + +`LegacyCatalog` está en `Contracts`, pero Catalog lo marcó `#[Internal]` porque va a desaparecer: el atributo siempre gana. + +El value object `Money` no da problemas porque `Shared` está configurado como módulo abierto (un shared kernel): + +```php +// config/cordon.php +'modules' => [ + 'Shared' => ['open' => true], +], +``` + +## Arréglalo + +Billing necesita un precio, no un modelo. Catalog ya publica un contrato; añádele un método `find()` que devuelva un objeto de datos (por ejemplo `ProductData`, en `Catalog\Data`) y úsalo: + +```php +namespace App\Modules\Billing\Services; + +use App\Modules\Catalog\Contracts\ProductCatalog; +use App\Modules\Shared\Money; + +final class CheckoutService +{ + public function __construct(private ProductCatalog $catalog) {} + + public function total(int $productId): Money + { + return new Money($this->catalog->find($productId)->priceInCents); + } +} +``` + +El ciclo es otro tipo de problema. Catalog escucha el evento `InvoicePaid` de Billing para actualizar el stock, mientras Billing usa el contrato de Catalog para poner precio. Escuchar un evento también es una dependencia, así que los dos módulos dependen el uno del otro. Una de las dos flechas tiene que desaparecer: por ejemplo, Billing recibe los precios de quien lo llama en lugar de preguntar a Catalog, o Catalog expone un contrato `ReservesStock` que llama el checkout. La herramienta te dice que el ciclo existe y su camino más corto; qué dependencia invertir es una decisión de diseño. + +## Adoptarlo en un proyecto real + +Nadie arregla 300 violaciones en un pull request. Regístralas en un baseline, haz commit, y solo fallarán las nuevas: + +```bash +php artisan cordon:verify --generate-baseline +``` + +Después, un módulo por sprint: `php artisan cordon:verify --no-baseline --module=Billing`. + +## Dónde más funciona + +- Pest: `expect('Billing')->toRespectBoundaries();` +- PHPStan: la regla `internal_access` aparece en tu editor. +- Documentación: `php artisan cordon:docs` genera diagramas Mermaid de las dependencias reales y un inventario de eventos. + +## Lo que no es + +No sustituye a Deptrac (muy bueno para capas) ni a `arch()` de Pest (muy bueno para convenciones dentro de un módulo); se centra en las flechas entre módulos. El análisis estático también tiene límites: los nombres de clase en strings y los tipos que solo están en docblocks no se detectan, a propósito, para evitar falsos positivos. + +Si mantienes un monolito modular en Laravel, me gustaría saber cómo proteges hoy los límites y dónde este enfoque no encajaría. + +Enlaces: [repositorio](https://github.com/ChrisAbner/Cordon-Modulith) · [documentación](https://chrisabner.github.io/Cordon-Modulith/) · [Packagist](https://packagist.org/packages/chrisabner/cordon-modulith). Cordon Modulith es un proyecto de la comunidad, sin afiliación ni respaldo de Laravel. diff --git a/docs/plan/lanzamiento/06-mantenedores.md b/docs/plan/lanzamiento/06-mantenedores.md new file mode 100644 index 0000000..659f1d9 --- /dev/null +++ b/docs/plan/lanzamiento/06-mantenedores.md @@ -0,0 +1,19 @@ +# Mensaje a mantenedores de nwidart e InterNACHI (borrador) + +Enviar como Discussion (o issue si no hay Discussions) en cada repo, **después** de publicar la v0.1 y con el sitio de docs en línea. Adaptar el nombre del paquete y un ejemplo con su estructura. Tú lo envías, no los agentes. + +--- + +**Title:** Complementary package: verifying boundaries between modules + +Hi! Thanks for maintaining {nwidart/laravel-modules | InterNACHI/modular}. I've been using it to structure a modular monolith, and I built a small dev package that complements it rather than replacing anything: [Cordon Modulith](https://github.com/ChrisAbner/Cordon-Modulith). + +It reads your module layout (it detects {`module.json` and `config/modules.php` | `app-modules/*/composer.json`} automatically), parses the code statically and reports when a module uses another module's internal classes, depends on a module it didn't declare, or is part of a dependency cycle. It never creates, registers or boots modules. + +I'm not asking for anything in particular, but a few ideas in case they're useful: + +- If you think it fits, a link in the docs section about organising modules ("verifying boundaries between modules") could help users who ask how to keep modules independent. +- If there are layout details I'm getting wrong ({custom paths, namespaces, `app/` folder | several PSR-4 roots per module}), I'd love to fix them. There are fixtures for your layout in `tests/Fixtures/`. +- If you ever consider native boundary checks, I'd be happy to share what I learned or contribute. + +Thanks again for your work. diff --git a/docs/plan/lanzamiento/07-cfp.md b/docs/plan/lanzamiento/07-cfp.md new file mode 100644 index 0000000..2c87bb7 --- /dev/null +++ b/docs/plan/lanzamiento/07-cfp.md @@ -0,0 +1,25 @@ +# Propuesta de charla (CFP, borrador) + +Para Laracon, Laravel Live o meetups locales. Formato: 25–30 minutos. En inglés; versión en español para meetups hispanohablantes. + +--- + +**Title:** Your modules are just folders: verifying boundaries in a Laravel modular monolith + +**Abstract (short):** +Modular monoliths promise independent modules inside one Laravel app. In practice, one `use` statement at a time, every module ends up depending on every other one. This talk shows how to make a module's public API explicit (contracts, data objects, events), how to verify it automatically with static analysis, and how to adopt it in a large existing codebase without stopping feature work. + +**Description:** +1. The problem (5 min): a real modular Laravel app after a year. Coupling you can't see in the folder tree: cross-module Eloquent relations, jobs calling other modules' services, event cycles. +2. Public API of a module (7 min): conventions that scale (`Contracts`, `Data`, `Events`), what to do with models, shared kernels, and the `#[PublicApi]` escape hatch. +3. Verifying it (8 min): how static analysis maps every class reference to a module (nikic/php-parser, no autoloading), the three rules (internal access, undeclared dependencies, cycles), live demo in CI with annotations. Honest comparison with Deptrac and Pest `arch()`. +4. Adoption (5 min): baselines, one module per sprint, Pest and PHPStan integrations, AI coding agents that respect boundaries. +5. Living documentation (3 min): generating dependency diagrams and an event inventory from the code. + +**Takeaways:** a checklist for defining module APIs, a strategy to break cycles with events or dependency inversion, and a way to stop boundaries from eroding on every pull request, with any tool. + +**Audience:** intermediate to advanced Laravel developers working on medium or large applications. + +**Speaker notes:** the tool used in the demo is open source (Cordon Modulith), but the ideas apply to any tool; the talk is not a product pitch. Not affiliated with Laravel. + +**Links:** https://github.com/ChrisAbner/Cordon-Modulith · https://chrisabner.github.io/Cordon-Modulith/ diff --git a/docs/plan/lanzamiento/README.md b/docs/plan/lanzamiento/README.md new file mode 100644 index 0000000..8334b9f --- /dev/null +++ b/docs/plan/lanzamiento/README.md @@ -0,0 +1,15 @@ +# Materiales de lanzamiento + +Borradores del brief 04. **Los publicas tú**, después de revisarlos. Ninguno afirma afiliación con Laravel. + +| # | Entregable | Archivo | Cuándo | +|---|---|---|---| +| 1 | RFC en GitHub Discussions | [01-rfc-discussions.md](01-rfc-discussions.md) | Antes de la v0.1, para recoger feedback | +| 2 | Envío a Laravel News | [02-laravel-news.md](02-laravel-news.md) | Día del lanzamiento de la v0.1 | +| 3 | Post en r/laravel | [03-reddit.md](03-reddit.md) | Día del lanzamiento (+0) | +| 4 | Hilo de X | [04-hilo-x.md](04-hilo-x.md) | Día del lanzamiento, con el GIF | +| 5 | Artículo dev.to (inglés y español) | [05-articulo-en.md](05-articulo-en.md), [05-articulo-es.md](05-articulo-es.md) | +2 días | +| 6 | Mensaje a mantenedores de nwidart e InterNACHI | [06-mantenedores.md](06-mantenedores.md) | +1 semana, con docs en línea | +| 7 | Propuesta de charla (CFP) | [07-cfp.md](07-cfp.md) | Según calendario de eventos | + +Los enlaces ya son concretos (repo, sitio de docs y Packagist); comprobar antes de publicar que el repo es público, que el sitio de docs y el paquete de Packagist responden y que la salida de ejemplo coincide con la versión publicada. diff --git a/docs/plan/originales/PLAN.md b/docs/plan/originales/PLAN.md new file mode 100644 index 0000000..8a25792 --- /dev/null +++ b/docs/plan/originales/PLAN.md @@ -0,0 +1,41 @@ +# Plan del proyecto Cordon + +Documentación interna de planificación (en español). La documentación pública del paquete está en inglés en la raíz del repo. + +## Estado actual (MVP 0.1) + +| Pieza | Estado | +|---|---| +| Estructura del repo, CI, Pint, PHPStan, Pest | Hecho | +| Contratos del núcleo (`src/Contracts`) | Hecho, congelados | +| Resolvers: namespace, nwidart, InterNACHI + autodetección | Hecho | +| Extracción estática con nikic/php-parser | Hecho | +| Reglas: `internal_access`, `undeclared_dependency`, `cycles` | Hecho | +| Baseline | Hecho | +| Reporters: text, json, github | Hecho | +| Comandos `cordon:verify` y `cordon:modules` | Hecho | +| Guideline de Laravel Boost | Hecho | +| Tests unitarios y de feature con fixtures | Escritos, **pendientes de ejecutar** | +| Nombre final y vendor de Composer | **Pendiente** (Fase 0) | + +**Importante:** el código se escribió sin poder ejecutar PHP. La primera tarea obligatoria es `composer install && composer check` y corregir lo que falle (brief 01). + +## Cómo trabajar con enjambres de agentes + +1. Cada fase tiene un brief en `enjambres/` con objetivo, archivos permitidos, tareas, tests de aceptación, definición de terminado y un prompt listo para pegar. +2. Los agentes leen primero `AGENTS.md`, `docs/architecture.md` y los proyectos de `referencias.md` marcados para su brief. +3. Un enjambre no toca archivos fuera de su alcance. Tú integras y mergeas. +4. Cualquier cambio a contratos, formato de config, baseline u opciones de comandos requiere un ADR aprobado por ti. +5. Una fase se cierra solo cuando `composer check` pasa en CI y se cumple su definición de terminado. + +## Índice + +- [roadmap.md](roadmap.md): fases, calendario y criterios de salida. +- [referencias.md](referencias.md): proyectos de referencia y qué estudiar de cada uno por brief. +- [enjambres/00-fundacion.md](enjambres/00-fundacion.md) +- [enjambres/01-verificacion-nucleo.md](enjambres/01-verificacion-nucleo.md) +- [enjambres/02-integraciones.md](enjambres/02-integraciones.md) +- [enjambres/03-docs-e-ia.md](enjambres/03-docs-e-ia.md) +- [enjambres/04-lanzamiento.md](enjambres/04-lanzamiento.md) +- [enjambres/05-documentacion-viva.md](enjambres/05-documentacion-viva.md) +- [enjambres/06-ecosistema.md](enjambres/06-ecosistema.md) diff --git a/docs/plan/originales/README.md b/docs/plan/originales/README.md new file mode 100644 index 0000000..92458d9 --- /dev/null +++ b/docs/plan/originales/README.md @@ -0,0 +1,195 @@ +# Cordon for Laravel + +**Cordon off your modules.** Verifiable boundaries between the modules of a Laravel application, checked in CI. + +> **Status: 0.1 (alpha).** The public API may change before 1.0. +> **Working name:** "Cordon" is pending trademark and availability checks, and `your-vendor` is a placeholder for the final Composer vendor. + +Modular monoliths in Laravel usually rely on nwidart/laravel-modules, InterNACHI/modular or a hand-made `app/Modules` folder. All of them organise code, but none of them stops the `Billing` module from reaching into `Catalog`'s models. Six months later the "modules" are just folders. + +Cordon reads your code statically, builds the dependency graph between modules and fails the build when a module: + +- uses **internal classes** of another module instead of its public API, +- depends on a module it **did not declare**, or +- takes part in a **dependency cycle**. + +It is inspired by [Spring Modulith](https://spring.io/projects/spring-modulith) and Shopify's Packwerk, adapted to Laravel conventions. + +```text +$ php artisan cordon:verify + +Cordon analysed 212 files in 6 modules (148 cross-module references). + +x [internal_access] Modules/Billing/app/Services/CheckoutService.php:13 + Module [Billing] uses Modules\Catalog\Models\Product, which is internal to module [Catalog]. Depend on its public API instead (a class in a public namespace such as Contracts, or one marked #[PublicApi]). + +x [cycles] + Modules [Billing, Catalog] form a dependency cycle: Billing -> Catalog -> Billing. Break it by inverting one dependency, for example with an event or a contract. + +2 boundary violations. +``` + +## Requirements + +- PHP 8.3+ +- Laravel 12 or 13 + +## Installation + +```bash +composer require --dev your-vendor/cordon +``` + +Cordon detects your module layout automatically. Check what it found: + +```bash +php artisan cordon:modules +``` + +Then verify the boundaries: + +```bash +php artisan cordon:verify +``` + +Optionally publish the config file: + +```bash +php artisan vendor:publish --tag=cordon-config +``` + +## Adopting Cordon in an existing project + +Existing codebases usually start with many violations. Record them in a baseline so the build only fails on **new** ones, then burn the baseline down over time: + +```bash +php artisan cordon:verify --generate-baseline # writes cordon-baseline.json +php artisan cordon:verify # passes; new violations fail +php artisan cordon:verify --no-baseline # shows everything again +``` + +Commit `cordon-baseline.json`. Entries are keyed by rule, file and target class (not line numbers), so unrelated edits don't invalidate them. + +## The public API of a module + +Other modules may only use classes that belong to a module's public API. A class is public when, in order of precedence: + +1. it is marked `#[Cordon\Attributes\Internal]` → **internal**, always; +2. it is marked `#[Cordon\Attributes\PublicApi]` → public; +3. its module is configured as `open` → public (handy for a shared kernel); +4. it lives under one of the public namespaces, relative to the module: `Contracts`, `Events`, `Data`, `Enums`, `Exceptions` by default, plus the module's own `public` list → public; +5. otherwise → **internal**. + +```php +namespace Modules\Catalog\Support; + +use Cordon\Attributes\PublicApi; + +#[PublicApi] +final class PriceFormatter +{ + // ... +} +``` + +The recommended way for modules to talk to each other is through contracts (interfaces bound in the module's service provider), events and data objects. + +## Configuration + +```php +// config/cordon.php +return [ + 'resolver' => 'auto', // auto, namespace, nwidart, internachi + + 'public_namespaces' => ['Contracts', 'Events', 'Data', 'Enums', 'Exceptions'], + + 'modules' => [ + 'Billing' => [ + 'depends_on' => ['Catalog', 'Shared'], // anything else is reported + 'public' => ['Services\\BillingService'], // extra public classes/namespaces + ], + 'Shared' => ['open' => true], // every class is public + ], + + 'rules' => [ + 'internal_access' => true, + 'undeclared_dependency' => true, + 'cycles' => true, + ], + + 'exclude' => ['vendor', 'node_modules', 'tests', 'Tests'], + + 'baseline' => 'cordon-baseline.json', +]; +``` + +`depends_on` is opt-in per module: modules without it are not checked by `undeclared_dependency`. + +### Module layouts + +| Resolver | Detected when | Module | Namespace | +|---|---|---|---| +| `nwidart` | `Modules/*/module.json` exists | `Modules/Blog` → `Blog` | `Modules\Blog` (from `config/modules.php`) | +| `internachi` | `app-modules/` exists | `app-modules/billing` → `billing` | from the module's `composer.json` PSR-4 entry | +| `namespace` | fallback | `app/Modules/Billing` → `Billing` | `App\Modules\Billing` | + +Paths and namespaces can be overridden under `resolvers` in the config file. + +## Rules + +| Rule | Reports | +|---|---| +| `internal_access` | A module using a class that is not part of another module's public API. One report per file and target class. | +| `undeclared_dependency` | A module with `depends_on` using a module that is not listed. One report per file and target module. | +| `cycles` | Modules that depend on each other in a cycle, with the shortest cycle path. | + +## Continuous integration + +Use the `github` format to get annotations on pull requests: + +```yaml +# .github/workflows/cordon.yml +name: cordon +on: [pull_request] +jobs: + cordon: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + - run: composer install --no-interaction --prefer-dist + - run: php artisan cordon:verify --format=github +``` + +`--format=json` prints a machine readable report. The command exits with `1` when there are violations, `0` otherwise and `2` on invalid options. + +## AI coding agents + +Cordon ships a [Laravel Boost](https://laravel.com/docs/boost) guideline (`resources/boost/guidelines/core.blade.php`) so agents know they must go through a module's public API and run `cordon:verify`. Boost picks it up when you run `php artisan boost:install`. + +## How it works + +Cordon parses every PHP file inside your modules with [nikic/php-parser](https://github.com/nikic/PHP-Parser). Your code is never loaded or executed, so it works on code that doesn't boot and on classes that don't exist yet. It records every class reference (`new`, static calls, type declarations, `extends`/`implements`, traits, attributes, `instanceof`, `catch`...) and maps each one to its module by namespace. Import statements alone, function calls and constants don't count. See [docs/architecture.md](docs/architecture.md). + +**Known limitations in 0.1:** docblock-only types (`@var`, generics), string class names (`'App\\Foo'`, `app('...')`) and dynamic references are not detected. Code outside modules (for example `app/Http`) is not analysed. + +## Roadmap + +- Pest expectation and PHPStan rule +- Living documentation: Mermaid/C4 diagrams per module, event inventory +- Filament plugin with the module graph +- Extension API for custom rules + +See [docs/plan/roadmap.md](docs/plan/roadmap.md) (Spanish). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). AI agents: read [AGENTS.md](AGENTS.md) first. + +## License + +MIT. See [LICENSE.md](LICENSE.md). + +Cordon is a community project and is not affiliated with or endorsed by Laravel. diff --git a/docs/plan/originales/cordon.zip b/docs/plan/originales/cordon.zip new file mode 100644 index 0000000..b7899eb Binary files /dev/null and b/docs/plan/originales/cordon.zip differ diff --git a/docs/plan/originales/referencias.md b/docs/plan/originales/referencias.md new file mode 100644 index 0000000..2d899e9 --- /dev/null +++ b/docs/plan/originales/referencias.md @@ -0,0 +1,53 @@ +# Proyectos de referencia + +Qué estudiar de cada proyecto y en qué brief se usa. Regla para los enjambres: **estudiar, no copiar**. La mayoría son MIT; si se reutiliza código, conservar el aviso de licencia y mencionarlo en el PR. Nunca importar decisiones que choquen con los contratos congelados (`AGENTS.md`). + +## 1. Concepto: qué construir + +| Proyecto | Lenguaje | Qué estudiar | Brief | +|---|---|---|---| +| [Spring Modulith](https://github.com/spring-projects/spring-modulith) | Java | `ApplicationModules.verify()`, "named interfaces" (equivalente a nuestro `public`), documentación generada (diagramas y canvas por módulo), event publication registry | 02, 05, 06 | +| [Packwerk](https://github.com/Shopify/packwerk) | Ruby | Dependencias por paquete en `package.yml` (nuestro `depends_on`), `package_todo.yml` (nuestro baseline), flujo de adopción en monolitos grandes | 01, 03 | +| [packwerk-extensions](https://github.com/rubyatscale/packwerk-extensions) | Ruby | Por qué la verificación de privacidad acabó separada del núcleo: lecciones sobre fricción y adopción | 03, 04 | +| [ArchUnit](https://github.com/TNG/ArchUnit) | Java | Reglas de arquitectura expresadas como tests; API fluida y mensajes de error | 02A | + +## 2. Técnica en PHP: cómo construirlo + +| Proyecto | Qué estudiar | Brief | +|---|---|---| +| [Deptrac](https://github.com/deptrac/deptrac) | Uso de nikic/php-parser, "collectors", resolución de referencias, formato de baseline, rendimiento y caché. Es la alternativa que la gente usa hoy a mano: la comparativa debe ser honesta | 01B, 01D, 03A | +| [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) | Documentación de `NameResolver`, visitors y nodos de v5. Fuente de los casos límite de extracción | 01A, 01B | +| [PHPStan](https://github.com/phpstan/phpstan) / [Larastan](https://github.com/larastan/larastan) | Formato del baseline, calidad de los mensajes de error, infraestructura de reglas y extensiones | 01A, 02B | +| [PHPat](https://github.com/carlosas/phpat) | Tests de arquitectura sobre PHPStan: API de reglas y casos límite | 02A, 02B | +| [PHPArkitect](https://github.com/phparkitect/arkitect) | Reglas de arquitectura con análisis estático, formato de salida y baseline | 01B, 02A | +| [Pest `arch()`](https://pestphp.com/docs/arch-testing) | La opción que viene incluida en Laravel. Documentar su límite (solo ve clases que existen y se pueden cargar): es nuestro diferenciador | 02A, 03A | + +## 3. Ecosistema Laravel: dónde encajar + +| Proyecto | Qué estudiar | Brief | +|---|---|---| +| [nwidart/laravel-modules](https://github.com/nWidart/laravel-modules) | Estructura de módulos (v11+ con `app/`), `module.json`, config `modules.paths.modules` y `modules.namespace` | 01C | +| [InterNACHI/modular](https://github.com/InterNACHI/modular) | Estructura `app-modules/`, `composer.json` por módulo, config `app-modules` | 01C | +| [happenv-com/laravel-true-modular](https://github.com/happenv-com/laravel-true-modular) | Competidor directo: seguir releases, funcionalidades y posicionamiento | 03A, 04 | +| [Laravel Boost](https://github.com/laravel/boost) | Formato de guidelines y skills para paquetes de terceros | 03C | +| [Laravel Pint](https://github.com/laravel/pint), [Pest](https://github.com/pestphp/pest), [Orchestra Testbench](https://github.com/orchestral/testbench) | Estándar de calidad y testing de paquetes Laravel | 01A | +| Paquetes de [Spatie](https://github.com/spatie) | README, documentación y changelog de referencia para paquetes Laravel | 03A | + +## 4. Fuera de PHP: ideas puntuales + +| Proyecto | Lenguaje | Qué estudiar | Brief | +|---|---|---|---| +| [import-linter](https://github.com/seddonym/import-linter) | Python | Tipos de contrato claros (independencia, capas, prohibido) y su configuración | 06A | +| [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) | JavaScript | Visualización y reporte de grafos de dependencias | 05A | +| [Nx `enforce-module-boundaries`](https://nx.dev/features/enforce-module-boundaries) | JavaScript/TypeScript | Etiquetas para permitir o prohibir dependencias entre grupos de módulos (idea posterior a 1.0) | 06A | + +## Cómo usar este documento con un enjambre + +Añade al prompt del brief: + +``` +Antes de implementar, lee docs/plan/referencias.md y estudia los proyectos marcados +para tu brief. Entrega un resumen de 5 líneas por proyecto: qué hace bien, qué +aplicaríamos a Cordon y qué descartamos porque choca con AGENTS.md o los ADRs. +No copies código; si reutilizas algo, indica la licencia en el PR. +``` diff --git a/docs/plan/publicacion/README.md b/docs/plan/publicacion/README.md new file mode 100644 index 0000000..05f441c --- /dev/null +++ b/docs/plan/publicacion/README.md @@ -0,0 +1,65 @@ +# Publicación de la v0.1.0 + +Material listo para pegar. Los pasos generales y su orden están en [../siguientes-pasos.md](../siguientes-pasos.md); aquí solo el recorrido de clics. + +| Archivo | Para qué sirve | +|---|---| +| [pr-main.md](pr-main.md) | Título y descripción (en inglés) del pull request de la rama a `main`. | +| [release-v0.1.0.md](release-v0.1.0.md) | Título y notas (en inglés) del GitHub Release. | +| [../lanzamiento/](../lanzamiento/README.md) | Borradores de RFC, Laravel News, Reddit, X, artículos, mantenedores y CFP (ya con enlaces reales). | +| [../video.md](../video.md) | Guion del vídeo de 3 minutos. | + +Orden recomendado: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8. Antes del paso 6 cambia en `CHANGELOG.md` `## [Unreleased] - 0.1.0` por `## [0.1.0] - AAAA-MM-DD` (puede ir en el propio PR). + +## 1. Abrir el pull request + +1. GitHub → repo `ChrisAbner/Cordon-Modulith` → pestaña **Pull requests** → **New pull request**. +2. base: `main`, compare: `claude/cordon-modulith-naming-1w2srv` → **Create pull request**. +3. Pega el título y la descripción de [pr-main.md](pr-main.md). +4. Espera a que pasen `tests`, `quality` y `benchmark` (primera vez que corre el CI). Corrige lo que falle en la rama. +5. **Merge pull request** (mejor "Create a merge commit" para conservar los 14 commits). + +## 2. Hacer público el repositorio + +Settings → General → bajar a **Danger Zone** → **Change repository visibility** → **Change to public** → confirma escribiendo el nombre del repo. Hazlo antes de Pages (en el plan gratuito Pages exige repo público) y de Packagist. + +## 3. Activar GitHub Pages + +Settings → **Pages** → **Build and deployment** → **Source: GitHub Actions**. Después lanza el workflow: Actions → **docs** → **Run workflow** (rama `main`), o haz push de cambios en `docs-site/`. Sitio esperado: https://chrisabner.github.io/Cordon-Modulith/ + +## 4. Activar Discussions + +Settings → General → sección **Features** → marca **Discussions**. Luego pestaña **Discussions** → **New discussion** → categoría **Ideas** → pega el RFC de [../lanzamiento/01-rfc-discussions.md](../lanzamiento/01-rfc-discussions.md). + +## 5. Crear el tag y el release + +1. En local, sobre `main` actualizado: + + ```bash + git checkout main && git pull + git tag v0.1.0 + git push origin v0.1.0 + ``` + +2. GitHub → **Releases** → **Draft a new release** → **Choose a tag**: `v0.1.0` → Release title: `v0.1.0`. +3. Pega las notas de [release-v0.1.0.md](release-v0.1.0.md), marca **Set as a pre-release** → **Publish release**. + +## 6. Enviar el paquete a Packagist + +1. Inicia sesión en [packagist.org](https://packagist.org) con GitHub (**Log in with GitHub**). +2. **Submit** → https://packagist.org/packages/submit → Repository URL: `https://github.com/ChrisAbner/Cordon-Modulith` → **Check** → **Submit**. +3. Comprueba que el paquete es `chrisabner/cordon-modulith` y que aparece la versión `v0.1.0`. + +## 7. Webhook de actualización automática + +Al iniciar sesión con GitHub, Packagist instala su integración. Verifica en Packagist → tu paquete → si aparece el aviso "This package is not auto-updated", pulsa **Enable GitHub Hook** (o **Update** en tu perfil → Settings → **Connect GitHub**). Comprobación: en GitHub → Settings → **Webhooks** debe existir uno de `packagist.org`. + +Luego prueba en un proyecto limpio: `composer require --dev chrisabner/cordon-modulith`. + +## 8. Opcional: GitHub Marketplace (la acción) + +1. Repo → **Releases** → **Draft a new release** (o edita el de `v0.1.0`). +2. Marca **Publish this Action to the GitHub Marketplace** (aparece porque existe `action.yml` en la raíz; acepta el acuerdo de distribución si te lo pide). +3. Elige categoría (por ejemplo *Code quality*) y publica. `action.yml` ya define nombre, icono y color. + +Después, sigue con los materiales de [../lanzamiento/README.md](../lanzamiento/README.md). diff --git a/docs/plan/publicacion/pr-main.md b/docs/plan/publicacion/pr-main.md new file mode 100644 index 0000000..ddd9f20 --- /dev/null +++ b/docs/plan/publicacion/pr-main.md @@ -0,0 +1,67 @@ +# Pull request: rama a `main` + +- **Rama origen:** `claude/cordon-modulith-naming-1w2srv` +- **Destino:** `main` + +## Título + +```text +Cordon Modulith 0.1.0: verifiable module boundaries for Laravel +``` + +## Descripción (pegar tal cual) + +````markdown +## Summary + +First release candidate of **Cordon Modulith** (`chrisabner/cordon-modulith`): a Laravel dev dependency that statically verifies the boundaries between the modules of an application. It parses the code with nikic/php-parser (analysed code is never loaded), maps class references to modules and runs rules. `origin/main` only has the initial commit, so this PR brings the whole project. + +### What is in 0.1.0 + +- **Commands:** `cordon:verify` (`text`, `json` and `github` formats, `--module`, `--generate-baseline`, `--no-baseline`), `cordon:modules` and `cordon:docs` (Mermaid dependency diagrams, a canvas per module, event inventory). +- **Module resolvers:** plain namespaces (`app/Modules`), nwidart/laravel-modules and InterNACHI/modular, with auto-detection. +- **Rules:** `internal_access`, `undeclared_dependency`, `cycles`, plus custom rules (classes implementing `Cordon\Contracts\Rule` in the `rules` config). +- **Public API model:** public namespaces, `#[PublicApi]`, `#[Internal]`, per-module `public` list and `open` modules. +- **Baseline** for gradual adoption in existing projects. +- **Integrations:** Pest expectation `toRespectBoundaries()`, PHPStan rule `cordon.internalAccess`, reusable GitHub Action (`action.yml`), Laravel Boost guideline and the `cordon-fix-violations` skill. +- **Docs and quality:** VitePress documentation site (`docs-site/`, deployed by `docs.yml`), ADRs 0001-0009, real-project fixtures, an analysis benchmark (`composer bench`, 1,000 files under 10 s) and CI (`tests.yml`). + +See [CHANGELOG.md](CHANGELOG.md) for the full list. + +### Decisions + +ADRs 0005-0009 (`--module`, Pest expectation, PHPStan rule, `cordon:docs`, custom rules) were implemented as `proposed` and have been **accepted by the maintainer** in this branch, as `AGENTS.md` requires for changes to commands, config and public API. + +## Test plan / status + +Run locally (Windows, Xdebug off) before opening the PR: + +- [x] `pest`: 103 passed, 1 skipped (264 assertions); the skipped test is Windows-only and runs on CI +- [x] `phpstan analyse`: no errors (level 8) +- [x] `pint --test`: passed +- [x] CI matrix reproduced locally: PHP 8.3/8.4 x Laravel 12/13 all green; benchmark 1,000 files in 5.3 s on a slower Windows machine +- [x] Field tests on 6 public projects (2 nwidart, 2 InterNACHI, 2 `app/Modules`/DDD, up to 939 files and 38 modules): 1,300+ violations reviewed, 0 false positives. They surfaced two bugs, fixed here with fixtures: config files using `base_path()` could not be loaded outside Laravel, and mixed directory separators on Windows + +CI on GitHub only runs on `main` and pull requests, so this PR is the first time the three jobs run: `tests`, `quality` (Pint + PHPStan) and `benchmark`. + +## How to review + +1. Read `README.md` and `CHANGELOG.md` for the scope, then `docs/adr/` for the decisions behind it. +2. Follow the pipeline in `src/`: `Resolvers` -> `Analysis` (`FileCollector`, `PhpParserExtractor`, `Analyzer`) -> `Rules` -> `Baseline` -> `Reporters`. The core is framework-agnostic; only `src/Laravel`, `src/Testing` and `src/PHPStan` use Laravel. +3. Detection behaviour lives in `tests/Fixtures/` (with `expected.json` for the `real-*` ones) and is asserted by `tests/Unit` and `tests/Feature`. +4. Try it: `composer install && composer check`, then `vendor/bin/pest tests/Feature/CommandsTest.php`. +5. `docs-site/` is the public documentation; `docs/plan/` is internal planning (in Spanish) and can be skimmed. + +The branch has 18 commits, roughly one per phase, so reviewing commit by commit works well. + +## Checklist + +- [ ] The three CI jobs are green (`tests`, `quality`, `benchmark`) +- [ ] ADRs 0005-0009 read and accepted +- [ ] README and `docs-site/` reviewed +- [ ] No text claims affiliation with or endorsement by Laravel +- [ ] `CHANGELOG.md` heading changed from `[Unreleased] - 0.1.0` to `[0.1.0] - YYYY-MM-DD` at release time +- [ ] After merging: enable GitHub Pages (Source: GitHub Actions), then tag `v0.1.0` + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +```` diff --git a/docs/plan/publicacion/release-v0.1.0.md b/docs/plan/publicacion/release-v0.1.0.md new file mode 100644 index 0000000..647d252 --- /dev/null +++ b/docs/plan/publicacion/release-v0.1.0.md @@ -0,0 +1,64 @@ +# GitHub Release v0.1.0 + +- **Tag:** `v0.1.0` (sobre `main`, después de mergear el PR). +- **Release title:** `v0.1.0` +- Marcar "Set as a pre-release": el README declara 0.1 alpha. + +## Notas (pegar tal cual) + +````markdown +First public release of **Cordon Modulith**: verifiable boundaries between the modules of a Laravel application, checked in CI. It statically parses your code (nothing is loaded or executed) and fails the build when a module uses another module's internal classes, depends on a module it did not declare, or takes part in a dependency cycle. + +> **Status: 0.1 (alpha).** The public API may change before 1.0. Cordon Modulith is a community project and is not affiliated with or endorsed by Laravel. + +## Install + +Requires PHP 8.3+ and Laravel 12 or 13. + +```bash +composer require --dev chrisabner/cordon-modulith +php artisan cordon:modules # check the detected modules +php artisan cordon:verify # verify the boundaries +``` + +Existing project with many violations? Record them and only fail on new ones: + +```bash +php artisan cordon:verify --generate-baseline +``` + +Use it in CI with the reusable action: + +```yaml +steps: + - uses: actions/checkout@v4 + - uses: ChrisAbner/Cordon-Modulith@v0.1.0 +``` + +## Highlights + +- **Three rules:** `internal_access`, `undeclared_dependency` and `cycles`, plus your own rules through `Cordon\Contracts\Rule`. +- **Public API per module:** `Contracts`, `Events`, `Data`, `Enums` and `Exceptions` by default, `#[PublicApi]` and `#[Internal]` attributes, per-module `public` lists and `open` modules for shared kernels. +- **Works with your layout:** nwidart/laravel-modules, InterNACHI/modular or a plain `app/Modules` folder, auto-detected. +- **Adopt gradually:** baseline file (`--generate-baseline`, `--no-baseline`) and `--module` to report one module at a time. +- **Where you already work:** `text`, `json` and `github` (pull request annotations) output; Pest expectation `expect('Billing')->toRespectBoundaries()`; PHPStan rule `cordon.internalAccess`; reusable GitHub Action. +- **Living documentation:** `php artisan cordon:docs` generates Mermaid dependency diagrams, a canvas per module and an event inventory. +- **AI coding agents:** a Laravel Boost guideline and the `cordon-fix-violations` skill. +- **Fast:** about 1 ms per file, so 1,000 files in roughly a second (benchmark in CI with a 10 s budget). + +## Known limitations + +- Docblock-only types (`@var`, generics), string class names (`'App\\Foo'`, `app('...')`) and dynamic references are not detected, on purpose, to avoid false positives. +- Code outside modules (for example `app/Http`) is not analysed. +- Listening to another module's event counts as a dependency (for `depends_on` and cycles). +- Dependency cycles and `depends_on` are only checked by `cordon:verify`, not by the PHPStan rule. +- The PHPStan rule reads `config/cordon.php` without booting Laravel, so keep that file a plain array. +- Validated on fixtures modelled after real projects; feedback from real applications is very welcome. + +## Links + +- Documentation: https://chrisabner.github.io/Cordon-Modulith/ +- Packagist: https://packagist.org/packages/chrisabner/cordon-modulith +- Changelog: https://github.com/ChrisAbner/Cordon-Modulith/blob/main/CHANGELOG.md +- Issues and feedback: https://github.com/ChrisAbner/Cordon-Modulith/issues +```` diff --git a/docs/plan/referencias.md b/docs/plan/referencias.md new file mode 100644 index 0000000..cf9b24d --- /dev/null +++ b/docs/plan/referencias.md @@ -0,0 +1,53 @@ +# Proyectos de referencia + +Qué estudiar de cada proyecto y en qué brief se usa. Regla para los enjambres: **estudiar, no copiar**. La mayoría son MIT; si se reutiliza código, conservar el aviso de licencia y mencionarlo en el PR. Nunca importar decisiones que choquen con los contratos congelados (`AGENTS.md`). + +## 1. Concepto: qué construir + +| Proyecto | Lenguaje | Qué estudiar | Brief | +|---|---|---|---| +| [Spring Modulith](https://github.com/spring-projects/spring-modulith) | Java | `ApplicationModules.verify()`, "named interfaces" (equivalente a nuestro `public`), documentación generada (diagramas y canvas por módulo), event publication registry | 02, 05, 06 | +| [Packwerk](https://github.com/Shopify/packwerk) | Ruby | Dependencias por paquete en `package.yml` (nuestro `depends_on`), `package_todo.yml` (nuestro baseline), flujo de adopción en monolitos grandes | 01, 03 | +| [packwerk-extensions](https://github.com/rubyatscale/packwerk-extensions) | Ruby | Por qué la verificación de privacidad acabó separada del núcleo: lecciones sobre fricción y adopción | 03, 04 | +| [ArchUnit](https://github.com/TNG/ArchUnit) | Java | Reglas de arquitectura expresadas como tests; API fluida y mensajes de error | 02A | + +## 2. Técnica en PHP: cómo construirlo + +| Proyecto | Qué estudiar | Brief | +|---|---|---| +| [Deptrac](https://github.com/deptrac/deptrac) | Uso de nikic/php-parser, "collectors", resolución de referencias, formato de baseline, rendimiento y caché. Es la alternativa que la gente usa hoy a mano: la comparativa debe ser honesta | 01B, 01D, 03A | +| [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) | Documentación de `NameResolver`, visitors y nodos de v5. Fuente de los casos límite de extracción | 01A, 01B | +| [PHPStan](https://github.com/phpstan/phpstan) / [Larastan](https://github.com/larastan/larastan) | Formato del baseline, calidad de los mensajes de error, infraestructura de reglas y extensiones | 01A, 02B | +| [PHPat](https://github.com/carlosas/phpat) | Tests de arquitectura sobre PHPStan: API de reglas y casos límite | 02A, 02B | +| [PHPArkitect](https://github.com/phparkitect/arkitect) | Reglas de arquitectura con análisis estático, formato de salida y baseline | 01B, 02A | +| [Pest `arch()`](https://pestphp.com/docs/arch-testing) | La opción que viene incluida en Laravel. Documentar su límite (solo ve clases que existen y se pueden cargar): es nuestro diferenciador | 02A, 03A | + +## 3. Ecosistema Laravel: dónde encajar + +| Proyecto | Qué estudiar | Brief | +|---|---|---| +| [nwidart/laravel-modules](https://github.com/nWidart/laravel-modules) | Estructura de módulos (v11+ con `app/`), `module.json`, config `modules.paths.modules` y `modules.namespace` | 01C | +| [InterNACHI/modular](https://github.com/InterNACHI/modular) | Estructura `app-modules/`, `composer.json` por módulo, config `app-modules` | 01C | +| [happenv-com/laravel-true-modular](https://github.com/happenv-com/laravel-true-modular) | Competidor directo: seguir releases, funcionalidades y posicionamiento | 03A, 04 | +| [Laravel Boost](https://github.com/laravel/boost) | Formato de guidelines y skills para paquetes de terceros | 03C | +| [Laravel Pint](https://github.com/laravel/pint), [Pest](https://github.com/pestphp/pest), [Orchestra Testbench](https://github.com/orchestral/testbench) | Estándar de calidad y testing de paquetes Laravel | 01A | +| Paquetes de [Spatie](https://github.com/spatie) | README, documentación y changelog de referencia para paquetes Laravel | 03A | + +## 4. Fuera de PHP: ideas puntuales + +| Proyecto | Lenguaje | Qué estudiar | Brief | +|---|---|---|---| +| [import-linter](https://github.com/seddonym/import-linter) | Python | Tipos de contrato claros (independencia, capas, prohibido) y su configuración | 06A | +| [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) | JavaScript | Visualización y reporte de grafos de dependencias | 05A | +| [Nx `enforce-module-boundaries`](https://nx.dev/features/enforce-module-boundaries) | JavaScript/TypeScript | Etiquetas para permitir o prohibir dependencias entre grupos de módulos (idea posterior a 1.0) | 06A | + +## Cómo usar este documento con un enjambre + +Añade al prompt del brief: + +``` +Antes de implementar, lee docs/plan/referencias.md y estudia los proyectos marcados +para tu brief. Entrega un resumen de 5 líneas por proyecto: qué hace bien, qué +aplicaríamos a Cordon Modulith y qué descartamos porque choca con AGENTS.md o los ADRs. +No copies código; si reutilizas algo, indica la licencia en el PR. +``` diff --git a/docs/plan/roadmap.md b/docs/plan/roadmap.md new file mode 100644 index 0000000..8f2e173 --- /dev/null +++ b/docs/plan/roadmap.md @@ -0,0 +1,36 @@ +# Roadmap + +Objetivo: ser la forma recomendada de verificar límites entre módulos en Laravel, como complemento de nwidart, InterNACHI o una carpeta `app/Modules`, nunca como reemplazo. + +## Definición de MVP desplegable (v0.1.0 en Packagist) + +- `composer check` en verde en la matriz PHP 8.3/8.4 × Laravel 12/13. +- Probado a mano en al menos 3 proyectos reales (uno por tipo de resolver) sin falsos positivos. +- Nombre y vendor definitivos, README revisado por ti. +- Tag `v0.1.0` y paquete publicado en Packagist. + +Fases 0, 1 y la parte mínima de 3 bastan para el MVP. El resto construye adopción. + +## Fases + +| Fase | Semanas | Estado | Criterio de salida | +|---|---|---|---| +| 0. Fundación | 1 | Parcial: nombre y vendor decididos, falta verificar marcas | Nombre verificado, repo en GitHub, CI ejecutándose | +| 1. Núcleo | 2–5 | Hecho: tests en verde, 5 fixtures reales, 1.000 archivos en ~1 s; falta prueba manual en proyectos reales | Tests en verde, 5+ fixtures reales, 0 falsos positivos, 1.000 archivos en < 10 s | +| 2. Integraciones | 6–7 | Hecho (ADR 0005–0007 aceptados) | Expectativa Pest, regla PHPStan, GitHub Action reutilizable | +| 3. Docs e IA | 7–8 | Hecho salvo app demo (repo aparte) y prueba con persona ajena | Sitio de docs, app demo, skill de Boost, guion de vídeo | +| 4. Lanzamiento v0.1 | 9–10 | Borradores listos en `lanzamiento/`; publicación pendiente | Artículo en Laravel News enviado, posts publicados, contacto con mantenedores | +| 5. Documentación viva | Meses 3–4 | Hecho (`cordon:docs`, ADR 0008 aceptado) | `cordon:docs` genera diagramas Mermaid e inventario de eventos | +| 6. Ecosistema y v1.0 | Meses 5–6+ | API de reglas propias hecha (ADR 0009); Filament y v1.0 pendientes | Plugin Filament, API de reglas propias, v1.0 con SemVer estricto | + +## Métricas objetivo (escenario realista) + +- Mes 3: 5.000 instalaciones, 150 estrellas. +- Mes 6: 15.000 instalaciones, 400 estrellas. +- Mes 12: 15.000–40.000 instalaciones. + +## Riesgos vigilados + +- happenv-com/laravel-true-modular madura rápido: diferenciarse con adaptadores múltiples y baseline. +- nwidart o InterNACHI añaden verificación nativa: proponer colaboración temprana. +- Nombre: verificar disponibilidad y marcas de "Cordon Modulith" antes de publicar (Fase 0). diff --git a/docs/plan/siguientes-pasos.md b/docs/plan/siguientes-pasos.md new file mode 100644 index 0000000..18ce22e --- /dev/null +++ b/docs/plan/siguientes-pasos.md @@ -0,0 +1,98 @@ +# Siguientes pasos + +Lo que queda para publicar la v0.1.0 de Cordon Modulith y lanzarla. Todo lo que se podía hacer desde el código ya está hecho (ver [README.md](README.md)). Estos pasos los decides o los ejecutas tú, en este orden. Los textos y los clics exactos para GitHub y Packagist están en [publicacion/](publicacion/README.md). + +## 1. Integrar la rama en `main` + +La rama `claude/cordon-modulith-naming-1w2srv` tiene todo el trabajo. El CI solo corre en `main` y en pull requests, así que todavía no se ha ejecutado en GitHub. + +- [x] Reproducir la matriz en local (29/09/2026): PHP 8.3/8.4 × Laravel 12/13 en verde, Pint y PHPStan en verde, benchmark de 1.000 archivos en 5,3 s con Xdebug apagado. +- [ ] Abrir un pull request de la rama a `main`. Título y cuerpo en [publicacion/pr-main.md](publicacion/pr-main.md). +- [ ] Comprobar que pasan los tres jobs de `tests.yml` en GitHub (`tests`, `quality`, `benchmark`). +- [ ] Mergear. + +Si ejecutas las herramientas en Windows: Pint falla con `core.autocrlf=true` porque deja CRLF en el árbol de trabajo. El `.gitattributes` ya fuerza `eol=lf`; tras hacer commit, `git rm --cached -r . && git reset --hard` renormaliza tu copia. Ejecuta PHP con `-d xdebug.mode=off`: Xdebug en modo `develop` hace el análisis 3–4 veces más lento. + +## 2. Decidir los ADR propuestos + +- [x] ADR 0005–0009 aceptados el 29/09/2026 (`--module`, Pest, PHPStan, `cordon:docs`, reglas propias). + +## 3. Cerrar la Fase 0 (nombre y repositorio) + +- [ ] Verificar la marca "Cordon Modulith" a mano: las bases no se pueden consultar de forma automática. No se encontró ningún bloqueo, pero eso no descarta nada (veredicto: amarillo). Buscar `cordon`, `cordon modulith` y `modulith`, marcas vivas, clases 9 y 42: + - USPTO: + - EUIPO / TMview: + - WIPO: +- [x] Añadir al README un aviso de no afiliación con Spring / VMware / Broadcom ("Modulith" viene de Spring Modulith, aunque es un término genérico). Usar siempre "Cordon Modulith" completo, nunca "Modulith" solo. +- [ ] Revisar competidores de nicho en Packagist con "modulith" en el nombre: `modulith/arch-check`, `modulith-php/enforcer`, `kalinkocode/laravel-modulith`. +- [x] Packagist: a 29/09/2026 `chrisabner/cordon-modulith` sigue libre. El más cercano es `cordon/account-review`, un bundle de Symfony sin relación. +- [ ] Decidir si reservas dominio. A 29/09/2026 están libres `cordonmodulith.dev`, `cordonmodulith.com` y `cordon-modulith.dev`; `.io` no se pudo comprobar. +- [ ] Hacer público el repositorio `ChrisAbner/Cordon-Modulith`. +- [ ] Activar GitHub Pages (Settings → Pages → Source: **GitHub Actions**). Si no se activa, el workflow `docs.yml` falla al hacer push a `main`. +- [ ] Activar GitHub Discussions y publicar el RFC ([lanzamiento/01-rfc-discussions.md](lanzamiento/01-rfc-discussions.md)). + +## 4. Probar en proyectos reales + +Criterio de salida de la Fase 1: uno por resolver, sin falsos positivos. Hecho el 29/09/2026 con 6 proyectos públicos, ejecutando el núcleo con `StandaloneConfig` (sin instalar los proyectos): + +| Resolver | Proyecto | Módulos | Archivos | Violaciones | Falsos positivos | +|---|---|---|---|---|---| +| nwidart | [openclassify](https://github.com/openclassify/openclassify) | 18 | 295 | 153 | 0 | +| nwidart | [ShopSmith](https://github.com/morpheusadam/ShopSmith) | 38 | 939 | 613 | 0 | +| InterNACHI | [recruit-party-quest](https://github.com/3pontos-tech/recruit-party-quest) | 17 | 584 | 363 | 0 | +| InterNACHI | [100DiasDeCodigo](https://github.com/he4rt/100DiasDeCodigo) | 5 | 70 | 15 | 0 | +| namespace | [monica](https://github.com/monicahq/monica) (`app/Domains`) | 3–27 | 582 | 57–124 | 0 | +| namespace | [modular-monolith-laravel](https://github.com/avosalmon/modular-monolith-laravel) | 4 | 65 | 2 | 0 | + +- [x] Proyectos nwidart, InterNACHI y `app/Modules`/DDD. +- [x] Bug encontrado y corregido con fixtures (`nwidart-lowercase`, `nwidart-unloadable`): fuera de Laravel, un `config/modules.php` con `base_path()` no se cargaba y se usaba `Modules` en silencio. Ahora se resuelve y, si un config sigue sin poder evaluarse, la regla de PHPStan lo avisa (`cordon.configuration`). +- [x] Bug de Windows corregido: `Module::ownsPath()` con separadores mezclados. +- [x] Tiempo en el proyecto más grande (939 archivos): 4–6 s en caliente en Windows. La primera pasada en frío puede tardar más de 30 s por la lectura inicial de archivos (probablemente Defender) y por Xdebug. `cordon:verify` y `cordon:docs` avisan ahora por stderr cuando Xdebug está activo. +- [ ] Opcional: probarlo tú instalado con `composer require` en un proyecto tuyo con Laravel 12/13 y PHPStan. + +## 5. App demo y prueba con una persona ajena (brief 03) + +- [x] Demo creada en local en `C:\wamp64\www\cordon-demo`: Laravel 13 con nwidart, 4 módulos (Catalog, Orders, Billing, Shared), `main` con 4 violaciones (una o más por regla) y rama `fixed` en verde. Solo git local, sin remoto. +- [ ] Crear el repo `ChrisAbner/cordon-demo` en GitHub y subir `main` y `fixed`. Tras publicar la v0.1.0, cambiar en ambas ramas el repositorio path por `chrisabner/cordon-modulith:^0.1` (paso descrito en su README) para que el CI funcione: rojo en `main`, verde en `fixed`. +- [x] Prueba simulada de un recién llegado: 3 minutos de lectura y comandos hasta la primera verificación (más 21 de descargas de Composer). Sus fricciones (Pest sin `tests/Pest.php`, `phpstan.neon` completo, ejemplo de `cordon:modules`) ya están corregidas en la documentación. +- [ ] Pedir a una persona real que lo instale siguiendo solo la documentación. Objetivo: primera verificación en menos de 15 minutos. +- [ ] Revisar y aprobar el README y la portada del sitio (`docs-site/index.md`). +- [ ] Grabar el vídeo ([video.md](video.md)) y el GIF para el README. La demo sirve de base. + +## 6. Publicar la v0.1.0 + +- [ ] En `CHANGELOG.md`, cambiar `## [Unreleased] - 0.1.0` por `## [0.1.0] - AAAA-MM-DD`. +- [ ] Crear y subir el tag: + + ```bash + git tag v0.1.0 + git push origin v0.1.0 + ``` + +- [ ] Crear el GitHub Release con [publicacion/release-v0.1.0.md](publicacion/release-v0.1.0.md). La GitHub Action se referencia como `ChrisAbner/Cordon-Modulith@v0.1.0`, así que el tag tiene que existir. (`action.yml` ya no está en `export-ignore`: GitHub descarga las acciones como archivo exportado y sin él la acción fallaría.) +- [ ] Registrar el paquete en [packagist.org](https://packagist.org/packages/submit) con la URL del repo y activar el webhook de GitHub para actualizaciones automáticas. +- [ ] Comprobar en un proyecto limpio: `composer require --dev chrisabner/cordon-modulith`. +- [ ] Opcional: publicar la acción en GitHub Marketplace. + +## 7. Lanzamiento (brief 04) + +Los borradores y el calendario están en [lanzamiento/README.md](lanzamiento/README.md). + +- [x] Sustituir los enlaces "(link)" por las URL del repo, del sitio de docs y de Packagist. +- [x] Comprobar que la salida de ejemplo coincide con la real (verificado contra los fixtures el 29/09/2026). Repetirlo si cambia algo antes de publicar. +- [x] Ningún texto afirma afiliación con Laravel. + +Orden sugerido: RFC → Laravel News, r/laravel y X el día del lanzamiento → dev.to (+2 días) → mantenedores de nwidart e InterNACHI (+1 semana) → CFP según calendario. + +## 8. Después de la v0.1 (Fase 6) + +- [ ] **6B · Plugin de Filament:** paquete aparte con el grafo de módulos y la lista de violaciones. Puede reutilizar `Analyzer::snapshot()` y `DocumentationGenerator`. +- [ ] **6C · v1.0:** congelar la API pública (incluidos `Rule` y `AnalysisContext` por el ADR 0009), escribir la política de SemVer y la guía de actualización desde 0.x. +- [ ] **6D · Sostenibilidad:** GitHub Sponsors. Revisar la política de marca de Laravel antes de cualquier uso comercial. +- [ ] Mejoras candidatas: caché por hash de archivo, diagramas C4, y aplicar el baseline de Cordon también en la regla de PHPStan. +- [ ] Surgidas de las pruebas reales (29/09/2026): + - **ADR candidato: `public_namespaces` en cualquier nivel.** Hoy solo cuenta el primer segmento, así que `Requisitions\Enums\X` y `Requisitions\Events\Y` son internos; fueron ~20 % de las violaciones en una app InterNACHI. Ya está documentado. + - **Mensaje de ciclos:** lista todo el componente conexo (hasta 30 módulos) aunque el camino mostrado tenga 2–3. Ojo con la clave del baseline si se cambia. + - **Ciclos formados solo por eventos públicos:** hoy cuentan como ciclo (correcto), pero el mensaje sugiere "usa un evento". Ajustar el texto para ese caso. + - **`FileCollector` con `scandir`:** ahorra 0,1–1 s en Windows, con la misma lista de archivos. Parche medido en el scratchpad de la sesión, no aplicado. + - **Aviso de configuración duplicado:** con PHPStan en paralelo puede salir una vez por worker. diff --git a/docs/plan/video.md b/docs/plan/video.md new file mode 100644 index 0000000..2210c8b --- /dev/null +++ b/docs/plan/video.md @@ -0,0 +1,22 @@ +# Guion de vídeo (3 minutos) + +Brief 03D. Narración en inglés (audiencia global); las indicaciones de escena están en español. Grabar en una terminal a 120 columnas con fuente grande y el editor al lado. Usar la app de `tests/Fixtures/real-nwidart-shop` (o la app demo cuando exista). + +| Tiempo | Escena | Narración | +|---|---|---| +| 0:00–0:20 | Árbol de carpetas `Modules/Catalog`, `Modules/Orders`, `Modules/Payments` en el editor. | "This Laravel app has three modules. Nice folders. But folders don't stop anyone from importing another module's models." | +| 0:20–0:40 | Abrir `Modules/Orders/app/Models/Order.php`, resaltar `belongsTo(Product::class)`. | "Here, Orders reaches straight into Catalog's Eloquent model. Six months from now, renaming a column in Catalog breaks Orders, and nobody knows why." | +| 0:40–1:00 | `composer require --dev chrisabner/cordon-modulith` y `php artisan cordon:modules`. | "Cordon Modulith is a dev dependency. It detects your module layout, here nwidart, with no configuration." | +| 1:00–1:30 | `php artisan cordon:verify`. Pausa en las dos violaciones. | "It parses the code statically and finds two problems: Orders uses an internal class of Catalog, and Orders and Payments depend on each other in a cycle." | +| 1:30–2:00 | Pull request en GitHub con la anotación en la línea. | "In CI, the reusable GitHub Action turns each violation into an annotation on the pull request." | +| 2:00–2:30 | Editor: sustituir el modelo por `ProductCatalog` + `ProductData`; volver a ejecutar, queda solo el ciclo. Mostrar el cambio a evento en `Payments`. | "The fix is to depend on Catalog's public API: a contract and a data object. And to break the cycle, Payments publishes an event instead of calling Orders." | +| 2:30–2:50 | En un proyecto grande: `cordon:verify --generate-baseline`, luego `cordon:verify` en verde. | "Big legacy app? Record today's violations in a baseline. The build only fails on new ones, and you burn the baseline down module by module." | +| 2:50–3:00 | Página de docs y `php artisan cordon:docs` con el diagrama Mermaid renderizado. | "There's a Pest expectation, a PHPStan rule, a Boost skill for AI agents and living documentation. Cordon off your modules." | + +## Notas de producción + +- Sin música con letra; subtítulos en inglés y español. +- GIF corto (0:40–1:30) para el hilo de X y el README. +- No mostrar el logo de Laravel ni sugerir afiliación. +- Enlaces para la descripción del vídeo: repositorio https://github.com/ChrisAbner/Cordon-Modulith, documentación https://chrisabner.github.io/Cordon-Modulith/ (guía de inicio: https://chrisabner.github.io/Cordon-Modulith/guide/introduction) y Packagist https://packagist.org/packages/chrisabner/cordon-modulith. +- Salida real (fixture `real-nwidart-shop`, ejecutada en 0.1): `cordon:modules` lista Catalog, Orders y Payments con el resolver `nwidart`; `cordon:verify` da dos violaciones: `internal_access` en `Modules/Orders/app/Models/Order.php:13` (usa `Modules\Catalog\Models\Product`) y `cycles` `Orders -> Payments -> Orders`. Con `--generate-baseline` la segunda ejecución muestra "No boundary violations. (2 suppressed by the baseline)". diff --git a/extension.neon b/extension.neon new file mode 100644 index 0000000..67fcac8 --- /dev/null +++ b/extension.neon @@ -0,0 +1,16 @@ +parameters: + cordon: + basePath: %currentWorkingDirectory% + +parametersSchema: + cordon: structure([ + basePath: string() + ]) + +services: + - + class: Cordon\PHPStan\InternalAccessRule + arguments: + basePath: %cordon.basePath% + tags: + - phpstan.rules.rule diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..c9e3d00 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + level: 8 + paths: + - src diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..4c092b8 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + tests/Unit + + + tests/Feature + + + tests/PHPStan + + + + + src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..bf4a0ee --- /dev/null +++ b/pint.json @@ -0,0 +1,4 @@ +{ + "preset": "laravel", + "exclude": ["tests/Fixtures"] +} diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php new file mode 100644 index 0000000..139f66f --- /dev/null +++ b/resources/boost/guidelines/core.blade.php @@ -0,0 +1,10 @@ +## Cordon Modulith (module boundaries) + +This application uses Cordon Modulith to enforce boundaries between its modules. Follow these rules when writing or changing code: + +- A module may only use another module's public API: classes under its public namespaces (by default `Contracts`, `Events`, `Data`, `Enums` and `Exceptions`) or classes marked with `#[\Cordon\Attributes\PublicApi]`. +- Never use models, services, jobs, actions or other internal classes from a different module. Add a contract, event or data object to the target module's public API instead, and depend on that. +- To react to something that happens in another module without depending on it, listen to one of its public events. +- If a module declares `depends_on` in `config/cordon.php`, only add a new module dependency there when it is intended, and never add it just to silence a violation. +- Avoid dependency cycles between modules; invert one side with an event or a contract. +- Run `php artisan cordon:verify` after changes that touch more than one module and fix the violations. Do not add new violations to the baseline file. diff --git a/resources/boost/skills/cordon-fix-violations/SKILL.md b/resources/boost/skills/cordon-fix-violations/SKILL.md new file mode 100644 index 0000000..1159265 --- /dev/null +++ b/resources/boost/skills/cordon-fix-violations/SKILL.md @@ -0,0 +1,201 @@ +--- +name: cordon-fix-violations +description: Fix module boundary violations reported by Cordon Modulith (php artisan cordon:verify). Use when cordon:verify fails, when a change touches more than one module, or when asked to make modules respect their boundaries. +--- + +# Fixing Cordon Modulith violations + +Cordon Modulith checks that modules only talk to each other through their public API. This skill explains how to read its report and fix each kind of violation properly. + +## 1. Get the report + +```bash +php artisan cordon:verify --format=json +``` + +Each entry in `violations` has: + +| Field | Meaning | +|---|---| +| `rule` | `internal_access`, `undeclared_dependency` or `cycles` | +| `file`, `line` | Where the offending reference is (null for `cycles`) | +| `source_module` | The module that has the problem | +| `target_module` | The module it reaches into | +| `target` | The class used (`internal_access`), the module (`undeclared_dependency`) or the cycle path (`cycles`) | + +Run `php artisan cordon:modules` to see each module's namespace, path and declared dependencies. + +## 2. Rules you must follow + +- **Never add a violation to the baseline** (`--generate-baseline`) to make the build pass. The baseline is only for adopting Cordon Modulith in an existing project, and the maintainers decide when to regenerate it. +- **Never add a module to `depends_on`** in `config/cordon.php` only to silence `undeclared_dependency`. Ask the user if the dependency is intended. +- **Never mark a class `#[PublicApi]` just to silence `internal_access`.** Only do it when the class was designed to be used by other modules (stable, no persistence details). Prefer a contract or a data object. +- Do not move classes into a public namespace (`Contracts`, `Events`, `Data`, `Enums`, `Exceptions`) unless they really are contracts, events, data objects, enums or exceptions. +- Re-run `php artisan cordon:verify` after each fix. + +## 3. internal_access + +The source module uses a class that is internal to the target module (a model, service, job, action...). + +**Fix: depend on a contract and a data object that the target module exposes.** + +Before (in the `Billing` module): + +```php +use Modules\Catalog\Models\Product; + +final class CheckoutService +{ + public function total(int $productId): int + { + return Product::findOrFail($productId)->price_in_cents; + } +} +``` + +After. In the `Catalog` module, add a contract, a data object and an implementation, and bind it in the module's service provider: + +```php +// Modules/Catalog/app/Contracts/ProductCatalog.php +namespace Modules\Catalog\Contracts; + +use Modules\Catalog\Data\ProductData; + +interface ProductCatalog +{ + public function find(int $id): ProductData; +} + +// Modules/Catalog/app/Data/ProductData.php +namespace Modules\Catalog\Data; + +final readonly class ProductData +{ + public function __construct(public int $id, public string $name, public int $priceInCents) {} +} + +// Modules/Catalog/app/Services/EloquentProductCatalog.php (internal) +namespace Modules\Catalog\Services; + +use Modules\Catalog\Contracts\ProductCatalog; +use Modules\Catalog\Data\ProductData; +use Modules\Catalog\Models\Product; + +final class EloquentProductCatalog implements ProductCatalog +{ + public function find(int $id): ProductData + { + $product = Product::findOrFail($id); + + return new ProductData($product->id, $product->name, $product->price_in_cents); + } +} + +// In CatalogServiceProvider::register() +$this->app->bind(ProductCatalog::class, EloquentProductCatalog::class); +``` + +Then in `Billing`: + +```php +use Modules\Catalog\Contracts\ProductCatalog; + +final class CheckoutService +{ + public function __construct(private ProductCatalog $catalog) {} + + public function total(int $productId): int + { + return $this->catalog->find($productId)->priceInCents; + } +} +``` + +Other valid fixes: + +- The source module only needs to **react** to something in the target module: listen to a public event instead of calling its services. +- The target class is a stable value object already designed for sharing (for example a `Money` class): mark it `#[\Cordon\Attributes\PublicApi]`, or move it to a shared kernel module configured with `'open' => true`. Confirm with the user first. + +Eloquent relations across modules (`belongsTo(OtherModule\Models\X::class)`) are a common source of `internal_access`. Store the foreign key and load the data through the other module's contract instead of a relation. + +## 4. undeclared_dependency + +The source module declares `depends_on` in `config/cordon.php` and uses a module that is not listed. + +1. Decide whether the dependency is intended. If the user confirms it is, add the module to `depends_on`. +2. If it is not intended, remove the dependency: move the logic to the module that owns the data, or invert it so the other module depends on this one (for example, this module publishes an event in its own `Events` namespace and the other module listens to it). Listening to the other module's event still counts as depending on it. +3. Never add the dependency without confirmation. + +```php +// config/cordon.php +'modules' => [ + 'Notifications' => ['depends_on' => ['Accounts', 'Invoicing']], // only when intended +], +``` + +## 5. cycles + +Two or more modules depend on each other, for example `Orders -> Payments -> Orders`. The `target` field shows the shortest cycle. + +**Fix: invert one of the dependencies.** Pick the edge that is least essential and replace it: + +- **With an event:** instead of `Payments` calling `Orders` to mark an order as paid, `Payments` dispatches its own public event and `Orders` listens to it. Then only `Orders -> Payments` remains. + +Before (in `Payments`): + +```php +use Modules\Orders\Contracts\OrderRepository; + +final class Gateway +{ + public function __construct(private OrderRepository $orders) {} + + public function capture(int $orderId): void + { + // ... charge the card + $this->orders->markAsPaid($orderId); + } +} +``` + +After: + +```php +// Modules/Payments/app/Events/PaymentCaptured.php +namespace Modules\Payments\Events; + +final readonly class PaymentCaptured +{ + public function __construct(public int $orderId) {} +} + +// Modules/Payments/app/Services/Gateway.php +use Modules\Payments\Events\PaymentCaptured; + +final class Gateway +{ + public function capture(int $orderId): void + { + // ... charge the card + event(new PaymentCaptured($orderId)); + } +} + +// Modules/Orders/app/Listeners/MarkOrderAsPaid.php +use Modules\Payments\Events\PaymentCaptured; + +final class MarkOrderAsPaid +{ + public function handle(PaymentCaptured $event): void { /* ... */ } +} +``` + +- **With a contract owned by the caller (dependency inversion):** the lower-level module defines an interface in its own `Contracts` namespace and the higher-level module implements it and binds it in its service provider. + +After the change, run `php artisan cordon:verify` again: a cycle is fixed only when no edge remains in one of the directions. + +## 6. Finish + +- `php artisan cordon:verify` exits with 0. +- The baseline file is unchanged (or smaller, if you fixed violations that were in it: run `php artisan cordon:verify --generate-baseline` only if the user asks you to shrink it). +- Explain to the user which contracts, events or data objects you added and why. diff --git a/src/Analysis/AnalysisContext.php b/src/Analysis/AnalysisContext.php new file mode 100644 index 0000000..4a9037b --- /dev/null +++ b/src/Analysis/AnalysisContext.php @@ -0,0 +1,23 @@ + $edges Cross-module references only. + */ + public function __construct( + public ModuleMap $modules, + public SymbolTable $symbols, + public PublicApiPolicy $policy, + public array $edges, + ) {} +} diff --git a/src/Analysis/Analyzer.php b/src/Analysis/Analyzer.php new file mode 100644 index 0000000..da4e5de --- /dev/null +++ b/src/Analysis/Analyzer.php @@ -0,0 +1,105 @@ + extract declarations/references -> build cross-module edges -> run rules. + */ +final readonly class Analyzer +{ + /** + * @param list $rules + */ + public function __construct( + private DependencyExtractor $extractor, + private FileCollector $files, + private PublicApiPolicy $policy, + private array $rules, + ) {} + + public function analyze(ModuleMap $modules, ?string $basePath = null): Result + { + return $this->check($this->snapshot($modules), $basePath); + } + + /** + * Run the rules on an existing snapshot. + */ + public function check(Snapshot $snapshot, ?string $basePath = null): Result + { + $violations = []; + + foreach ($this->rules as $rule) { + foreach ($rule->check($snapshot->context) as $violation) { + $violations[] = $violation; + } + } + + return new Result( + $snapshot->context->modules, + $violations, + count($snapshot->analyses), + count($snapshot->context->edges), + $snapshot->parseErrors, + 0, + $basePath, + ); + } + + /** + * Extract every module file and build the cross-module edges, without running rules. + */ + public function snapshot(ModuleMap $modules): Snapshot + { + $analyses = []; + $parseErrors = []; + $seen = []; + + foreach ($modules as $module) { + foreach ($this->files->collect($module->path) as $file) { + if (isset($seen[$file])) { + continue; + } + + $seen[$file] = true; + $analysis = $this->extractor->extract($file); + + if ($analysis->error !== null) { + $parseErrors[$file] = $analysis->error; + } + + $analyses[] = $analysis; + } + } + + $edges = []; + + foreach ($analyses as $analysis) { + $from = $modules->forPath($analysis->file); + + if ($from === null) { + continue; + } + + foreach ($analysis->references as $reference) { + $to = $modules->forClass($reference->target); + + if ($to !== null && $to->name !== $from->name) { + $edges[] = new Edge($from, $to, $reference); + } + } + } + + return new Snapshot( + new AnalysisContext($modules, SymbolTable::fromAnalyses($analyses), $this->policy, $edges), + $analyses, + $parseErrors, + ); + } +} diff --git a/src/Analysis/ClassDeclaration.php b/src/Analysis/ClassDeclaration.php new file mode 100644 index 0000000..94e0754 --- /dev/null +++ b/src/Analysis/ClassDeclaration.php @@ -0,0 +1,18 @@ + $declarations + * @param list $references + */ + public function __construct( + public string $file, + public array $declarations = [], + public array $references = [], + public ?string $error = null, + ) {} +} diff --git a/src/Analysis/FileCollector.php b/src/Analysis/FileCollector.php new file mode 100644 index 0000000..091fb27 --- /dev/null +++ b/src/Analysis/FileCollector.php @@ -0,0 +1,57 @@ + $excludeDirectories Directory names skipped at any depth. + */ + public function __construct(private array $excludeDirectories = ['vendor', 'node_modules']) {} + + /** + * @return list + */ + public function collect(string $path): array + { + if (! is_dir($path)) { + return []; + } + + $excluded = array_flip($this->excludeDirectories); + + $filter = new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + static function (SplFileInfo $current) use ($excluded): bool { + if ($current->isDir()) { + return ! isset($excluded[$current->getFilename()]) && ! str_starts_with($current->getFilename(), '.'); + } + + return $current->getExtension() === 'php' + && ! str_ends_with($current->getFilename(), '.blade.php'); + }, + ); + + $files = []; + + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator($filter) as $file) { + $files[] = $file->getPathname(); + } + + sort($files); + + return $files; + } +} diff --git a/src/Analysis/ModuleFilter.php b/src/Analysis/ModuleFilter.php new file mode 100644 index 0000000..a8799ed --- /dev/null +++ b/src/Analysis/ModuleFilter.php @@ -0,0 +1,56 @@ + $modules + */ + public static function apply(Result $result, array $modules): Result + { + return new Result( + $result->modules, + array_values(array_filter( + $result->violations, + static fn (Violation $violation): bool => self::involvesAny($violation, $modules), + )), + $result->filesAnalysed, + $result->crossModuleReferences, + $result->parseErrors, + $result->suppressed, + $result->basePath, + ); + } + + /** + * @param list $modules + */ + public static function involvesAny(Violation $violation, array $modules): bool + { + foreach ($modules as $module) { + if (self::involves($violation, $module)) { + return true; + } + } + + return false; + } + + public static function involves(Violation $violation, string $module): bool + { + if ($violation->rule === CycleRule::ID && $violation->target !== null) { + return in_array($module, explode(' -> ', $violation->target), true); + } + + return $violation->sourceModule === $module; + } +} diff --git a/src/Analysis/PhpParserExtractor.php b/src/Analysis/PhpParserExtractor.php new file mode 100644 index 0000000..13626f9 --- /dev/null +++ b/src/Analysis/PhpParserExtractor.php @@ -0,0 +1,49 @@ +parser = $parser ?? (new ParserFactory)->createForNewestSupportedVersion(); + } + + public function extract(string $file): FileAnalysis + { + $code = @file_get_contents($file); + + if ($code === false) { + return new FileAnalysis($file, error: 'File could not be read.'); + } + + try { + $ast = $this->parser->parse($code) ?? []; + } catch (Error $error) { + return new FileAnalysis($file, error: $error->getMessage()); + } + + $collector = new ReferenceCollector($file); + + $traverser = new NodeTraverser; + $traverser->addVisitor(new NameResolver); + $traverser->addVisitor($collector); + $traverser->traverse($ast); + + return new FileAnalysis($file, $collector->declarations(), $collector->references()); + } +} diff --git a/src/Analysis/PublicApiPolicy.php b/src/Analysis/PublicApiPolicy.php new file mode 100644 index 0000000..1d194cb --- /dev/null +++ b/src/Analysis/PublicApiPolicy.php @@ -0,0 +1,46 @@ + #[PublicApi] > open module > public namespaces + * (global config + module "public" list) > internal by default. + */ +final readonly class PublicApiPolicy +{ + /** + * @param list $publicNamespaces Namespaces relative to each module, e.g. "Contracts". + */ + public function __construct( + private array $publicNamespaces = ['Contracts', 'Events', 'Data', 'Enums', 'Exceptions'], + ) {} + + public function isPublic(string $fqcn, Module $module, ?ClassDeclaration $declaration = null): bool + { + if ($declaration?->visibility === DeclaredVisibility::Internal) { + return false; + } + + if ($declaration?->visibility === DeclaredVisibility::PublicApi || $module->open) { + return true; + } + + $relative = $module->relativeName($fqcn); + + foreach ([...$this->publicNamespaces, ...$module->publicApi] as $prefix) { + $prefix = trim($prefix, '\\'); + + if ($prefix !== '' && ($relative === $prefix || str_starts_with($relative, $prefix.'\\'))) { + return true; + } + } + + return false; + } +} diff --git a/src/Analysis/Reference.php b/src/Analysis/Reference.php new file mode 100644 index 0000000..2b4559e --- /dev/null +++ b/src/Analysis/Reference.php @@ -0,0 +1,18 @@ + */ + private array $declarations = []; + + /** @var list */ + private array $references = []; + + /** @var list */ + private array $classStack = []; + + public function __construct(private readonly string $file) {} + + public function enterNode(Node $node) + { + if ($node instanceof Node\Stmt\ClassLike) { + $fqcn = $node->namespacedName?->toString(); + + if ($fqcn !== null) { + $this->declarations[] = new ClassDeclaration($fqcn, $this->file, $node->getStartLine(), $this->visibilityOf($node)); + } + + // Anonymous classes are attributed to the enclosing class. + $this->classStack[] = $fqcn ?? $this->currentClass(); + + return null; + } + + // Function and constant names are not class dependencies. + if (($node instanceof Node\Expr\FuncCall || $node instanceof Node\Expr\ConstFetch) && $node->name instanceof Node\Name) { + $node->name->setAttribute(self::SKIP, true); + + return null; + } + + if ($node instanceof Node\Name\FullyQualified && $node->getAttribute(self::SKIP) !== true) { + $this->references[] = new Reference($this->file, $this->currentClass(), $node->toString(), $node->getStartLine()); + } + + return null; + } + + public function leaveNode(Node $node) + { + if ($node instanceof Node\Stmt\ClassLike) { + array_pop($this->classStack); + } + + return null; + } + + /** + * @return list + */ + public function declarations(): array + { + return $this->declarations; + } + + /** + * @return list + */ + public function references(): array + { + return $this->references; + } + + private function currentClass(): ?string + { + return $this->classStack === [] ? null : $this->classStack[array_key_last($this->classStack)]; + } + + private function visibilityOf(Node\Stmt\ClassLike $node): DeclaredVisibility + { + foreach ($node->attrGroups as $group) { + foreach ($group->attrs as $attribute) { + $name = ltrim($attribute->name->toString(), '\\'); + + if ($name === Internal::class) { + return DeclaredVisibility::Internal; + } + + if ($name === PublicApi::class) { + return DeclaredVisibility::PublicApi; + } + } + } + + return DeclaredVisibility::Unspecified; + } +} diff --git a/src/Analysis/Result.php b/src/Analysis/Result.php new file mode 100644 index 0000000..e0063a9 --- /dev/null +++ b/src/Analysis/Result.php @@ -0,0 +1,45 @@ + $violations + * @param array $parseErrors file => message + */ + public function __construct( + public ModuleMap $modules, + public array $violations, + public int $filesAnalysed, + public int $crossModuleReferences, + public array $parseErrors = [], + public int $suppressed = 0, + public ?string $basePath = null, + ) {} + + public function hasViolations(): bool + { + return $this->violations !== []; + } + + public function withBaseline(Baseline $baseline): self + { + [$remaining, $suppressed] = $baseline->filter($this->violations, $this->basePath); + + return new self( + $this->modules, + $remaining, + $this->filesAnalysed, + $this->crossModuleReferences, + $this->parseErrors, + $this->suppressed + $suppressed, + $this->basePath, + ); + } +} diff --git a/src/Analysis/Snapshot.php b/src/Analysis/Snapshot.php new file mode 100644 index 0000000..764b117 --- /dev/null +++ b/src/Analysis/Snapshot.php @@ -0,0 +1,22 @@ + $analyses + * @param array $parseErrors file => message + */ + public function __construct( + public AnalysisContext $context, + public array $analyses, + public array $parseErrors = [], + ) {} +} diff --git a/src/Analysis/SymbolTable.php b/src/Analysis/SymbolTable.php new file mode 100644 index 0000000..4de7d9a --- /dev/null +++ b/src/Analysis/SymbolTable.php @@ -0,0 +1,42 @@ + $classes + */ + private function __construct(private array $classes) {} + + /** + * @param iterable $analyses + */ + public static function fromAnalyses(iterable $analyses): self + { + $classes = []; + + foreach ($analyses as $analysis) { + foreach ($analysis->declarations as $declaration) { + $classes[strtolower($declaration->fqcn)] = $declaration; + } + } + + return new self($classes); + } + + public function get(string $fqcn): ?ClassDeclaration + { + return $this->classes[strtolower(ltrim($fqcn, '\\'))] ?? null; + } + + public function count(): int + { + return count($this->classes); + } +} diff --git a/src/Analysis/Violation.php b/src/Analysis/Violation.php new file mode 100644 index 0000000..029f2da --- /dev/null +++ b/src/Analysis/Violation.php @@ -0,0 +1,45 @@ +rule, Paths::relative($basePath, $this->file) ?? '', $this->target ?? '']); + } + + /** + * @return array{rule: string, message: string, file: string|null, line: int|null, source_module: string|null, target_module: string|null, target: string|null} + */ + public function toArray(?string $basePath = null): array + { + return [ + 'rule' => $this->rule, + 'message' => $this->message, + 'file' => Paths::relative($basePath, $this->file), + 'line' => $this->line, + 'source_module' => $this->sourceModule, + 'target_module' => $this->targetModule, + 'target' => $this->target, + ]; + } +} diff --git a/src/Attributes/Internal.php b/src/Attributes/Internal.php new file mode 100644 index 0000000..3f113fb --- /dev/null +++ b/src/Attributes/Internal.php @@ -0,0 +1,13 @@ + $entries + */ + private function __construct(private array $entries) {} + + public static function empty(): self + { + return new self([]); + } + + /** + * @param list $violations + */ + public static function fromViolations(array $violations, ?string $basePath = null): self + { + $entries = []; + + foreach ($violations as $violation) { + $key = $violation->baselineKey($basePath); + + if (isset($entries[$key])) { + $entries[$key]['count']++; + + continue; + } + + $entries[$key] = [ + 'rule' => $violation->rule, + 'file' => Paths::relative($basePath, $violation->file), + 'target' => $violation->target, + 'count' => 1, + ]; + } + + ksort($entries); + + return new self($entries); + } + + public static function load(string $file): self + { + $contents = @file_get_contents($file); + + if ($contents === false) { + throw new RuntimeException(sprintf('Unable to read the Cordon baseline [%s].', $file)); + } + + $data = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + $entries = []; + + foreach (is_array($data) ? ($data['violations'] ?? []) : [] as $entry) { + if (! is_array($entry) || ! isset($entry['rule'])) { + continue; + } + + $rule = (string) $entry['rule']; + $path = isset($entry['file']) ? (string) $entry['file'] : null; + $target = isset($entry['target']) ? (string) $entry['target'] : null; + + $entries[implode('|', [$rule, $path ?? '', $target ?? ''])] = [ + 'rule' => $rule, + 'file' => $path, + 'target' => $target, + 'count' => max(1, (int) ($entry['count'] ?? 1)), + ]; + } + + return new self($entries); + } + + public function save(string $file): void + { + $json = json_encode( + ['version' => self::VERSION, 'violations' => array_values($this->entries)], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, + ); + + if (@file_put_contents($file, $json.PHP_EOL) === false) { + throw new RuntimeException(sprintf('Unable to write the Cordon baseline [%s].', $file)); + } + } + + public function count(): int + { + return count($this->entries); + } + + /** + * @param list $violations + * @return array{0: list, 1: int} Remaining violations and the number suppressed. + */ + public function filter(array $violations, ?string $basePath = null): array + { + $remaining = array_map(static fn (array $entry): int => $entry['count'], $this->entries); + $kept = []; + $suppressed = 0; + + foreach ($violations as $violation) { + $key = $violation->baselineKey($basePath); + + if (($remaining[$key] ?? 0) > 0) { + $remaining[$key]--; + $suppressed++; + + continue; + } + + $kept[] = $violation; + } + + return [$kept, $suppressed]; + } +} diff --git a/src/Contracts/DependencyExtractor.php b/src/Contracts/DependencyExtractor.php new file mode 100644 index 0000000..2b76c45 --- /dev/null +++ b/src/Contracts/DependencyExtractor.php @@ -0,0 +1,16 @@ +...). + * Machine readable formats must return false. + */ + public function formatted(): bool; +} diff --git a/src/Contracts/Rule.php b/src/Contracts/Rule.php new file mode 100644 index 0000000..6a0ae7c --- /dev/null +++ b/src/Contracts/Rule.php @@ -0,0 +1,21 @@ + + */ + public function check(AnalysisContext $context): iterable; +} diff --git a/src/Documentation/DocumentationGenerator.php b/src/Documentation/DocumentationGenerator.php new file mode 100644 index 0000000..b1825a3 --- /dev/null +++ b/src/Documentation/DocumentationGenerator.php @@ -0,0 +1,425 @@ +.md + * (one canvas per module) and events.md (event inventory). Output is deterministic so it can be committed. + */ +final class DocumentationGenerator +{ + /** + * @param list $violations + * @return array file name => Markdown + */ + public function generate(Snapshot $snapshot, array $violations, EventInventory $events, ?string $basePath = null): array + { + $modules = $snapshot->context->modules->all(); + $dependencies = $this->dependencies($snapshot->context->edges); + $internal = $this->internalPairs($violations); + + $files = ['README.md' => $this->overview($modules, $dependencies, $internal, $violations, $events)]; + + foreach ($modules as $module) { + $files[$this->fileName($module)] = $this->canvas($module, $snapshot, $dependencies, $internal, $violations, $events, $basePath); + } + + $files['events.md'] = $this->eventInventory($events, $basePath); + + return $files; + } + + /** + * @param list $modules + * @param array>> $dependencies from => to => class => true + * @param array $internal "from|to" pairs with internal access + * @param list $violations + */ + private function overview(array $modules, array $dependencies, array $internal, array $violations, EventInventory $events): string + { + $lines = [ + '# Modules', + '', + '> Generated by `php artisan cordon:docs` from the code. Do not edit by hand.', + '', + sprintf('%d modules, %d events, %d boundary violations.', count($modules), count($events->events), count($violations)), + '', + '## Dependencies', + '', + ...$this->diagram(array_map(static fn (Module $module): string => $module->name, $modules), $dependencies, $internal), + '', + 'Solid arrows are dependencies through the public API. Red arrows include internal access.', + '', + '| Module | Namespace | Depends on | Used by | Violations |', + '|---|---|---|---|---|', + ]; + + foreach ($modules as $module) { + $lines[] = sprintf( + '| [%s](%s) | `%s` | %s | %s | %d |', + $module->name, + $this->fileName($module), + $module->namespace, + $this->list(array_keys($dependencies[$module->name] ?? [])), + $this->list($this->usedBy($module->name, $dependencies)), + count(array_filter($violations, static fn (Violation $v): bool => ModuleFilter::involves($v, $module->name))), + ); + } + + $lines[] = ''; + $lines[] = 'See also the [event inventory](events.md).'; + + return implode("\n", $lines)."\n"; + } + + /** + * @param array>> $dependencies + * @param array $internal + * @param list $violations + */ + private function canvas(Module $module, Snapshot $snapshot, array $dependencies, array $internal, array $violations, EventInventory $events, ?string $basePath): string + { + $name = $module->name; + $outgoing = $dependencies[$name] ?? []; + $incoming = []; + + foreach ($dependencies as $from => $targets) { + if (isset($targets[$name])) { + $incoming[$from] = $targets[$name]; + } + } + + ksort($incoming); + $neighbours = array_values(array_unique([$name, ...array_keys($outgoing), ...array_keys($incoming)])); + sort($neighbours); + $local = []; + + foreach ($dependencies as $from => $targets) { + foreach ($targets as $to => $classes) { + if ($from === $name || $to === $name) { + $local[$from][$to] = $classes; + } + } + } + + $lines = [ + '# Module '.$name, + '', + '> Generated by `php artisan cordon:docs` from the code. Do not edit by hand.', + '', + '| | |', + '|---|---|', + '| Namespace | `'.$module->namespace.'` |', + '| Path | `'.Paths::relative($basePath, $module->path).'` |', + '| Declared dependencies (`depends_on`) | '.match (true) { + $module->dependsOn === null => '(not declared)', + $module->dependsOn === [] => '(none)', + default => implode(', ', $module->dependsOn), + }.' |', + '| Open (every class public) | '.($module->open ? 'yes' : 'no').' |', + '', + '## Dependencies', + '', + ...$this->diagram($neighbours, $local, $internal, $name), + '', + '## Public API', + '', + ...$this->bullets($this->publicApi($module, $snapshot), 'No public classes yet.'), + '', + '## Uses', + '', + ...$this->classTable($outgoing, $snapshot, 'Does not use other modules.'), + '', + '## Used by', + '', + ...$this->classTable($incoming, $snapshot, 'Not used by other modules.', $module), + '', + '## Events', + '', + ...$this->moduleEvents($name, $events), + '', + '## Violations', + '', + ]; + + $own = array_values(array_filter($violations, static fn (Violation $v): bool => ModuleFilter::involves($v, $name))); + + if ($own === []) { + $lines[] = 'None.'; + } + + foreach ($own as $violation) { + $location = $violation->file === null + ? '' + : ' `'.Paths::relative($basePath, $violation->file).($violation->line === null ? '' : ':'.$violation->line).'`'; + + $lines[] = sprintf('- **%s**%s: %s', $violation->rule, $location, $violation->message); + } + + return implode("\n", $lines)."\n"; + } + + private function eventInventory(EventInventory $events, ?string $basePath): string + { + $lines = [ + '# Events', + '', + '> Generated by `php artisan cordon:docs` from the code. Do not edit by hand.', + '', + 'Events are classes under a module\'s `Events` namespace, or dispatched with `event(new X)`, `Event::dispatch()` or registered with `Event::listen()`.', + '', + ]; + + if ($events->events === []) { + $lines[] = 'No events found.'; + + return implode("\n", $lines)."\n"; + } + + $lines[] = '| Event | Module | Published by | Listened by |'; + $lines[] = '|---|---|---|---|'; + + foreach ($events->events as $event) { + $lines[] = sprintf( + '| `%s` | %s | %s | %s |', + $event->fqcn, + $event->module, + $this->usages($event->publishers, $basePath), + $this->usages($event->listeners, $basePath), + ); + } + + return implode("\n", $lines)."\n"; + } + + /** + * @param list $nodes + * @param array>> $dependencies + * @param array $internal + * @return list + */ + private function diagram(array $nodes, array $dependencies, array $internal, ?string $highlight = null): array + { + $ids = array_flip($nodes); + $lines = ['```mermaid', 'flowchart LR']; + + foreach ($nodes as $index => $node) { + $lines[] = sprintf(' m%d["%s"]', $index, str_replace('"', '#quot;', $node)); + } + + $red = []; + $link = 0; + + foreach ($dependencies as $from => $targets) { + foreach ($targets as $to => $classes) { + if (! isset($ids[$from], $ids[$to])) { + continue; + } + + $lines[] = sprintf(' m%d -->|%d| m%d', $ids[$from], count($classes), $ids[$to]); + + if (isset($internal[$from.'|'.$to])) { + $red[] = $link; + } + + $link++; + } + } + + if ($red !== []) { + $lines[] = ' linkStyle '.implode(',', $red).' stroke:#d33,stroke-width:2px'; + } + + if ($highlight !== null && isset($ids[$highlight])) { + $lines[] = sprintf(' style m%d stroke-width:3px', $ids[$highlight]); + } + + $lines[] = '```'; + + return $lines; + } + + /** + * @param array> $byModule module => class => true + * @return list + */ + private function classTable(array $byModule, Snapshot $snapshot, string $empty, ?Module $owner = null): array + { + if ($byModule === []) { + return [$empty]; + } + + $lines = ['| Module | Classes |', '|---|---|']; + + foreach ($byModule as $module => $classes) { + $target = $owner ?? $snapshot->context->modules->get($module); + $names = []; + + foreach (array_keys($classes) as $class) { + $public = $target === null + || $snapshot->context->policy->isPublic($class, $target, $snapshot->context->symbols->get($class)); + + $names[] = '`'.($target === null ? $class : $target->relativeName($class)).'`'.($public ? '' : ' (internal)'); + } + + $lines[] = sprintf('| %s | %s |', $module, implode(', ', $names)); + } + + return $lines; + } + + /** + * @return list + */ + private function moduleEvents(string $module, EventInventory $events): array + { + $sections = [ + 'Owned' => $events->ownedBy($module), + 'Published' => $events->publishedBy($module), + 'Listened to' => $events->listenedBy($module), + ]; + + $lines = []; + + foreach ($sections as $title => $entries) { + $lines[] = sprintf('- %s: %s', $title, $entries === [] + ? 'none' + : implode(', ', array_map(static fn (EventEntry $entry): string => '`'.$entry->fqcn.'`', $entries))); + } + + return $lines; + } + + /** + * @return list + */ + private function publicApi(Module $module, Snapshot $snapshot): array + { + $classes = []; + + foreach ($snapshot->analyses as $analysis) { + foreach ($analysis->declarations as $declaration) { + if ($module->ownsClass($declaration->fqcn) + && $snapshot->context->modules->forClass($declaration->fqcn)?->name === $module->name + && $snapshot->context->policy->isPublic($declaration->fqcn, $module, $declaration)) { + $classes[] = '`'.$module->relativeName($declaration->fqcn).'`'; + } + } + } + + sort($classes); + + return array_values(array_unique($classes)); + } + + /** + * @param list $edges + * @return array>> + */ + private function dependencies(array $edges): array + { + $dependencies = []; + + foreach ($edges as $edge) { + $dependencies[$edge->from->name][$edge->to->name][$edge->reference->target] = true; + } + + ksort($dependencies); + + foreach ($dependencies as &$targets) { + ksort($targets); + + foreach ($targets as &$classes) { + ksort($classes); + } + } + + return $dependencies; + } + + /** + * @param list $violations + * @return array + */ + private function internalPairs(array $violations): array + { + $pairs = []; + + foreach ($violations as $violation) { + if ($violation->rule === InternalAccessRule::ID && $violation->sourceModule !== null && $violation->targetModule !== null) { + $pairs[$violation->sourceModule.'|'.$violation->targetModule] = true; + } + } + + return $pairs; + } + + /** + * @param array>> $dependencies + * @return list + */ + private function usedBy(string $module, array $dependencies): array + { + $users = []; + + foreach ($dependencies as $from => $targets) { + if (isset($targets[$module])) { + $users[] = $from; + } + } + + return $users; + } + + /** + * @param list $usages + */ + private function usages(array $usages, ?string $basePath): string + { + if ($usages === []) { + return '-'; + } + + return implode('
', array_map( + static fn (array $usage): string => sprintf( + '%s `%s`', + $usage['module'], + $usage['class'] ?? Paths::relative($basePath, $usage['file']).':'.$usage['line'], + ), + $usages, + )); + } + + /** + * @param list $items + */ + private function list(array $items): string + { + return $items === [] ? '-' : implode(', ', $items); + } + + /** + * @param list $items + * @return list + */ + private function bullets(array $items, string $empty): array + { + return $items === [] ? [$empty] : array_map(static fn (string $item): string => '- '.$item, $items); + } + + private function fileName(Module $module): string + { + return 'modules/'.$module->name.'.md'; + } +} diff --git a/src/Documentation/EventCollector.php b/src/Documentation/EventCollector.php new file mode 100644 index 0000000..55a6284 --- /dev/null +++ b/src/Documentation/EventCollector.php @@ -0,0 +1,178 @@ +dispatch(new X), + * Event::dispatch(new X), X::dispatch(), X::dispatchIf(), X::dispatchUnless(), X::broadcast(). + * Listen: handle(X $event) / __invoke(X $event) in a class, Event::listen(X::class, ...). + */ +final class EventCollector extends NodeVisitorAbstract +{ + private const EVENT_FACADE = 'Illuminate\\Support\\Facades\\Event'; + + private const STATIC_DISPATCH = ['dispatch', 'dispatchif', 'dispatchunless', 'broadcast']; + + /** @var list */ + private array $usages = []; + + /** @var list */ + private array $classStack = []; + + public function __construct(private readonly string $file) {} + + public function enterNode(Node $node) + { + if ($node instanceof Node\Stmt\ClassLike) { + $this->classStack[] = $node->namespacedName?->toString() ?? $this->currentClass(); + + return null; + } + + match (true) { + $node instanceof Node\Stmt\ClassMethod => $this->listenerMethod($node), + $node instanceof Expr\FuncCall => $this->functionCall($node), + $node instanceof Expr\MethodCall => $this->methodCall($node), + $node instanceof Expr\StaticCall => $this->staticCall($node), + default => null, + }; + + return null; + } + + public function leaveNode(Node $node) + { + if ($node instanceof Node\Stmt\ClassLike) { + array_pop($this->classStack); + } + + return null; + } + + /** + * @return list + */ + public function usages(): array + { + return $this->usages; + } + + private function listenerMethod(Node\Stmt\ClassMethod $node): void + { + if ($this->currentClass() === null || ! in_array($node->name->toLowerString(), ['handle', '__invoke'], true)) { + return; + } + + $type = $node->params[0]->type ?? null; + + foreach ($this->classNames($type) as $name) { + $this->add(EventUsage::LISTEN, $name, $node->getStartLine(), false); + } + } + + private function functionCall(Expr\FuncCall $node): void + { + if ($node->name instanceof Name && in_array($node->name->toLowerString(), ['event', 'broadcast'], true)) { + $this->dispatchedInstance($node->args[0] ?? null, $node->getStartLine()); + } + } + + private function methodCall(Expr\MethodCall $node): void + { + if ($node->name instanceof Node\Identifier && in_array($node->name->toLowerString(), ['dispatch', 'broadcast'], true)) { + $this->dispatchedInstance($node->args[0] ?? null, $node->getStartLine()); + } + } + + private function staticCall(Expr\StaticCall $node): void + { + if (! $node->class instanceof Name\FullyQualified || ! $node->name instanceof Node\Identifier) { + return; + } + + $class = $node->class->toString(); + $method = $node->name->toLowerString(); + + if ($class === self::EVENT_FACADE) { + if ($method === 'dispatch') { + $this->dispatchedInstance($node->args[0] ?? null, $node->getStartLine()); + } + + if ($method === 'listen') { + $this->listenedClasses($node->args[0] ?? null, $node->getStartLine()); + } + + return; + } + + if (in_array($method, self::STATIC_DISPATCH, true)) { + $this->add(EventUsage::DISPATCH, $class, $node->getStartLine(), false); + } + } + + private function dispatchedInstance(?Node $argument, int $line): void + { + if ($argument instanceof Arg && $argument->value instanceof Expr\New_ && $argument->value->class instanceof Name\FullyQualified) { + $this->add(EventUsage::DISPATCH, $argument->value->class->toString(), $line, true); + } + } + + private function listenedClasses(?Node $argument, int $line): void + { + if (! $argument instanceof Arg) { + return; + } + + $values = $argument->value instanceof Expr\Array_ + ? array_map(static fn (Node\ArrayItem $item): Expr => $item->value, $argument->value->items) + : [$argument->value]; + + foreach ($values as $value) { + if ($value instanceof Expr\ClassConstFetch + && $value->class instanceof Name\FullyQualified + && $value->name instanceof Node\Identifier + && $value->name->toLowerString() === 'class') { + $this->add(EventUsage::LISTEN, $value->class->toString(), $line, true); + } + } + } + + /** + * @return list + */ + private function classNames(?Node $type): array + { + return match (true) { + $type instanceof Name\FullyQualified => [$type->toString()], + $type instanceof Node\NullableType => $this->classNames($type->type), + $type instanceof Node\UnionType, $type instanceof Node\IntersectionType => array_merge(...array_map( + fn (Node $inner): array => $this->classNames($inner), + $type->types, + )), + default => [], + }; + } + + /** + * @param EventUsage::DISPATCH|EventUsage::LISTEN $kind + */ + private function add(string $kind, string $event, int $line, bool $explicit): void + { + $this->usages[] = new EventUsage($kind, $event, $this->currentClass(), $this->file, $line, $explicit); + } + + private function currentClass(): ?string + { + return $this->classStack === [] ? null : $this->classStack[array_key_last($this->classStack)]; + } +} diff --git a/src/Documentation/EventEntry.php b/src/Documentation/EventEntry.php new file mode 100644 index 0000000..1f9dcd3 --- /dev/null +++ b/src/Documentation/EventEntry.php @@ -0,0 +1,19 @@ + $publishers + * @param list $listeners + */ + public function __construct( + public string $fqcn, + public string $module, + public array $publishers, + public array $listeners, + ) {} +} diff --git a/src/Documentation/EventExtractor.php b/src/Documentation/EventExtractor.php new file mode 100644 index 0000000..f013e94 --- /dev/null +++ b/src/Documentation/EventExtractor.php @@ -0,0 +1,51 @@ +parser = $parser ?? (new ParserFactory)->createForNewestSupportedVersion(); + } + + /** + * @return list + */ + public function extract(string $file): array + { + $code = @file_get_contents($file); + + if ($code === false) { + return []; + } + + try { + $ast = $this->parser->parse($code) ?? []; + } catch (Error) { + return []; + } + + // Names must be resolved in a first pass: the collector inspects child + // nodes (new X inside event(...)) before a single pass would reach them. + $ast = (new NodeTraverser(new NameResolver))->traverse($ast); + + $collector = new EventCollector($file); + (new NodeTraverser($collector))->traverse($ast); + + return $collector->usages(); + } +} diff --git a/src/Documentation/EventInventory.php b/src/Documentation/EventInventory.php new file mode 100644 index 0000000..5f86027 --- /dev/null +++ b/src/Documentation/EventInventory.php @@ -0,0 +1,139 @@ + $events + */ + private function __construct(public array $events) {} + + public static function build(Snapshot $snapshot, EventExtractor $extractor = new EventExtractor): self + { + $modules = $snapshot->context->modules; + $usages = []; + + foreach ($snapshot->analyses as $analysis) { + if ($analysis->error === null) { + array_push($usages, ...$extractor->extract($analysis->file)); + } + } + + $events = []; + + foreach ($usages as $usage) { + $module = $modules->forClass($usage->event); + + if ($module !== null && ($usage->explicit || self::inEventsNamespace($usage->event, $module))) { + $events[strtolower($usage->event)] ??= $usage->event; + } + } + + foreach ($snapshot->analyses as $analysis) { + foreach ($analysis->declarations as $declaration) { + $module = $modules->forClass($declaration->fqcn); + + if ($module !== null && self::inEventsNamespace($declaration->fqcn, $module)) { + $events[strtolower($declaration->fqcn)] ??= $declaration->fqcn; + } + } + } + + ksort($events); + $entries = []; + + foreach ($events as $key => $fqcn) { + $matching = array_values(array_filter($usages, static fn (EventUsage $usage): bool => strtolower($usage->event) === $key)); + + $entries[] = new EventEntry( + $fqcn, + $modules->forClass($fqcn)->name ?? '', + self::withModules($modules, array_values(array_filter($matching, static fn (EventUsage $u): bool => $u->kind === EventUsage::DISPATCH))), + self::withModules($modules, array_values(array_filter($matching, static fn (EventUsage $u): bool => $u->kind === EventUsage::LISTEN))), + ); + } + + return new self($entries); + } + + /** + * Events a module dispatches (its own or other modules'). + * + * @return list + */ + public function publishedBy(string $module): array + { + return array_values(array_filter($this->events, static fn (EventEntry $entry): bool => in_array($module, array_column($entry->publishers, 'module'), true))); + } + + /** + * Events a module listens to. + * + * @return list + */ + public function listenedBy(string $module): array + { + return array_values(array_filter($this->events, static fn (EventEntry $entry): bool => in_array($module, array_column($entry->listeners, 'module'), true))); + } + + /** + * Events that belong to a module. + * + * @return list + */ + public function ownedBy(string $module): array + { + return array_values(array_filter($this->events, static fn (EventEntry $entry): bool => $entry->module === $module)); + } + + private static function inEventsNamespace(string $fqcn, Module $module): bool + { + $relative = $module->relativeName($fqcn); + + return str_starts_with($relative, 'Events\\') || str_contains($relative, '\\Events\\'); + } + + /** + * @param list $usages + * @return list + */ + private static function withModules(ModuleMap $modules, array $usages): array + { + $rows = []; + $seen = []; + + foreach ($usages as $usage) { + $key = $usage->file.'|'.$usage->sourceClass; + + if (isset($seen[$key])) { + continue; + } + + $seen[$key] = true; + $rows[] = [ + 'module' => $modules->forPath($usage->file)->name ?? '', + 'class' => $usage->sourceClass, + 'file' => $usage->file, + 'line' => $usage->line, + ]; + } + + return $rows; + } +} diff --git a/src/Documentation/EventUsage.php b/src/Documentation/EventUsage.php new file mode 100644 index 0000000..36d2128 --- /dev/null +++ b/src/Documentation/EventUsage.php @@ -0,0 +1,30 @@ +message()) !== null) { + $this->output->getErrorStyle()->writeln(''.$message.''); + } + + $modules = $verifier->modules(); + + if (count($modules) === 0) { + $this->warn('No modules found. Check the resolver settings in config/cordon.php.'); + + return self::SUCCESS; + } + + $output = $this->option('output'); + $directory = Paths::join($verifier->basePath(), is_string($output) && $output !== '' ? $output : 'docs/architecture'); + + $snapshot = $analyzer->snapshot($modules); + $result = $analyzer->check($snapshot, $verifier->basePath()); + $files = (new DocumentationGenerator)->generate($snapshot, $result->violations, EventInventory::build($snapshot), $verifier->basePath()); + + foreach ($files as $name => $contents) { + $path = $directory.DIRECTORY_SEPARATOR.$name; + + if (! is_dir(dirname($path)) && ! @mkdir(dirname($path), 0777, true) && ! is_dir(dirname($path))) { + $this->error(sprintf('Unable to create the directory [%s].', dirname($path))); + + return self::FAILURE; + } + + file_put_contents($path, $contents); + } + + $this->info(sprintf( + 'Documented %d modules in %s (%d files).', + count($modules), + Paths::relative($verifier->basePath(), $directory), + count($files), + )); + + return self::SUCCESS; + } +} diff --git a/src/Laravel/Commands/ModulesCommand.php b/src/Laravel/Commands/ModulesCommand.php new file mode 100644 index 0000000..d1ca7d4 --- /dev/null +++ b/src/Laravel/Commands/ModulesCommand.php @@ -0,0 +1,50 @@ +laravel->basePath(); + $modules = $resolver->resolve()->configure((array) $config->get('cordon.modules', [])); + + $this->line(sprintf('Resolver: %s', ResolverFactory::driver($config, $basePath))); + + if (count($modules) === 0) { + $this->warn('No modules found. Check the resolver settings in config/cordon.php.'); + + return self::SUCCESS; + } + + $this->table( + ['Module', 'Namespace', 'Path', 'Depends on', 'Open'], + array_map(static fn (Module $module): array => [ + $module->name, + $module->namespace, + (string) Paths::relative($basePath, $module->path), + match (true) { + $module->dependsOn === null => '(not declared)', + $module->dependsOn === [] => '(none)', + default => implode(', ', $module->dependsOn), + }, + $module->open ? 'yes' : 'no', + ], $modules->all()), + ); + + return self::SUCCESS; + } +} diff --git a/src/Laravel/Commands/VerifyCommand.php b/src/Laravel/Commands/VerifyCommand.php new file mode 100644 index 0000000..715709b --- /dev/null +++ b/src/Laravel/Commands/VerifyCommand.php @@ -0,0 +1,115 @@ +option('format'); + $format = is_string($format) ? $format : 'text'; + $reporter = $this->reporter($format); + + if ($reporter === null) { + $this->error(sprintf('Unknown format [%s]. Use text, json or github.', $format)); + + return self::INVALID; + } + + if (($message = $xdebug->message()) !== null) { + $this->output->getErrorStyle()->writeln(''.$message.''); + } + + foreach ($verifier->configurationWarnings() as $warning) { + $this->output->getErrorStyle()->writeln(''.$warning.''); + } + + $modules = $verifier->modules(); + + if (count($modules) === 0) { + $this->output->getErrorStyle()->writeln('No modules found. Check the resolver settings in config/cordon.php (php artisan cordon:modules).'); + + return self::SUCCESS; + } + + $only = array_values(array_map('strval', (array) $this->option('module'))); + + foreach ($only as $name) { + if (! $modules->has($name)) { + $this->error(sprintf('Unknown module [%s]. Run php artisan cordon:modules to list the detected modules.', $name)); + + return self::INVALID; + } + } + + if ($only !== [] && $this->option('generate-baseline')) { + $this->error('The --module option cannot be combined with --generate-baseline: the baseline must cover every module.'); + + return self::INVALID; + } + + $result = $verifier->analyze(); + $basePath = $verifier->basePath(); + + if ($this->option('generate-baseline')) { + $baselineFile = $verifier->baselineFile(); + Baseline::fromViolations($result->violations, $basePath)->save($baselineFile); + + $this->info(sprintf( + 'Baseline written to %s with %d violation(s).', + Paths::relative($basePath, $baselineFile), + count($result->violations), + )); + + return self::SUCCESS; + } + + if (! $this->option('no-baseline')) { + $result = $verifier->applyBaseline($result); + } + + if ($only !== []) { + $result = ModuleFilter::apply($result, $only); + } + + $this->output->write( + $reporter->render($result), + false, + $reporter->formatted() ? OutputInterface::OUTPUT_NORMAL : OutputInterface::OUTPUT_RAW, + ); + + return $result->hasViolations() ? self::FAILURE : self::SUCCESS; + } + + private function reporter(string $format): ?Reporter + { + return match ($format) { + 'text' => new TextReporter, + 'json' => new JsonReporter, + 'github' => new GithubReporter, + default => null, + }; + } +} diff --git a/src/Laravel/CordonServiceProvider.php b/src/Laravel/CordonServiceProvider.php new file mode 100644 index 0000000..8efde10 --- /dev/null +++ b/src/Laravel/CordonServiceProvider.php @@ -0,0 +1,77 @@ +mergeConfigFrom(__DIR__.'/../../config/cordon.php', 'cordon'); + + $this->app->bind(ModuleResolver::class, function ($app): ModuleResolver { + return ResolverFactory::make($app->make(Repository::class), $app->basePath()); + }); + + $this->app->bind(DependencyExtractor::class, fn (): DependencyExtractor => new PhpParserExtractor); + + $this->app->bind(Analyzer::class, function ($app): Analyzer { + /** @var Repository $config */ + $config = $app->make(Repository::class); + + return new Analyzer( + $app->make(DependencyExtractor::class), + new FileCollector(self::strings($config->get('cordon.exclude', ['vendor', 'node_modules']))), + new PublicApiPolicy(self::strings($config->get('cordon.public_namespaces', []))), + RuleSet::fromConfig((array) $config->get('cordon.rules', []), fn (string $class): Rule => $app->make($class)), + ); + }); + + $this->app->bind(Verifier::class, fn ($app): Verifier => new Verifier( + $app->make(ModuleResolver::class), + $app->make(Analyzer::class), + $app->make(Repository::class), + $app->basePath(), + )); + } + + public function boot(): void + { + if (! $this->app->runningInConsole()) { + return; + } + + $this->publishes([ + __DIR__.'/../../config/cordon.php' => $this->app->configPath('cordon.php'), + ], 'cordon-config'); + + $this->commands([ + VerifyCommand::class, + ModulesCommand::class, + DocsCommand::class, + ]); + } + + /** + * @return list + */ + private static function strings(mixed $value): array + { + return array_values(array_map('strval', array_filter((array) $value, 'is_scalar'))); + } +} diff --git a/src/Laravel/ProjectContainer.php b/src/Laravel/ProjectContainer.php new file mode 100644 index 0000000..07e43c9 --- /dev/null +++ b/src/Laravel/ProjectContainer.php @@ -0,0 +1,68 @@ +join($this->basePath, $path); + } + + public function path(string $path = ''): string + { + return $this->join($this->basePath('app'), $path); + } + + public function bootstrapPath(string $path = ''): string + { + return $this->join($this->basePath('bootstrap'), $path); + } + + public function configPath(string $path = ''): string + { + return $this->join($this->basePath('config'), $path); + } + + public function databasePath(string $path = ''): string + { + return $this->join($this->basePath('database'), $path); + } + + public function langPath(string $path = ''): string + { + return $this->join($this->basePath('lang'), $path); + } + + public function publicPath(string $path = ''): string + { + return $this->join($this->basePath('public'), $path); + } + + public function resourcePath(string $path = ''): string + { + return $this->join($this->basePath('resources'), $path); + } + + public function storagePath(string $path = ''): string + { + return $this->join($this->basePath('storage'), $path); + } + + private function join(string $base, string $path): string + { + return $path === '' ? $base : $base.DIRECTORY_SEPARATOR.ltrim($path, '/\\'); + } +} diff --git a/src/Laravel/ResolverFactory.php b/src/Laravel/ResolverFactory.php new file mode 100644 index 0000000..e3c7608 --- /dev/null +++ b/src/Laravel/ResolverFactory.php @@ -0,0 +1,78 @@ + new NamespaceResolver( + Paths::join($basePath, self::string($config->get('cordon.resolvers.namespace.path'), 'app/Modules')), + self::string($config->get('cordon.resolvers.namespace.namespace'), 'App\\Modules'), + ), + 'nwidart' => new NwidartResolver( + self::nwidartPath($config, $basePath), + self::string($config->get('cordon.resolvers.nwidart.namespace') ?? $config->get('modules.namespace'), 'Modules'), + ), + 'internachi' => new InternachiResolver(self::internachiPath($config, $basePath)), + default => throw new InvalidArgumentException(sprintf( + 'Unknown Cordon resolver [%s]. Use auto, namespace, nwidart or internachi.', + $driver, + )), + }; + } + + /** + * The configured resolver, with "auto" replaced by the detected one. + */ + public static function driver(Repository $config, string $basePath): string + { + $driver = self::string($config->get('cordon.resolver'), 'auto'); + + if ($driver !== 'auto') { + return $driver; + } + + if (NwidartResolver::detect(self::nwidartPath($config, $basePath))) { + return 'nwidart'; + } + + if (is_dir(self::internachiPath($config, $basePath))) { + return 'internachi'; + } + + return 'namespace'; + } + + private static function nwidartPath(Repository $config, string $basePath): string + { + $path = $config->get('cordon.resolvers.nwidart.path') ?? $config->get('modules.paths.modules'); + + return Paths::join($basePath, self::string($path, 'Modules')); + } + + private static function internachiPath(Repository $config, string $basePath): string + { + $path = $config->get('cordon.resolvers.internachi.path') ?? $config->get('app-modules.modules_directory'); + + return Paths::join($basePath, self::string($path, 'app-modules')); + } + + private static function string(mixed $value, string $default): string + { + return is_string($value) && $value !== '' ? $value : $default; + } +} diff --git a/src/Laravel/StandaloneConfig.php b/src/Laravel/StandaloneConfig.php new file mode 100644 index 0000000..f54e851 --- /dev/null +++ b/src/Laravel/StandaloneConfig.php @@ -0,0 +1,164 @@ + + */ +final class StandaloneConfig implements ArrayAccess, Repository +{ + /** + * @param array $items + * @param list $warnings + */ + public function __construct(private array $items = [], private array $warnings = []) {} + + public static function load(string $basePath): self + { + $warnings = []; + $defaults = self::requireArray(dirname(__DIR__, 2).'/config/cordon.php', $basePath) ?? []; + $cordon = self::requireArray($basePath.'/config/cordon.php', $basePath, $warnings) ?? []; + + $items = ['cordon' => array_replace($defaults, $cordon)]; + + foreach (['modules', 'app-modules'] as $name) { + $config = self::requireArray($basePath.'/config/'.$name.'.php', $basePath, $warnings); + + if ($config !== null) { + $items[$name] = $config; + } + } + + return new self($items, $warnings); + } + + /** + * Project config files that could not be evaluated without the framework. + * Each message states the problem and the fix. + * + * @return list + */ + public function warnings(): array + { + return $this->warnings; + } + + public function has($key): bool + { + return Arr::has($this->items, $key); + } + + /** + * @param array|string $key + */ + public function get($key, $default = null): mixed + { + if (is_array($key)) { + return array_map(fn ($k) => Arr::get($this->items, $k), $key); + } + + return Arr::get($this->items, $key, $default); + } + + /** + * @return array + */ + public function all(): array + { + return $this->items; + } + + /** + * @param array|string $key + */ + public function set($key, $value = null): void + { + $values = is_array($key) ? $key : [$key => $value]; + + foreach ($values as $k => $v) { + Arr::set($this->items, (string) $k, $v); + } + } + + public function prepend($key, $value): void + { + $array = (array) $this->get($key, []); + array_unshift($array, $value); + $this->set($key, $array); + } + + public function push($key, $value): void + { + $array = (array) $this->get($key, []); + $array[] = $value; + $this->set($key, $array); + } + + public function offsetExists(mixed $offset): bool + { + return $this->has((string) $offset); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->get((string) $offset); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->set((string) $offset, $value); + } + + public function offsetUnset(mixed $offset): void + { + $this->set((string) $offset, null); + } + + /** + * Config files may call framework helpers. base_path() and friends resolve + * against $basePath through a bare container (nothing is booted or + * autoloaded from the project). When a file still cannot be evaluated, + * a warning is recorded and the resolvers fall back to their defaults. + * + * @param list $warnings + * @return array|null + */ + private static function requireArray(string $file, string $basePath, array &$warnings = []): ?array + { + if (! is_file($file)) { + return null; + } + + $previous = Container::getInstance(); + Container::setInstance(new ProjectContainer($basePath)); + + try { + $config = (static fn (): mixed => require $file)(); + } catch (Throwable $e) { + $warnings[] = sprintf( + 'Cordon could not evaluate config/%s (%s), so its settings were ignored and the defaults are used. Set the matching paths in config/cordon.php (for example resolvers.nwidart.path) so it does not depend on that file.', + basename($file), + $e->getMessage(), + ); + + return null; + } finally { + Container::setInstance($previous); + } + + return is_array($config) ? $config : null; + } +} diff --git a/src/Laravel/Verifier.php b/src/Laravel/Verifier.php new file mode 100644 index 0000000..2c2ccfb --- /dev/null +++ b/src/Laravel/Verifier.php @@ -0,0 +1,90 @@ +basePath; + } + + /** + * Modules with their per-module settings applied. + */ + public function modules(): ModuleMap + { + return $this->modules ??= $this->resolver->resolve()->configure($this->moduleConfig()); + } + + /** + * Problems in the per-module config, such as unknown module names. + * + * @return list + */ + public function configurationWarnings(): array + { + return $this->resolver->resolve()->configurationWarnings($this->moduleConfig()); + } + + /** + * Every violation, ignoring the baseline. + */ + public function analyze(): Result + { + return $this->analyzer->analyze($this->modules(), $this->basePath); + } + + /** + * Violations that are not recorded in the baseline file, when there is one. + */ + public function verify(): Result + { + return $this->applyBaseline($this->analyze()); + } + + public function applyBaseline(Result $result): Result + { + $file = $this->baselineFile(); + + return is_file($file) ? $result->withBaseline(Baseline::load($file)) : $result; + } + + public function baselineFile(): string + { + $file = $this->config->get('cordon.baseline', 'cordon-baseline.json'); + + return Paths::join($this->basePath, is_string($file) && $file !== '' ? $file : 'cordon-baseline.json'); + } + + /** + * @return array + */ + private function moduleConfig(): array + { + return (array) $this->config->get('cordon.modules', []); + } +} diff --git a/src/Laravel/XdebugWarning.php b/src/Laravel/XdebugWarning.php new file mode 100644 index 0000000..3b8d134 --- /dev/null +++ b/src/Laravel/XdebugWarning.php @@ -0,0 +1,48 @@ +loaded = $loaded ?? extension_loaded('xdebug'); + $this->mode = strtolower(trim($mode ?? self::detectMode())); + } + + public function isActive(): bool + { + return $this->loaded && ! in_array($this->mode, ['', 'off'], true); + } + + public function message(): ?string + { + return $this->isActive() + ? 'Xdebug is enabled, which makes the analysis several times slower. Run with XDEBUG_MODE=off to speed it up.' + : null; + } + + private static function detectMode(): string + { + $env = getenv('XDEBUG_MODE'); + + if (is_string($env) && trim($env) !== '') { + return $env; + } + + $ini = ini_get('xdebug.mode'); + + return is_string($ini) ? $ini : ''; + } +} diff --git a/src/Module/Module.php b/src/Module/Module.php new file mode 100644 index 0000000..0aa357f --- /dev/null +++ b/src/Module/Module.php @@ -0,0 +1,74 @@ +|null $dependsOn Declared dependencies; null means "not declared" (not enforced). + * @param list $publicApi Extra namespaces or classes, relative to the module namespace, exposed as public API. + */ + public function __construct( + public string $name, + public string $namespace, + public string $path, + public ?array $dependsOn = null, + public array $publicApi = [], + public bool $open = false, + ) {} + + public static function make(string $name, string $namespace, string $path): self + { + return new self($name, trim($namespace, '\\'), rtrim($path, '/\\')); + } + + public function ownsClass(string $fqcn): bool + { + return str_starts_with(ltrim($fqcn, '\\'), $this->namespace.'\\'); + } + + public function ownsPath(string $file): bool + { + $file = str_replace('\\', '/', $file); + $path = rtrim(str_replace('\\', '/', $this->path), '/'); + + return str_starts_with($file, $path.'/'); + } + + /** + * The class name relative to the module namespace, e.g. "Models\Product". + */ + public function relativeName(string $fqcn): string + { + return substr(ltrim($fqcn, '\\'), strlen($this->namespace) + 1); + } + + /** + * @param array $config + */ + public function withConfig(array $config): self + { + $dependsOn = $this->dependsOn; + + if (array_key_exists('depends_on', $config)) { + $dependsOn = is_array($config['depends_on']) + ? array_values(array_map('strval', $config['depends_on'])) + : null; + } + + $publicApi = isset($config['public']) && is_array($config['public']) + ? array_values(array_map('strval', $config['public'])) + : $this->publicApi; + + return new self( + $this->name, + $this->namespace, + $this->path, + $dependsOn, + $publicApi, + (bool) ($config['open'] ?? $this->open), + ); + } +} diff --git a/src/Module/ModuleMap.php b/src/Module/ModuleMap.php new file mode 100644 index 0000000..3223b4c --- /dev/null +++ b/src/Module/ModuleMap.php @@ -0,0 +1,150 @@ + + */ +final class ModuleMap implements Countable, IteratorAggregate +{ + /** @var array */ + private array $modules = []; + + /** + * @param iterable $modules + */ + public function __construct(iterable $modules = []) + { + foreach ($modules as $module) { + $this->modules[$module->name] = $module; + } + + ksort($this->modules); + } + + public function get(string $name): ?Module + { + return $this->modules[$name] ?? null; + } + + public function has(string $name): bool + { + return isset($this->modules[$name]); + } + + /** + * @return list + */ + public function all(): array + { + return array_values($this->modules); + } + + /** + * @return list + */ + public function names(): array + { + return array_map(static fn (Module $module): string => $module->name, $this->all()); + } + + /** + * The module owning a class, by longest matching namespace. + */ + public function forClass(string $fqcn): ?Module + { + $match = null; + + foreach ($this->modules as $module) { + if ($module->ownsClass($fqcn) && ($match === null || strlen($module->namespace) > strlen($match->namespace))) { + $match = $module; + } + } + + return $match; + } + + /** + * The module owning a file, by longest matching path. + */ + public function forPath(string $file): ?Module + { + $match = null; + + foreach ($this->modules as $module) { + if ($module->ownsPath($file) && ($match === null || strlen($module->path) > strlen($match->path))) { + $match = $module; + } + } + + return $match; + } + + /** + * Apply per-module settings (depends_on, public, open) from config. + * + * @param array $config + */ + public function configure(array $config): self + { + $modules = []; + + foreach ($this->modules as $module) { + $settings = $config[$module->name] ?? null; + $modules[] = is_array($settings) ? $module->withConfig($settings) : $module; + } + + return new self($modules); + } + + /** + * Human readable problems in the per-module config (unknown module names). + * + * @param array $config + * @return list + */ + public function configurationWarnings(array $config): array + { + $warnings = []; + + foreach ($config as $name => $settings) { + $name = (string) $name; + + if (! $this->has($name)) { + $warnings[] = sprintf('config/cordon.php references unknown module [%s].', $name); + + continue; + } + + $dependsOn = is_array($settings) && is_array($settings['depends_on'] ?? null) ? $settings['depends_on'] : []; + + foreach ($dependsOn as $dependency) { + if (! $this->has((string) $dependency)) { + $warnings[] = sprintf('Module [%s] declares a dependency on unknown module [%s].', $name, (string) $dependency); + } + } + } + + return $warnings; + } + + public function count(): int + { + return count($this->modules); + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->all()); + } +} diff --git a/src/PHPStan/InternalAccessRule.php b/src/PHPStan/InternalAccessRule.php new file mode 100644 index 0000000..988a061 --- /dev/null +++ b/src/PHPStan/InternalAccessRule.php @@ -0,0 +1,174 @@ + + */ +final class InternalAccessRule implements Rule +{ + private ?ModuleMap $modules = null; + + private ?PublicApiPolicy $policy = null; + + /** @var list */ + private array $exclude = []; + + private bool $enabled = true; + + /** @var list */ + private array $pendingWarnings = []; + + public function __construct( + private readonly ReflectionProvider $reflectionProvider, + private readonly string $basePath, + ) {} + + public function getNodeType(): string + { + return FileNode::class; + } + + /** + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + [$modules, $policy] = $this->boot(); + + $errors = []; + + // Reported once, on the first analysed file, so a broken config is not silent. + foreach ($this->pendingWarnings as $warning) { + $errors[] = RuleErrorBuilder::message($warning)->identifier('cordon.configuration')->line(1)->build(); + } + + $this->pendingWarnings = []; + + $file = $scope->getFile(); + $from = $modules->forPath($file); + + if (! $this->enabled || $from === null || $this->isExcluded($from, $file)) { + return $errors; + } + + $reported = []; + + foreach ((new PhpParserExtractor)->extract($file)->references as $reference) { + $target = $reference->target; + $to = $modules->forClass($target); + + if ($to === null || $to->name === $from->name || isset($reported[$target])) { + continue; + } + + if ($policy->isPublic($target, $to, $this->declaration($target))) { + continue; + } + + $reported[$target] = true; + $errors[] = RuleErrorBuilder::message(sprintf( + 'Module [%s] uses %s, which is internal to module [%s].', + $from->name, + $target, + $to->name, + )) + ->identifier('cordon.internalAccess') + ->line($reference->line) + ->tip('Depend on its public API instead: a class in a public namespace such as Contracts, or one marked #[PublicApi].') + ->build(); + } + + return $errors; + } + + /** + * @return array{0: ModuleMap, 1: PublicApiPolicy} + */ + private function boot(): array + { + if ($this->modules === null || $this->policy === null) { + $config = StandaloneConfig::load($this->basePath); + $this->pendingWarnings = $config->warnings(); + + $this->modules = ResolverFactory::make($config, $this->basePath) + ->resolve() + ->configure((array) $config->get('cordon.modules', [])); + $this->policy = new PublicApiPolicy(self::strings($config->get('cordon.public_namespaces', []))); + $this->exclude = self::strings($config->get('cordon.exclude', ['vendor', 'node_modules'])); + $this->enabled = $config->get('cordon.rules.'.CordonInternalAccessRule::ID, true) !== false; + } + + return [$this->modules, $this->policy]; + } + + private function isExcluded(Module $module, string $file): bool + { + $relative = (string) Paths::relative($module->path, $file); + $directories = array_slice(explode('/', $relative), 0, -1); + + return array_intersect($directories, $this->exclude) !== []; + } + + private function declaration(string $class): ?ClassDeclaration + { + if (! $this->reflectionProvider->hasClass($class)) { + return null; + } + + $native = $this->reflectionProvider->getClass($class)->getNativeReflection(); + $visibility = DeclaredVisibility::Unspecified; + + foreach ($native->getAttributes() as $attribute) { + $name = ltrim($attribute->getName(), '\\'); + + if ($name === Internal::class) { + $visibility = DeclaredVisibility::Internal; + + break; + } + + if ($name === PublicApi::class) { + $visibility = DeclaredVisibility::PublicApi; + } + } + + return new ClassDeclaration($class, (string) $native->getFileName(), (int) $native->getStartLine(), $visibility); + } + + /** + * @return list + */ + private static function strings(mixed $value): array + { + return array_values(array_map('strval', array_filter((array) $value, 'is_scalar'))); + } +} diff --git a/src/Reporters/GithubReporter.php b/src/Reporters/GithubReporter.php new file mode 100644 index 0000000..d62b349 --- /dev/null +++ b/src/Reporters/GithubReporter.php @@ -0,0 +1,62 @@ +violations as $violation) { + $properties = []; + + if ($violation->file !== null) { + $properties['file'] = (string) Paths::relative($result->basePath, $violation->file); + $properties['line'] = (string) ($violation->line ?? 1); + } + + $properties['title'] = 'Cordon: '.$violation->rule; + + $encoded = []; + + foreach ($properties as $name => $value) { + $encoded[] = $name.'='.self::escapeProperty($value); + } + + $lines[] = sprintf('::error %s::%s', implode(',', $encoded), self::escapeData($violation->message)); + } + + $lines[] = sprintf( + 'Cordon: %d violation(s), %d suppressed by the baseline.', + count($result->violations), + $result->suppressed, + ); + + return implode("\n", $lines)."\n"; + } + + private static function escapeData(string $value): string + { + return str_replace(['%', "\r", "\n"], ['%25', '%0D', '%0A'], $value); + } + + private static function escapeProperty(string $value): string + { + return str_replace(['%', "\r", "\n", ':', ','], ['%25', '%0D', '%0A', '%3A', '%2C'], $value); + } +} diff --git a/src/Reporters/JsonReporter.php b/src/Reporters/JsonReporter.php new file mode 100644 index 0000000..7a75a64 --- /dev/null +++ b/src/Reporters/JsonReporter.php @@ -0,0 +1,43 @@ +parseErrors as $file => $error) { + $parseErrors[] = ['file' => Paths::relative($result->basePath, $file), 'error' => $error]; + } + + return json_encode([ + 'summary' => [ + 'modules' => count($result->modules), + 'files' => $result->filesAnalysed, + 'cross_module_references' => $result->crossModuleReferences, + 'violations' => count($result->violations), + 'suppressed' => $result->suppressed, + 'parse_errors' => count($parseErrors), + ], + 'violations' => array_map( + static fn (Violation $violation): array => $violation->toArray($result->basePath), + $result->violations, + ), + 'parse_errors' => $parseErrors, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR).PHP_EOL; + } +} diff --git a/src/Reporters/TextReporter.php b/src/Reporters/TextReporter.php new file mode 100644 index 0000000..53b6210 --- /dev/null +++ b/src/Reporters/TextReporter.php @@ -0,0 +1,70 @@ +Cordon analysed %d files in %d modules (%d cross-module references).', + $result->filesAnalysed, + count($result->modules), + $result->crossModuleReferences, + ), + '', + ]; + + foreach ($result->violations as $violation) { + $location = ''; + + if ($violation->file !== null) { + $location = ' '.OutputFormatter::escape((string) Paths::relative($result->basePath, $violation->file)) + .($violation->line !== null ? ':'.$violation->line : ''); + } + + $lines[] = sprintf('x [%s]%s', $violation->rule, $location); + $lines[] = ' '.OutputFormatter::escape($violation->message); + $lines[] = ''; + } + + foreach ($result->parseErrors as $file => $error) { + $lines[] = sprintf( + '! Skipped %s (parse error: %s)', + OutputFormatter::escape((string) Paths::relative($result->basePath, $file)), + OutputFormatter::escape($error), + ); + } + + if ($result->parseErrors !== []) { + $lines[] = ''; + } + + $count = count($result->violations); + + $summary = $count === 0 + ? 'No boundary violations.' + : sprintf('%d boundary %s.', $count, $count === 1 ? 'violation' : 'violations'); + + if ($result->suppressed > 0) { + $summary .= sprintf(' (%d suppressed by the baseline)', $result->suppressed); + } + + $lines[] = $summary; + + return implode(PHP_EOL, $lines).PHP_EOL; + } +} diff --git a/src/Resolvers/InternachiResolver.php b/src/Resolvers/InternachiResolver.php new file mode 100644 index 0000000..a11a4d9 --- /dev/null +++ b/src/Resolvers/InternachiResolver.php @@ -0,0 +1,63 @@ + Modules\Billing (module name "billing"). + */ +final readonly class InternachiResolver implements ModuleResolver +{ + public function __construct(private string $path) {} + + public function resolve(): ModuleMap + { + $modules = []; + + foreach (Paths::childDirectories($this->path) as $directory) { + $namespace = $this->namespaceFrom($directory.DIRECTORY_SEPARATOR.'composer.json'); + + if ($namespace !== null) { + $modules[] = Module::make(basename($directory), $namespace, $directory); + } + } + + return new ModuleMap($modules); + } + + private function namespaceFrom(string $composerFile): ?string + { + if (! is_file($composerFile)) { + return null; + } + + try { + $composer = json_decode((string) file_get_contents($composerFile), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + $psr4 = is_array($composer) ? ($composer['autoload']['psr-4'] ?? null) : null; + + if (! is_array($psr4) || $psr4 === []) { + return null; + } + + foreach ($psr4 as $namespace => $directory) { + if (is_string($directory) && trim($directory, '/') === 'src') { + return trim((string) $namespace, '\\'); + } + } + + return trim((string) array_key_first($psr4), '\\'); + } +} diff --git a/src/Resolvers/NamespaceResolver.php b/src/Resolvers/NamespaceResolver.php new file mode 100644 index 0000000..11d08a7 --- /dev/null +++ b/src/Resolvers/NamespaceResolver.php @@ -0,0 +1,34 @@ + App\Modules\Billing. + */ +final readonly class NamespaceResolver implements ModuleResolver +{ + public function __construct( + private string $path, + private string $namespace, + ) {} + + public function resolve(): ModuleMap + { + $modules = []; + + foreach (Paths::childDirectories($this->path) as $directory) { + $name = basename($directory); + $modules[] = Module::make($name, trim($this->namespace, '\\').'\\'.$name, $directory); + } + + return new ModuleMap($modules); + } +} diff --git a/src/Resolvers/NwidartResolver.php b/src/Resolvers/NwidartResolver.php new file mode 100644 index 0000000..98b5be5 --- /dev/null +++ b/src/Resolvers/NwidartResolver.php @@ -0,0 +1,49 @@ + Modules\Blog. + */ +final readonly class NwidartResolver implements ModuleResolver +{ + public function __construct( + private string $path, + private string $namespace = 'Modules', + ) {} + + public function resolve(): ModuleMap + { + $modules = []; + + foreach (Paths::childDirectories($this->path) as $directory) { + if (! is_file($directory.DIRECTORY_SEPARATOR.'module.json')) { + continue; + } + + $name = basename($directory); + $modules[] = Module::make($name, trim($this->namespace, '\\').'\\'.$name, $directory); + } + + return new ModuleMap($modules); + } + + public static function detect(string $path): bool + { + foreach (Paths::childDirectories($path) as $directory) { + if (is_file($directory.DIRECTORY_SEPARATOR.'module.json')) { + return true; + } + } + + return false; + } +} diff --git a/src/Rules/CycleRule.php b/src/Rules/CycleRule.php new file mode 100644 index 0000000..a8fa883 --- /dev/null +++ b/src/Rules/CycleRule.php @@ -0,0 +1,164 @@ + B -> A). + */ +final class CycleRule implements Rule +{ + public const ID = 'cycles'; + + public function id(): string + { + return self::ID; + } + + public function check(AnalysisContext $context): iterable + { + $graph = $this->graph($context); + + foreach ($this->stronglyConnectedComponents($graph) as $component) { + if (count($component) < 2) { + continue; + } + + sort($component); + $path = implode(' -> ', $this->shortestCycle($component[0], $graph, array_flip($component))); + + yield new Violation( + self::ID, + sprintf( + 'Modules [%s] form a dependency cycle: %s. Break it by inverting one dependency, for example with an event or a contract.', + implode(', ', $component), + $path, + ), + null, + null, + $component[0], + null, + $path, + ); + } + } + + /** + * @return array> + */ + private function graph(AnalysisContext $context): array + { + $targets = []; + + foreach ($context->modules->names() as $name) { + $targets[$name] = []; + } + + foreach ($context->edges as $edge) { + $targets[$edge->from->name][$edge->to->name] = true; + } + + $graph = []; + + foreach ($targets as $name => $neighbours) { + $neighbours = array_map('strval', array_keys($neighbours)); + sort($neighbours); + $graph[(string) $name] = $neighbours; + } + + return $graph; + } + + /** + * Tarjan's algorithm. + * + * @param array> $graph + * @return list> + */ + private function stronglyConnectedComponents(array $graph): array + { + $index = 0; + $stack = []; + $onStack = []; + $indices = []; + $lowLinks = []; + $components = []; + + $connect = function (string $node) use (&$connect, &$index, &$stack, &$onStack, &$indices, &$lowLinks, &$components, $graph): void { + $indices[$node] = $index; + $lowLinks[$node] = $index; + $index++; + $stack[] = $node; + $onStack[$node] = true; + + foreach ($graph[$node] ?? [] as $next) { + if (! isset($indices[$next])) { + $connect($next); + $lowLinks[$node] = min($lowLinks[$node], $lowLinks[$next]); + } elseif (isset($onStack[$next])) { + $lowLinks[$node] = min($lowLinks[$node], $indices[$next]); + } + } + + if ($lowLinks[$node] === $indices[$node]) { + $component = []; + + do { + $member = (string) array_pop($stack); + unset($onStack[$member]); + $component[] = $member; + } while ($member !== $node); + + $components[] = $component; + } + }; + + foreach (array_keys($graph) as $node) { + if (! isset($indices[(string) $node])) { + $connect((string) $node); + } + } + + return $components; + } + + /** + * Shortest path from $start back to itself inside one component (breadth first). + * + * @param array> $graph + * @param array $members + * @return list + */ + private function shortestCycle(string $start, array $graph, array $members): array + { + $queue = [[$start]]; + $visited = [$start => true]; + + while ($queue !== []) { + $path = array_shift($queue); + $node = $path[array_key_last($path)]; + + foreach ($graph[$node] ?? [] as $next) { + if (! isset($members[$next])) { + continue; + } + + if ($next === $start) { + return [...$path, $start]; + } + + if (! isset($visited[$next])) { + $visited[$next] = true; + $queue[] = [...$path, $next]; + } + } + } + + return [...array_map('strval', array_keys($members)), $start]; + } +} diff --git a/src/Rules/InternalAccessRule.php b/src/Rules/InternalAccessRule.php new file mode 100644 index 0000000..cd75f45 --- /dev/null +++ b/src/Rules/InternalAccessRule.php @@ -0,0 +1,53 @@ +edges as $edge) { + $target = $edge->reference->target; + $key = $edge->reference->sourceFile.'|'.$target; + + if (isset($reported[$key]) || $context->policy->isPublic($target, $edge->to, $context->symbols->get($target))) { + continue; + } + + $reported[$key] = true; + + yield new Violation( + self::ID, + sprintf( + 'Module [%s] uses %s, which is internal to module [%s]. Depend on its public API instead (a class in a public namespace such as Contracts, or one marked #[PublicApi]).', + $edge->from->name, + $target, + $edge->to->name, + ), + $edge->reference->sourceFile, + $edge->reference->line, + $edge->from->name, + $edge->to->name, + $target, + ); + } + } +} diff --git a/src/Rules/RuleSet.php b/src/Rules/RuleSet.php new file mode 100644 index 0000000..0d03399 --- /dev/null +++ b/src/Rules/RuleSet.php @@ -0,0 +1,79 @@ + MyRule::class]. + * + * @param array $config e.g. ['cycles' => false, MyRule::class] + * @param (Closure(class-string): Rule)|null $make Builds custom rules (the Laravel container in the service provider). + * @return list + */ + public static function fromConfig(array $config = [], ?Closure $make = null): array + { + $available = [ + InternalAccessRule::ID => static fn (): Rule => new InternalAccessRule, + UndeclaredDependencyRule::ID => static fn (): Rule => new UndeclaredDependencyRule, + CycleRule::ID => static fn (): Rule => new CycleRule, + ]; + + $rules = []; + + foreach ($available as $id => $factory) { + if (($config[$id] ?? true) !== false) { + $rules[] = $factory(); + } + } + + $ids = array_fill_keys(array_keys($available), true); + + foreach ($config as $key => $value) { + if (! is_string($value)) { + continue; + } + + $rule = self::custom($value, $make); + + if (isset($ids[$rule->id()])) { + throw new InvalidArgumentException(sprintf( + 'Custom rule [%s] uses the id [%s], which is already taken. Return a unique id from %s::id().', + $value, + $rule->id(), + $value, + )); + } + + $ids[$rule->id()] = true; + $rules[] = $rule; + } + + return $rules; + } + + /** + * @param (Closure(class-string): Rule)|null $make + */ + private static function custom(string $class, ?Closure $make): Rule + { + if (! class_exists($class) || ! is_subclass_of($class, Rule::class)) { + throw new InvalidArgumentException(sprintf( + 'Custom rule [%s] must be an existing class that implements %s.', + $class, + Rule::class, + )); + } + + return $make !== null ? $make($class) : new $class; + } +} diff --git a/src/Rules/UndeclaredDependencyRule.php b/src/Rules/UndeclaredDependencyRule.php new file mode 100644 index 0000000..8778300 --- /dev/null +++ b/src/Rules/UndeclaredDependencyRule.php @@ -0,0 +1,60 @@ +edges as $edge) { + $declared = $edge->from->dependsOn; + + if ($declared === null || in_array($edge->to->name, $declared, true)) { + continue; + } + + $key = $edge->reference->sourceFile.'|'.$edge->to->name; + + if (isset($reported[$key])) { + continue; + } + + $reported[$key] = true; + + yield new Violation( + self::ID, + sprintf( + 'Module [%s] depends on module [%s] (via %s), but [%s] is not listed in its depends_on.', + $edge->from->name, + $edge->to->name, + $edge->reference->target, + $edge->to->name, + ), + $edge->reference->sourceFile, + $edge->reference->line, + $edge->from->name, + $edge->to->name, + $edge->to->name, + ); + } + } +} diff --git a/src/Support/Paths.php b/src/Support/Paths.php new file mode 100644 index 0000000..4a6b64f --- /dev/null +++ b/src/Support/Paths.php @@ -0,0 +1,72 @@ + + */ + public static function childDirectories(string $path): array + { + if (! is_dir($path)) { + return []; + } + + $directories = []; + + foreach (new DirectoryIterator($path) as $item) { + if ($item->isDot() || ! $item->isDir() || str_starts_with($item->getFilename(), '.')) { + continue; + } + + $directories[] = $item->getPathname(); + } + + sort($directories); + + return $directories; + } +} diff --git a/src/Testing/BoundaryExpectation.php b/src/Testing/BoundaryExpectation.php new file mode 100644 index 0000000..8caa7c6 --- /dev/null +++ b/src/Testing/BoundaryExpectation.php @@ -0,0 +1,62 @@ +basePath), + ); + } + + /** + * @param list $violations + */ + private static function message(string $module, array $violations, ?string $basePath): string + { + $lines = [sprintf( + 'Module [%s] does not respect its boundaries (%d %s):', + $module, + count($violations), + count($violations) === 1 ? 'violation' : 'violations', + )]; + + foreach ($violations as $violation) { + $location = $violation->file === null + ? '' + : ' '.Paths::relative($basePath, $violation->file).($violation->line === null ? '' : ':'.$violation->line); + + $lines[] = sprintf(' - [%s]%s %s', $violation->rule, $location, $violation->message); + } + + return implode(PHP_EOL, $lines); + } +} diff --git a/src/Testing/Cordon.php b/src/Testing/Cordon.php new file mode 100644 index 0000000..96e8263 --- /dev/null +++ b/src/Testing/Cordon.php @@ -0,0 +1,91 @@ + + */ + public static function modules(): array + { + return self::verifier()->modules()->names(); + } + + /** + * The verification result with the baseline applied. + */ + public static function result(): Result + { + $verifier = self::verifier(); + $key = self::cacheKey($verifier); + + if (self::$result === null || self::$key !== $key) { + self::$result = $verifier->verify(); + self::$key = $key; + } + + return self::$result; + } + + /** + * Violations caused by a module, plus the dependency cycles it takes part in. + * + * @return list + */ + public static function violationsOf(string $module): array + { + return ModuleFilter::apply(self::result(), [$module])->violations; + } + + public static function flush(): void + { + self::$key = null; + self::$result = null; + } + + private static function verifier(): Verifier + { + $container = class_exists(Container::class) ? Container::getInstance() : null; + + if ($container === null || ! $container->bound(Verifier::class)) { + throw new LogicException('Cordon Modulith needs a booted Laravel application. Use the expectation in a test that extends your Tests\TestCase.'); + } + + return $container->make(Verifier::class); + } + + private static function cacheKey(Verifier $verifier): string + { + $baseline = $verifier->baselineFile(); + $config = Container::getInstance()->make(Repository::class)->get('cordon'); + + return implode('|', [ + $verifier->basePath(), + md5(serialize($config)), + is_file($baseline) ? (string) filemtime($baseline).':'.(string) filesize($baseline) : 'no-baseline', + ]); + } +} diff --git a/src/Testing/expectations.php b/src/Testing/expectations.php new file mode 100644 index 0000000..366f7b2 --- /dev/null +++ b/src/Testing/expectations.php @@ -0,0 +1,18 @@ +toRespectBoundaries() when Pest is installed. + * Loaded through Composer's "files" autoload; does nothing without Pest. + */ +if (class_exists(Expectation::class)) { + (new Expectation(null))->extend('toRespectBoundaries', function (): Expectation { + BoundaryExpectation::assert($this->value); + + return $this; + }); +} diff --git a/tests/Benchmark/run.php b/tests/Benchmark/run.php new file mode 100644 index 0000000..0ffa1ad --- /dev/null +++ b/tests/Benchmark/run.php @@ -0,0 +1,106 @@ +resolve(); +$analyzer = new Analyzer(new PhpParserExtractor, new FileCollector, new PublicApiPolicy, RuleSet::fromConfig([])); + +$start = hrtime(true); +$result = $analyzer->analyze($modules, $root); +$seconds = (hrtime(true) - $start) / 1e9; + +printf( + "%d files, %d modules, %d cross-module references, %d violations: %.2f s (%.2f ms/file, peak memory %.1f MB)\n", + $result->filesAnalysed, + count($modules), + $result->crossModuleReferences, + count($result->violations), + $seconds, + $seconds * 1000 / max(1, $result->filesAnalysed), + memory_get_peak_usage(true) / 1048576, +); + +if ($maxSeconds !== null && $seconds > $maxSeconds) { + fwrite(STDERR, sprintf("Slower than the %.1f s budget.\n", $maxSeconds)); + exit(1); +} + +/** + * Each file is a class of about 60 lines with references to its own module, + * to other modules' contracts and, now and then, to another module's internals. + */ +function generate(string $root, int $files, int $moduleCount): void +{ + if (is_file($root.'/.complete')) { + return; + } + + $perModule = (int) ceil($files / $moduleCount); + + for ($m = 0; $m < $moduleCount; $m++) { + $module = 'Module'.$m; + @mkdir($root."/app/Modules/{$module}/Contracts", 0777, true); + @mkdir($root."/app/Modules/{$module}/Services", 0777, true); + + for ($i = 0; $i < $perModule && ($m * $perModule + $i) < $files; $i++) { + $isContract = $i % 5 === 0; + $namespace = "App\\Modules\\{$module}\\".($isContract ? 'Contracts' : 'Services'); + $class = ($isContract ? 'Contract' : 'Service').$i; + $other = 'Module'.(($m + 1 + $i) % $moduleCount); + $contract = 'Contract'.(($i - $i % 5) % max(1, $perModule)); + $internal = $i % 17 === 0 ? "\\App\\Modules\\{$other}\\Services\\Service1" : "\\App\\Modules\\{$module}\\Services\\Service1"; + + $methods = ''; + + for ($k = 0; $k < 8; $k++) { + $methods .= << \$x * 2, range(0, \$value)); + + return count(\$items) > 100 ? new {$internal} : null; + } + + PHP; + } + + $keyword = $isContract ? 'interface' : 'final class'; + $body = $isContract ? " public function handle(): void;\n" : $methods; + + file_put_contents( + $root."/app/Modules/{$module}/".($isContract ? 'Contracts' : 'Services')."/{$class}.php", + "artisan('cordon:verify') + ->expectsOutputToContain('internal_access') + ->assertExitCode(1); +}); + +it('prints a machine readable report', function () { + $this->artisan('cordon:verify', ['--format' => 'json']) + ->expectsOutputToContain('"violations": 3') + ->assertExitCode(1); +}); + +it('prints GitHub annotations', function () { + $this->artisan('cordon:verify', ['--format' => 'github']) + ->expectsOutputToContain('::error file=') + ->assertExitCode(1); +}); + +it('rejects unknown formats', function () { + $this->artisan('cordon:verify', ['--format' => 'xml'])->assertExitCode(2); +}); + +it('passes after generating a baseline and fails again without it', function () { + $this->artisan('cordon:verify', ['--generate-baseline' => true])->assertExitCode(0); + + expect((string) config('cordon.baseline'))->toBeFile(); + + $this->artisan('cordon:verify') + ->expectsOutputToContain('suppressed by the baseline') + ->assertExitCode(0); + + $this->artisan('cordon:verify', ['--no-baseline' => true])->assertExitCode(1); +}); + +it('lists the detected modules', function () { + $this->artisan('cordon:modules') + ->expectsOutputToContain('Billing') + ->assertExitCode(0); +}); + +it('limits the report to one module with --module', function () { + $this->artisan('cordon:verify', ['--module' => ['Shared']]) + ->expectsOutputToContain('No boundary violations') + ->assertExitCode(0); + + $this->artisan('cordon:verify', ['--module' => ['Billing'], '--format' => 'json']) + ->expectsOutputToContain('"violations": 3') + ->assertExitCode(1); +}); + +it('keeps the dependency cycles a module takes part in', function () { + $exitCode = Artisan::call('cordon:verify', ['--module' => ['Catalog'], '--format' => 'json']); + $report = json_decode(Artisan::output(), true); + + expect($exitCode)->toBe(1) + ->and(array_column($report['violations'], 'rule'))->toBe(['cycles']) + ->and($report['violations'][0]['target'])->toBe('Billing -> Catalog -> Billing'); +}); + +it('rejects unknown modules and --module with --generate-baseline', function () { + $this->artisan('cordon:verify', ['--module' => ['Ghost']])->assertExitCode(2); + $this->artisan('cordon:verify', ['--module' => ['Billing'], '--generate-baseline' => true])->assertExitCode(2); + + expect((string) config('cordon.baseline'))->not->toBeFile(); +}); + +it('runs custom rules from the config', function () { + config()->set('cordon.rules', [DeclareDependenciesRule::class]); + + $this->artisan('cordon:verify', ['--format' => 'json']) + ->expectsOutputToContain('"rule": "declare_dependencies"') + ->assertExitCode(1); +}); diff --git a/tests/Feature/DocsCommandTest.php b/tests/Feature/DocsCommandTest.php new file mode 100644 index 0000000..c76fb8a --- /dev/null +++ b/tests/Feature/DocsCommandTest.php @@ -0,0 +1,21 @@ +artisan('cordon:docs', ['--output' => $output]) + ->expectsOutputToContain('Documented 3 modules') + ->assertExitCode(0); + + expect($output.'/README.md')->toBeFile() + ->and($output.'/events.md')->toBeFile() + ->and($output.'/modules/Billing.md')->toBeFile() + ->and((string) file_get_contents($output.'/modules/Billing.md'))->toContain('**internal_access**'); + + foreach ([...glob($output.'/modules/*.md'), ...glob($output.'/*.md')] as $file) { + unlink($file); + } + + rmdir($output.'/modules'); + rmdir($output); +}); diff --git a/tests/Feature/PestExpectationTest.php b/tests/Feature/PestExpectationTest.php new file mode 100644 index 0000000..ac1653d --- /dev/null +++ b/tests/Feature/PestExpectationTest.php @@ -0,0 +1,44 @@ + Cordon::flush()); + +afterEach(function () { + @unlink((string) config('cordon.baseline')); +}); + +it('lists the detected modules', function () { + expect(Cordon::modules())->toBe(['Billing', 'Catalog', 'Shared']); +}); + +it('passes for a module without violations', function () { + expect('Shared')->toRespectBoundaries(); +}); + +it('fails with every violation of the module', function () { + expect(fn () => expect('Billing')->toRespectBoundaries()) + ->toThrow(AssertionFailedError::class, 'Module [Billing] does not respect its boundaries (3 violations)'); +}); + +it('includes the dependency cycles the module takes part in', function () { + expect(fn () => expect('Catalog')->toRespectBoundaries()) + ->toThrow(AssertionFailedError::class, '[cycles]'); +}); + +it('works on every module with each', function () { + expect(fn () => expect(Cordon::modules())->each->toRespectBoundaries()) + ->toThrow(AssertionFailedError::class, 'Module [Billing]'); +}); + +it('rejects unknown modules', function () { + expect(fn () => expect('Ghost')->toRespectBoundaries()) + ->toThrow(AssertionFailedError::class, 'Unknown module [Ghost]. Detected modules: Billing, Catalog, Shared.'); +}); + +it('respects the baseline', function () { + $this->artisan('cordon:verify', ['--generate-baseline' => true])->assertExitCode(0); + + expect('Billing')->toRespectBoundaries(); +}); diff --git a/tests/Feature/XdebugWarningTest.php b/tests/Feature/XdebugWarningTest.php new file mode 100644 index 0000000..a4e4829 --- /dev/null +++ b/tests/Feature/XdebugWarningTest.php @@ -0,0 +1,116 @@ +sink)($message.($newline ? PHP_EOL : '')); + } + }; + $output->setErrorOutput(new StreamOutput($stderr, StreamOutput::VERBOSITY_NORMAL, false)); + + return [ + $output, + function () use (&$stdout): string { + return $stdout; + }, + function () use ($stderr): string { + rewind($stderr); + + return (string) stream_get_contents($stderr); + }, + ]; +} + +it('is active when Xdebug is loaded with a mode other than off', function () { + expect((new XdebugWarning(true, 'develop'))->isActive())->toBeTrue() + ->and((new XdebugWarning(true, 'debug,develop'))->message())->toContain('XDEBUG_MODE=off'); +}); + +it('is inactive when the mode is off or empty', function () { + expect((new XdebugWarning(true, 'off'))->isActive())->toBeFalse() + ->and((new XdebugWarning(true, ' OFF '))->isActive())->toBeFalse() + ->and((new XdebugWarning(true, ''))->message())->toBeNull(); +}); + +it('is inactive when Xdebug is not loaded', function () { + expect((new XdebugWarning(false, 'develop'))->isActive())->toBeFalse(); +}); + +it('lets the XDEBUG_MODE environment variable override the ini setting', function () { + $previous = getenv('XDEBUG_MODE'); + $ini = ini_get('xdebug.mode'); + + try { + putenv('XDEBUG_MODE=off'); + expect((new XdebugWarning(true))->isActive())->toBeFalse(); + + putenv('XDEBUG_MODE=develop'); + expect((new XdebugWarning(true))->isActive())->toBeTrue(); + } finally { + $previous === false ? putenv('XDEBUG_MODE') : putenv('XDEBUG_MODE='.$previous); + } + + expect($ini)->toBe(ini_get('xdebug.mode')); +}); + +it('warns on stderr and keeps the JSON on stdout valid', function () { + app()->instance(XdebugWarning::class, new XdebugWarning(true, 'develop')); + [$output, $stdout, $stderr] = split_output(); + + $exitCode = Artisan::call('cordon:verify', ['--format' => 'json'], $output); + $report = json_decode($stdout(), true); + + expect($exitCode)->toBe(1) + ->and($report)->toBeArray() + ->and($stderr())->toContain('Xdebug is enabled') + ->and($stdout())->not->toContain('Xdebug'); +}); + +it('does not warn when Xdebug is off', function () { + app()->instance(XdebugWarning::class, new XdebugWarning(true, 'off')); + [$output, $stdout, $stderr] = split_output(); + + Artisan::call('cordon:verify', ['--format' => 'json'], $output); + + expect($stderr())->toBe('') + ->and(json_decode($stdout(), true))->toBeArray(); +}); + +it('warns on stderr when generating the documentation', function () { + app()->instance(XdebugWarning::class, new XdebugWarning(true, 'develop')); + [$output, $stdout, $stderr] = split_output(); + $directory = sys_get_temp_dir().'/cordon-docs-xdebug-'.uniqid(); + + Artisan::call('cordon:docs', ['--output' => $directory], $output); + + expect($stderr())->toContain('Xdebug is enabled') + ->and($stdout())->not->toContain('Xdebug'); + + foreach ([...glob($directory.'/modules/*.md'), ...glob($directory.'/*.md')] as $file) { + unlink($file); + } + + rmdir($directory.'/modules'); + rmdir($directory); +}); diff --git a/tests/Fixtures/Rules/DeclareDependenciesRule.php b/tests/Fixtures/Rules/DeclareDependenciesRule.php new file mode 100644 index 0000000..36e0190 --- /dev/null +++ b/tests/Fixtures/Rules/DeclareDependenciesRule.php @@ -0,0 +1,32 @@ +modules as $module) { + if ($module->dependsOn === null) { + yield new Violation( + $this->id(), + sprintf('Module [%s] does not declare depends_on. List the modules it may use in config/cordon.php.', $module->name), + sourceModule: $module->name, + target: $module->name, + ); + } + } + } +} diff --git a/tests/Fixtures/Rules/DuplicateIdRule.php b/tests/Fixtures/Rules/DuplicateIdRule.php new file mode 100644 index 0000000..0b82e9f --- /dev/null +++ b/tests/Fixtures/Rules/DuplicateIdRule.php @@ -0,0 +1,19 @@ + 'match', + default => 'other', + }; + } + + public function check(object $value): bool + { + return $value instanceof InstanceOfTarget; + } + + public function catches(): void + { + try { + helper(); + } catch (CaughtA|CaughtB) { + } + } + + public function union(UnionA|UnionB $value): void {} + + public function intersection(IntersectionA&IntersectionB $value): void {} + + public function dnf((DnfA&DnfB)|DnfC|null $value): void {} + + public function classConstant(): string + { + return ClassConstant::class; + } + + public function staticMembers(): array + { + return [StaticPropertyTarget::$value, ConstantFetchTarget::VALUE]; + } + + public function initializer(object $value = new NewInInitializer): object + { + return $value; + } + + public function staticClosure(): \Closure + { + return static fn (ClosureParameter $parameter): StaticClosureReturn => $parameter->get(); + } +} diff --git a/tests/Fixtures/events-app/app/Modules/Billing/Contracts/Mailer.php b/tests/Fixtures/events-app/app/Modules/Billing/Contracts/Mailer.php new file mode 100644 index 0000000..a9182b5 --- /dev/null +++ b/tests/Fixtures/events-app/app/Modules/Billing/Contracts/Mailer.php @@ -0,0 +1,5 @@ +orderId); + } +} diff --git a/tests/Fixtures/events-app/app/Modules/Orders/Actions/PlaceOrder.php b/tests/Fixtures/events-app/app/Modules/Orders/Actions/PlaceOrder.php new file mode 100644 index 0000000..9f0caba --- /dev/null +++ b/tests/Fixtures/events-app/app/Modules/Orders/Actions/PlaceOrder.php @@ -0,0 +1,15 @@ + null); + } +} diff --git a/tests/Fixtures/internachi-app/app-modules/billing/composer.json b/tests/Fixtures/internachi-app/app-modules/billing/composer.json new file mode 100644 index 0000000..c1ce520 --- /dev/null +++ b/tests/Fixtures/internachi-app/app-modules/billing/composer.json @@ -0,0 +1,9 @@ +{ + "name": "modules/billing", + "autoload": { + "psr-4": { + "Modules\\Billing\\Tests\\": "tests/", + "Modules\\Billing\\": "src/" + } + } +} diff --git a/tests/Fixtures/internachi-app/app-modules/billing/src/Contracts/ChargesCustomers.php b/tests/Fixtures/internachi-app/app-modules/billing/src/Contracts/ChargesCustomers.php new file mode 100644 index 0000000..9a90795 --- /dev/null +++ b/tests/Fixtures/internachi-app/app-modules/billing/src/Contracts/ChargesCustomers.php @@ -0,0 +1,7 @@ +price); + } +} diff --git a/tests/Fixtures/namespace-app/app/Modules/Billing/Services/InvoiceRenderer.php b/tests/Fixtures/namespace-app/app/Modules/Billing/Services/InvoiceRenderer.php new file mode 100644 index 0000000..61c2c16 --- /dev/null +++ b/tests/Fixtures/namespace-app/app/Modules/Billing/Services/InvoiceRenderer.php @@ -0,0 +1,14 @@ +invoiceId); + } +} diff --git a/tests/Fixtures/namespace-app/app/Modules/Catalog/Models/Product.php b/tests/Fixtures/namespace-app/app/Modules/Catalog/Models/Product.php new file mode 100644 index 0000000..5d9d86c --- /dev/null +++ b/tests/Fixtures/namespace-app/app/Modules/Catalog/Models/Product.php @@ -0,0 +1,8 @@ + [ + 'Shared' => ['open' => true], + ], +]; diff --git a/tests/Fixtures/nwidart-app/Modules/Blog/app/Models/Post.php b/tests/Fixtures/nwidart-app/Modules/Blog/app/Models/Post.php new file mode 100644 index 0000000..829c153 --- /dev/null +++ b/tests/Fixtures/nwidart-app/Modules/Blog/app/Models/Post.php @@ -0,0 +1,7 @@ + 'Modules', + 'paths' => [ + 'modules' => base_path('modules'), + 'assets' => public_path('modules'), + ], +]; diff --git a/tests/Fixtures/nwidart-lowercase/modules/Admin/app/Models/User.php b/tests/Fixtures/nwidart-lowercase/modules/Admin/app/Models/User.php new file mode 100644 index 0000000..2588391 --- /dev/null +++ b/tests/Fixtures/nwidart-lowercase/modules/Admin/app/Models/User.php @@ -0,0 +1,5 @@ + Nwidart\Modules\Commands\CommandsList::defaultCommands(), +]; diff --git a/tests/Fixtures/real-ddd-modules/app/Modules/Inventory/Application/Queries/StockLevel.php b/tests/Fixtures/real-ddd-modules/app/Modules/Inventory/Application/Queries/StockLevel.php new file mode 100644 index 0000000..a654f48 --- /dev/null +++ b/tests/Fixtures/real-ddd-modules/app/Modules/Inventory/Application/Queries/StockLevel.php @@ -0,0 +1,8 @@ + Sales -> Inventory" + ] +} diff --git a/tests/Fixtures/real-internachi-saas/app-modules/accounts/composer.json b/tests/Fixtures/real-internachi-saas/app-modules/accounts/composer.json new file mode 100644 index 0000000..ae263d7 --- /dev/null +++ b/tests/Fixtures/real-internachi-saas/app-modules/accounts/composer.json @@ -0,0 +1,15 @@ +{ + "name": "modules/accounts", + "autoload": { + "psr-4": { + "Modules\\Accounts\\": "src/", + "Modules\\Accounts\\Database\\Factories\\": "database/factories/", + "Modules\\Accounts\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\Accounts\\Tests\\": "tests/" + } + } +} diff --git a/tests/Fixtures/real-internachi-saas/app-modules/accounts/database/factories/TeamFactory.php b/tests/Fixtures/real-internachi-saas/app-modules/accounts/database/factories/TeamFactory.php new file mode 100644 index 0000000..f107059 --- /dev/null +++ b/tests/Fixtures/real-internachi-saas/app-modules/accounts/database/factories/TeamFactory.php @@ -0,0 +1,16 @@ +teams->find($teamId); + $invoice = Invoice::create(['team_id' => $team->id]); + + InvoiceIssued::dispatch($invoice->id); + + return $invoice; + } +} diff --git a/tests/Fixtures/real-internachi-saas/app-modules/invoicing/src/Events/InvoiceIssued.php b/tests/Fixtures/real-internachi-saas/app-modules/invoicing/src/Events/InvoiceIssued.php new file mode 100644 index 0000000..a112c86 --- /dev/null +++ b/tests/Fixtures/real-internachi-saas/app-modules/invoicing/src/Events/InvoiceIssued.php @@ -0,0 +1,12 @@ +belongsTo(Team::class); + } +} diff --git a/tests/Fixtures/real-internachi-saas/app-modules/notifications/composer.json b/tests/Fixtures/real-internachi-saas/app-modules/notifications/composer.json new file mode 100644 index 0000000..68e8503 --- /dev/null +++ b/tests/Fixtures/real-internachi-saas/app-modules/notifications/composer.json @@ -0,0 +1,15 @@ +{ + "name": "modules/notifications", + "autoload": { + "psr-4": { + "Modules\\Notifications\\": "src/", + "Modules\\Notifications\\Database\\Factories\\": "database/factories/", + "Modules\\Notifications\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\Notifications\\Tests\\": "tests/" + } + } +} diff --git a/tests/Fixtures/real-internachi-saas/app-modules/notifications/src/Listeners/SendInvoiceEmail.php b/tests/Fixtures/real-internachi-saas/app-modules/notifications/src/Listeners/SendInvoiceEmail.php new file mode 100644 index 0000000..e32d42d --- /dev/null +++ b/tests/Fixtures/real-internachi-saas/app-modules/notifications/src/Listeners/SendInvoiceEmail.php @@ -0,0 +1,16 @@ +loadMigrationsFrom(module_path('Catalog', 'database/migrations')); + } +} diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Catalog/composer.json b/tests/Fixtures/real-nwidart-shop/Modules/Catalog/composer.json new file mode 100644 index 0000000..03ca4f5 --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Catalog/composer.json @@ -0,0 +1,9 @@ +{ + "name": "nwidart/catalog", + "autoload": { + "psr-4": { + "Modules\\Catalog\\": "app/", + "Modules\\Catalog\\Database\\Factories\\": "database/factories/" + } + } +} diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Catalog/database/factories/ProductFactory.php b/tests/Fixtures/real-nwidart-shop/Modules/Catalog/database/factories/ProductFactory.php new file mode 100644 index 0000000..ebf913b --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Catalog/database/factories/ProductFactory.php @@ -0,0 +1,16 @@ + $this->faker->word()]; + } +} diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Catalog/database/migrations/2024_01_01_000000_create_products_table.php b/tests/Fixtures/real-nwidart-shop/Modules/Catalog/database/migrations/2024_01_01_000000_create_products_table.php new file mode 100644 index 0000000..3de96e3 --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Catalog/database/migrations/2024_01_01_000000_create_products_table.php @@ -0,0 +1,16 @@ +id(); + $table->string('name'); + }); + } +}; diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Catalog/module.json b/tests/Fixtures/real-nwidart-shop/Modules/Catalog/module.json new file mode 100644 index 0000000..c21954f --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Catalog/module.json @@ -0,0 +1,7 @@ +{ + "name": "Catalog", + "alias": "catalog", + "providers": [ + "Modules\\Catalog\\Providers\\CatalogServiceProvider" + ] +} diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Catalog/resources/views/index.blade.php b/tests/Fixtures/real-nwidart-shop/Modules/Catalog/resources/views/index.blade.php new file mode 100644 index 0000000..aed382d --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Catalog/resources/views/index.blade.php @@ -0,0 +1,2 @@ +@php($orders = \Modules\Orders\Models\Order::all()) +
{{ $orders->count() }}
diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Catalog/routes/web.php b/tests/Fixtures/real-nwidart-shop/Modules/Catalog/routes/web.php new file mode 100644 index 0000000..888c342 --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Catalog/routes/web.php @@ -0,0 +1,5 @@ + $this->catalog->find($order->product_id)]; + } +} diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Orders/app/Listeners/MarkOrderAsPaid.php b/tests/Fixtures/real-nwidart-shop/Modules/Orders/app/Listeners/MarkOrderAsPaid.php new file mode 100644 index 0000000..5291125 --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Orders/app/Listeners/MarkOrderAsPaid.php @@ -0,0 +1,16 @@ +orders->markAsPaid($event->orderId); + } +} diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Orders/app/Models/Order.php b/tests/Fixtures/real-nwidart-shop/Modules/Orders/app/Models/Order.php new file mode 100644 index 0000000..a89ed61 --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Orders/app/Models/Order.php @@ -0,0 +1,15 @@ +belongsTo(Product::class); + } +} diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Orders/app/Providers/OrdersServiceProvider.php b/tests/Fixtures/real-nwidart-shop/Modules/Orders/app/Providers/OrdersServiceProvider.php new file mode 100644 index 0000000..9744708 --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Orders/app/Providers/OrdersServiceProvider.php @@ -0,0 +1,13 @@ +loadMigrationsFrom(module_path('Orders', 'database/migrations')); + } +} diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Orders/composer.json b/tests/Fixtures/real-nwidart-shop/Modules/Orders/composer.json new file mode 100644 index 0000000..9732d8f --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Orders/composer.json @@ -0,0 +1,9 @@ +{ + "name": "nwidart/orders", + "autoload": { + "psr-4": { + "Modules\\Orders\\": "app/", + "Modules\\Orders\\Database\\Factories\\": "database/factories/" + } + } +} diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Orders/module.json b/tests/Fixtures/real-nwidart-shop/Modules/Orders/module.json new file mode 100644 index 0000000..12d58e6 --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Orders/module.json @@ -0,0 +1,7 @@ +{ + "name": "Orders", + "alias": "orders", + "providers": [ + "Modules\\Orders\\Providers\\OrdersServiceProvider" + ] +} diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Orders/routes/web.php b/tests/Fixtures/real-nwidart-shop/Modules/Orders/routes/web.php new file mode 100644 index 0000000..2f47233 --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Orders/routes/web.php @@ -0,0 +1,6 @@ +create(); + app(Gateway::class); +}); diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Payments/app/Events/PaymentCaptured.php b/tests/Fixtures/real-nwidart-shop/Modules/Payments/app/Events/PaymentCaptured.php new file mode 100644 index 0000000..e7f4717 --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Payments/app/Events/PaymentCaptured.php @@ -0,0 +1,8 @@ +loadMigrationsFrom(module_path('Payments', 'database/migrations')); + } +} diff --git a/tests/Fixtures/real-nwidart-shop/Modules/Payments/app/Services/Gateway.php b/tests/Fixtures/real-nwidart-shop/Modules/Payments/app/Services/Gateway.php new file mode 100644 index 0000000..764f46b --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/Modules/Payments/app/Services/Gateway.php @@ -0,0 +1,16 @@ +all(); + } +} diff --git a/tests/Fixtures/real-nwidart-shop/expected.json b/tests/Fixtures/real-nwidart-shop/expected.json new file mode 100644 index 0000000..48333ec --- /dev/null +++ b/tests/Fixtures/real-nwidart-shop/expected.json @@ -0,0 +1,9 @@ +{ + "description": "nwidart/laravel-modules v11+ (app/ folder, factories, migrations, routes, Blade views, module tests). Orders reads Catalog's Eloquent model; Orders and Payments depend on each other through a contract and an event.", + "resolver": "nwidart", + "modules": ["Catalog", "Orders", "Payments"], + "violations": [ + "internal_access|Modules/Orders/app/Models/Order.php|Modules\\Catalog\\Models\\Product", + "cycles||Orders -> Payments -> Orders" + ] +} diff --git a/tests/Fixtures/real-src-domain/expected.json b/tests/Fixtures/real-src-domain/expected.json new file mode 100644 index 0000000..a27f244 --- /dev/null +++ b/tests/Fixtures/real-src-domain/expected.json @@ -0,0 +1,16 @@ +{ + "description": "Domain-driven layout in src/Domain with a custom namespace. Clean project: docblock types and string class names must not be reported.", + "resolver": "namespace", + "modules": ["Billing", "Identity"], + "config": { + "resolver": "namespace", + "resolvers": { + "namespace": {"path": "src/Domain", "namespace": "Domain"} + }, + "modules": { + "Billing": {"depends_on": ["Identity"]}, + "Identity": {"depends_on": []} + } + }, + "violations": [] +} diff --git a/tests/Fixtures/real-src-domain/src/Domain/Billing/Actions/ChargeUser.php b/tests/Fixtures/real-src-domain/src/Domain/Billing/Actions/ChargeUser.php new file mode 100644 index 0000000..9e2addc --- /dev/null +++ b/tests/Fixtures/real-src-domain/src/Domain/Billing/Actions/ChargeUser.php @@ -0,0 +1,23 @@ +users->find($userId) ?? throw new PaymentFailed; + } +} diff --git a/tests/Fixtures/real-src-domain/src/Domain/Billing/Exceptions/PaymentFailed.php b/tests/Fixtures/real-src-domain/src/Domain/Billing/Exceptions/PaymentFailed.php new file mode 100644 index 0000000..3abcd94 --- /dev/null +++ b/tests/Fixtures/real-src-domain/src/Domain/Billing/Exceptions/PaymentFailed.php @@ -0,0 +1,7 @@ + + */ + public function all(): array; + + public function find(int $id): ?UserData; +} diff --git a/tests/Fixtures/real-src-domain/src/Domain/Identity/Data/UserData.php b/tests/Fixtures/real-src-domain/src/Domain/Identity/Data/UserData.php new file mode 100644 index 0000000..1034ab9 --- /dev/null +++ b/tests/Fixtures/real-src-domain/src/Domain/Identity/Data/UserData.php @@ -0,0 +1,8 @@ + + */ +final class InternalAccessRuleTest extends RuleTestCase +{ + private const APP = __DIR__.'/../Fixtures/namespace-app'; + + private const TIP = 'Depend on its public API instead: a class in a public namespace such as Contracts, or one marked #[PublicApi].'; + + private string $app = self::APP; + + protected function getRule(): Rule + { + return new InternalAccessRule($this->createReflectionProvider(), (string) realpath($this->app)); + } + + public function test_it_reports_internal_classes_of_other_modules(): void + { + $this->analyse([self::APP.'/app/Modules/Billing/Services/CheckoutService.php'], [ + ['Module [Billing] uses App\Modules\Catalog\Models\Product, which is internal to module [Catalog].', 13, self::TIP], + ]); + } + + public function test_it_honours_the_internal_attribute_through_static_reflection(): void + { + $this->analyse([self::APP.'/app/Modules/Billing/Services/InvoiceRenderer.php'], [ + ['Module [Billing] uses App\Modules\Catalog\Contracts\LegacyCatalog, which is internal to module [Catalog].', 10, self::TIP], + ]); + } + + public function test_it_accepts_public_api_and_skips_excluded_directories(): void + { + $this->analyse([ + self::APP.'/app/Modules/Catalog/Listeners/UpdateStockOnInvoicePaid.php', + self::APP.'/app/Modules/Billing/tests/CheckoutServiceFixture.php', + ], []); + } + + public function test_it_reports_a_project_config_it_cannot_evaluate_once(): void + { + $this->app = __DIR__.'/../Fixtures/nwidart-unloadable'; + + $file = $this->app.'/Modules/Blog/app/Post.php'; + + $this->analyse([$file], [ + [ + 'Cordon could not evaluate config/modules.php (Class "Nwidart\Modules\Commands\CommandsList" not found), so its settings were ignored and the defaults are used. Set the matching paths in config/cordon.php (for example resolvers.nwidart.path) so it does not depend on that file.', + 1, + ], + ]); + } +} diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..79fc515 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,48 @@ +extend(TestCase::class)->in('Feature'); + +function fixture_path(string $path = ''): string +{ + return rtrim(__DIR__.'/Fixtures/'.ltrim($path, '/'), '/'); +} + +/** + * Analyse tests/Fixtures/namespace-app (modules Billing, Catalog, Shared). + * + * @param array $moduleConfig + * @param array $rules + */ +function analyse_namespace_app(array $moduleConfig = ['Shared' => ['open' => true]], array $rules = []): Result +{ + $modules = (new NamespaceResolver(fixture_path('namespace-app/app/Modules'), 'App\\Modules')) + ->resolve() + ->configure($moduleConfig); + + $analyzer = new Analyzer( + new PhpParserExtractor, + new FileCollector(['vendor', 'node_modules', 'tests', 'Tests']), + new PublicApiPolicy, + RuleSet::fromConfig($rules), + ); + + return $analyzer->analyze($modules, fixture_path('namespace-app')); +} + +/** + * @return list + */ +function violations_for(Result $result, string $rule): array +{ + return array_values(array_filter($result->violations, fn (Violation $violation) => $violation->rule === $rule)); +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..4577a80 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,23 @@ +set('cordon.resolver', 'namespace'); + $app['config']->set('cordon.resolvers.namespace.path', __DIR__.'/Fixtures/namespace-app/app/Modules'); + $app['config']->set('cordon.resolvers.namespace.namespace', 'App\\Modules'); + $app['config']->set('cordon.modules', ['Shared' => ['open' => true]]); + $app['config']->set('cordon.baseline', sys_get_temp_dir().'/cordon-feature-baseline-'.getmypid().'.json'); + } +} diff --git a/tests/Unit/AnalyzerTest.php b/tests/Unit/AnalyzerTest.php new file mode 100644 index 0000000..dbd97ae --- /dev/null +++ b/tests/Unit/AnalyzerTest.php @@ -0,0 +1,86 @@ + $v->target, violations_for(analyse_namespace_app(), 'internal_access')); + + expect($targets)->toEqualCanonicalizing([ + 'App\\Modules\\Catalog\\Models\\Product', + 'App\\Modules\\Catalog\\Contracts\\LegacyCatalog', + ]); +}); + +it('points to the file, line and modules of the offending reference', function () { + $matches = array_values(array_filter( + violations_for(analyse_namespace_app(), 'internal_access'), + fn (Violation $v) => $v->target === 'App\\Modules\\Catalog\\Models\\Product', + )); + + expect($matches)->toHaveCount(1) + ->and(str_replace('\\', '/', (string) $matches[0]->file))->toEndWith('Billing/Services/CheckoutService.php') + ->and($matches[0]->line)->toBe(13) + ->and($matches[0]->sourceModule)->toBe('Billing') + ->and($matches[0]->targetModule)->toBe('Catalog'); +}); + +it('allows public namespaces, #[PublicApi] classes, public events and open modules', function () { + $targets = array_map(fn (Violation $v) => $v->target, violations_for(analyse_namespace_app(), 'internal_access')); + + expect($targets) + ->not->toContain('App\\Modules\\Catalog\\Contracts\\ProductCatalog') + ->not->toContain('App\\Modules\\Catalog\\Support\\PriceFormatter') + ->not->toContain('App\\Modules\\Billing\\Events\\InvoicePaid') + ->not->toContain('App\\Modules\\Shared\\Money'); +}); + +it('treats classes of a module that is not open as internal', function () { + $targets = array_map(fn (Violation $v) => $v->target, violations_for(analyse_namespace_app([]), 'internal_access')); + + expect($targets)->toContain('App\\Modules\\Shared\\Money'); +}); + +it('ignores namespaced function calls', function () { + $targets = array_map(fn (Violation $v) => (string) $v->target, analyse_namespace_app()->violations); + + expect($targets)->not->toContain('App\\Modules\\Billing\\log_payment'); +}); + +it('detects dependency cycles between modules', function () { + $cycles = violations_for(analyse_namespace_app(), 'cycles'); + + expect($cycles)->toHaveCount(1) + ->and($cycles[0]->target)->toBe('Billing -> Catalog -> Billing'); +}); + +it('only checks depends_on for modules that declare it', function () { + expect(violations_for(analyse_namespace_app(), 'undeclared_dependency'))->toBeEmpty(); + + $violations = violations_for(analyse_namespace_app([ + 'Shared' => ['open' => true], + 'Billing' => ['depends_on' => ['Catalog']], + ]), 'undeclared_dependency'); + + expect($violations)->toHaveCount(1) + ->and($violations[0]->sourceModule)->toBe('Billing') + ->and($violations[0]->targetModule)->toBe('Shared'); +}); + +it('can disable rules', function () { + expect(violations_for(analyse_namespace_app(rules: ['cycles' => false]), 'cycles'))->toBeEmpty(); +}); + +it('skips excluded directories such as tests', function () { + foreach (analyse_namespace_app()->violations as $violation) { + expect((string) Paths::relative(fixture_path('namespace-app'), $violation->file))->not->toContain('/tests/'); + } +}); + +it('reports the expected totals for the fixture app', function () { + $result = analyse_namespace_app(); + + expect($result->violations)->toHaveCount(3) + ->and(count($result->modules))->toBe(3) + ->and($result->parseErrors)->toBeEmpty(); +}); diff --git a/tests/Unit/BaselineTest.php b/tests/Unit/BaselineTest.php new file mode 100644 index 0000000..8f1ff99 --- /dev/null +++ b/tests/Unit/BaselineTest.php @@ -0,0 +1,39 @@ +filter([$known, $new], '/app'); + + expect($suppressed)->toBe(1) + ->and($remaining)->toBe([$new]); +}); + +it('survives a save and load round trip and ignores line changes', function () { + $file = sys_get_temp_dir().'/cordon-baseline-'.uniqid().'.json'; + + Baseline::fromViolations([violation('/app/Modules/A/X.php', 10)], '/app')->save($file); + [$remaining, $suppressed] = Baseline::load($file)->filter([violation('/app/Modules/A/X.php', 99)], '/app'); + unlink($file); + + expect($remaining)->toBeEmpty() + ->and($suppressed)->toBe(1); +}); + +it('only suppresses as many occurrences as were recorded', function () { + $baseline = Baseline::fromViolations([violation('/app/X.php')], '/app'); + + [$remaining, $suppressed] = $baseline->filter([violation('/app/X.php'), violation('/app/X.php')], '/app'); + + expect($remaining)->toHaveCount(1) + ->and($suppressed)->toBe(1); +}); diff --git a/tests/Unit/CustomRulesTest.php b/tests/Unit/CustomRulesTest.php new file mode 100644 index 0000000..5210930 --- /dev/null +++ b/tests/Unit/CustomRulesTest.php @@ -0,0 +1,40 @@ + false, DeclareDependenciesRule::class]); + + expect(array_map(fn ($rule) => $rule->id(), $rules))->toBe(['internal_access', 'undeclared_dependency', 'declare_dependencies']); +}); + +it('reports custom rule violations', function () { + $result = analyse_namespace_app( + ['Shared' => ['open' => true], 'Catalog' => ['depends_on' => ['Billing']]], + ['internal_access' => false, 'undeclared_dependency' => false, 'cycles' => false, 'mine' => DeclareDependenciesRule::class], + ); + + expect(array_map(fn (Violation $v) => $v->sourceModule, $result->violations))->toBe(['Billing', 'Shared']); +}); + +it('builds custom rules with the given factory', function () { + $made = []; + RuleSet::fromConfig([DeclareDependenciesRule::class], function (string $class) use (&$made) { + $made[] = $class; + + return new $class; + }); + + expect($made)->toBe([DeclareDependenciesRule::class]); +}); + +it('rejects classes that are not rules and duplicated ids', function (array $config, string $message) { + expect(fn () => RuleSet::fromConfig($config))->toThrow(InvalidArgumentException::class, $message); +})->with([ + 'missing class' => [['App\\Missing\\Rule'], 'must be an existing class that implements'], + 'not a rule' => [[stdClass::class], 'must be an existing class that implements'], + 'duplicated id' => [[DuplicateIdRule::class], 'uses the id [cycles], which is already taken'], +]); diff --git a/tests/Unit/DocumentationTest.php b/tests/Unit/DocumentationTest.php new file mode 100644 index 0000000..d713128 --- /dev/null +++ b/tests/Unit/DocumentationTest.php @@ -0,0 +1,93 @@ +resolve(); + $analyzer = new Analyzer(new PhpParserExtractor, new FileCollector, new PublicApiPolicy, RuleSet::fromConfig()); + + return [$analyzer, $analyzer->snapshot($modules)]; +} + +/** + * @return array + */ +function events_by_class(): array +{ + $events = []; + + foreach (EventInventory::build(events_app()[1])->events as $event) { + $events[$event->fqcn] = $event; + } + + return $events; +} + +it('finds events by namespace and by explicit dispatch or listen, but not jobs', function () { + expect(array_keys(events_by_class()))->toBe([ + 'App\\Modules\\Billing\\Events\\InvoiceVoided', + 'App\\Modules\\Billing\\Events\\PaymentFailed', + 'App\\Modules\\Orders\\Events\\OrderPlaced', + 'App\\Modules\\Shipping\\Domain\\ParcelLost', + ]); +}); + +it('records who publishes and who listens to each event', function () { + $placed = events_by_class()['App\\Modules\\Orders\\Events\\OrderPlaced']; + $failed = events_by_class()['App\\Modules\\Billing\\Events\\PaymentFailed']; + + expect(array_column($placed->publishers, 'class'))->toBe(['App\\Modules\\Orders\\Actions\\PlaceOrder']) + ->and(array_column($placed->listeners, 'class'))->toBe([ + 'App\\Modules\\Billing\\Listeners\\ChargeOrder', + 'App\\Modules\\Shipping\\Providers\\ShippingServiceProvider', + ]) + ->and(array_column($failed->publishers, 'module'))->toBe(['Billing']) + ->and(array_column($failed->listeners, 'module'))->toBe(['Shipping']) + ->and(events_by_class()['App\\Modules\\Billing\\Events\\InvoiceVoided']->publishers)->toBe([]); +}); + +it('generates an overview, a canvas per module and the event inventory', function () { + [$analyzer, $snapshot] = events_app(); + $violations = $analyzer->check($snapshot)->violations; + + $files = (new DocumentationGenerator)->generate($snapshot, $violations, EventInventory::build($snapshot), fixture_path('events-app')); + + expect(array_keys($files))->toBe(['README.md', 'modules/Billing.md', 'modules/Orders.md', 'modules/Shipping.md', 'events.md']) + ->and($files['README.md']) + ->toContain('```mermaid') + ->toContain('m0["Billing"]') + ->toContain('m2 -->|2| m1') + ->toContain('linkStyle 3 stroke:#d33') + ->toContain('| [Shipping](modules/Shipping.md) | `App\\Modules\\Shipping` | Billing, Orders | - | 1 |') + ->and($files['modules/Shipping.md']) + ->toContain('| Orders | `Events\\OrderPlaced`, `Models\\Order` (internal) |') + ->toContain('**internal_access** `app/Modules/Shipping/Listeners/PrepareParcel.php:11`') + ->toContain('- Owned: `App\\Modules\\Shipping\\Domain\\ParcelLost`') + ->and($files['modules/Orders.md']) + ->toContain('- `Events\\OrderPlaced`') + ->not->toContain('- `Models\\Order`') + ->toContain('| Shipping | `Events\\OrderPlaced`, `Models\\Order` (internal) |') + ->and($files['events.md']) + ->toContain('| `App\\Modules\\Orders\\Events\\OrderPlaced` | Orders | Orders `App\\Modules\\Orders\\Actions\\PlaceOrder` |'); +}); + +it('produces the same output on every run', function () { + [$analyzer, $snapshot] = events_app(); + $violations = $analyzer->check($snapshot)->violations; + $generate = fn () => (new DocumentationGenerator)->generate($snapshot, $violations, EventInventory::build($snapshot), fixture_path('events-app')); + + expect($generate())->toBe($generate()); +}); diff --git a/tests/Unit/ExtractorEdgeCasesTest.php b/tests/Unit/ExtractorEdgeCasesTest.php new file mode 100644 index 0000000..be96767 --- /dev/null +++ b/tests/Unit/ExtractorEdgeCasesTest.php @@ -0,0 +1,72 @@ + + */ +function edge_case_targets(string $file): array +{ + $analysis = (new PhpParserExtractor)->extract(fixture_path('edge-cases/'.$file)); + + expect($analysis->error)->toBeNull(); + + $targets = array_map(fn (Reference $reference) => $reference->target, $analysis->references); + + return array_values(array_unique(array_filter($targets, fn (string $target) => str_starts_with($target, 'Edge\\Target\\')))); +} + +it('detects every class position of modern PHP', function (string $target) { + expect(edge_case_targets('References.php'))->toContain('Edge\\Target\\'.$target); +})->with([ + 'enum implements' => 'EnumInterface', + 'trait use inside a trait' => 'SomeTrait', + 'attribute' => 'MarkerAttribute', + 'class constant as attribute argument' => 'AttributeArgument', + 'promoted property' => 'PromotedProperty', + 'anonymous class parent' => 'AnonymousBase', + 'first-class callable' => 'CallableTarget', + 'instanceof inside match' => 'MatchTarget', + 'instanceof' => 'InstanceOfTarget', + 'multi-catch first type' => 'CaughtA', + 'multi-catch second type' => 'CaughtB', + 'union type' => 'UnionA', + 'union type second member' => 'UnionB', + 'intersection type' => 'IntersectionA', + 'intersection type second member' => 'IntersectionB', + 'DNF type' => 'DnfA', + 'DNF type intersection member' => 'DnfB', + 'DNF type union member' => 'DnfC', + '::class constant' => 'ClassConstant', + 'static closure parameter' => 'ClosureParameter', + 'static closure return type' => 'StaticClosureReturn', + 'static property fetch' => 'StaticPropertyTarget', + 'class constant fetch' => 'ConstantFetchTarget', + 'new in initializer' => 'NewInInitializer', +]); + +it('does not report imports that are never used', function () { + expect(edge_case_targets('References.php'))->not->toContain('Edge\\Target\\UnusedImport'); +}); + +it('does not report functions, constants, docblocks, strings or self references', function () { + expect(edge_case_targets('NotReferences.php'))->toBe([]); +}); + +it('attributes references inside anonymous classes to the enclosing class', function () { + $analysis = (new PhpParserExtractor)->extract(fixture_path('edge-cases/References.php')); + $reference = array_values(array_filter($analysis->references, fn (Reference $r) => $r->target === 'Edge\\Target\\AnonymousBase'))[0]; + + expect($reference->sourceClass)->toBe('Edge\\Source\\References'); +}); + +it('declares enums and traits', function () { + $analysis = (new PhpParserExtractor)->extract(fixture_path('edge-cases/References.php')); + + expect(array_map(fn ($declaration) => $declaration->fqcn, $analysis->declarations))->toBe([ + 'Edge\\Source\\Status', + 'Edge\\Source\\LocalTrait', + 'Edge\\Source\\References', + ]); +}); diff --git a/tests/Unit/ExtractorTest.php b/tests/Unit/ExtractorTest.php new file mode 100644 index 0000000..76989d9 --- /dev/null +++ b/tests/Unit/ExtractorTest.php @@ -0,0 +1,39 @@ +extract(fixture_path('namespace-app/app/Modules/Catalog/Support/PriceFormatter.php')); + + expect($analysis->declarations)->toHaveCount(1) + ->and($analysis->declarations[0]->fqcn)->toBe('App\\Modules\\Catalog\\Support\\PriceFormatter') + ->and($analysis->declarations[0]->visibility)->toBe(DeclaredVisibility::PublicApi); +}); + +it('marks #[Internal] declarations', function () { + $analysis = (new PhpParserExtractor)->extract(fixture_path('namespace-app/app/Modules/Catalog/Contracts/LegacyCatalog.php')); + + expect($analysis->declarations[0]->visibility)->toBe(DeclaredVisibility::Internal); +}); + +it('records class references but not imports or function calls', function () { + $analysis = (new PhpParserExtractor)->extract(fixture_path('namespace-app/app/Modules/Catalog/Listeners/UpdateStockOnInvoicePaid.php')); + $targets = array_map(fn (Reference $reference) => $reference->target, $analysis->references); + + expect($targets)->toBe(['App\\Modules\\Billing\\Events\\InvoicePaid']) + ->and($analysis->references[0]->sourceClass)->toBe('App\\Modules\\Catalog\\Listeners\\UpdateStockOnInvoicePaid') + ->and($analysis->references[0]->line)->toBe(9); +}); + +it('returns a parse error instead of throwing', function () { + $file = sys_get_temp_dir().'/cordon-broken-'.uniqid().'.php'; + file_put_contents($file, 'extract($file); + unlink($file); + + expect($analysis->error)->not->toBeNull() + ->and($analysis->references)->toBeEmpty(); +}); diff --git a/tests/Unit/RealProjectsTest.php b/tests/Unit/RealProjectsTest.php new file mode 100644 index 0000000..b0e0eba --- /dev/null +++ b/tests/Unit/RealProjectsTest.php @@ -0,0 +1,63 @@ + + */ +function real_projects(): array +{ + $projects = []; + + foreach (glob(fixture_path('real-*'), GLOB_ONLYDIR) ?: [] as $directory) { + $projects[basename($directory)] = [$directory]; + } + + return $projects; +} + +it('reports exactly the reviewed violations of each real project', function (string $directory) { + $expected = json_decode((string) file_get_contents($directory.'/expected.json'), true, 512, JSON_THROW_ON_ERROR); + $defaults = require dirname(__DIR__, 2).'/config/cordon.php'; + $cordon = array_replace($defaults, $expected['config'] ?? []); + $config = new Repository([...($expected['laravel'] ?? []), 'cordon' => $cordon]); + + expect(ResolverFactory::driver($config, $directory))->toBe($expected['resolver']); + + $modules = ResolverFactory::make($config, $directory)->resolve(); + + expect($modules->names())->toBe($expected['modules']) + ->and($modules->configurationWarnings($cordon['modules']))->toBe([]); + + $analyzer = new Analyzer( + new PhpParserExtractor, + new FileCollector($cordon['exclude']), + new PublicApiPolicy($cordon['public_namespaces']), + RuleSet::fromConfig($cordon['rules']), + ); + + $result = $analyzer->analyze($modules->configure($cordon['modules']), $directory); + $keys = array_map(fn (Violation $violation) => $violation->baselineKey($directory), $result->violations); + sort($keys); + + $expectedKeys = $expected['violations']; + sort($expectedKeys); + + expect($result->parseErrors)->toBe([]) + ->and($keys)->toBe($expectedKeys); +})->with(real_projects()); + +it('has at least five real project fixtures', function () { + expect(count(real_projects()))->toBeGreaterThanOrEqual(5); +}); diff --git a/tests/Unit/ResolverTest.php b/tests/Unit/ResolverTest.php new file mode 100644 index 0000000..0eb60f0 --- /dev/null +++ b/tests/Unit/ResolverTest.php @@ -0,0 +1,67 @@ +resolve(); + + expect($modules->names())->toBe(['Billing', 'Catalog', 'Shared']) + ->and($modules->get('Billing')?->namespace)->toBe('App\\Modules\\Billing'); +}); + +it('resolves nwidart modules that have a module.json', function () { + $modules = (new NwidartResolver(fixture_path('nwidart-app/Modules'), 'Modules'))->resolve(); + + expect($modules->names())->toBe(['Blog', 'Shop']) + ->and($modules->get('Blog')?->namespace)->toBe('Modules\\Blog') + ->and(NwidartResolver::detect(fixture_path('nwidart-app/Modules')))->toBeTrue() + ->and(NwidartResolver::detect(fixture_path('namespace-app/app/Modules')))->toBeFalse(); +}); + +it('resolves InterNACHI modules from composer.json', function () { + $modules = (new InternachiResolver(fixture_path('internachi-app/app-modules')))->resolve(); + + expect($modules->names())->toBe(['billing', 'orders']) + ->and($modules->get('billing')?->namespace)->toBe('Modules\\Billing') + ->and($modules->get('orders')?->namespace)->toBe('Modules\\Orders'); +}); + +it('maps classes and files to the most specific module', function () { + $modules = new ModuleMap([ + Module::make('App', 'App', '/project/app'), + Module::make('Billing', 'App\\Billing', '/project/app/Billing'), + ]); + + expect($modules->forClass('App\\Billing\\Invoice')?->name)->toBe('Billing') + ->and($modules->forClass('App\\Other\\Thing')?->name)->toBe('App') + ->and($modules->forClass('Vendor\\Package\\Thing'))->toBeNull() + ->and($modules->forPath('/project/app/Billing/Invoice.php')?->name)->toBe('Billing') + ->and($modules->forPath('/project/app/Kernel.php')?->name)->toBe('App'); +})->skipOnWindows(); + +it('applies per-module config and warns about unknown modules', function () { + $modules = (new NamespaceResolver(fixture_path('namespace-app/app/Modules'), 'App\\Modules'))->resolve(); + $config = [ + 'Billing' => ['depends_on' => ['Catalog', 'Ghost'], 'public' => ['Services\\CheckoutService']], + 'Nope' => ['open' => true], + ]; + + $configured = $modules->configure($config); + + expect($configured->get('Billing')?->dependsOn)->toBe(['Catalog', 'Ghost']) + ->and($configured->get('Billing')?->publicApi)->toBe(['Services\\CheckoutService']) + ->and($configured->get('Catalog')?->dependsOn)->toBeNull() + ->and($modules->configurationWarnings($config))->toHaveCount(2); +}); + +it('matches file paths regardless of directory separators', function () { + $module = Module::make('Billing', 'App\Modules\Billing', 'C:\project\app/Modules\Billing'); + + expect($module->ownsPath('C:/project/app/Modules/Billing/Services/A.php'))->toBeTrue() + ->and($module->ownsPath('C:\project\app\Modules\Billing\Services\A.php'))->toBeTrue() + ->and($module->ownsPath('C:/project/app/Modules/BillingExtra/A.php'))->toBeFalse(); +}); diff --git a/tests/Unit/RulesTest.php b/tests/Unit/RulesTest.php new file mode 100644 index 0000000..3545727 --- /dev/null +++ b/tests/Unit/RulesTest.php @@ -0,0 +1,52 @@ + new Edge($from, $to, new Reference('/p/'.$from->name.'/X.php', null, $to->name.'\\Y', 1)); + + $context = new AnalysisContext( + new ModuleMap([$a, $b, $c]), + SymbolTable::fromAnalyses([]), + new PublicApiPolicy, + [$edge($a, $b), $edge($b, $c), $edge($c, $a)], + ); + + $violations = iterator_to_array((new CycleRule)->check($context), false); + + expect($violations)->toHaveCount(1) + ->and($violations[0]->target)->toBe('A -> B -> C -> A'); +}); + +it('applies the public API precedence rules', function () { + $policy = new PublicApiPolicy(['Contracts']); + $module = Module::make('Billing', 'App\\Billing', '/p/Billing'); + $open = Module::make('Shared', 'App\\Shared', '/p/Shared')->withConfig(['open' => true]); + $custom = $module->withConfig(['public' => ['Services\\Checkout']]); + + $internal = new ClassDeclaration('App\\Billing\\Contracts\\Old', '/f.php', 1, DeclaredVisibility::Internal); + $public = new ClassDeclaration('App\\Billing\\Models\\Invoice', '/f.php', 1, DeclaredVisibility::PublicApi); + + expect($policy->isPublic('App\\Billing\\Contracts\\Gateway', $module))->toBeTrue() + ->and($policy->isPublic('App\\Billing\\ContractsHelper', $module))->toBeFalse() + ->and($policy->isPublic('App\\Billing\\Models\\Invoice', $module))->toBeFalse() + ->and($policy->isPublic('App\\Billing\\Models\\Invoice', $module, $public))->toBeTrue() + ->and($policy->isPublic('App\\Billing\\Contracts\\Old', $module, $internal))->toBeFalse() + ->and($policy->isPublic('App\\Shared\\Money', $open))->toBeTrue() + ->and($policy->isPublic('App\\Billing\\Services\\Checkout', $custom))->toBeTrue() + ->and($policy->isPublic('App\\Billing\\Services\\Checkout\\Step', $custom))->toBeTrue() + ->and($policy->isPublic('App\\Billing\\Services\\Refunds', $custom))->toBeFalse(); +}); diff --git a/tests/Unit/StandaloneConfigTest.php b/tests/Unit/StandaloneConfigTest.php new file mode 100644 index 0000000..2f45b97 --- /dev/null +++ b/tests/Unit/StandaloneConfigTest.php @@ -0,0 +1,66 @@ +get('cordon.modules'))->toBe(['Shared' => ['open' => true]]) + ->and($config->get('cordon.public_namespaces'))->toBe(['Contracts', 'Events', 'Data', 'Enums', 'Exceptions']) + ->and($config->get('cordon.resolvers.namespace.path'))->toBe('app/Modules'); +}); + +it('ignores config files that need the framework', function () { + $base = sys_get_temp_dir().'/cordon-standalone-'.uniqid(); + mkdir($base.'/config', 0777, true); + file_put_contents($base.'/config/modules.php', ' ["modules" => not_a_laravel_helper("Modules")]];'); + file_put_contents($base.'/config/app-modules.php', ' "packages"];'); + + $config = StandaloneConfig::load($base); + + array_map('unlink', glob($base.'/config/*.php')); + rmdir($base.'/config'); + rmdir($base); + + expect($config->has('modules'))->toBeFalse() + ->and($config->get('app-modules.modules_directory'))->toBe('packages') + ->and($config->get('cordon.resolver'))->toBe('auto'); +}); + +it('resolves path helpers in project config against the project root', function () { + $base = fixture_path('nwidart-lowercase'); + $config = StandaloneConfig::load($base); + + expect($config->get('modules.paths.modules'))->toBe($base.DIRECTORY_SEPARATOR.'modules') + ->and($config->get('modules.paths.assets'))->toBe($base.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'modules') + ->and($config->warnings())->toBe([]); + + $modules = ResolverFactory::make($config, $base)->resolve(); + + expect(ResolverFactory::driver($config, $base))->toBe('nwidart') + ->and($modules->names())->toBe(['Admin', 'Billing']); +}); + +it('does not leak its temporary container into the host process', function () { + $before = Container::getInstance(); + + StandaloneConfig::load(fixture_path('nwidart-lowercase')); + + expect(Container::getInstance())->toBe($before); +}); + +it('warns when a project config file cannot be evaluated', function () { + $config = StandaloneConfig::load(fixture_path('nwidart-unloadable')); + + expect($config->has('modules'))->toBeFalse() + ->and($config->warnings())->toHaveCount(1) + ->and($config->warnings()[0])->toContain('config/modules.php') + ->and($config->warnings()[0])->toContain('CommandsList') + ->and($config->warnings()[0])->toContain('config/cordon.php'); +}); + +it('does not warn about config files that do not exist', function () { + expect(StandaloneConfig::load(fixture_path('namespace-app'))->warnings())->toBe([]); +});