Skip to content

Commit 33abf58

Browse files
committed
Merge feature/package-type-api-surface: package-type-aware API-surface enforcement
2 parents 7db4c42 + 4530da2 commit 33abf58

36 files changed

Lines changed: 1301 additions & 1 deletion

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
!/bin/infection
88
!/bin/managed-source
99
!/bin/mdlinks
10+
!/bin/package-type-check
1011
!/bin/php-cs-fixer
1112
!/bin/phpstan
1213
!/bin/phpunit-check-annotation

‎bin/package-type-check‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env php
2+
<?php declare(strict_types=1);
3+
$files = [
4+
__DIR__.'/../../../autoload.php',
5+
__DIR__.'/../vendor/autoload.php',
6+
];
7+
8+
$autoloadFileFound = false;
9+
foreach ($files as $file) {
10+
if (file_exists($file)) {
11+
require $file;
12+
$autoloadFileFound = true;
13+
break;
14+
}
15+
}
16+
17+
if (!$autoloadFileFound) {
18+
echo 'You need to set up the project dependencies using the following commands:'.PHP_EOL.
19+
'curl -s http://getcomposer.org/installer | php'.PHP_EOL.
20+
'php composer.phar install'.PHP_EOL;
21+
die(1);
22+
}
23+
24+
die(\LTS\PHPQA\PackageType\ExplicitPackageTypeCheck::main());

‎composer.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@
5252
"LTS\\PHPQA\\Tests\\": [
5353
"tests/"
5454
],
55+
"LTS\\PHPQA\\Tests\\Assets\\PHPStan\\ApiOrInternal\\": [
56+
"tests/assets/PHPStan/ApiOrInternal/"
57+
],
5558
"LTS\\PHPQA\\Tests\\Assets\\PHPStan\\DeprecatedPhpunit\\": [
5659
"tests/assets/PHPStan/DeprecatedPhpunit/"
5760
],
@@ -67,6 +70,7 @@
6770
"bin/composer-require-checker",
6871
"bin/infection",
6972
"bin/mdlinks",
73+
"bin/package-type-check",
7074
"bin/php-cs-fixer",
7175
"bin/phpstan",
7276
"bin/phpunit-check-annotation",

‎composer.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Arkitect\Expression\ForClasses\NotDependsOnTheseNamespaces;
6+
use Arkitect\Expression\ForClasses\NotResideInTheseNamespaces;
7+
use Arkitect\Rules\Rule;
8+
9+
/**
10+
* Reusable CONSUMER API-boundary rule factory.
11+
*
12+
* Returns PHPArkitect rules a *consumer* of a `type: library` package applies to
13+
* its OWN `src/` to enforce that it reaches the library only through the
14+
* library's public `@api` namespace — never its `@internal` ones. This is the
15+
* hard, shipped counterpart to the per-class `@api`/`@internal` classification
16+
* (see docs/tools/requireApiOrInternal.md): the library declares its surface,
17+
* the consumer's CI forbids crossing it.
18+
*
19+
* Loaded the same way as the shipped rule tiers — via an env var the pipeline
20+
* exports — from a consumer's `qaConfig/phparkitect.php`:
21+
*
22+
* $consumerMustOnlyDependOn = require getenv('PHPQACI_ARKITECT_CONSUMER_API_BOUNDARY');
23+
* $config->add(
24+
* ClassSet::fromDir(__DIR__ . '/../src'),
25+
* ...$consumerMustOnlyDependOn(
26+
* 'Ballicom\AccountsIq\Facade', // the library's public @api namespace
27+
* 'Ballicom\AccountsIq\Gateway', // its @internal namespaces …
28+
* 'Ballicom\AccountsIq\Api',
29+
* ),
30+
* );
31+
*
32+
* The rule applies to every class that is NOT itself part of the library's
33+
* namespaces (i.e. the consumer's own code) and forbids it from depending on any
34+
* listed internal namespace. The public namespace stays allowed.
35+
*
36+
* @return callable(string, string...): list<Rule>
37+
*/
38+
return static function (string $publicNamespace, string ...$internalNamespaces): array {
39+
if ([] === $internalNamespaces) {
40+
throw new InvalidArgumentException(
41+
'consumerMustOnlyDependOn() requires at least one internal namespace to forbid '
42+
. '(the library namespaces consumers must not reach into).',
43+
);
44+
}
45+
46+
return [
47+
Rule::allClasses()
48+
->that(new NotResideInTheseNamespaces($publicNamespace, ...$internalNamespaces))
49+
->should(new NotDependsOnTheseNamespaces($internalNamespaces))
50+
->because(\sprintf(
51+
'consumers must reach this library only through its public @api namespace %s; '
52+
. '%s are @internal and may change without notice',
53+
$publicNamespace,
54+
implode(', ', $internalNamespaces),
55+
)),
56+
];
57+
};

‎docs/tools/packageType.md‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Package Type Declaration Check
2+
3+
An **always-on** php-qa-ci pipeline tool that fails the build unless the
4+
project's `composer.json` declares its `type` **explicitly**.
5+
6+
Composer silently defaults an omitted `type` to `library`. That default quietly
7+
makes a real architectural decision — *is this an installable dependency or an
8+
application?* — without anyone choosing it. This check rejects the silent default
9+
so the decision is **conscious**: the file must say `library`, `project`, or any
10+
other explicit Composer type.
11+
12+
## Why it matters
13+
14+
The declared type drives php-qa-ci's API-surface discipline:
15+
16+
- **`type: library`** — an installable dependency. Its public surface is a
17+
consumer contract, so the
18+
[`RequireApiOrInternalTagRule`](requireApiOrInternal.md) PHPStan rule then
19+
requires every public class-like to be classified `@api` / `@internal`.
20+
- **`type: project`** — an application. No consumers, so the classification rule
21+
no-ops.
22+
- **other explicit types** (`metapackage`, `composer-plugin`, …) — accepted as a
23+
conscious declaration; treated as non-library for classification.
24+
25+
If the type were left to Composer's default, a library could ship an
26+
unclassified, accidental public surface. Forcing the declaration is the forcing
27+
function that makes the rest of the discipline reliable.
28+
29+
## How it runs
30+
31+
- **Part of the full pipeline**: runs automatically in the **linting phase** of a
32+
plain `bin/qa`, right after the Composer checks.
33+
- **Standalone**: `vendor/bin/qa -t packageType` (aliases: `-t pt`, `-t packagetype`).
34+
- **Binary**: `bin/package-type-check` (delegates to
35+
`LTS\PHPQA\PackageType\ExplicitPackageTypeCheck::main()`).
36+
37+
The tool reads `{projectRoot}/composer.json` and:
38+
39+
- **passes (exit 0)** when `type` is a non-blank string; or
40+
- **fails (exit 1)** when `type` is absent, blank, or not a string — printing the
41+
guidance to declare it.
42+
43+
## No opt-out
44+
45+
Unlike most baseline checks this one has **no escape hatch**: declaring one
46+
`composer.json` line is trivial, and the app-vs-library decision must always be
47+
made. Every consumer's `composer.json` must carry an explicit `type`.
48+
49+
## How to fix a failure
50+
51+
Add an explicit `type` to `composer.json`:
52+
53+
```json
54+
{
55+
"name": "acme/widget",
56+
"type": "library"
57+
}
58+
```
59+
60+
Choose:
61+
62+
- **`library`** if this package is installed as a dependency by other projects
63+
(then classify its public surface — see
64+
[requireApiOrInternal.md](requireApiOrInternal.md)).
65+
- **`project`** if this is a deployable application with no consumers.
66+
67+
## Design / implementation notes
68+
69+
- Pure decision core:
70+
[`ExplicitPackageTypeDetector`](./../../src/PackageType/ExplicitPackageTypeDetector.php)
71+
— `check(decoded composer.json): ?string` (null = OK, else the guidance
72+
message); exhaustively unit-tested.
73+
- Thin I/O runner:
74+
[`ExplicitPackageTypeCheck`](./../../src/PackageType/ExplicitPackageTypeCheck.php)
75+
— maps the verdict to stdout + exit code.

‎docs/tools/phpstan.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ PHP-QA-CI ships custom PHPStan rules that are auto-loaded via the extension inst
5353
- **ForbidEmptyCatchBlockRule** -- Requires catch blocks to have a body
5454
- **RequireDeclareStrictTypesRule** -- Requires `declare(strict_types=1)` in all PHP files
5555
- **RequireSensitiveParameterAttributeRule** -- Requires `#[\SensitiveParameter]` on plaintext credential parameters (configurable name patterns / ignore substrings via the `phpqaciSensitiveParameter` parameters block)
56+
- **RequireApiOrInternalTagRule** -- Package-type-aware: for a `type: library` project, every public class-like must be classified as exactly one of `@api` / `@internal`; no-ops for other package types. Generated/managed namespaces are exempt via the `phpqaciApiOrInternal.ignoredNamespacePrefixes` parameter. Full guidance (incl. the deliberate `@api`-vs-`@internal` judgement): [tools/requireApiOrInternal.md](requireApiOrInternal.md)
5657

5758
See the README "Configuring RequireSensitiveParameterAttributeRule" section for the full config keys and defaults.
5859

‎docs/tools/requireApiOrInternal.md‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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).

‎includes/generic/allLintingTools.inc.bash‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ Checking for Composer Issues
1414

1515
runToolGuarded composerChecks
1616

17+
echo "
18+
19+
Checking Package Type Is Declared
20+
---------------------------------
21+
"
22+
23+
runTool packageType
24+
1725
echo "
1826
Setting Strict Types If It's Missing
1927
-------------------------------------
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
###############################################################################
2+
# packageType — always-on check: composer.json MUST declare `type` explicitly.
3+
#
4+
# Composer silently defaults an omitted `type` to `library`, quietly deciding
5+
# app-vs-library for you. This check rejects the silent default so the decision
6+
# is conscious: the file must say `library` (an installable dependency — whose
7+
# public surface is then @api/@internal-classified by RequireApiOrInternalTagRule)
8+
# or `project` (an application), or any other explicit Composer type.
9+
#
10+
# Default-on, no opt-out: declaring one composer.json line is trivial and the
11+
# app-vs-library decision must always be made.
12+
#
13+
# Runs in the linting phase, right after composerChecks. The PHP entrypoint is
14+
# bin/package-type-check (delegates to
15+
# LTS\PHPQA\PackageType\ExplicitPackageTypeCheck::main()).
16+
###############################################################################
17+
18+
packageTypeExitCode=99
19+
while ((packageTypeExitCode > 0)); do
20+
# Run inside an `if` so a non-zero exit is captured without aborting under the
21+
# pipeline's `set -e` — the failure is handled explicitly by the abort branch.
22+
if phpNoXdebug -f "$binDir"/package-type-check; then
23+
packageTypeExitCode=0
24+
else
25+
packageTypeExitCode=$?
26+
tryAgainOrAbort "Package Type Declaration Check"
27+
fi
28+
done

0 commit comments

Comments
 (0)