Skip to content

Hardening grid and resource rules, grid-bundle 1.15/1.16 compatibility - #113

Merged
shochdoerfer merged 18 commits into
bitExpert:masterfrom
shochdoerfer:refactor/phpstan_improvements
Sep 27, 2026
Merged

shochdoerfer merged 18 commits into
bitExpert:masterfrom
shochdoerfer:refactor/phpstan_improvements

Conversation

@shochdoerfer

Copy link
Copy Markdown
Member

Hardens the grid and resource rules, closes validation gaps that left several Sylius filter types unchecked, and gives the grid-bundle 1.15 → 1.16 interface move explicit test coverage on both sides.

Raised the supported grid-bundle floor to ^1.15 and split CI into explicit ^1.15 <1.16 and ^1.16 lanes, each pinned to its own constraint so the low lane can't silently drift to the high one.

The generic catch-all filter node now recognises both the legacy and the 1.16 FilterInterface, which otherwise stopped matching on 1.16.

…n config

All three ignoreErrors entries are unmatched and therefore silently
suppressed by reportUnmatchedIgnoredErrors: false:

- AbstractGridBuilderRule.php does not exist in the codebase at all.
- The two 'Cannot call' entries no longer match, since the collectors now
  hand rules a ClassReflection plus an already-resolved property name
  instead of a mixed value that gets called.

Removing the flag restores the default reportUnmatchedIgnoredErrors:
true, so any future ignoreErrors entry that stops matching is reported
instead of silently rotting.
The declared ^1.13 floor was never true and had never been exercised,
because CI installs the committed lockfile (which resolves 1.15.0) and
never runs a lowest-dependency job.

Verified by installing each version and running the real test suite:

- 1.13.0: fatal error. Sylius\Component\Grid\Attribute\AsGrid does not
  exist, and AbstractGrid in 1.13 has no default getName(), so the
  #[AsGrid] test fixtures are abstract-incomplete.
- 1.14.0: 1 failure. AsGrid exists, but EnumFilter was only added in
  1.15, so the filter fixture's EnumFilter case collects nothing.
- 1.15.0 and 1.16.1: 12 tests / 14 assertions pass.

Only the lock content-hash is updated; no resolved version changed.
The previous workflow ran a single 'composer install', so CI only ever
exercised whatever the committed lockfile happened to pin. That hid both
the broken ^1.13 floor and any regression in the 1.16 code path.

CI now installs one explicit constraint per lane instead of reusing the
lockfile. Pinning per lane matters: if the lock were kept, bumping it to
1.16 would silently turn the low lane into a second high lane and the
matrix would stop testing anything.

- '^1.15 <1.16'  -> resolves 1.15.1, legacy Sylius\Bundle\GridBundle
                    builder interfaces plus the #[AsGrid] attribute
- '^1.16'        -> resolves 1.16.1, Sylius\Component\Grid interfaces

Both lanes verified locally: 12 tests / 14 assertions and a clean
'level: max' run. Added a 'show resolved versions' step so a future
lane failure is diagnosable without re-running composer by hand, and
fail-fast: false so one red lane does not hide the other's result.

Note: 'composer update' resolves the full declared ranges, so the
PHPStan and PHPUnit patch versions now float in CI. That is intended:
this package is an extension whose job is to support a range, and the
committed lockfile remains the local-development pin.
Both rules called Type::getValue() on an attribute argument behind a
'@var array<string, ConstantStringType>' annotation. The annotation was
an unchecked assumption, not a guarantee, so any argument that is not a
constant string aborted the entire run:

    Internal error: Call to undefined method PHPStan\Type\ObjectType::getValue()

Because a PHPStan internal error invalidates the whole result
('Result is incomplete because of severe errors'), one bad attribute
suppressed every other finding in the project, including the unrelated
collector-based grid rules.

Reproduced pre-fix, both arguments crash the analysis:
  #[AsResource(formType: new stdClass())]   ObjectType
  #[Index(grid: [self::class])]             ConstantArrayType
  #[AsResource(formType: self::class)]      fine

The rules now accept only a single constant string, via
getConstantStrings(), and skip everything else. A non-string argument is
not resolvable to a class, so skipping is the honest answer.

Behaviour change: #[AsResource(formType: 123)] previously reported
'Form Type "123" not found!'; it is now skipped, because an int is not
a class-string and reporting it as a missing class was misleading.

IndexOperationNeedsGridClassRule had no test at all, so this fix also
adds the first coverage for it. New fixtures are registered in
autoload-dev.files, as the other fixtures are.

15 tests / 17 assertions, level: max clean. Reverting the fix makes the
new tests error out, so they genuinely cover the regression.
The rule listened on MethodReturnStatementsNode, which is emitted once per
method, but #[AsGrid(resourceClass: ...)] is a class-level attribute. Three
consequences:

1. A grid declaring no method was never visited, so its resource class was
   never validated at all.
2. The error was anchored to whichever method happened to be visited first.
   The existing attribute test expected line 24, which is the buildGrid()
   method, rather than line 21 where #[AsGrid] actually is.
3. AbstractGrid was required before the attribute was read, so a grid that
   does not extend the legacy base class had its resource class skipped
   entirely. Since grid-bundle 1.16 a grid may implement
   Sylius\Component\Grid\GridInterface instead, and Sylius deprecated
   Sylius\Bundle\GridBundle\Grid\GridInterface with 'will be removed in 2.0'.
   This is the direction the framework is moving, not an exotic case.

The rule now listens on InClassNode, so it runs exactly once per class, and
the attribute is checked without any hierarchy gate: the attribute is what
makes the class a grid. The legacy getResourceClass() path keeps its gate on
AbstractGrid or ResourceAwareGridInterface, so an unrelated class with a
similarly named method is still ignored.

The error is now reported on the #[AsGrid(...)] attribute itself, or on the
getResourceClass() declaration for the legacy path.

Also guards the attribute argument with getConstantStrings(), matching the
fix for the resource rules. The old code indexed getConstantStrings()[0]
directly, so a non-constant resourceClass raised 'Undefined array key 0'
and aborted the analysis.

The legacy method body is read from the AST rather than from the resolved
method return type: a 'getResourceClass(): string' returning Supplier::class
has the plain type 'string' while treatPhpDocTypesAsCertain is false, which
would lose the class name.

Verified: 17 tests pass on both lanes, 1.15 skips the >= 1.16 only test and
1.16 runs it, level: max clean on both. Reverting the rule makes the new
tests fail, so they cover the regression.
GridBuilderFieldIsPartOfResourceClass resolved the resource class once
per grid and then reused that variable for every field. The recursive
branch replaces it with the type of the segment it just resolved, so
once a dotted field such as "address.city" had been walked, every
following field was validated against App\Entity\Address instead of
App\Entity\Supplier.

The recursive walk also kept the resolved PHPStan Type around and called
ClassReflection methods on it. Type::hasProperty() is declared on Type
and answers with a TrinaryLogic, which casts to bool as true, so
!$resourceClass->hasProperty($name) was always false. Every segment past
the first one of a recursive field therefore passed silently, and so
did every field that followed a recursive field.

Walking a field path now resolves each segment back to a
ClassReflection, and the resource class is re-resolved for each field.
The type of a segment is only narrowed down when exactly one class name
can be determined, so an unresolvable path reports the existing
"Unable to identify the type" error instead of passing.

Adds fixtures for a field declared after a recursive field and for a
missing third segment of a recursive field. Neither was reported before
this change.
composer show accepts a single package plus an optional version, so
passing three package names aborted with "Too many arguments" and
exited 1. The step failed every CI run on both lanes.
Filter::supports() asked whether the class the static call was made on
was a supertype of FilterInterface, which asks the opposite of what was
intended. The result was never yes, so the node matched nothing and no
filter without a dedicated node was ever collected. On grid-bundle 1.15
that silently skipped Filter, BooleanFilter, DateFilter and MoneyFilter.

The node also only knew the old bundle interface. From 1.16 on the
concrete filter factories return
Sylius\Component\Grid\Builder\Filter\FilterInterface, so both
interfaces are now checked, the same way the collectors accept both.

The registry asks every node until one supports the filter, so once the
generic node started matching it had to be registered after the concrete
ones. Its getFilterFields() only reads the first argument, so leaving it
in front of StringFilter and SelectFilter would have hidden the field
array those nodes read from their later arguments. extension.neon now
registers it last and says why.

Adds a Filter::create() case to the grid fixture; it was not reported
before this change.
The field collector only accepted a static call named create().
CallableField::createForService() builds the same kind of field for a
service-backed callable and takes the field name as its first argument,
exactly like create(), so it was dropped before any field node saw it and
the field was never validated against the resource class.

Both factory names are now accepted. This is the only alternative factory
among the grid-bundle field and filter classes; every other one uses
create(), so the filter collector is left as it is.

Adds a createForService() case to the grid fixture; it was not reported
before this change.
getCollectors() builds its own registry, so it did not follow the
reordering of the catch-all node. Leaving the two out of step would let
the tests pass against an order the extension does not ship.

Note that the built-in outcome does not depend on the order: only
Filter implements FilterInterface, so the catch-all node and the
name-based nodes match disjoint sets. The order matters for custom
nodes registered for custom filter classes, which the catch-all would
otherwise shadow. That case has no automated coverage yet.
These three built-in filters were never validated. They are plain final
classes with a static create() that returns FilterInterface, but they do
not themselves implement FilterInterface, so the catch-all Filter node
could not match them and they had no dedicated node. A grid using them
was silently unchecked.

All three take the field name as the first create() argument, so each
node is a plain name match plus the shared snake_case conversion.
MoneyFilter's second argument is the currency code and is deliberately
ignored, like EnumFilter's option arguments.

Verified the new nodes are load-bearing: pointing one FILTER_TYPE at a
wrong class name makes the corresponding expectation disappear.
Most of the drift was in the grid layer, and it was actively misleading
rather than merely incomplete:

- Both grid rules were documented as Rule<CollectedDataNode>, which is
  right, while their own @implements Rule<StaticCall> docblocks are
  wrong. The file now flags that getNodeType() is authoritative and
  logs the stale docblock as Known Issue 2.
- The new-namespace "limitation" was wrong. grid-bundle 1.16 moved the
  interfaces, not the concrete classes, so matching the bundle namespace
  is correct on both lanes. Added a Grid Bundle 1.16 section explaining
  why there is nothing in the Component namespace to match.
- The Filter node inversion was still listed as a live bug and as a
  "Bug?" in the node table. It is fixed; replaced with the registry
  ordering constraint, and the table now carries the real create()
  signatures instead of "likely" guesses.
- ResourceAwareGridNeedsResourceClass was listed as
  MethodReturnStatementsNode. It is InClassNode and checks the
  #AsGrid attribute before any method.
- Known Issue 6 pointed at the wrong fixture for the lowercase class.
- BooleanFilter, DateFilter and MoneyFilter are missing from the node
  lists, and the field collector now accepts createForService.

Added the C4 per-field re-resolution invariant, the fixture-sharing and
line-number traps, and the requirement to prove a new node is actually
load-bearing before trusting a green suite.
Both rules declared @implements Rule<StaticCall> while getNodeType()
returns CollectedDataNode::class. The redundant
`if (!$node instanceof CollectedDataNode)` guard narrowed $node back to
the correct type inside the method body, which is precisely why the
wrong annotation never produced a PHPStan error: the guard was hiding
it. Removing the guard without fixing the docblock makes
$node->get() fail as an undefined method on PhpParser\Node.

The parameter stays typed as Node because Rule::processNode() declares
it that way and narrowing a parameter is a contravariance violation.

Verified on grid-bundle ^1.15 (17 tests/18 assertions/1 skipped) and
^1.16 (17 tests/19 assertions), PHPStan level max clean on both.
Known Issue 2 described a defect that d73d56a fixed, so it is replaced
by the underlying trap, which is still live for anyone adding a rule:
a defensive instanceof guard re-narrows the parameter and silently
suppresses a wrong @implements generic from PHPStan entirely.
PHPStan's Rule interface declares `@return class-string<TNodeType>`. Three
rules overrode that with a bare `class-string`, which widens the type and
throws the generic away, and the two Resource rules had no annotation at
all. All five now carry `class-string<CollectedDataNode>` or
`class-string<InClassNode>`.

This is a safety net, not just tidying: with the generic preserved,
PHPStan now rejects a getNodeType() that disagrees with the
@implements annotation. Verified by pointing one rule's getNodeType() at
a different node class, which produces
"should return class-string<...> but returns string". That is the exact
class of bug fixed in d73d56a, previously invisible until a runtime
failure.

Also drops an unused catch variable in the field rule.
The catch-all Filter node matches anything implementing FilterInterface, so
being registered before a more specific node silently shadows it. Nothing
tested that, because the rule tests hand-build their own registry and never
read extension.neon.

Adds a user-style filter that implements FilterInterface, plus a grid whose
single CustomFilter::create() call yields a different field depending on
which node resolves it. Three cases:

  * catch-all alone resolves the class, proving the fixture is wired up
  * dedicated node before catch-all -> dedicated node wins
  * catch-all before dedicated node -> catch-all shadows it

The dedicated node reports 'customNodeField' while the catch-all reports the
create() argument, so a passing assertion can only come from the intended
node. A fourth test parses extension.neon and asserts the catch-all is
registered last, which is the invariant that actually ships.

CustomFilter.php implements every interface method rather than stubbing
them, and is intentionally left in the PHPStan scope: on grid-bundle 1.16
the legacy FilterInterface extends the new Component one, so analysing it
is what proves the implementation still holds on that lane.
@shochdoerfer shochdoerfer added this to the 0.4.0 milestone Sep 27, 2026
@shochdoerfer
shochdoerfer merged commit dc82d24 into bitExpert:master Sep 27, 2026
2 checks passed
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.

1 participant