Skip to content

fix: Phpstan errors in current master build#413

Open
acoulton wants to merge 5 commits intoBehat:masterfrom
acoulton:fix-phpstan
Open

fix: Phpstan errors in current master build#413
acoulton wants to merge 5 commits intoBehat:masterfrom
acoulton:fix-phpstan

Conversation

@acoulton
Copy link
Copy Markdown
Contributor

@acoulton acoulton commented Mar 24, 2026

The master branch is failing static analysis due to changes in recent phpstan versions.

One of these I think is a valid issue that had not been detected previously, though fixing it involved working around a limitation in narrowing the types of arrays that I've reported to phpstan in phpstan/phpstan#14360

The other is to do with type narrowing / confusion in how our LoaderInterface and the GherkinFileLoader are tagged as generics. We were previously making an assumption that wasn't guaranteed to be correct. I've updated the generics tagging to fix this.

Since phpstan@2.1.41, it checks that `implode` is only called with
an array of strings.

We were not previously checking this for the overall list of `$words`,
but were only asserting it in specific places e.g. within the usort
callback.

Moved the assertion to the start of the loop, which allows us to
simplify the checks in later code.

Unfortunately I have had to add a phpdoc tag to tell phpstan that the
array type has been narrowed - I've not found a way to have it infer
that at runtime (I've opened a feature request for this).

This also required updating our php-cs-fixer config to keep docblocks
that contain `@var` tags even if they are not actually docblocks.
Since phpstan@2.1.40, we have been getting an error on the test due to a
failed type check on calling `assertCount` with the result of
`$this->loader->load()`.

This is because before load, the test was also causing `->supports()`
multiple times with hardcoded strings.

The `->supports()` method in the parent LoaderInterface is tagged with:

```
* @phpstan-assert-if-true =LoaderInterface<TSupportedResourceType> $this
```

This meant that when we asserted `->supports('features/tables.feature')`
was true, phpstan narrowed the type of `$this->loader` to
`GherkinFileLoader&LoaderInterface<'features/tables.feature'>`

The next call that asserted `->supports('features/pystring.feature')`
then caused phpstan to decide that `$this->loader` was now a `*NEVER*`
type. That caused all subsequent calls to `->load()` to fail analysis.

I have refactored the tests so that we are not calling `supports`
multiple times with hardcoded strings in the same test method.
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 24, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.71%. Comparing base (74c6d6a) to head (dbaeb9e).

Additional details and impacted files
@@            Coverage Diff            @@
##             master     #413   +/-   ##
=========================================
  Coverage     95.71%   95.71%           
  Complexity      685      685           
=========================================
  Files            45       45           
  Lines          2031     2031           
=========================================
  Hits           1944     1944           
  Misses           87       87           
Flag Coverage Δ
php8.1 95.71% <ø> (ø)
php8.1--with=symfony/yaml:^5.4 95.71% <ø> (ø)
php8.1--with=symfony/yaml:^6.4 95.71% <ø> (ø)
php8.2 95.71% <ø> (ø)
php8.3 95.71% <ø> (ø)
php8.4 95.71% <ø> (ø)
php8.5 95.71% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Comment on lines -104 to -106
$this->assertTrue($this->loader->supports('features/tables.feature'));
$actual = [];
foreach (array_keys($expected) as $resource) {
$actual[$resource] = $this->loader->supports($resource);
}

$this->assertTrue($this->loader->supports('features/pystring.feature'));
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the first assertTrue($this->loader->supports(...)) call, phpstan was narrowing the type of $this->loader to Behat\Gherkin\Loader\GherkinFileLoader&Behat\Gherkin\Loader\LoaderInterface<'features/tables.feature'>.

The second call then caused phpstan to decide that $this->loader was a *NEVER* type.

And this then caused it to decide that the next call to $this->loader->load(...) would return *ERROR*. That then caused an argument type error when we passed it to assertCount().

This appears to be because there's a conflict between:

  • the GherkinFileLoader being tagged as @extends AbstractFileLoader<string>
  • the LoaderInterface::supports being tagged with @phpstan-assert-if-true =LoaderInterface<TSupportedResourceType> $this

It's fine if you call the ->supports() with a string variable, because that causes phpstan to infer it as LoaderInterface<string> which matches the class definition. But it causes a problem if you call it with hardcoded values, since phpstan then treats it as LoaderInterface<'value of first string passed'>.

I've created a simplified reproduction case in the phpstan playground
https://phpstan.org/r/6250ecaa-ed3e-48dd-81cf-80d3cf25db3c but I'm not confident enough in phpstan generics to say whether this is a phpstan bug or an issue with our tagging. @stof, do you have an opinion?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the issue is our generic type. supports should refine the type of $resource (to tell phpstan it is compatible with the type of the loader), not of $this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stof thank you!

That makes sense. If I modify the example case to assert the type of the resource rather than the instance, that looks like it works:

https://phpstan.org/r/091134b1-4a3e-4514-a63d-17b7c6ccb795

@acoulton acoulton requested a review from stof March 24, 2026 12:13
Originally, `LoaderInterface::supports()` was tagged with
`@phpstan-assert-if-true` to refine the type of the loader itself.

This was used so that the `Gherkin::resolveLoader()` method could safely
/ automatically narrow from a `LoaderInterface<*>` to a
`LoaderInterface<ActualTypeOfResourceThatWasPassed>`.

The problem with this is that it also means that calling `->supports` on
an instance of a known `LoaderInterface` subclass will change the type
of the instance.

If an implementation returns true for `->supports('some value')` then
phpstan will treat it as `LoaderInterface<'some value'>`. But that's not
necessarily correct - just because `'some value'` passes, doesn't mean
that the instance will *only* accept 'some value'. It could be
`LoaderInterface<string>` or `LoaderInterface<string|Stringable>` or
even `LoaderInterface<mixed>`.

Calling `->supports()` tells us something about the type of the
instance, but not enough to guarantee it.

This becomes a problem if calling `->supports()` multiple times with
different hardcoded values, as phpstan will be unable to resolve the
potential intersection types and will instead treat it as a `*NEVER*`.

The only thing we reliably know is that if `->supports($resource)`
passes then `$resource` must be a type of `TResourceType` and can be
safely passed to `->load($resource)` on the same instance).

Therefore I have flipped the logic of the `@assert-if-true` tag to
refine the type of the resource rather than the instance.

However, we still need to be able to narrow the type of the Loader in
`Gherkin::resolveLoader()`. I think the best way to do that is to
manually hint it within the resolveLoader method. It's safe here,
because we assume that consumers will call `resolveLoader` for each
individual resource, so it doesn't matter if the return typehint is
actually stricter than the class that has been returned.
* @param TSupportedResourceType $resource Resource to load
*
* @phpstan-assert-if-true =LoaderInterface<TSupportedResourceType> $this
* @phpstan-assert-if-true =TResourceType $resource
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @stof identified, calling supports('foo') doesn't guarantee that the instance only accepts 'foo', so we can't safely refine the instance itself to LoaderInterface<'foo'>. Our instance could as easily be (probably is) LoaderInterface<string> or an even wider type.

However we can safely refine $resource - if it passes supports() then it must be a type of TResourceType and safe to pass to ->load($resource).

Comment on lines +154 to +159
/**
* If the loader supports the provided resource, then it can safely be typed as
* LoaderInterface<ResourceType>.
*
* @var LoaderInterface<TResourceType> $loader
*/
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still need to narrow the type returned from resolveLoader, because $this->loaders is LoaderInterface<*> and the method needs to return LoaderInterface<TResourceType>.

Here, we assume that the caller will call ->resolveLoader($resource)->load($resource) for each resource - they won't attempt to reuse the loader for other resources.

Therefore, it doesn't matter if the type we return is stricter than the actual implementation of the loader we're returning, so long as it accepts the type of resource that was passed in. So the easiest way to do this is just to explicitly tag it the specialised type for phpstan.

@acoulton
Copy link
Copy Markdown
Contributor Author

@stof I've reverted the test refactoring and (I think) fixed the generic / phpstan tagging instead.

bin/update_i18n Outdated
$words = [$words];
}

assert(array_find($words, fn ($word) => is_string($word)) === null, 'Expected $words to be an array of strings');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this assertion looks weird to me. Shouldn't it assert that it cannot find any non-string value instead ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Urgh, yeah of course it should. I'd tried a whole range of options for trying to get phpstan to narrow the type itself without success, and mangled the one I'd settled on when I added it back to this file. Will fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

* There does not currently seem to be a way to force phpstan to detect this type narrowing at runtime
* https://github.com/phpstan/phpstan/issues/14360
*
* @var array<string> $words
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this @var should probably go near the assignment of the value (on line 24) instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately it can't go higher up because if it's before the assert(array_filter(...)) then phpstan will complain that the assertions are redundant https://phpstan.org/r/5e86b039-21e8-4bb9-82a1-4877aef0c1c2

Unless we only tag it, and don't assert at runtime at all, but that feels a bit like cheating

Accidentally missed the ! operator when bringing back the syntax from the file I'd been experimenting in.
@acoulton acoulton requested a review from stof March 31, 2026 09:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants