|
| 1 | +# RequireApiOrInternalTagRule — package-type-aware API-surface classification |
| 2 | + |
| 3 | +A **default-on** PHPStan rule (`phpqaci.requireApiOrInternalTag`) that makes a |
| 4 | +library declare its public contract explicitly: when the consuming project's |
| 5 | +Composer `type` is **`library`**, every public class-like must be classified as |
| 6 | +exactly one of `@api` or `@internal`. |
| 7 | + |
| 8 | +It ships in [`rules-default.neon`](./../../rules-default.neon), so it runs for any |
| 9 | +project that includes php-qa-ci's default rule set — no opt-in. |
| 10 | + |
| 11 | +## What it enforces |
| 12 | + |
| 13 | +For a `type: library` package (an installable dependency — **and Composer's silent |
| 14 | +default when `type` is omitted**), each public class-like (`class`, `interface`, |
| 15 | +`enum`, `trait`) must carry exactly one classifying tag in its docblock: |
| 16 | + |
| 17 | +| Docblock | Verdict | Result | |
| 18 | +| ------------------------------- | ----------- | ------------------------------- | |
| 19 | +| `@api` only | classified | ✅ pass | |
| 20 | +| `@internal` only | classified | ✅ pass | |
| 21 | +| neither `@api` nor `@internal` | **Missing** | ❌ "classify it" | |
| 22 | +| **both** `@api` and `@internal` | **Both** | ❌ "contradiction — choose one" | |
| 23 | + |
| 24 | +For any other package type (`project` application, `metapackage`, |
| 25 | +`composer-plugin`, …) there is no consumer-facing surface, so the rule **no-ops**. |
| 26 | + |
| 27 | +Anonymous classes are skipped (no name to classify, no public contract). |
| 28 | + |
| 29 | +> **Why per-class classification?** `@internal` is the ecosystem-standard, |
| 30 | +> tool-enforced marker (PHPStan/Psalm/PhpStorm all treat a root-namespace |
| 31 | +> `@internal` symbol as off-limits to consumers), but **no tool infers the |
| 32 | +> inverse** — "this isn't `@api`, therefore it's internal". So the only |
| 33 | +> enforceable model is to require an explicit, per-class choice. The rule forces |
| 34 | +> the choice to be *made*; making it *correctly* is the author's job (below). |
| 35 | +
|
| 36 | +## The `@api` vs `@internal` judgement (read this before tagging) |
| 37 | + |
| 38 | +This rule is a **quality ratchet**: it does not decide for you, it makes you |
| 39 | +decide deliberately. The choice is genuinely two-sided: |
| 40 | + |
| 41 | +- **`@internal`** — not part of the supported contract; the library may change or |
| 42 | + remove it in any release. Marking something `@internal` that consumers |
| 43 | + legitimately need **over-restricts** them (or pushes them to depend on internals |
| 44 | + anyway). |
| 45 | +- **`@api`** — a supported public contract. Changing its signature/behaviour later |
| 46 | + is a **breaking change** for every consumer. Marking something `@api` |
| 47 | + prematurely **commits the library** to maintaining it. |
| 48 | + |
| 49 | +**Safe default: tag `@internal`.** Promote a class to `@api` only when consumers |
| 50 | +genuinely need it *and* the library is willing to support it long-term. A small, |
| 51 | +deliberate `@api` surface (often a single facade) is the goal; everything behind |
| 52 | +it stays `@internal` and free to evolve. |
| 53 | + |
| 54 | +## Exempting generated / managed code |
| 55 | + |
| 56 | +Generated and managed trees cannot carry hand-authored tags. Exempt them by |
| 57 | +fully-qualified namespace **prefix** in your `qaConfig/phpstan.neon`: |
| 58 | + |
| 59 | +```neon |
| 60 | +parameters: |
| 61 | + phpqaciApiOrInternal: |
| 62 | + ignoredNamespacePrefixes: |
| 63 | + - App\Generated |
| 64 | + - App\PhpQaCi |
| 65 | +``` |
| 66 | + |
| 67 | +The default is an empty list (nothing exempt). |
| 68 | + |
| 69 | +## How to fix a violation |
| 70 | + |
| 71 | +- **Missing** — add one tag to the class docblock. Default to `@internal`: |
| 72 | + |
| 73 | + ```php |
| 74 | + /** |
| 75 | + * @internal |
| 76 | + */ |
| 77 | + final class OrderMapper { /* … */ } |
| 78 | + ``` |
| 79 | + |
| 80 | + Promote to `@api` only for the deliberate public surface. |
| 81 | + |
| 82 | +- **Both** — remove the wrong one. A class is either supported (`@api`) or not |
| 83 | + (`@internal`), never both. |
| 84 | + |
| 85 | +## Relationship to the explicit-`type` requirement |
| 86 | + |
| 87 | +This rule treats an **undeclared** `type` as `library` (the safe side — keep the |
| 88 | +surface discipline on). Separately, the always-on |
| 89 | +[Package Type Declaration Check](packageType.md) **hard-fails** a `composer.json` |
| 90 | +that does not declare `type` explicitly, so the app-vs-library decision is |
| 91 | +conscious rather than inherited from Composer's silent default. A package that is |
| 92 | +really an application sets `type: project` (and this rule then no-ops); a real |
| 93 | +library sets `type: library` and classifies its surface. |
| 94 | + |
| 95 | +## Enforcing the boundary in consumers |
| 96 | + |
| 97 | +Classifying the surface declares the contract; it does not stop a *consumer* from |
| 98 | +reaching into `@internal` code anyway. php-qa-ci ships a reusable PHPArkitect |
| 99 | +**consumer API-boundary factory** for that hard half — a consumer applies it to |
| 100 | +its own `src/` to forbid depending on a library's internal namespaces (only the |
| 101 | +public `@api` namespace is allowed). |
| 102 | + |
| 103 | +It is loaded the same way as the shipped rule tiers — via an env var the pipeline |
| 104 | +exports (`PHPQACI_ARKITECT_CONSUMER_API_BOUNDARY`) — from the consumer's |
| 105 | +`qaConfig/phparkitect.php`: |
| 106 | + |
| 107 | +```php |
| 108 | +$consumerMustOnlyDependOn = require getenv('PHPQACI_ARKITECT_CONSUMER_API_BOUNDARY'); |
| 109 | + |
| 110 | +$config->add( |
| 111 | + ClassSet::fromDir(__DIR__ . '/../src'), |
| 112 | + ...$consumerMustOnlyDependOn( |
| 113 | + 'Ballicom\AccountsIq\Facade', // the library's public @api namespace |
| 114 | + 'Ballicom\AccountsIq\Gateway', // its @internal namespaces, off-limits … |
| 115 | + 'Ballicom\AccountsIq\Api', |
| 116 | + ), |
| 117 | +); |
| 118 | +``` |
| 119 | + |
| 120 | +Any consumer class that depends on a listed internal namespace then fails |
| 121 | +`bin/qa -t arch`, naming the offending class. The factory lives at |
| 122 | +[`configDefaults/generic/phparkitect-consumer-api-boundary.php`](./../../configDefaults/generic/phparkitect-consumer-api-boundary.php). |
| 123 | + |
| 124 | +## Design / implementation notes |
| 125 | + |
| 126 | +- Pure decision core: [`ApiOrInternalTagDetector`](./../../src/PHPStan/Rules/ApiOrInternalTagDetector.php) |
| 127 | + (+ `ApiOrInternalTagVerdict`) — unit-tested exhaustively, no PHPStan Scope needed. |
| 128 | +- Rule: [`RequireApiOrInternalTagRule`](./../../src/PHPStan/Rules/RequireApiOrInternalTagRule.php) |
| 129 | + (hooks `InClassNode`; reads the class docblock for a standalone `@api`/`@internal` |
| 130 | + tag). |
| 131 | +- Package kind: [`ProjectComposerTypeReader`](./../../src/PackageType/ProjectComposerTypeReader.php) |
| 132 | + — injectable seam over the project `composer.json` (mockable in tests). |
0 commit comments