rasuvaeff / understudy-psalm
Psalm plugin for the understudy test double library: matcher-aware setup closures and misuse diagnostics
Package info
github.com/rasuvaeff/understudy-psalm
Type:psalm-plugin
pkg:composer/rasuvaeff/understudy-psalm
Requires
- php: 8.3 - 8.5
- nikic/php-parser: ^5.0
- rasuvaeff/understudy: ^0.1 || ^0.2 || ^0.3
- vimeo/psalm: ^6.16
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- rasuvaeff/rector-named-literals: ^1.0
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.39
This package is auto-updated.
Last update: 2026-08-27 17:55:33 UTC
README
Psalm plugin for understudy.
Using an AI coding assistant? Point it at llms.txt.
Requirements
- PHP 8.3 - 8.5
vimeo/psalm^6.16rasuvaeff/understudy^0.1
Installation
composer require --dev rasuvaeff/understudy-psalm
vendor/bin/psalm-plugin enable rasuvaeff/understudy-psalm
What it does
understudy specifies a call by making it inside a closure:
when(fn () => $repo->find(Arg::int(min: 1)))->returns($book);
Arg::int() is declared mixed, because a matcher has to be passable
wherever the contract declares anything at all. Psalm reports that as an
argument-type error — correctly, in general, and wrongly here.
The plugin drops that report inside a specification closure, and only there:
| Where | Psalm |
|---|---|
when(fn () => $repo->find(Arg::int())) |
silent |
Understudy::expect(fn () => $repo->find(Arg::any())) |
silent |
Understudy::calls(fn () => $repo->find(Arg::any())) |
silent |
Understudy::verifySequence(fn () => ..., fn () => ...) |
silent |
$repo->find(Arg::int()) — a real call |
still an error |
The last row is the point. A matcher reaching a real call raises
MatcherLeaked at runtime, and a plugin that hid it would be worse than no
plugin at all.
What else it reports
| Reported as | For |
|---|---|
UnderstudyMisuse |
A matcher whose kind the parameter can never accept. A closure that specifies nothing, or two calls, or calls a static method. Cardinality no run can satisfy. verify() arguments that contradict each other. |
Psalm's own InvalidArgument |
returns() and answers() against the method being specified. The plugin does not check these — it fills in the builder's template parameter, and WhenBuilder<TReturn> already declares returns(TReturn ...). Psalm does the rest. |
| Psalm's own array and method diagnostics | wire(), whose shape is read from the named class's constructor: an unknown key is an error, and each double is typed as its contract. A dynamic class-string is left alone. |
Everything the plugin is not sure about stays silent. A false accusation costs more than a missed one here, because the engine still catches what static analysis misses.
API
| Type | Purpose |
|---|---|
UnderstudyMisuse |
The issue every diagnostic of this plugin is reported as. One type rather than one per rule: you either have understudy analysing your specifications or you do not, and needing to silence each rule separately would be a worse contract than needing to silence none. |
Plugin |
The entry point Psalm loads, registered through extra.psalm.pluginClass. Nothing else in this package is public — the handlers it registers are @internal, and what they decide is the plugin's behaviour, not its API. |
The understudy family
| Package | What it is |
|---|---|
| rasuvaeff/understudy | The engine: doubles, matchers, expectations, verification. |
| rasuvaeff/understudy-testo | Testo adapter — verification and reset around every test. |
| rasuvaeff/understudy-phpunit | PHPUnit and Pest adapter — the same, through a trait. |
| rasuvaeff/understudy-psalm (this package) | Psalm plugin — matcher-aware specifications and misuse diagnostics. |
| rasuvaeff/understudy-phpstan | PHPStan extension — the same for PHPStan, plus its own rules. |
Development
make build # validate + cs + psalm + unit + integration
make test-integration
The integration suite runs real Psalm processes over a fixture project, twice: once with the plugin and once without. The run without it is what proves the run with it means anything — a plugin that loads and does nothing passes a positive fixture exactly as well as one that works.
License
BSD-3-Clause.