diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..c4f2b4d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,31 @@ +name: Bug report +description: A crash, a missed violation or wrong output. +labels: ["bug"] +body: + - type: textarea + id: what + attributes: + label: What happened + description: What you ran, what you expected and what you got instead. + validations: + required: true + - type: textarea + id: output + attributes: + label: Output + description: Full command output or stack trace. + render: text + - type: textarea + id: reproduce + attributes: + label: How to reproduce + description: The smallest code or project layout that shows the problem. A failing test or fixture is even better. + validations: + required: true + - type: input + id: versions + attributes: + label: Versions + placeholder: Cordon Modulith 0.1.0, PHP 8.4, Laravel 13, nwidart/laravel-modules 13 + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e368393 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Questions and ideas + url: https://github.com/ChrisAbner/Cordon-Modulith/discussions + about: Ask how to model your modules or propose a new rule. + - name: Documentation + url: https://chrisabner.github.io/Cordon-Modulith/ + about: Installation, configuration, rules and recipes. + - name: Security vulnerability + url: https://github.com/ChrisAbner/Cordon-Modulith/security/advisories/new + about: Report security issues privately, not as a public issue. diff --git a/.github/ISSUE_TEMPLATE/false_positive.yml b/.github/ISSUE_TEMPLATE/false_positive.yml new file mode 100644 index 0000000..b0bd41d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/false_positive.yml @@ -0,0 +1,41 @@ +name: False positive +description: Cordon Modulith reports something that is not a real boundary violation. +labels: ["bug", "false positive"] +body: + - type: markdown + attributes: + value: A rule that reports correct code is a bug. The fastest way to get it fixed is the smallest code that reproduces it. + - type: textarea + id: report + attributes: + label: Reported violation + description: Paste the output of `php artisan cordon:verify` for this case. + render: text + validations: + required: true + - type: textarea + id: code + attributes: + label: Code that triggers it + description: The relevant lines of the file, including its `namespace` and `use` statements. + render: php + validations: + required: true + - type: textarea + id: why + attributes: + label: Why it is not a violation + validations: + required: true + - type: input + id: layout + attributes: + label: Module layout + description: "Output of `php artisan cordon:modules` (first lines are enough), e.g. nwidart, InterNACHI or app/Modules." + - type: input + id: versions + attributes: + label: Versions + placeholder: Cordon Modulith 0.1.0, PHP 8.4, Laravel 13 + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..4b2d441 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,23 @@ +name: Feature request +description: Suggest a rule, an option or an integration. +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What boundary problem in your codebase does this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: How it could work, with an example of the output or config if possible. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives + description: Workarounds you use today, or other tools that do something similar. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..593a5b0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## What and why + + + +## Checklist + +- [ ] Tests added or updated; a bug fix starts with a failing test +- [ ] New detection behaviour has a fixture in `tests/Fixtures` +- [ ] `composer check` passes (Pint, PHPStan, Pest) +- [ ] No change to `src/Contracts`, the config shape, the baseline format or command options without an ADR in `docs/adr` +- [ ] `CHANGELOG.md` updated if users will notice the change diff --git a/.github/social-preview.png b/.github/social-preview.png new file mode 100644 index 0000000..4b8c35b Binary files /dev/null and b/.github/social-preview.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8088e09..cd04344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ 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 +## [0.1.0] - 2026-09-29 ### Added - `cordon:verify` command with `text`, `json` and `github` output formats. @@ -11,6 +11,7 @@ All notable changes to Cordon Modulith are documented here. The project follows - 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. +- Global `public_namespaces` entries match at any depth of the class namespace (e.g. `Invoices\Enums\Status` is public), see ADR 0010; the per-module `public` list stays anchored at the module root. - 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. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..14a3ecc --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,7 @@ +# Code of conduct + +This project follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +In short: be respectful and constructive, assume good intent, and keep discussions about the code and the ideas, not the people. Harassment, insults and personal attacks are not accepted in issues, pull requests, discussions or any other project space. + +To report unacceptable behaviour, contact the maintainer, [@ChrisAbner](https://github.com/ChrisAbner), privately through GitHub. Reports are handled confidentially. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ce7d6c..4cba5fc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,4 +27,4 @@ Please open a pull request with a failing test or fixture, even if you don't hav ## Security -Please report security issues privately to the maintainers instead of opening a public issue. +Please report security issues privately; see [SECURITY.md](SECURITY.md). Everyone taking part in the project follows the [code of conduct](CODE_OF_CONDUCT.md). diff --git a/README.md b/README.md index 8411d27..a6e5171 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ # Cordon Modulith +[![Tests](https://github.com/ChrisAbner/Cordon-Modulith/actions/workflows/tests.yml/badge.svg)](https://github.com/ChrisAbner/Cordon-Modulith/actions/workflows/tests.yml) +[![Latest version](https://img.shields.io/packagist/v/chrisabner/cordon-modulith.svg)](https://packagist.org/packages/chrisabner/cordon-modulith) +[![Downloads](https://img.shields.io/packagist/dt/chrisabner/cordon-modulith.svg)](https://packagist.org/packages/chrisabner/cordon-modulith) +[![PHP](https://img.shields.io/packagist/dependency-v/chrisabner/cordon-modulith/php.svg)](composer.json) +[![License](https://img.shields.io/github/license/ChrisAbner/Cordon-Modulith.svg)](LICENSE.md) + **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. +> **Status: 0.1 (alpha).** The public API may change before 1.0. [Documentation](https://chrisabner.github.io/Cordon-Modulith/) 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. @@ -12,6 +18,8 @@ Cordon Modulith reads your code statically, builds the dependency graph between - depends on a module it **did not declare**, or - takes part in a **dependency cycle**. +It also keeps AI coding agents honest: Cordon Modulith ships Laravel Boost resources that teach agents to go through a module's public API and to fix the violations `cordon:verify` reports. + It is inspired by [Spring Modulith](https://spring.io/projects/spring-modulith) and Shopify's Packwerk, adapted to Laravel conventions. ```text @@ -79,11 +87,7 @@ Other modules may only use classes that belong to a module's public API. A class 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. +`public_namespaces` match at **any depth** relative to the module: an entry counts when it appears as a whole segment (or a contiguous run of segments, such as `Http\Resources`) in the class's namespace. `Modules\Billing\Contracts\Gateway`, `Modules\Billing\Invoices\Enums\Status` and `Modules\Billing\Invoices\Events\Sub\InvoicePaid` are public; `Modules\Billing\Invoices\MyEnums\Status` and `Modules\Billing\EnumsHelper\Foo` are not (whole segments only). The module's own `public` list is different: its entries stay anchored at the module root, e.g. `Services\BillingService`. If a nested `Enums` or `Events` class must stay internal, mark it with `#[Internal]`. `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). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0b18525 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security policy + +## Supported versions + +Cordon Modulith is in its 0.x series. Security fixes are released for the latest minor version only. + +| Version | Supported | +|---|---| +| 0.1.x | Yes | + +## Reporting a vulnerability + +Please do not open a public issue. Report it privately through GitHub instead: go to the [Security tab](https://github.com/ChrisAbner/Cordon-Modulith/security) and click **Report a vulnerability**. + +Include the Cordon Modulith version, PHP and Laravel versions, and the smallest project or file that reproduces the problem. You will get an answer within 7 days. Once a fix is released, the advisory is published with credit to you unless you prefer otherwise. + +## Scope + +Cordon Modulith is a development dependency. It parses your code statically and never loads or executes the classes it analyses. It does evaluate your project's config files (`config/cordon.php`, `config/modules.php`, `config/app-modules.php`) to find your modules, so treat those files as code. diff --git a/config/cordon.php b/config/cordon.php index 9791a18..6eead3d 100644 --- a/config/cordon.php +++ b/config/cordon.php @@ -45,7 +45,8 @@ |-------------------------------------------------------------------------- | | 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) + | classes under one of these namespaces, matched as whole segments at any + | depth relative to the module namespace (Enums also covers Invoices\Enums) | or classes marked with #[Cordon\Attributes\PublicApi]. | #[Cordon\Attributes\Internal] always wins. | diff --git a/docs-site/guide/public-api.md b/docs-site/guide/public-api.md index 7accbd3..3bc7818 100644 --- a/docs-site/guide/public-api.md +++ b/docs-site/guide/public-api.md @@ -8,11 +8,7 @@ Other modules may only use classes that belong to a module's public API. A class 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. +`public_namespaces` match at **any depth** relative to the module: an entry counts when it appears as a whole segment (or a contiguous run of segments, such as `Http\Resources`) in the class's namespace. `Modules\Billing\Contracts\Gateway`, `Modules\Billing\Invoices\Enums\Status` and `Modules\Billing\Invoices\Events\Sub\InvoicePaid` are public; `Modules\Billing\Invoices\MyEnums\Status` and `Modules\Billing\EnumsHelper\Foo` are not (whole segments only). The module's own `public` list is different: its entries stay anchored at the module root, e.g. `Services\BillingService`. If a nested `Enums` or `Events` class must stay internal, mark it with `#[Internal]`. `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). @@ -23,6 +19,7 @@ For a module `Modules\Catalog`: | `Modules\Catalog\Contracts\ProductCatalog` | yes | `Contracts` namespace | | `Modules\Catalog\Data\ProductData` | yes | `Data` namespace | | `Modules\Catalog\Events\ProductDiscontinued` | yes | `Events` namespace | +| `Modules\Catalog\Products\Enums\Status` | yes | `Enums` at any depth | | `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 | diff --git a/docs/adr/0010-nested-public-namespaces.md b/docs/adr/0010-nested-public-namespaces.md new file mode 100644 index 0000000..99717e8 --- /dev/null +++ b/docs/adr/0010-nested-public-namespaces.md @@ -0,0 +1,22 @@ +# ADR 0010: Nested public namespaces + +**Status:** accepted +**Date:** 2026-09-29 + +## Context +ADR 0003 defines the public API by namespaces relative to the module (`Contracts`, `Events`, `Data`, `Enums`, `Exceptions` by default). The first implementation matched only the first segment relative to the module root. + +Field tests on real applications showed that about 20% of the `internal_access` reports in some apps were classes such as `He4rt\Recruitment\Requisitions\Enums\RequisitionStatusEnum` or `...\Requisitions\Events\JobRequisitionGenerationEvent`. Their relative name starts with a feature folder (`Requisitions`), so they were reported, but users see enums and events as public API wherever they live. + +## Decision +- An entry of the global `public_namespaces` list matches at any depth: a class is public when the entry appears as a contiguous run of whole segments in its namespace relative to the module (the short class name is excluded). Multi-segment entries such as `Http\Resources` match the same way. +- With module root `Modules\Billing`: `Contracts\Charges`, `Invoices\Enums\Status` and `Invoices\Events\Sub\InvoicePaid` are public; `EnumsHelper\Foo`, `Invoices\MyEnums\Status` and `Invoices\Status` are internal. +- A class whose relative name is exactly an entry at the module root (`Modules\Billing\Enums`) stays public, as before. +- The per-module `public` list (`modules.X.public`) keeps its root-anchored behaviour: it names specific classes or namespaces such as `Services\BillingService`. +- Precedence is unchanged: `#[Internal]` > `#[PublicApi]` > open module > public namespaces > internal. `#[Internal]` remains the way to keep a nested `Enums` or `Events` class internal. +- No change to the config shape, the contracts or the baseline format. + +## Consequences +- Fewer reports, in line with the zero-false-positives rule: nested enums, events, data objects, contracts and exceptions are no longer flagged. +- Possible under-reporting when a team meant a nested `Events` (or similar) namespace to be internal. Mark those classes with `#[Internal]`. +- Existing baselines may contain entries for classes that are now public. They are harmless: `Baseline::filter()` only suppresses violations that are actually found, and unmatched entries are ignored. They can be dropped by regenerating the baseline with `--generate-baseline`. diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php index 139f66f..207b3e3 100644 --- a/resources/boost/guidelines/core.blade.php +++ b/resources/boost/guidelines/core.blade.php @@ -2,7 +2,7 @@ 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]`. +- A module may only use another module's public API: classes under its public namespaces at any depth (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. diff --git a/resources/boost/skills/cordon-fix-violations/SKILL.md b/resources/boost/skills/cordon-fix-violations/SKILL.md index 1159265..820302f 100644 --- a/resources/boost/skills/cordon-fix-violations/SKILL.md +++ b/resources/boost/skills/cordon-fix-violations/SKILL.md @@ -30,7 +30,7 @@ Run `php artisan cordon:modules` to see each module's namespace, path and declar - **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. +- Do not move classes into a public namespace (`Contracts`, `Events`, `Data`, `Enums`, `Exceptions`, at any depth) unless they really are contracts, events, data objects, enums or exceptions. - Re-run `php artisan cordon:verify` after each fix. ## 3. internal_access diff --git a/src/Analysis/PublicApiPolicy.php b/src/Analysis/PublicApiPolicy.php index 1d194cb..dda5a2b 100644 --- a/src/Analysis/PublicApiPolicy.php +++ b/src/Analysis/PublicApiPolicy.php @@ -10,12 +10,13 @@ * Decides whether a class belongs to its module's public API. * * Precedence: #[Internal] > #[PublicApi] > open module > public namespaces - * (global config + module "public" list) > internal by default. + * (global config, matched at any depth; module "public" list, anchored at the + * module root) > internal by default. */ final readonly class PublicApiPolicy { /** - * @param list $publicNamespaces Namespaces relative to each module, e.g. "Contracts". + * @param list $publicNamespaces Namespaces matched at any depth relative to each module, e.g. "Contracts". */ public function __construct( private array $publicNamespaces = ['Contracts', 'Events', 'Data', 'Enums', 'Exceptions'], @@ -33,7 +34,13 @@ public function isPublic(string $fqcn, Module $module, ?ClassDeclaration $declar $relative = $module->relativeName($fqcn); - foreach ([...$this->publicNamespaces, ...$module->publicApi] as $prefix) { + foreach ($this->publicNamespaces as $entry) { + if ($this->matchesAnywhere($relative, trim($entry, '\\'))) { + return true; + } + } + + foreach ($module->publicApi as $prefix) { $prefix = trim($prefix, '\\'); if ($prefix !== '' && ($relative === $prefix || str_starts_with($relative, $prefix.'\\'))) { @@ -43,4 +50,24 @@ public function isPublic(string $fqcn, Module $module, ?ClassDeclaration $declar return false; } + + /** + * Whether the entry appears as a contiguous run of whole segments in the + * namespace of the relative class name (the short class name is excluded), + * or is the relative name itself. + */ + private function matchesAnywhere(string $relative, string $entry): bool + { + if ($entry === '') { + return false; + } + + if ($relative === $entry) { + return true; + } + + $namespace = substr($relative, 0, (int) strrpos($relative, '\\')); + + return str_contains('\\'.$namespace.'\\', '\\'.$entry.'\\'); + } } diff --git a/tests/Fixtures/nested-public-namespaces/app/Modules/Billing/Services/Reporter.php b/tests/Fixtures/nested-public-namespaces/app/Modules/Billing/Services/Reporter.php new file mode 100644 index 0000000..a31e7e6 --- /dev/null +++ b/tests/Fixtures/nested-public-namespaces/app/Modules/Billing/Services/Reporter.php @@ -0,0 +1,14 @@ +isPublic($fqcn, billing_module()))->toBe($public); +})->with([ + 'top-level Contracts' => ['Modules\Billing\Contracts\Charges', true], + 'root class named like the entry' => ['Modules\Billing\Enums', true], + 'nested Enums' => ['Modules\Billing\Invoices\Enums\Status', true], + 'nested Events deeper still' => ['Modules\Billing\Invoices\Events\Sub\InvoicePaid', true], + 'entry only as a name prefix' => ['Modules\Billing\EnumsHelper\Foo', false], + 'entry only as a name suffix' => ['Modules\Billing\Invoices\MyEnums\Status', false], + 'short class name is not a namespace' => ['Modules\Billing\Invoices\Enums', false], + 'plain nested class' => ['Modules\Billing\Invoices\Status', false], + 'plain top-level class' => ['Modules\Billing\Services\Charger', false], +]); + +it('matches multi-segment entries as a contiguous run anywhere', function (string $fqcn, bool $public) { + expect((new PublicApiPolicy(['Http\Resources']))->isPublic($fqcn, billing_module()))->toBe($public); +})->with([ + 'at root' => ['Modules\Billing\Http\Resources\InvoiceResource', true], + 'nested' => ['Modules\Billing\Invoices\Http\Resources\InvoiceResource', true], + 'not contiguous' => ['Modules\Billing\Http\Foo\Resources\InvoiceResource', false], + 'wrong order' => ['Modules\Billing\Resources\Http\InvoiceResource', false], +]); + +it('keeps the per-module public list anchored at the module root', function () { + $policy = new PublicApiPolicy([]); + $module = billing_module(['Services\BillingService', 'Api']); + + expect($policy->isPublic('Modules\Billing\Services\BillingService', $module))->toBeTrue() + ->and($policy->isPublic('Modules\Billing\Api\V1\Thing', $module))->toBeTrue() + ->and($policy->isPublic('Modules\Billing\Invoices\Api\Thing', $module))->toBeFalse() + ->and($policy->isPublic('Modules\Billing\Invoices\Services\BillingService', $module))->toBeFalse(); +}); + +it('lets #[Internal] keep a nested public-namespace class internal', function () { + $declaration = new ClassDeclaration('Modules\\Billing\\Invoices\\Enums\\Status', 'Status.php', 1, DeclaredVisibility::Internal); + + expect((new PublicApiPolicy)->isPublic('Modules\Billing\Invoices\Enums\Status', billing_module(), $declaration))->toBeFalse(); +}); + +it('does not report nested public-namespace classes of another module', function () { + $modules = (new NamespaceResolver(fixture_path('nested-public-namespaces/app/Modules'), 'App\Modules'))->resolve(); + $analyzer = new Analyzer( + new PhpParserExtractor, + new FileCollector(['vendor', 'node_modules', 'tests', 'Tests']), + new PublicApiPolicy, + RuleSet::fromConfig([]), + ); + + $targets = array_map( + fn (Violation $v) => $v->target, + violations_for($analyzer->analyze($modules, fixture_path('nested-public-namespaces')), 'internal_access'), + ); + + expect($targets)->toEqualCanonicalizing([ + 'App\Modules\Ledger\Invoices\Models\Invoice', + 'App\Modules\Ledger\Invoices\MyEnums\Kind', + 'App\Modules\Ledger\Invoices\Enums\Hidden', + ]); +});