ci: gate every PR on tests, lint, and static analysis - #147
Merged
Conversation
None of these were failing tests, and no test could have caught them — PHP tolerates every one at runtime. Extra arguments to a userland method are silently discarded, initInterception() exists on the trait so the call resolves, and docblocks are invisible to the interpreter. Only static analysis sees them, and nothing ran it. Six came in through PR #15 (the PluginProxy → generated-interceptor rewrite) on 2026-04-05; the rest date to the January CLI batches and a February routing fix. They survived review because the PR checklist in pr-review-process.md never listed PHPStan. - PluginInterceptor passed a third argument to generateInterfaceWrapper() and generateConcreteSubclass(), which take two. Dead arguments, removed. - PluginInterceptedInterface declared only getPluginTarget(), yet PluginInterceptor calls initInterception() through that type. Declared it on the interface, where the contract belongs — the PluginInterception trait already satisfies it. - ModuleManifest::$singletons was annotated array<string, …> while list-style entries use int keys, which is exactly what BindingRegistry::registerModule() branches on via is_int(). The code was right and the annotation lied, making PHPStan call a live branch impossible. Behaviour is unchanged and already covered by BindingRegistryTest. - Command and CommandDefinition gained list<string> for $aliases; collectInterfaceMethods() gained its ReflectionClass generic; Input dropped an array_values() call on a value that is already a list. The one remaining error, trait.unused on PluginInterception, is a limitation rather than a defect: every runtime consumer is an eval-generated class, so no source-visible `use` exists. Ignored narrowly by identifier and path, with the reason recorded in phpstan.neon. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was no PR CI in this repo at all — the only workflows were auto-label, deploy-docs, readme-package-check, split, and sync-issue-templates. Nothing ran tests, phpcs, php-cs-fixer, or PHPStan on a pull request, so "develop is green" rested entirely on whoever last remembered to run them by hand. That is how core carried twelve PHPStan errors for months, and how php-cs-fixer drifted on 129 files. Add a CI workflow with three jobs — Tests, Lint, Static analysis — triggered on every pull request with no paths: filter, so no PR can route around the gate. Add composer phpstan and composer ci so the gate is one command locally, and add both to the PR review checklist that omitted PHPStan. The integration-destructive group is deliberately excluded from the gate: it deletes vendor/ and composer.lock, runs composer update, and re-runs the whole suite in a subprocess, so it measures install integrity rather than behaviour and is order-dependent under --parallel. It now runs nightly instead of never, and bin/release.sh still runs it before any tag is cut. phpcs.xml excludes the deliberately-unparseable codeindexer fixture. It cannot be valid PHP by construction — that is the test case — and phpcs aborts on the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mechanical only — no behaviour, no logic, no test expectations changed. Produced entirely by `composer cs:fix`; every hunk is one of the two formatters' own output. php-cs-fixer was failing a dry run on 129 files on develop, and phpcbf on 40 across 15. Neither was ever enforced repo-wide: the review checklist asks for both, but only "on touched files", so drift accumulated everywhere nobody happened to edit. The CI gate added in the previous commit checks the whole repo, which cannot go green while that backlog exists. The two tools also disagreed on twelve files — phpcbf's multi-line reformatting produced output php-cs-fixer wanted to change back. `composer cs:fix` runs php-cs-fixer first and phpcbf second, so phpcbf wins, and running it once converges: a second pass reports zero for both. That ordering is now load-bearing for the Lint job, not just a convenience. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
php-cs-fixer lints every file before fixing and exits 4 when one will not parse, even with zero files to change. Three fixtures are invalid PHP on purpose — the config and codeindexer packages test what happens when the tooling is handed a broken file — so the Lint job would have failed on arrival, permanently, on a condition no one could fix without deleting the test cases. Caught by running the gate end to end and checking the real exit code. Piping the command through grep, as I had been, reports grep's status and hides this completely. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three tests passed only on the author's machine. The gate caught all three on its
first run, which is the argument for the gate.
- ratelimiter and skeleton hardcoded /opt/homebrew/Cellar/php/8.5.1_2/bin/php,
and ratelimiter fell back to /opt/homebrew/bin/composer. pr-review-process.md
forbids exactly this ("no hardcoded paths or environment-specific values — use
PHP_BINARY"), but nothing enforced it. Both now use PHP_BINARY; the composer
lookup skips rather than guessing a Homebrew path.
- PidFileTest built its process group through a string proc_open command, which
runs via /bin/sh, so the reported pid belongs to the shell rather than to PHP.
macOS sh execs in place and the pids coincide; Ubuntu's dash does not, so
posix_setsid() created the group under a pid the assertions never inspected.
Switched to the array form, which execs directly, and added a pcntl/posix guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two problems, one root cause.
packages/corecarried 12 PHPStan errors and php-cs-fixer had drifted on 129 files, because this repo had no PR CI of any kind — the only workflows were auto-label, deploy-docs, readme-package-check, split, and sync-issue-templates. "develop is green" rested entirely on whoever last remembered to run the tools by hand, andpr-review-process.md's checklist never even listed PHPStan.This fixes everything that was red and adds the gate that stops it recurring.
The 12 PHPStan errors were never failing tests
No test could have caught any of them — PHP tolerates every one at runtime. Extra arguments to a userland method are silently discarded;
initInterception()exists on the trait so the call resolves; docblocks are invisible to the interpreter. The code worked. Its declared types lied about it.PluginInterceptorgenerateInterfaceWrapper()/generateConcreteSubclass(), which take 2. Dead arguments removed.PluginInterceptedInterfacegetPluginTarget(), yetPluginInterceptorcallsinitInterception()through that type. Declared on the interface where the contract belongs.ModuleManifest::$singletonsarray<string, …>while list-style entries use int keys — exactly whatBindingRegistry::registerModule()branches on viais_int(). The annotation made PHPStan call a live branch impossible. Behaviour unchanged, already covered byBindingRegistryTest.Command,CommandDefinition$aliasesmissinglist<string>InterceptorClassGeneratorcollectInterfaceMethods()missing itsReflectionClassgenericInputarray_values()on a value that is already a listProvenance: six arrived via PR #15 (the PluginProxy → generated-interceptor rewrite, 2026-04-05), the rest from the January CLI batches and a February routing fix. All went through normal review.
One error is ignored rather than fixed:
trait.unusedonPluginInterception. Every runtime consumer is an eval-generated class, so no source-visibleuseexists for PHPStan to find. It is a limitation of analysing generated code, not a defect — ignored narrowly by identifier and path, with the reason recorded inphpstan.neon. This is the repo's first PHPStan ignore, so it deserves a look. The alternative is replacing the trait with a collaborator object, which the trait's own docblock already flags as desirable ("a deliberate exception to the No Traits code standard") — worth its own PR, not this one.The gate
Three jobs on every pull request, with no
paths:filter so nothing can route around it:composer testphpcs+php-cs-fixer --dry-runcomposer phpstanAlso adds
composer phpstanandcomposer ci(the whole gate in one command), and puts PHPStan into the review checklist that omitted it.integration-destructiveis deliberately not in the gate: it deletesvendor/andcomposer.lock, runscomposer update, and re-runs the entire suite in a subprocess, so it measures install integrity rather than behaviour and is order-dependent under--parallel. It now runs nightly instead of never, andbin/release.shstill runs it before any tag is cut.The 142-file style commit
dd46d89is mechanical only — every hunk iscomposer cs:fixoutput, no behaviour or test expectations touched. It is isolated in its own commit so the real changes stay readable.Worth knowing: the two formatters disagreed on 12 files — phpcbf's multi-line reformatting produced output php-cs-fixer wanted to revert.
cs:fixruns php-cs-fixer first and phpcbf second, so phpcbf wins and one pass converges. That ordering is now load-bearing for the Lint job rather than a convenience.Test plan
composer ciexits 0 — verified as a real exit code, not through a pipephpcs0,php-cs-fixer0,phpstan0,pest0composer test— 6884 passed (up from 6871)tests/CiWorkflowTest.php— 10 tests asserting the gate has nopaths:escape hatch, pins PHP 8.5 in every job, pins actions to a major version, keeps the destructive group on a schedule, and excludes the unparseable fixturespackages/core/tests/Unit/Plugin/PluginInterceptedInterfaceTest.php— pins theinitInterception()contract against the trait's signatureNote on the fixture exclusions
php-cs-fixerlints before fixing and exits 4 when any file will not parse, even with zero files to change. Three fixtures are invalid PHP on purpose — the config and codeindexer packages test what happens when the tooling meets a broken file — so the Lint job would have failed on arrival, permanently, on a condition nobody could fix without deleting the test cases. Both linters now exclude them.What the gate caught on its first run
Three tests passed locally and failed in CI — they had only ever run on one machine:
ratelimiter/RenameReferencesTestandskeleton/PackageStructureTesthardcoded/opt/homebrew/Cellar/php/8.5.1_2/bin/php, with a/opt/homebrew/bin/composerfallback.pr-review-process.mdforbids exactly this — "no hardcoded paths or environment-specific values, usePHP_BINARY" — but nothing enforced it. NowPHP_BINARY, and the composer lookup skips instead of guessing.devserver/PidFileTestbuilt its process group via a stringproc_opencommand, which runs through/bin/sh, so the reported pid is the shell's rather than PHP's. macOSshexecs in place and the pids coincide; Ubuntu'sdashdoes not, soposix_setsid()created the group under a pid the assertions never inspected. Switched to the array form, which execs directly, plus apcntl/posixguard.That last one is a genuine portability bug in the test, not a CI quirk — it would have misled anyone debugging
devserveron Linux.Follow-ups this surfaced
Not fixed here, deliberately:
phpstan.neonanalyses onlypackages/core/src. Acrosspackages/*/srcat the same level there are 262 errors.