From 9b51d56a7ca11b779986881fb58b588b0b7d5b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 20:42:11 +0200 Subject: [PATCH 01/18] Update docs --- AGENTS.md | 498 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 +- 2 files changed, 499 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c035f51 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,498 @@ +# AGENTS.md – PHPStan Sylius Extension + +This file provides structured context for AI agents (e.g., copilot, Claude, etc.) working on the `bitexpert/phpstan-sylius` codebase. It documents architecture, conventions, and key implementation details to enable rapid, accurate contributions. + +--- + +## Overview + +- **Package**: `bitexpert/phpstan-sylius` +- **Type**: PHPStan extension (type: `phpstan-extension` in `composer.json`) +- **Purpose**: Additional static analysis rules for Sylius projects, validating grid configurations and resource metadata. +- **Requirements**: PHP ^8.2, PHPStan ^2.1, Sylius ^2.0 (resource-bundle ^1.12, grid-bundle ^1.13). +- **License**: MIT + +### Installation & Usage + +Install via Composer as a dev dependency: +```bash +composer require --dev bitexpert/phpstan-sylius +``` + +The extension is auto-discovered via `composer.json` `extra.phpstan.includes` (`extension.neon`). + +--- + +## Project Structure + +``` +src/ +└─ bitExpert/PHPStan/ + ├─ Util/PropertyName.php # Utility for snake_case → camelCase conversion + ├─ Sylius/ + │ ├─ Collector/Grid/ + │ │ ├─ AbstractGridClassCollector.php # Base: grid detection logic + │ │ ├─ CollectRessourceClassForGridClass.php # Collector: grid ↔ resource mapping + │ │ ├─ CollectFieldsForGridClass.php # Collector: grid field definitions + │ │ └─ CollectFilterForGridClass.php # Collector: grid filter definitions + │ │ + │ ├─ Collector/Grid/Field/ + │ │ ├─ FieldNode.php # Interface for field type descriptors + │ │ ├─ FieldRegistry.php # Interface + │ │ ├─ FieldRegistryFactory.php # DI factory for field registry + │ │ ├─ DefaultFieldRegistry.php # Concrete registry + │ │ └─ *FieldNode.php # StringFieldNode, DateTimeFieldNode, TwigFieldNode, EnumFieldNode, CallableFieldNode, GenericFieldNode + │ │ + │ ├─ Collector/Grid/Filter/ + │ │ ├─ FilterNode.php # Interface for filter type descriptors + │ │ ├─ FilterRegistry.php # Interface + │ │ ├─ FilterRegistryFactory.php # DI factory for filter registry + │ │ ├─ DefaultFilterRegistry.php # Concrete registry + │ │ └─ *Filter.php # EntityFilter, EnumFilter, ExistsFilter, Filter, SelectFilter, StringFilter + │ │ + │ └─ Rule/ + │ ├─ Grid/ + │ │ ├─ ResourceAwareGridNeedsResourceClass.php # Rule: check grid resource class exists + │ │ ├─ GridBuilderFieldIsPartOfResourceClass.php # Rule: grid fields must match resource + │ │ └─ GridBuilderFilterIsPartOfResourceClass.php# Rule: grid filters must match resource + │ │ + │ └─ Resource/ + │ ├─ IndexOperationNeedsGridClassRule.php # Rule: Index metadata grid exists + │ └─ ResourceAttributeNeedsFormTypeRule.php # Rule: AsResource form type exists +``` + +**Note**: `CollectRessourceClassForGridClass.php` contains a typo: `Ressource` (double `s`), which is preserved for historical compatibility. + +--- + +## Key Conventions + +### 1. Coding Standards +- PSR-2 (with PHP-CS-Fixer config): + - Short array syntax `[]` + - Strict types declared per file (`declare(strict_types=1);`) + - `final` classes where appropriate + - `readonly` properties (PHP 8.1+) + - Constructor property promotion + - No trailing whitespace, single space around concatenation +- **Strict mode**: All files are strict-typed. +- **PHP Version**: Minimum 8.2 (uses readonly properties, union types, attributes, etc.) + +### 2. File Naming +- Classes are `PascalCase`. +- Test files end with `UnitTest.php`. +- Data fixtures (for analysis) use descriptive names like `grid.php`, `entity.php`, often matching test method context. + +### 3. Namespace +- Source: `bitExpert\PHPStan\` +- Tests: `bitExpert\PHPStan\` (autoload-dev maps tests to same namespace) + +--- + +## Architecture & Workflow + +### Two-Layer Pattern: Collectors → Rules + +PHPStan extensions in this project follow a two-phase approach: + +1. **Collectors** scan AST and return lightweight tuples (grid class, property/filter names, line numbers). +2. **Rules** consume collected data (via `CollectedDataNode`) and perform validation, reporting errors. + +This separation improves performance (collectors can run in parallel) and keeps rules focused on analysis rather than parsing. + +### Rule Execution Flow + +| Rule | Node Type | Key Logic | +|------|-----------|-----------| +| `ResourceAwareGridNeedsResourceClass` | `MethodReturnStatementsNode` | Checks `getResourceClass()` or `#AsGrid(resourceClass:)` for existing class. | +| `GridBuilderFieldIsPartOfResourceClass` | `CollectedDataNode` | Validates every grid field exists as a property/getter on the resource class. Supports recursive fields (`address.city`). | +| `GridBuilderFilterIsPartOfResourceClass` | `CollectedDataNode` | Validates every grid filter field exists on resource. Does **not** support recursive checks (dots are skipped). | +| `IndexOperationNeedsGridClassRule` | `InClassNode` | Validates `#[Index(grid: '...')]` refers to an existing grid class. | +| `ResourceAttributeNeedsFormTypeRule` | `InClassNode` | Validates `#[AsResource(formType: '...')]` refers to an existing form type. | + +--- + +## Rules Reference + +### `ResourceAwareGridNeedsResourceClass` + +- **Namespace**: `bitExpert\PHPStan\Sylius\Rule\Grid` +- **Node type**: `MethodReturnStatementsNode` +- **Scope**: Only subclasses of `Sylius\Bundle\GridBundle\Grid\AbstractGrid` or classes with `#AsGrid` attribute. +- **Checks**: + - **New API**: `#[Sylius\Component\Grid\Attribute\AsGrid(resourceClass: 'App\Entity\Supplier')]` + - **Old API**: `public function getResourceClass(): string { return Supplier::class; }` +- **Error**: `sylius.grid.resourceClassRequired` → `Resource class "%s" not found!` + +--- + +### `GridBuilderFieldIsPartOfResourceClass` + +- **Namespace**: `bitExpert\PHPStan\Sylius\Rule\Grid` +- **Node type**: `CollectedDataNode` +- **Collectors used**: + - `CollectRessourceClassForGridClass` → `gridClass → resourceClass` + - `CollectFieldsForGridClass` → `gridClass → [fieldName, line]` +- **Validation**: + - Non-dotted field names (e.g., `name`) → check property or `get{Name}` method. + - Dotted field names (e.g., `address.city`) → recursive property traversal: + 1. Check first segment (`address`) exists. + 2. Determine next type via getter return or property type (native → PHPDoc). + 3. Repeat until all segments validated. +- **Special case**: Field name `.` means "whole object passed"; ignored. +- **Errors**: + - `sylius.grid.resourceClassMissingProperty`: Field missing as property/getter. + - `sylius.grid.resourceClassPropertyMissingType`: Unable to identify type for recursive path. +- **Identifier note**: Grammar intentionally uses "needs to exists" (not "need to exist") in error messages. + +--- + +### `GridBuilderFilterIsPartOfResourceClass` + +- **Namespace**: `bitExpert\PHPStan\Sylius\Rule\Grid` +- **Node type**: `CollectedDataNode` +- **Collectors used**: + - `CollectRessourceClassForGridClass` → `gridClass → resourceClass` + - `CollectFilterForGridClass` → `gridClass → [filterFieldList, line]` +- **Validation**: + - Each filter field (from array) checked against resource class. + - **No recursion**: Fields containing `.` (e.g., `address.city`) are **skipped** entirely. +- **Error**: `sylius.grid.resourceClassMissingFilter` → `The filter field "%s" needs to exists as property in resource class "%s".` + +--- + +### `IndexOperationNeedsGridClassRule` + +- **Namespace**: `bitExpert\PHPStan\Sylius\Rule\Resource` +- **Node type**: `InClassNode` +- **Scope**: Classes implementing `Sylius\Resource\Model\ResourceInterface`. +- **Checks**: `#[Sylius\Resource\Metadata\Index(grid: 'App\Grid\MyGrid')]` +- **Error**: `sylius.resource.gridClassNotFound` → `Grid class "%s" not found!` + +--- + +### `ResourceAttributeNeedsFormTypeRule` + +- **Namespace**: `bitExpert\PHPStan\Sylius\Rule\Resource` +- **Node type**: `InClassNode` +- **Scope**: Classes implementing `Sylius\Resource\Model\ResourceInterface`. +- **Checks**: `#[Sylius\Resource\Metadata\AsResource(formType: 'App\Form\MyType')]` +- **Error**: `sylius.resource.formTypeNotFound` → `Form Type "%s" not found!` + +--- + +## Collectors Reference + +### `AbstractGridClassCollector` + +- **Purpose**: Base helper for detecting grid classes. +- **Key methods**: + - `scopeIsGrid(Scope $scope): bool` + - Checks for `#AsGrid` attribute OR subclass of `Sylius\Bundle\GridBundle\Grid\AbstractGrid`. + - Catches any `Throwable` to avoid crashes. + - `isSubtypeOf(Type $type, string $superType): bool` + - Returns `true` if `$type` is a subtype of `$superType`. +- **Usage**: All grid collectors extend this. + +--- + +### `CollectRessourceClassForGridClass` + +- **Namespace**: `bitExpert\PHPStan\Sylius\Collector\Grid` +- **Type**: `Collector` +- **Returns**: `[gridClass, resourceClass, line]` +- **Logic**: + 1. Check `#AsGrid(resourceClass: ...)` attribute for constant string. + 2. Fallback to `getResourceClass()` method returning `String_` or `ClassConstFetch`. +- **Note**: Typo in class name (`Ressource` with double `s`). + +--- + +### `CollectFieldsForGridClass` + +- **Type**: `Collector` +- **Validates node**: + - Must be a static `create()` call. + - Must be inside grid scope (`scopeIsGrid`). + - Return type must be subtype of `Sylius\Component\Grid\Builder\Field\FieldInterface` (new) **or** `Sylius\Bundle\GridBundle\Builder\Field\FieldInterface` (old). +- **Field resolution**: + - Iterates `FieldRegistry` entries (ordered by service registration). + - First `supports()` match wins. + - Returns only the **first field name** from `getFieldNames()` (even if multiple returned). +- **Special handling**: If field name is `.` (object passed to field), returns `null` (ignored). + +--- + +### `CollectFilterForGridClass` + +- **Type**: `Collector, int<1, max>}>` (doc declares `-1|positive int` but actual line numbers are positive) +- **Validates node**: + - Static `create()` call. + - Grid scope. + - Return type subtype of `Sylius\Component\Grid\Builder\Filter\FilterInterface` (new) **or** `Sylius\Bundle\GridBundle\Builder\Filter\FilterInterface` (old). +- **Filter resolution**: + - Iterates `FilterRegistry` entries. + - First `supports()` match wins. + - Returns `[gridClass, array of field names, line]`. +- **Key**: Supports array of field names (e.g., `StringFilter::create('name', ['field1', 'field2'])`). + +--- + +## Field Registry & Field Nodes + +### Field Registry System + +- **Factory**: `FieldRegistryFactory::createRegistry()` uses PHPStan Container to collect all services tagged `phpstan.sylius.grid.field`. +- **Implementation**: `DefaultFieldRegistry` stores an immutable array of `FieldNode[]`. + +### Field Nodes + +All field nodes implement `FieldNode`: + +```php +interface FieldNode { + public function supports(FullyQualified $nodeClass): bool; + public function getFieldNames(StaticCall $node): array; // snake_case → camelCase +} +``` + +#### Built-in Field Nodes + +| Class | Sylius Class Name | Argument(s) Processed | Conversion | +|-------|-------------------|-----------------------|------------| +| `StringFieldNode` | `Sylius\Bundle\GridBundle\Builder\Field\StringField` | args[0] string | snake → camel | +| `DateTimeFieldNode` | `Sylius\Bundle\GridBundle\Builder\Field\DateTimeField` | args[0] string | snake → camel | +| `TwigFieldNode` | `Sylius\Bundle\GridBundle\Builder\Field\TwigField` | args[0] string | snake → camel | +| `EnumFieldNode` | `Sylius\Bundle\GridBundle\Builder\Field\EnumField` | args[0] string | snake → camel | +| `CallableFieldNode` | `Sylius\Bundle\GridBundle\Builder\Field\CallableField` | args[0] string | snake → camel | +| `GenericFieldNode` | `Sylius\Bundle\GridBundle\Builder\Field\Field` | args[0] string | snake → camel | + +#### Important Notes + +- **All nodes only support the old `Sylius\Bundle\GridBundle` namespace**, even though collectors accept both old and new (`Sylius\Component\Grid`) interfaces. +- If a project uses the new `Sylius\Component\Grid\Builder\Field\Field` classes, they will **not** be recognized by any field node unless custom nodes are registered. +- The conversion helper `PropertyName::convertSnakeToCamelCase()` lowercases the entire string before shifting, so `My_Field` → `myField`. + +--- + +## Filter Registry & Filter Nodes + +### Filter Registry System + +- **Factory**: `FilterRegistryFactory::createRegistry()` collects services tagged `phpstan.sylius.grid.filter`. +- **Implementation**: `DefaultFilterRegistry` stores `FilterNode[]`. + +### Filter Nodes + +All filter nodes implement `FilterNode`: + +```php +interface FilterNode { + public function supports(FullyQualified $nodeClass): bool; + public function getFilterFields(StaticCall $node): array; // snake_case → camelCase +} +``` + +#### Built-in Filter Nodes + +| Class | Sylius Class Name | Argument Logic | Notes | +|-------|-------------------|----------------|-------| +| `EntityFilter` | `Sylius\Bundle\GridBundle\Builder\Filter\EntityFilter` | args[3] if array of strings; fallback to args[0] | Signature likely `create(string, string, bool, ?array)` | +| `EnumFilter` | `Sylius\Bundle\GridBundle\Builder\Filter\EnumFilter` | args[3] if string; fallback to args[0] | Signature likely `create(string, string, bool, ?string)` | +| `ExistsFilter` | `Sylius\Bundle\GridBundle\Builder\Filter\ExistsFilter` | args[1] if string; fallback to args[0] | Signature likely `create(string, string)` | +| `Filter` | `Sylius\Bundle\GridBundle\Builder\Filter\FilterInterface` | args[0] only | **Bug?**: `isSuperTypeOf` check appears inverted (should be `$filterType->isSuperTypeOf($nodeClassType)`). Practically only supports direct interface calls. | +| `SelectFilter` | `Sylius\Bundle\GridBundle\Builder\Filter\SelectFilter` | args[3] if string; fallback to args[0] | | +| `StringFilter` | `Sylius\Bundle\GridBundle\Builder\Filter\StringFilter` | args[1] if array of strings; fallback to args[0] | Signature likely `create(string, array|callable)` | + +#### Important Notes + +- Same limitation as field nodes: **only old bundle namespace** recognized. +- `Filter` class likely broken: its `supports()` logic is reversed, making it effectively unused. +- Recursive filter fields (e.g., `address.city`) are **skipped** by the rule, not by the collector. + +--- + +## Utility + +### `PropertyName::convertSnakeToCamelCase` + +- **Signature**: `static function convertSnakeToCamelCase(string $string): string` +- **Behavior**: + - If no `_`, returns input unchanged. + - Splits on `_`, lowercases each part, makes first part lowercase, rest `ucfirst`. + - Example: `user_profile_image` → `userProfileImage`. +- **Usage**: All field/filter nodes call this on the first string argument. + +--- + +## Configuration & Extension Points + +### `extension.neon` + +- **Rules** (registered with PHPStan): + ```neon + rules: + - bitExpert\PHPStan\Sylius\Rule\Grid\ResourceAwareGridNeedsResourceClass + - bitExpert\PHPStan\Sylius\Rule\Grid\GridBuilderFieldIsPartOfResourceClass + - bitExpert\PHPStan\Sylius\Rule\Grid\GridBuilderFilterIsPartOfResourceClass + - bitExpert\PHPStan\Sylius\Rule\Resource\IndexOperationNeedsGridClassRule + - bitExpert\PHPStan\Sylius\Rule\Resource\ResourceAttributeNeedsFormTypeRule + ``` + +- **Services**: + - `FieldRegistryFactory` → `syliusFieldTypeRegistry` via `createRegistry()`. + - `FilterRegistryFactory` → `syliusFilterTypeRegistry`. + - Collectors (`CollectRessourceClassForGridClass`, `CollectFieldsForGridClass`, `CollectFilterForGridClass`) tagged `phpstan.collector`. + - All field/filter nodes tagged with `phpstan.sylius.grid.field` / `phpstan.sylius.grid.filter`. + +### Custom Field/Filter Types + +To add custom types: + +1. Implement `FieldNode` or `FilterNode`. +2. Register service in `phpstan.neon` with the appropriate tag. + +Example (custom field node): +```neon +services: + - class: App\PHPStan\CustomFieldNode + tags: + - phpstan.sylius.grid.field +``` + +--- + +## Testing + +### Test Infrastructure + +- **Framework**: PHPUnit 11. +- **Configuration**: `phpunit.xml.dist` (suffix `UnitTest.php`, bootstrap `tests/bootstrap.php`). +- **Rules** use `PHPStan\Testing\RuleTestCase`. +- **Utility/Registry** use standard `PHPUnit\Framework\TestCase`. + +### Test Files + +| Test File | Purpose | Fixtures | +|-----------|---------|----------| +| `PropertyNameUnitTest` | Unit tests for snake → camel conversion | None | +| `ResourceAttributeNeedsFormTypeUnitTest` | Validates `AsResource` form type existence | `tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity.php` | +| `ResourceAwareGridNeedsResourceClassUnitTest` | Tests old + new grid API with missing resource class | `grid_needs_resource_model.php`, `grid_needs_resource_model_attr.php` | +| `ResourceAwareGridNeedsResourceClassValidUnitTest` | Validates correct configurations | `grid_valid.php`, `grid_valid_attr.php` | +| `GridBuilderFieldIsPartOfResourceClassUnitTest` | Tests invalid field detection | `grid.php` | +| `GridBuilderFieldIsPartOfResourceClassValidUnitTest` | Validates correct grids | `grid_valid.php` | +| `GridBuilderFilterIsPartOfResourceClassUnitTest` | Tests invalid filter detection | `grid.php` | +| `GridBuilderFilterIsPartOfResourceClassValidUnitTest` | Validates correct grids | `grid_valid.php` | +| `DefaultFilterRegistryUnitTest` | Registry functionality | None | + +### Fixtures + +All fixtures (for analysis) reside under `tests/bitExpert/PHPStan/Sylius/Rule/*/data/`: +- `entity.php` (Resource): `App\Entity\Status` enum, `Address`, `Supplier` (implements `ResourceInterface`, missing `name` property). +- `grid.php` (Grid): `AdminSupplierGrid` with invalid field/filter definitions. +- `grid_valid.php`: Correct grid configuration. +- `grid_valid_attr.php`: Correct `#AsGrid` usage. +- `grid_needs_resource_model.php`: Old API with one missing class. +- `grid_needs_resource_model_attr.php`: `#AsGrid` with one missing class. + +These files are loaded via `composer.json` `autoload-dev.files` so classes exist during analysis. + +--- + +## Development Commands + +From `composer.json`: + +| Command | Description | +|---------|-------------| +| `composer cs` | Check coding standards (dry-run) | +| `composer cs-fix` | Fix coding standards | +| `composer check-license` | Verify license compliance | +| `composer static-analysis` | Run PHPStan (uses `phpstan.dist.neon`) | +| `composer test` | Run PHPUnit tests | + +### CI Pipeline (`.github/workflows/ci.yml`) + +1. Checkout repo +2. Setup PHP 8.2 +3. Install dependencies +4. License check +5. Coding standards +6. Static analysis +7. Unit tests + +### Coding Standards + +- **Tool**: PHP-CS-Fixer +- **Config**: `.php-cs-fixer.dist.php` +- **Rules**: `@Symfony`, `@Symfony:risky`, strict comparison/param, declare strict, ordered imports, multi-line extends, trailing commas, etc. +- **Excludes**: `vendor/` + +--- + +## Known Issues & Gotchas + +### 1. Typo in Collector Name +- **File**: `CollectRessourceClassForGridClass.php` (`Ressource` double `s`). +- **Impact**: Must be preserved for BC; do not rename. + +### 2. Stale PHPStan Ignore +- `phpstan.dist.neon` ignores errors in `AbstractGridBuilderRule.php`, but this file **does not exist** in the codebase. +- **Action**: Can likely be removed. + +### 3. Field/Filter Support Limitation +- **All built-in nodes only support `Sylius\Bundle\GridBundle\*` namespace**, even though collectors accept both old and new (`Sylius\Component\Grid`) interfaces. +- **Consequence**: Projects using the new component interfaces must register custom nodes. +- **Recommendation**: Update field/filter nodes to support both namespaces. + +### 4. `Filter` Node Bug +- `Filter::supports()` logic uses `$nodeClassType->isSuperTypeOf($filterType)` when it should be the reverse. +- **Consequence**: The generic `Filter` node likely never matches any concrete filter class. +- **Impact**: May cause some custom filters to be silently skipped. + +### 5. Recursive Filter Fields Skipped +- The rule `GridBuilderFilterIsPartOfResourceClass` skips any filter field containing `.` (e.g., `address.city`). +- **Rationale**: Filters typically do not need recursive access, but this may be overly restrictive. +- **Consequence**: Users cannot validate recursive filter fields. + +### 6. Case Sensitivity in Fixtures +- Test fixtures use non-standard naming: `tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/entity.php` defines class `App\Entity\entity` (lowercase `entity`). +- **Impact**: Avoid confusion; stick to PSR-1 naming in new code. + +--- + +## Extending the Extension + +### Adding a New Rule + +1. Create rule class in `src/bitExpert/PHPStan/Sylius/Rule/...`. +2. Implement `PHPStan\Rules\Rule`. +3. Register in `extension.neon` under `rules`. +4. Add test in `tests/.../Rule/.../...UnitTest.php`. +5. Add fixture if needed. + +### Adding a New Collector + +1. Implement `PHPStan\Collectors\Collector`. +2. Extend `AbstractGridClassCollector` for grid-related logic. +3. Register in `extension.neon` with `phpstan.collector` tag. +4. Use in rule(s) by requesting collected data via `CollectedDataNode`. + +### Adding a New Field/Filter Node + +1. Implement `FieldNode` or `FilterNode`. +2. Tag service in `extension.neon` with `phpstan.sylius.grid.field` or `phpstan.sylius.grid.filter`. +3. Ensure `supports()` correctly checks class name. +4. Test with appropriate fixtures. + +--- + +## References + +- **PHPStan Docs**: [https://phpstan.org](https://phpstan.org) +- **Sylius Resource Bundle**: [https://github.com/Sylius/ResourceBundle](https://github.com/Sylius/ResourceBundle) +- **Sylius Grid Bundle**: [https://github.com/Sylius/GridBundle](https://github.com/Sylius/GridBundle) + +--- + +*Last updated: 2026-09-26* diff --git a/README.md b/README.md index 0b366ac..5ac22e3 100644 --- a/README.md +++ b/README.md @@ -37,14 +37,12 @@ This PHPStan extension works for both Sylius plugins and Sylius application proj The following rules have been implemented: - Rule to check if resource classes defined either via AbstractGrid::getResourceClass() or the #AsGrid attribute exist - Rule to check that configured grid fields belong to the configured resource class +- - custom field types are supported - Rule to check that configured filter fields belong to the configured resource class - custom filter types are supported - Rule to check that grid class configured via the `Index` attribute exists - Rule to check that form type configured via the `AsResource` attribute exists -Current assumptions: -- Grids are configured by extending the `Sylius\Bundle\GridBundle\Grid\AbstractGrid` class - ### Custom filter types To include your custom filter type in the checks, implement the `bitExpert\PHPStan\Sylius\Collector\Grid\Filter\FilterNode` interface and add a service to your `phpstan.neon` file and tag it with the `phpstan.sylius.grid.filter` tag. From c5021902d97a5441934e0991e943d41cd53cebf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 20:55:35 +0200 Subject: [PATCH 02/18] Remove dead ignoreErrors and reportUnmatchedIgnoredErrors from phpstan 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. --- phpstan.dist.neon | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/phpstan.dist.neon b/phpstan.dist.neon index c998124..8372945 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,6 +1,5 @@ parameters: level: max - reportUnmatchedIgnoredErrors: false treatPhpDocTypesAsCertain: false paths: - src @@ -13,13 +12,3 @@ parameters: - tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity.php fileExtensions: - php - ignoreErrors: - - - message: '~Cannot cast mixed to string.~' - path: src/bitExpert/PHPStan/Sylius/Rule/Grid/AbstractGridBuilderRule.php - - - message: '~Cannot call~' - path: src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php - - - message: '~Cannot call~' - path: src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClass.php From 96873dc91eefa7570d02c5c7e0fa735e6ae617b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 20:56:58 +0200 Subject: [PATCH 03/18] Raise grid-bundle dev floor from ^1.13 to ^1.15 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. --- composer.json | 2 +- composer.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 755622b..6bafd16 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ }, "require-dev": { "sylius/resource-bundle": "^1.12", - "sylius/grid-bundle": "^1.13", + "sylius/grid-bundle": "^1.15", "nikic/php-parser": "^5.4", "phpunit/phpunit": "^11.5", "friendsofphp/php-cs-fixer": "^3.69", diff --git a/composer.lock b/composer.lock index ce58728..7a5b22b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "67e6e8576547503d886971e1594d1c53", + "content-hash": "8d1c1b52ac01881ae4b0d930d5158e1f", "packages": [ { "name": "phpstan/phpstan", From 466b100186e46a012eae4e5685647df45f00d35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 20:58:22 +0200 Subject: [PATCH 04/18] Add explicit grid-bundle <1.16 and >=1.16 lanes to CI 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. --- .github/workflows/ci.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a2dd7f..ebfbc90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,22 @@ on: jobs: run: + name: php ${{ matrix.php-versions }} / grid-bundle ${{ matrix.grid-bundle }} runs-on: ${{ matrix.operating-system }} strategy: + fail-fast: false matrix: operating-system: ['ubuntu-latest'] php-versions: ['8.2'] + # The extension supports grid-bundle >= 1.15. Sylius 1.16 moved the + # builder interfaces into Sylius\Component\Grid and deprecated the + # Sylius\Bundle\GridBundle equivalents, so both sides of that boundary + # need explicit coverage. Constraints are pinned per lane instead of + # reusing the committed lockfile, otherwise bumping the lock to 1.16 + # would silently turn the low lane into a second high lane. + grid-bundle: + - '^1.15 <1.16' + - '^1.16' coveralls: [ false ] steps: - name: Checkout repo @@ -26,7 +37,12 @@ jobs: extensions: bcmath, gd - name: Install Composer dependencies - run: composer install + run: | + composer require --dev "sylius/grid-bundle:${{ matrix.grid-bundle }}" --no-update --no-interaction + composer update --no-interaction --no-progress + + - name: Show resolved dependency versions + run: composer show sylius/grid-bundle phpstan/phpstan phpunit/phpunit - name: Composer license check run: composer check-license From b00b24bc26a30172a7a2d15400cc5f1bf452b43b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 21:02:29 +0200 Subject: [PATCH 05/18] Stop resource attribute rules from crashing the analysis Both rules called Type::getValue() on an attribute argument behind a '@var array' 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. --- composer.json | 4 +- phpstan.dist.neon | 2 + .../IndexOperationNeedsGridClassRule.php | 45 ++++++---- .../ResourceAttributeNeedsFormTypeRule.php | 45 ++++++---- .../IndexOperationNeedsGridClassUnitTest.php | 57 +++++++++++++ ...ResourceAttributeNeedsFormTypeUnitTest.php | 20 +++++ .../Rule/Resource/data/entity_index.php | 19 +++++ .../data/entity_non_constant_attribute.php | 84 +++++++++++++++++++ 8 files changed, 247 insertions(+), 29 deletions(-) create mode 100644 tests/bitExpert/PHPStan/Sylius/Rule/Resource/IndexOperationNeedsGridClassUnitTest.php create mode 100644 tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_index.php create mode 100644 tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_non_constant_attribute.php diff --git a/composer.json b/composer.json index 6bafd16..d296a53 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,9 @@ "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php", "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model.php", "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_attr.php", - "tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity.php" + "tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity.php", + "tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_index.php", + "tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_non_constant_attribute.php" ] }, "extra": { diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 8372945..7177a87 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -10,5 +10,7 @@ parameters: - tests/bitExpert/PHPStan/Sylius/Rule/Grid//data/grid_needs_resource_model.php - tests/bitExpert/PHPStan/Sylius/Rule/Grid//data/grid_needs_resource_model_attr.php - tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity.php + - tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_index.php + - tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_non_constant_attribute.php fileExtensions: - php diff --git a/src/bitExpert/PHPStan/Sylius/Rule/Resource/IndexOperationNeedsGridClassRule.php b/src/bitExpert/PHPStan/Sylius/Rule/Resource/IndexOperationNeedsGridClassRule.php index d56816b..e735aff 100644 --- a/src/bitExpert/PHPStan/Sylius/Rule/Resource/IndexOperationNeedsGridClassRule.php +++ b/src/bitExpert/PHPStan/Sylius/Rule/Resource/IndexOperationNeedsGridClassRule.php @@ -18,7 +18,7 @@ use PHPStan\Reflection\ReflectionProvider; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; -use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\Type; /** * @implements Rule @@ -48,26 +48,43 @@ public function processNode(Node $node, Scope $scope): array $resourceClassAttributes = $classReflection->getAttributes(); foreach ($resourceClassAttributes as $attribute) { if ('Sylius\Resource\Metadata\Index' === $attribute->getName()) { - /** @var array $argumentTypes */ $argumentTypes = $attribute->getArgumentTypes(); - if (isset($argumentTypes['grid'])) { - $gridClass = $argumentTypes['grid']->getValue(); + $gridClass = self::resolveConstantString($argumentTypes['grid'] ?? null); + if (null === $gridClass) { + continue; + } - try { - $this->broker->getClass($gridClass); - } catch (\Throwable $e) { - $message = \sprintf('Grid class "%s" not found!', $gridClass); + try { + $this->broker->getClass($gridClass); + } catch (\Throwable) { + $message = \sprintf('Grid class "%s" not found!', $gridClass); - return [ - RuleErrorBuilder::message($message) - ->identifier('sylius.resource.gridClassNotFound') - ->build(), - ]; - } + return [ + RuleErrorBuilder::message($message) + ->identifier('sylius.resource.gridClassNotFound') + ->build(), + ]; } } } return []; } + + /** + * Attribute arguments are not guaranteed to be a single constant string, for + * example #[Index(grid: new SomeType())] yields an ObjectType. Calling + * Type::getValue() on such an argument raises an internal error and aborts the + * whole analysis, so only single constant strings are accepted. + */ + private static function resolveConstantString(?Type $argumentType): ?string + { + if (null === $argumentType) { + return null; + } + + $constantStrings = $argumentType->getConstantStrings(); + + return 1 === \count($constantStrings) ? $constantStrings[0]->getValue() : null; + } } diff --git a/src/bitExpert/PHPStan/Sylius/Rule/Resource/ResourceAttributeNeedsFormTypeRule.php b/src/bitExpert/PHPStan/Sylius/Rule/Resource/ResourceAttributeNeedsFormTypeRule.php index c4b2a65..21d8ec8 100644 --- a/src/bitExpert/PHPStan/Sylius/Rule/Resource/ResourceAttributeNeedsFormTypeRule.php +++ b/src/bitExpert/PHPStan/Sylius/Rule/Resource/ResourceAttributeNeedsFormTypeRule.php @@ -18,7 +18,7 @@ use PHPStan\Reflection\ReflectionProvider; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; -use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\Type; /** * @implements Rule @@ -48,26 +48,43 @@ public function processNode(Node $node, Scope $scope): array $resourceClassAttributes = $classReflection->getAttributes(); foreach ($resourceClassAttributes as $attribute) { if ('Sylius\Resource\Metadata\AsResource' === $attribute->getName()) { - /** @var array $argumentTypes */ $argumentTypes = $attribute->getArgumentTypes(); - if (isset($argumentTypes['formType'])) { - $formType = $argumentTypes['formType']->getValue(); + $formType = self::resolveConstantString($argumentTypes['formType'] ?? null); + if (null === $formType) { + continue; + } - try { - $this->broker->getClass($formType); - } catch (\Throwable $e) { - $message = \sprintf('Form Type "%s" not found!', $formType); + try { + $this->broker->getClass($formType); + } catch (\Throwable) { + $message = \sprintf('Form Type "%s" not found!', $formType); - return [ - RuleErrorBuilder::message($message) - ->identifier('sylius.resource.formTypeNotFound') - ->build(), - ]; - } + return [ + RuleErrorBuilder::message($message) + ->identifier('sylius.resource.formTypeNotFound') + ->build(), + ]; } } } return []; } + + /** + * Attribute arguments are not guaranteed to be a single constant string, for + * example #[AsResource(formType: new SomeType())] yields an ObjectType. + * Calling Type::getValue() on such an argument raises an internal error and + * aborts the whole analysis, so only single constant strings are accepted. + */ + private static function resolveConstantString(?Type $argumentType): ?string + { + if (null === $argumentType) { + return null; + } + + $constantStrings = $argumentType->getConstantStrings(); + + return 1 === \count($constantStrings) ? $constantStrings[0]->getValue() : null; + } } diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Resource/IndexOperationNeedsGridClassUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Resource/IndexOperationNeedsGridClassUnitTest.php new file mode 100644 index 0000000..8924d2c --- /dev/null +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Resource/IndexOperationNeedsGridClassUnitTest.php @@ -0,0 +1,57 @@ + + */ +class IndexOperationNeedsGridClassUnitTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new IndexOperationNeedsGridClassRule($this->createReflectionProvider()); + } + + #[Test] + public function ruleReportsMissingGridClass(): void + { + $this->analyse( + [__DIR__ . '/data/entity_index.php'], + [ + [ + 'Grid class "App\Grid\GridClassNotExists" not found!', + 10, + ], + ], + ); + } + + /** + * The fixture mixes AsResource and Index attributes, including constant-array + * and object arguments. None of those are a single constant string, so this + * rule must skip all of them. Before the fix, Index(grid: [self::class]) + * raised an internal error and aborted the analysis. + */ + #[Test] + public function ruleSkipsNonConstantGridArgument(): void + { + $this->analyse( + [__DIR__ . '/data/entity_non_constant_attribute.php'], + [], + ); + } +} diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Resource/ResourceAttributeNeedsFormTypeUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Resource/ResourceAttributeNeedsFormTypeUnitTest.php index b7e5da5..4d10025 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Resource/ResourceAttributeNeedsFormTypeUnitTest.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Resource/ResourceAttributeNeedsFormTypeUnitTest.php @@ -39,4 +39,24 @@ public function ruleForClassmethods(): void ], ); } + + /** + * Only the single constant string is reported. The object, constant-array + * and integer arguments must be skipped, which they were not before: the + * rule called Type::getValue() on them, which raised an internal error and + * aborted the analysis. + */ + #[Test] + public function ruleSkipsNonConstantFormTypeArgument(): void + { + $this->analyse( + [__DIR__ . '/data/entity_non_constant_attribute.php'], + [ + [ + 'Form Type "FormClassNotExists" not found!', + 75, + ], + ], + ); + } } diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_index.php b/tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_index.php new file mode 100644 index 0000000..edb2c3e --- /dev/null +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_index.php @@ -0,0 +1,19 @@ +id; + } +} diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_non_constant_attribute.php b/tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_non_constant_attribute.php new file mode 100644 index 0000000..7e7c727 --- /dev/null +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_non_constant_attribute.php @@ -0,0 +1,84 @@ +id; + } +} + +#[AsResource(formType: [self::class])] +class entityWithArrayFormType implements ResourceInterface +{ + private int $id; + + public function getId(): int + { + return $this->id; + } +} + +#[AsResource(formType: 123)] +class entityWithIntFormType implements ResourceInterface +{ + private int $id; + + public function getId(): int + { + return $this->id; + } +} + +#[Index(grid: new \stdClass())] +class entityWithNonConstantGrid implements ResourceInterface +{ + private int $id; + + public function getId(): int + { + return $this->id; + } +} + +#[Index(grid: [self::class])] +class entityWithArrayGrid implements ResourceInterface +{ + private int $id; + + public function getId(): int + { + return $this->id; + } +} + +/** + * A single constant string is still resolved, so a missing class is reported. + */ +#[AsResource(formType: 'FormClassNotExists')] +class entityWithConstantFormType implements ResourceInterface +{ + private int $id; + + public function getId(): int + { + return $this->id; + } +} From 43591c66e2476b60ff2fa06b713d77deb02980a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 21:11:54 +0200 Subject: [PATCH 06/18] Check grid resource class on InClassNode, attribute before hierarchy 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. --- composer.json | 2 + phpstan.dist.neon | 4 + .../ResourceAwareGridNeedsResourceClass.php | 196 +++++++++++++----- ...rceAwareGridNeedsResourceClassUnitTest.php | 46 +++- ..._needs_resource_model_native_interface.php | 43 ++++ .../grid_needs_resource_model_no_methods.php | 20 ++ 6 files changed, 256 insertions(+), 55 deletions(-) create mode 100644 tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_native_interface.php create mode 100644 tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_no_methods.php diff --git a/composer.json b/composer.json index d296a53..3e82d03 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,8 @@ "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php", "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model.php", "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_attr.php", + "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_no_methods.php", + "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_native_interface.php", "tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity.php", "tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_index.php", "tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_non_constant_attribute.php" diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 7177a87..40a4f6f 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -9,6 +9,10 @@ parameters: - tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php - tests/bitExpert/PHPStan/Sylius/Rule/Grid//data/grid_needs_resource_model.php - tests/bitExpert/PHPStan/Sylius/Rule/Grid//data/grid_needs_resource_model_attr.php + - tests/bitExpert/PHPStan/Sylius/Rule/Grid//data/grid_needs_resource_model_no_methods.php + # Only parses on grid-bundle >= 1.16, so it must not be statically analysed + # against the >= 1.15 lane. + - tests/bitExpert/PHPStan/Sylius/Rule/Grid//data/grid_needs_resource_model_native_interface.php - tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity.php - tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_index.php - tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity_non_constant_attribute.php diff --git a/src/bitExpert/PHPStan/Sylius/Rule/Grid/ResourceAwareGridNeedsResourceClass.php b/src/bitExpert/PHPStan/Sylius/Rule/Grid/ResourceAwareGridNeedsResourceClass.php index 77e988e..7e00ad4 100644 --- a/src/bitExpert/PHPStan/Sylius/Rule/Grid/ResourceAwareGridNeedsResourceClass.php +++ b/src/bitExpert/PHPStan/Sylius/Rule/Grid/ResourceAwareGridNeedsResourceClass.php @@ -14,22 +14,31 @@ use PhpParser\Node; use PhpParser\Node\Expr\ClassConstFetch; -use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Name; use PhpParser\Node\Scalar\String_; +use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\Return_; use PHPStan\Analyser\Scope; use PHPStan\Broker\ClassNotFoundException; -use PHPStan\Node\MethodReturnStatementsNode; +use PHPStan\Node\InClassNode; +use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; -use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; /** - * @implements Rule + * @implements Rule */ readonly class ResourceAwareGridNeedsResourceClass implements Rule { + private const AS_GRID_ATTRIBUTE = 'Sylius\Component\Grid\Attribute\AsGrid'; + + private const LEGACY_GRID_CLASS = 'Sylius\Bundle\GridBundle\Grid\AbstractGrid'; + + private const LEGACY_RESOURCE_AWARE_INTERFACE = 'Sylius\Bundle\GridBundle\Grid\ResourceAwareGridInterface'; + public function __construct(private ReflectionProvider $broker) { } @@ -39,78 +48,157 @@ public function __construct(private ReflectionProvider $broker) */ public function getNodeType(): string { - return MethodReturnStatementsNode::class; + return InClassNode::class; } public function processNode(Node $node, Scope $scope): array { - if (!$node instanceof MethodReturnStatementsNode) { + if (!$node instanceof InClassNode) { return []; } - // run the checks only for subclasses of \Sylius\Bundle\GridBundle\Grid\AbstractGrid - $classReflection = $scope->getClassReflection(); - if (null === $classReflection) { - return []; + $classReflection = $node->getClassReflection(); + $class = $node->getOriginalNode(); + + // The attribute is the explicit opt-in, so it takes precedence and the + // legacy method is not inspected a second time. + $resourceClass = $this->resolveResourceClassFromAttribute($classReflection->getAttributes()); + if (null !== $resourceClass) { + $error = $this->validateResourceClass($resourceClass, self::findAttributeLine($class)); + + return null === $error ? [] : [$error]; } - $parentType = new ObjectType('\Sylius\Bundle\GridBundle\Grid\AbstractGrid'); - $classType = new ObjectType($classReflection->getName()); - if (!$parentType->isSuperTypeOf($classType)->yes()) { - return []; + $resourceClass = $this->resolveResourceClassFromMethod($classReflection, $class, $scope); + if (null !== $resourceClass) { + $error = $this->validateResourceClass($resourceClass, self::findMethodLine($class, 'getResourceClass')); + + return null === $error ? [] : [$error]; } - $resourceClassName = ''; + return []; + } - // new Resource Bundle logic: check the #AsGrid attribute of the class - $attributes = $classReflection->getAttributes(); + /** + * The resource class declared by #[AsGrid(resourceClass: ...)]. This is + * deliberately not gated on the grid class hierarchy: since grid-bundle 1.16 + * a grid may implement Sylius\Component\Grid\GridInterface without extending + * Sylius\Bundle\GridBundle\Grid\AbstractGrid, and the attribute is what makes + * the class a grid in the first place. + * + * @param list<\PHPStan\Reflection\AttributeReflection> $attributes + */ + private function resolveResourceClassFromAttribute(array $attributes): ?string + { foreach ($attributes as $attribute) { - if ('Sylius\Component\Grid\Attribute\AsGrid' === $attribute->getName()) { - $argumentTypes = $attribute->getArgumentTypes(); - if (isset($argumentTypes['resourceClass'])) { - $resourceClassName = $argumentTypes['resourceClass']->getConstantStrings()[0]->getValue(); - break; - } + if (self::AS_GRID_ATTRIBUTE !== $attribute->getName()) { + continue; } - } - // old Resource Bundle logic: find the getResourceClass() method to get the resource class - if (empty($resourceClassName)) { - $methodReflection = $node->getMethodReflection(); - if ('getResourceClass' === $methodReflection->getName()) { - /** @var Return_[] $statements */ - $statements = $node->getStatements(); - if ($statements[0]->expr instanceof String_) { - $resourceClassName = $statements[0]->expr->value; - } elseif ($statements[0]->expr instanceof ClassConstFetch) { - /** @var FullyQualified $class */ - $class = $statements[0]->expr->class; - $resourceClassName = $class->name; - } else { - return []; - } + $argumentTypes = $attribute->getArgumentTypes(); + $resourceClass = self::resolveConstantString($argumentTypes['resourceClass'] ?? null); + if (null !== $resourceClass) { + return $resourceClass; } } - if (empty($resourceClassName)) { - return []; + return null; + } + + /** + * The resource class declared by the pre-1.14 + * ResourceAwareGridInterface::getResourceClass() method. + * + * The returned statement is read from the AST instead of the resolved method + * return type, because a method declared 'getResourceClass(): string' that + * returns Supplier::class has the plain type 'string' whenever PHPDoc types + * are not treated as certain, which would lose the class name. + */ + private function resolveResourceClassFromMethod(ClassReflection $classReflection, ClassLike $class, Scope $scope): ?string + { + $isSyliusGrid = $classReflection->isSubclassOf(self::LEGACY_GRID_CLASS) + || $classReflection->implementsInterface(self::LEGACY_RESOURCE_AWARE_INTERFACE); + if (!$isSyliusGrid) { + return null; + } + + $method = $class->getMethod('getResourceClass'); + if (null === $method) { + return null; + } + + foreach ($method->stmts ?? [] as $statement) { + if (!$statement instanceof Return_ || null === $statement->expr) { + continue; + } + + if ($statement->expr instanceof String_) { + return $statement->expr->value; + } + + if ($statement->expr instanceof ClassConstFetch && $statement->expr->class instanceof Name) { + return $scope->resolveName($statement->expr->class); + } + + return null; } + return null; + } + + private function validateResourceClass(string $resourceClass, int $line): ?IdentifierRuleError + { try { - $resourceClass = $this->broker->getClass($resourceClassName); - } catch (ClassNotFoundException $e) { - $message = \sprintf( - 'Resource class "%s" not found!', - $resourceClassName, - ); - - return [ - RuleErrorBuilder::message($message) - ->identifier('sylius.grid.resourceClassRequired') - ->build(), - ]; + $this->broker->getClass($resourceClass); + } catch (ClassNotFoundException) { + return RuleErrorBuilder::message(\sprintf('Resource class "%s" not found!', $resourceClass)) + ->identifier('sylius.grid.resourceClassRequired') + ->line($line) + ->build(); } - return []; + return null; + } + + /** + * Reports the error on the attribute itself rather than on the class or on + * whichever method happened to be visited first. + */ + private static function findAttributeLine(ClassLike $class): int + { + foreach ($class->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attribute) { + if (self::AS_GRID_ATTRIBUTE === $attribute->name->toString()) { + return $attribute->getStartLine(); + } + } + } + + return $class->getStartLine(); + } + + /** + * Reports the error on the getResourceClass() declaration rather than on the + * class. + */ + private static function findMethodLine(ClassLike $class, string $methodName): int + { + return $class->getMethod($methodName)?->getStartLine() ?? $class->getStartLine(); + } + + /** + * An attribute argument is not guaranteed to be a single constant string. + * Calling Type::getValue() on anything else raises an internal error and + * aborts the analysis, so only single constant strings are accepted. + */ + private static function resolveConstantString(?Type $argumentType): ?string + { + if (null === $argumentType) { + return null; + } + + $constantStrings = $argumentType->getConstantStrings(); + + return 1 === \count($constantStrings) ? $constantStrings[0]->getValue() : null; } } diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/ResourceAwareGridNeedsResourceClassUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/ResourceAwareGridNeedsResourceClassUnitTest.php index 7c7216f..9674c94 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/ResourceAwareGridNeedsResourceClassUnitTest.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/ResourceAwareGridNeedsResourceClassUnitTest.php @@ -47,8 +47,52 @@ public function ruleForAttr(): void [__DIR__ . '/data/grid_needs_resource_model_attr.php'], [ [ + // Reported on the #[AsGrid(resourceClass: ...)] attribute at line + // 21, not on whichever method happened to be visited first. 'Resource class "App\Entity\SupplierNotFound" not found!', - 24, + 21, + ], + ], + ); + } + + /** + * A grid is not required to declare any method. Because the rule listened on + * MethodReturnStatementsNode before, this class was never visited at all. + */ + #[Test] + public function ruleChecksGridWithoutAnyMethod(): void + { + $this->analyse( + [__DIR__ . '/data/grid_needs_resource_model_no_methods.php'], + [ + [ + 'Resource class "App\Entity\SupplierNotFound" not found!', + 17, + ], + ], + ); + } + + /** + * Since grid-bundle 1.16 a grid may implement + * Sylius\Component\Grid\GridInterface without extending the legacy + * AbstractGrid. The resource class of such a grid used to go unvalidated, + * because AbstractGrid was required before the attribute was even read. + */ + #[Test] + public function ruleChecksGridUsingTheNewGridInterface(): void + { + if (!\interface_exists('Sylius\Component\Grid\GridInterface')) { + self::markTestSkipped('Sylius\Component\Grid\GridInterface requires grid-bundle >= 1.16.'); + } + + $this->analyse( + [__DIR__ . '/data/grid_needs_resource_model_native_interface.php'], + [ + [ + 'Resource class "App\Entity\SupplierNotFound" not found!', + 35, ], ], ); diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_native_interface.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_native_interface.php new file mode 100644 index 0000000..53a9e31 --- /dev/null +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_native_interface.php @@ -0,0 +1,43 @@ += 1.16, and + * this file is registered in composer's autoload-dev files, so the declarations + * are guarded to keep the older lane from fataling on an unknown interface. + */ +if (\interface_exists(GridInterface::class)) { + #[AsGrid(resourceClass: Supplier::class)] + final class NativeGridWithExistingResourceClass implements GridInterface + { + public function getName(): string + { + return 'app_admin_supplier'; + } + } + + #[AsGrid(resourceClass: 'App\Entity\SupplierNotFound')] + final class NativeGridWithMissingResourceClass implements GridInterface + { + public function getName(): string + { + return 'app_admin_supplier_no_class'; + } + } +} diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_no_methods.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_no_methods.php new file mode 100644 index 0000000..5aaff70 --- /dev/null +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_no_methods.php @@ -0,0 +1,20 @@ + Date: Sat, 26 Sep 2026 21:23:17 +0200 Subject: [PATCH 07/18] Fix grid field rule leaking resource class across fields 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. --- .../GridBuilderFieldIsPartOfResourceClass.php | 140 ++++++++++++------ ...lderFieldIsPartOfResourceClassUnitTest.php | 17 +++ ...derFilterIsPartOfResourceClassUnitTest.php | 6 +- .../PHPStan/Sylius/Rule/Grid/data/entity.php | 17 +++ .../PHPStan/Sylius/Rule/Grid/data/grid.php | 9 ++ 5 files changed, 138 insertions(+), 51 deletions(-) diff --git a/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php b/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php index 0b2b4f8..d74763a 100644 --- a/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php +++ b/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php @@ -23,6 +23,7 @@ use PHPStan\Reflection\ReflectionProvider; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\Type\Type; /** * @implements Rule @@ -83,12 +84,16 @@ public function processNode(Node $node, Scope $scope): array /** @var array $gridResourceMap */ foreach ($gridResourceMap as $gridClassName => $resourceClassName) { if (isset($gridFieldsMap[$gridClassName])) { - $resourceClass = $this->broker->getClass($resourceClassName); - foreach ($gridFieldsMap[$gridClassName] as $field) { $fieldName = $field[0]; $lineNo = $field[1]; + // The resource class has to be re-resolved for every field. The + // recursive branch below walks down the field path and replaces + // this with the type of the last resolved segment, which would + // otherwise leak into the next field of the same grid. + $resourceClass = $this->broker->getClass($resourceClassName); + if (!\str_contains($fieldName, '.')) { // single property check $getterMethod = 'get' . \ucfirst($fieldName); @@ -105,62 +110,54 @@ public function processNode(Node $node, Scope $scope): array ->line($lineNo) ->build(); } - } else { - // recursive property check - $fieldNames = \explode('.', $fieldName); - while (\count($fieldNames) > 0) { - $fieldName = \array_shift($fieldNames); - - $getterMethod = 'get' . \ucfirst($fieldName); - if (!$resourceClass->hasProperty($fieldName) && !$resourceClass->hasMethod($getterMethod)) { + + continue; + } + + // recursive property check + $fieldNames = \explode('.', $fieldName); + while (\count($fieldNames) > 0) { + $segment = \array_shift($fieldNames); + $getterMethod = 'get' . \ucfirst($segment); + + if (!$resourceClass->hasProperty($segment) && !$resourceClass->hasMethod($getterMethod)) { + $message = \sprintf( + 'The field "%s" needs to exists as property in class "%s".', + $segment, + $resourceClassName, + ); + + $errors[] = RuleErrorBuilder::message($message) + ->identifier('sylius.grid.resourceClassMissingProperty') + ->file($gridFilesMap[$gridClassName]) + ->line($lineNo) + ->build(); + } + + // Resolve the type of this segment to keep walking. It has to + // stay a ClassReflection: Type::hasProperty() answers with a + // TrinaryLogic, and casting that object to bool is always + // true, so every later segment would silently pass. + $nextClass = $this->resolveNextClass($resourceClass, $segment, $getterMethod, $scope); + if (null === $nextClass) { + if (\count($fieldNames) > 0) { $message = \sprintf( - 'The field "%s" needs to exists as property in class "%s".', - $fieldName, - $resourceClassName, + 'Unable to identify the type of the field "%s" in class "%s".', + $segment, + $resourceClass->getName(), ); $errors[] = RuleErrorBuilder::message($message) - ->identifier('sylius.grid.resourceClassMissingProperty') + ->identifier('sylius.grid.resourceClassPropertyMissingType') ->file($gridFilesMap[$gridClassName]) ->line($lineNo) ->build(); } - try { - $resourceClass = $resourceClass->getMethod($getterMethod, $scope)->getOnlyVariant()->getReturnType(); - } catch (\Exception $e) { - try { - $property = $resourceClass->getProperty($fieldName, $scope); - if ($property->hasNativeType()) { - $resourceClass = $property->getNativeType(); - } elseif ($property->hasPHPDocType()) { - $resourceClass = $property->getPhpDocType(); - } elseif (\count($fieldNames) > 0) { - /** @var ClassReflection $resourceClass */ - $message = \sprintf( - 'Unable to identify the type of the field "%s" in class "%s".', - $fieldName, - $resourceClass->getName(), - ); - - $errors[] = RuleErrorBuilder::message($message) - ->identifier('sylius.grid.resourceClassPropertyMissingType') - ->file($gridFilesMap[$gridClassName]) - ->line($lineNo) - ->build(); - } - } catch (\Exception $e) { - /* @phpstan-ignore phpstanApi.class */ - if ($e instanceof MissingPropertyFromReflectionException) { - $errors[] = RuleErrorBuilder::message($e->getMessage()) - ->identifier('sylius.grid.resourceClassPropertyMissingType') - ->file($gridFilesMap[$gridClassName]) - ->line($lineNo) - ->build(); - } - } - } + break; } + + $resourceClass = $nextClass; } } } @@ -168,4 +165,51 @@ public function processNode(Node $node, Scope $scope): array return $errors; } + + /** + * Resolves the type a single field path segment points at, as a ClassReflection + * so the next segment can be looked up on it. Returns null when the type cannot + * be narrowed down to exactly one class. + */ + private function resolveNextClass( + ClassReflection $class, + string $segment, + string $getterMethod, + Scope $scope, + ): ?ClassReflection { + try { + return $this->toClassReflection($class->getMethod($getterMethod, $scope)->getOnlyVariant()->getReturnType()); + } catch (\Exception) { + // The getter is absent or is not resolvable, so fall back to the property. + } + + try { + $property = $class->getProperty($segment, $scope); + if ($property->hasNativeType()) { + return $this->toClassReflection($property->getNativeType()); + } + + if ($property->hasPHPDocType()) { + return $this->toClassReflection($property->getPhpDocType()); + } + } catch (MissingPropertyFromReflectionException $e) { + // Reported by the caller as a missing property on the parent segment. + } + + return null; + } + + private function toClassReflection(Type $type): ?ClassReflection + { + $classNames = $type->getObjectClassNames(); + if (1 !== \count($classNames)) { + return null; + } + + try { + return $this->broker->getClass($classNames[0]); + } catch (\Throwable) { + return null; + } + } } diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php index ecd80e2..5eda88c 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php @@ -59,6 +59,23 @@ public function testRule(): void 'The field "name" needs to exists as property in class "App\Entity\Supplier".', 29, ], + [ + // This field is declared after the recursive "address.city" field. + // Walking the recursive path replaced the resource class with the + // type of the last resolved segment, so this field was looked up on + // App\Entity\Address instead of App\Entity\Supplier and no error was + // ever reported for it. + 'The field "plainFieldAfterDottedField" needs to exists as property in class "App\Entity\Supplier".', + 38, + ], + [ + // Third segment of a recursive field. Once the walk leaves the + // ClassReflection it queried a Type, and Type::hasProperty() + // answers with a TrinaryLogic whose cast to bool is always true, + // so every segment past the first silently passed. + 'The field "missingThirdSegment" needs to exists as property in class "App\Entity\Supplier".', + 56, + ], ], ); } diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php index 496f461..698e1de 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php @@ -57,15 +57,15 @@ public function testRule(): void [ [ 'The filter field "name" needs to exists as property in resource class "App\Entity\Supplier".', - 41, + 44, ], [ 'The filter field "status123" needs to exists as property in resource class "App\Entity\Supplier".', - 44, + 47, ], [ 'The filter field "name" needs to exists as property in resource class "App\Entity\Supplier".', - 47, + 50, ], ], ); diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/entity.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/entity.php index bd7f85e..bc482f6 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/entity.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/entity.php @@ -12,14 +12,31 @@ enum Status: string case INACTIVE = 'inactive'; } +class Country +{ + private string $isoCode; + + public function getIsoCode(): string + { + return $this->isoCode; + } +} + class Address { private $city; + private Country $country; + public function getCity(): string { return $this->city; } + + public function getCountry(): Country + { + return $this->country; + } } class Supplier implements ResourceInterface diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php index e32ba32..3137ffa 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php @@ -34,6 +34,9 @@ public function buildGrid(GridBuilderInterface $gridBuilder): void $gridBuilder->addField( StringField::create('address.city')->setLabel('app.ui.address.city'), ); + $gridBuilder->addField( + StringField::create('plainFieldAfterDottedField')->setLabel('app.ui.plain'), + ); $gridBuilder->addField( StringField::create('.')->setLabel('app.ui.some_calculated_field'), ); @@ -46,6 +49,12 @@ public function buildGrid(GridBuilderInterface $gridBuilder): void $gridBuilder->addFilter( StringFilter::create('virtual-field', ['name', 'address.city']), ); + $gridBuilder->addField( + StringField::create('address.country.isoCode')->setLabel('app.ui.isoCode'), + ); + $gridBuilder->addField( + StringField::create('address.country.missingThirdSegment')->setLabel('app.ui.missing'), + ); } public function getResourceClass(): string From 17542aeece1ac4693ef2400908834f28ad08b98d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 21:30:54 +0200 Subject: [PATCH 08/18] Report each resolved version in its own composer show call 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. --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebfbc90..8adfab3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,10 @@ jobs: composer update --no-interaction --no-progress - name: Show resolved dependency versions - run: composer show sylius/grid-bundle phpstan/phpstan phpunit/phpunit + run: | + composer show sylius/grid-bundle + composer show phpstan/phpstan + composer show phpunit/phpunit - name: Composer license check run: composer check-license From 8bd398662abffb42c45acebb069e3cbb7caf1db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 21:33:56 +0200 Subject: [PATCH 09/18] Make the generic filter node match filters again 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. --- extension.neon | 9 ++++++--- .../Sylius/Collector/Grid/Filter/Filter.php | 17 ++++++++++++++--- ...uilderFieldIsPartOfResourceClassUnitTest.php | 6 +++--- ...ilderFilterIsPartOfResourceClassUnitTest.php | 13 ++++++++++--- .../PHPStan/Sylius/Rule/Grid/data/grid.php | 4 ++++ 5 files changed, 37 insertions(+), 12 deletions(-) diff --git a/extension.neon b/extension.neon index fc85a66..eadcf20 100644 --- a/extension.neon +++ b/extension.neon @@ -70,14 +70,17 @@ services: tags: - phpstan.sylius.grid.filter - - class: bitExpert\PHPStan\Sylius\Collector\Grid\Filter\Filter + class: bitExpert\PHPStan\Sylius\Collector\Grid\Filter\SelectFilter tags: - phpstan.sylius.grid.filter - - class: bitExpert\PHPStan\Sylius\Collector\Grid\Filter\SelectFilter + class: bitExpert\PHPStan\Sylius\Collector\Grid\Filter\StringFilter tags: - phpstan.sylius.grid.filter + + # Registered last on purpose: this node matches every filter that + # implements FilterInterface, so all concrete nodes have to be asked first. - - class: bitExpert\PHPStan\Sylius\Collector\Grid\Filter\StringFilter + class: bitExpert\PHPStan\Sylius\Collector\Grid\Filter\Filter tags: - phpstan.sylius.grid.filter diff --git a/src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/Filter.php b/src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/Filter.php index bc4c0e3..4dcd59e 100644 --- a/src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/Filter.php +++ b/src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/Filter.php @@ -21,15 +21,26 @@ final readonly class Filter implements FilterNode { - private const FILTER_TYPE = 'Sylius\\Bundle\\GridBundle\\Builder\\Filter\\FilterInterface'; + /** + * Both the old bundle interface and the one introduced in 1.16. The concrete + * grid-bundle filter factories return the new interface from 1.16 on, so + * checking only the old one would make this node silently stop matching. + */ + private const FILTER_TYPES = [ + 'Sylius\\Bundle\\GridBundle\\Builder\\Filter\\FilterInterface', + 'Sylius\\Component\\Grid\\Builder\\Filter\\FilterInterface', + ]; public function supports(FullyQualified $nodeClass): bool { try { - $filterType = new ObjectType(self::FILTER_TYPE); $nodeClassType = new ObjectType($nodeClass->toString()); - return $nodeClassType->isSuperTypeOf($filterType)->yes(); + foreach (self::FILTER_TYPES as $filterType) { + if ((new ObjectType($filterType))->isSuperTypeOf($nodeClassType)->yes()) { + return true; + } + } } catch (\Throwable $e) { } diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php index 5eda88c..7074c21 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php @@ -57,7 +57,7 @@ public function testRule(): void [ [ 'The field "name" needs to exists as property in class "App\Entity\Supplier".', - 29, + 30, ], [ // This field is declared after the recursive "address.city" field. @@ -66,7 +66,7 @@ public function testRule(): void // App\Entity\Address instead of App\Entity\Supplier and no error was // ever reported for it. 'The field "plainFieldAfterDottedField" needs to exists as property in class "App\Entity\Supplier".', - 38, + 39, ], [ // Third segment of a recursive field. Once the walk leaves the @@ -74,7 +74,7 @@ public function testRule(): void // answers with a TrinaryLogic whose cast to bool is always true, // so every segment past the first silently passed. 'The field "missingThirdSegment" needs to exists as property in class "App\Entity\Supplier".', - 56, + 57, ], ], ); diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php index 698e1de..6a3b728 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php @@ -57,15 +57,22 @@ public function testRule(): void [ [ 'The filter field "name" needs to exists as property in resource class "App\Entity\Supplier".', - 44, + 45, ], [ 'The filter field "status123" needs to exists as property in resource class "App\Entity\Supplier".', - 47, + 48, ], [ 'The filter field "name" needs to exists as property in resource class "App\Entity\Supplier".', - 50, + 51, + ], + [ + // Handled by the generic Filter node, whose supports() used to + // compare the subtypes the wrong way round and therefore never + // matched anything. + 'The filter field "missingGenericFilterField" needs to exists as property in resource class "App\Entity\Supplier".', + 60, ], ], ); diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php index 3137ffa..4355111 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php @@ -8,6 +8,7 @@ use App\Entity\Supplier; use Sylius\Bundle\GridBundle\Builder\Field\StringField; use Sylius\Bundle\GridBundle\Builder\Filter\EnumFilter; +use Sylius\Bundle\GridBundle\Builder\Filter\Filter; use Sylius\Bundle\GridBundle\Builder\Filter\StringFilter; use Sylius\Bundle\GridBundle\Builder\GridBuilderInterface; use Sylius\Bundle\GridBundle\Grid\AbstractGrid; @@ -55,6 +56,9 @@ public function buildGrid(GridBuilderInterface $gridBuilder): void $gridBuilder->addField( StringField::create('address.country.missingThirdSegment')->setLabel('app.ui.missing'), ); + $gridBuilder->addFilter( + Filter::create('missingGenericFilterField', 'string'), + ); } public function getResourceClass(): string From 01d0aff56eaee6a729d1ccf52781d4c2b1c9ee30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 21:36:17 +0200 Subject: [PATCH 10/18] Collect grid fields built with CallableField::createForService() 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. --- .../Collector/Grid/CollectFieldsForGridClass.php | 9 ++++++++- ...GridBuilderFieldIsPartOfResourceClassUnitTest.php | 12 +++++++++--- ...ridBuilderFilterIsPartOfResourceClassUnitTest.php | 8 ++++---- .../bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php | 4 ++++ 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/bitExpert/PHPStan/Sylius/Collector/Grid/CollectFieldsForGridClass.php b/src/bitExpert/PHPStan/Sylius/Collector/Grid/CollectFieldsForGridClass.php index c4e5296..002aacf 100644 --- a/src/bitExpert/PHPStan/Sylius/Collector/Grid/CollectFieldsForGridClass.php +++ b/src/bitExpert/PHPStan/Sylius/Collector/Grid/CollectFieldsForGridClass.php @@ -27,6 +27,13 @@ */ final class CollectFieldsForGridClass extends AbstractGridClassCollector implements Collector { + /** + * Grid fields are built through a static factory. Every field class uses create(), + * and CallableField additionally offers createForService() for a service-backed + * callable. Both take the field name as their first argument. + */ + private const FACTORY_METHODS = ['create', 'createForService']; + public function __construct(private readonly FieldRegistry $fieldRegistry) { } @@ -45,7 +52,7 @@ public function processNode(Node $node, Scope $scope): ?array return null; } - if ((!$node->name instanceof Identifier) || ('create' !== $node->name->toString())) { + if (!$node->name instanceof Identifier || !\in_array($node->name->toString(), self::FACTORY_METHODS, true)) { return null; } diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php index 7074c21..bc11ea7 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php @@ -57,7 +57,7 @@ public function testRule(): void [ [ 'The field "name" needs to exists as property in class "App\Entity\Supplier".', - 30, + 31, ], [ // This field is declared after the recursive "address.city" field. @@ -66,7 +66,7 @@ public function testRule(): void // App\Entity\Address instead of App\Entity\Supplier and no error was // ever reported for it. 'The field "plainFieldAfterDottedField" needs to exists as property in class "App\Entity\Supplier".', - 39, + 40, ], [ // Third segment of a recursive field. Once the walk leaves the @@ -74,7 +74,13 @@ public function testRule(): void // answers with a TrinaryLogic whose cast to bool is always true, // so every segment past the first silently passed. 'The field "missingThirdSegment" needs to exists as property in class "App\Entity\Supplier".', - 57, + 58, + ], + [ + // CallableField::createForService() was skipped by the collector, + // which only accepted a static call named create(). + 'The field "missingCallableServiceField" needs to exists as property in class "App\Entity\Supplier".', + 64, ], ], ); diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php index 6a3b728..1c8df77 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php @@ -57,22 +57,22 @@ public function testRule(): void [ [ 'The filter field "name" needs to exists as property in resource class "App\Entity\Supplier".', - 45, + 46, ], [ 'The filter field "status123" needs to exists as property in resource class "App\Entity\Supplier".', - 48, + 49, ], [ 'The filter field "name" needs to exists as property in resource class "App\Entity\Supplier".', - 51, + 52, ], [ // Handled by the generic Filter node, whose supports() used to // compare the subtypes the wrong way round and therefore never // matched anything. 'The filter field "missingGenericFilterField" needs to exists as property in resource class "App\Entity\Supplier".', - 60, + 61, ], ], ); diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php index 4355111..eb5997c 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php @@ -6,6 +6,7 @@ use App\Entity\Status; use App\Entity\Supplier; +use Sylius\Bundle\GridBundle\Builder\Field\CallableField; use Sylius\Bundle\GridBundle\Builder\Field\StringField; use Sylius\Bundle\GridBundle\Builder\Filter\EnumFilter; use Sylius\Bundle\GridBundle\Builder\Filter\Filter; @@ -59,6 +60,9 @@ public function buildGrid(GridBuilderInterface $gridBuilder): void $gridBuilder->addFilter( Filter::create('missingGenericFilterField', 'string'), ); + $gridBuilder->addField( + CallableField::createForService('missingCallableServiceField', 'app.some_service'), + ); } public function getResourceClass(): string From bff8e2b900a39fad6e0cbd2456f30cf9f3dd3d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 21:38:21 +0200 Subject: [PATCH 11/18] Keep the test filter registry in the order extension.neon uses 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. --- .../Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php index 1c8df77..c049e83 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php @@ -40,9 +40,11 @@ protected function getCollectors(): array $filters[] = new EntityFilter(); $filters[] = new EnumFilter(); $filters[] = new ExistsFilter(); - $filters[] = new Filter(); $filters[] = new SelectFilter(); $filters[] = new StringFilter(); + // Mirrors extension.neon: the catch-all has to come last so it cannot + // shadow a node registered for a custom filter class. + $filters[] = new Filter(); return [ new CollectRessourceClassForGridClass(), From 8b3184c699dfa7de8c36f31aa3fe21ec789b0f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 21:44:54 +0200 Subject: [PATCH 12/18] Add filter nodes for BooleanFilter, DateFilter and MoneyFilter 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. --- extension.neon | 12 +++++ .../Collector/Grid/Filter/BooleanFilter.php | 43 ++++++++++++++++++ .../Collector/Grid/Filter/DateFilter.php | 43 ++++++++++++++++++ .../Collector/Grid/Filter/MoneyFilter.php | 45 +++++++++++++++++++ ...lderFieldIsPartOfResourceClassUnitTest.php | 8 ++-- ...derFilterIsPartOfResourceClassUnitTest.php | 33 +++++++++++--- .../PHPStan/Sylius/Rule/Grid/data/grid.php | 12 +++++ 7 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/BooleanFilter.php create mode 100644 src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/DateFilter.php create mode 100644 src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/MoneyFilter.php diff --git a/extension.neon b/extension.neon index eadcf20..81e1761 100644 --- a/extension.neon +++ b/extension.neon @@ -65,6 +65,18 @@ services: class: bitExpert\PHPStan\Sylius\Collector\Grid\Filter\EnumFilter tags: - phpstan.sylius.grid.filter + - + class: bitExpert\PHPStan\Sylius\Collector\Grid\Filter\BooleanFilter + tags: + - phpstan.sylius.grid.filter + - + class: bitExpert\PHPStan\Sylius\Collector\Grid\Filter\DateFilter + tags: + - phpstan.sylius.grid.filter + - + class: bitExpert\PHPStan\Sylius\Collector\Grid\Filter\MoneyFilter + tags: + - phpstan.sylius.grid.filter - class: bitExpert\PHPStan\Sylius\Collector\Grid\Filter\ExistsFilter tags: diff --git a/src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/BooleanFilter.php b/src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/BooleanFilter.php new file mode 100644 index 0000000..2ccbaa8 --- /dev/null +++ b/src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/BooleanFilter.php @@ -0,0 +1,43 @@ +name; + } + + public function getFilterFields(StaticCall $node): array + { + $filterFields = []; + + if (isset($node->args[0])) { + $arg = $node->args[0]; + if ($arg instanceof Arg && $arg->value instanceof String_) { + $filterFields[] = PropertyName::convertSnakeToCamelCase($arg->value->value); + } + } + + return $filterFields; + } +} diff --git a/src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/DateFilter.php b/src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/DateFilter.php new file mode 100644 index 0000000..e30f54a --- /dev/null +++ b/src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/DateFilter.php @@ -0,0 +1,43 @@ +name; + } + + public function getFilterFields(StaticCall $node): array + { + $filterFields = []; + + if (isset($node->args[0])) { + $arg = $node->args[0]; + if ($arg instanceof Arg && $arg->value instanceof String_) { + $filterFields[] = PropertyName::convertSnakeToCamelCase($arg->value->value); + } + } + + return $filterFields; + } +} diff --git a/src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/MoneyFilter.php b/src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/MoneyFilter.php new file mode 100644 index 0000000..57e4e4a --- /dev/null +++ b/src/bitExpert/PHPStan/Sylius/Collector/Grid/Filter/MoneyFilter.php @@ -0,0 +1,45 @@ +name; + } + + public function getFilterFields(StaticCall $node): array + { + $filterFields = []; + + // create(string $name, string $currencyCode, ?int $scale = null): + // the currency code is the second argument, the field is the first one. + if (isset($node->args[0])) { + $arg = $node->args[0]; + if ($arg instanceof Arg && $arg->value instanceof String_) { + $filterFields[] = PropertyName::convertSnakeToCamelCase($arg->value->value); + } + } + + return $filterFields; + } +} diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php index bc11ea7..9a4afb9 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClassUnitTest.php @@ -57,7 +57,7 @@ public function testRule(): void [ [ 'The field "name" needs to exists as property in class "App\Entity\Supplier".', - 31, + 34, ], [ // This field is declared after the recursive "address.city" field. @@ -66,7 +66,7 @@ public function testRule(): void // App\Entity\Address instead of App\Entity\Supplier and no error was // ever reported for it. 'The field "plainFieldAfterDottedField" needs to exists as property in class "App\Entity\Supplier".', - 40, + 43, ], [ // Third segment of a recursive field. Once the walk leaves the @@ -74,13 +74,13 @@ public function testRule(): void // answers with a TrinaryLogic whose cast to bool is always true, // so every segment past the first silently passed. 'The field "missingThirdSegment" needs to exists as property in class "App\Entity\Supplier".', - 58, + 61, ], [ // CallableField::createForService() was skipped by the collector, // which only accepted a static call named create(). 'The field "missingCallableServiceField" needs to exists as property in class "App\Entity\Supplier".', - 64, + 67, ], ], ); diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php index c049e83..c8bead2 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php @@ -14,11 +14,14 @@ use bitExpert\PHPStan\Sylius\Collector\Grid\CollectFilterForGridClass; use bitExpert\PHPStan\Sylius\Collector\Grid\CollectRessourceClassForGridClass; +use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\BooleanFilter; +use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\DateFilter; use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\DefaultFilterRegistry; use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\EntityFilter; use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\EnumFilter; use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\ExistsFilter; use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\Filter; +use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\MoneyFilter; use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\SelectFilter; use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\StringFilter; use PHPStan\Rules\Rule; @@ -37,13 +40,16 @@ protected function getRule(): Rule protected function getCollectors(): array { $filters = []; + // Mirrors the registration order in extension.neon: the catch-all has to + // come last so it cannot shadow a node for a more specific class. $filters[] = new EntityFilter(); $filters[] = new EnumFilter(); + $filters[] = new BooleanFilter(); + $filters[] = new DateFilter(); + $filters[] = new MoneyFilter(); $filters[] = new ExistsFilter(); $filters[] = new SelectFilter(); $filters[] = new StringFilter(); - // Mirrors extension.neon: the catch-all has to come last so it cannot - // shadow a node registered for a custom filter class. $filters[] = new Filter(); return [ @@ -59,14 +65,14 @@ public function testRule(): void [ [ 'The filter field "name" needs to exists as property in resource class "App\Entity\Supplier".', - 46, + 55, ], [ - 'The filter field "status123" needs to exists as property in resource class "App\Entity\Supplier".', + 'The filter field "name" needs to exists as property in resource class "App\Entity\Supplier".', 49, ], [ - 'The filter field "name" needs to exists as property in resource class "App\Entity\Supplier".', + 'The filter field "status123" needs to exists as property in resource class "App\Entity\Supplier".', 52, ], [ @@ -74,7 +80,22 @@ public function testRule(): void // compare the subtypes the wrong way round and therefore never // matched anything. 'The filter field "missingGenericFilterField" needs to exists as property in resource class "App\Entity\Supplier".', - 61, + 64, + ], + [ + // Had no node at all, and they do not implement FilterInterface + // either, so the catch-all node never matched them. + 'The filter field "missingBooleanFilterField" needs to exists as property in resource class "App\Entity\Supplier".', + 70, + ], + [ + // snake_case is converted, like every other filter field. + 'The filter field "missingDateFilterField" needs to exists as property in resource class "App\Entity\Supplier".', + 73, + ], + [ + 'The filter field "missingMoneyFilterField" needs to exists as property in resource class "App\Entity\Supplier".', + 76, ], ], ); diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php index eb5997c..3d69175 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php @@ -8,8 +8,11 @@ use App\Entity\Supplier; use Sylius\Bundle\GridBundle\Builder\Field\CallableField; use Sylius\Bundle\GridBundle\Builder\Field\StringField; +use Sylius\Bundle\GridBundle\Builder\Filter\BooleanFilter; +use Sylius\Bundle\GridBundle\Builder\Filter\DateFilter; use Sylius\Bundle\GridBundle\Builder\Filter\EnumFilter; use Sylius\Bundle\GridBundle\Builder\Filter\Filter; +use Sylius\Bundle\GridBundle\Builder\Filter\MoneyFilter; use Sylius\Bundle\GridBundle\Builder\Filter\StringFilter; use Sylius\Bundle\GridBundle\Builder\GridBuilderInterface; use Sylius\Bundle\GridBundle\Grid\AbstractGrid; @@ -63,6 +66,15 @@ public function buildGrid(GridBuilderInterface $gridBuilder): void $gridBuilder->addField( CallableField::createForService('missingCallableServiceField', 'app.some_service'), ); + $gridBuilder->addFilter( + BooleanFilter::create('missingBooleanFilterField'), + ); + $gridBuilder->addFilter( + DateFilter::create('missing_date_filter_field'), + ); + $gridBuilder->addFilter( + MoneyFilter::create('missingMoneyFilterField', 'USD'), + ); } public function getResourceClass(): string From 081a305ce9f748ba911cc869b335f0219c09b7e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 21:49:30 +0200 Subject: [PATCH 13/18] Bring AGENTS.md back in line with the code 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, which is right, while their own @implements Rule 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. --- AGENTS.md | 231 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 166 insertions(+), 65 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c035f51..ee69825 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,8 @@ This file provides structured context for AI agents (e.g., copilot, Claude, etc. - **Package**: `bitexpert/phpstan-sylius` - **Type**: PHPStan extension (type: `phpstan-extension` in `composer.json`) - **Purpose**: Additional static analysis rules for Sylius projects, validating grid configurations and resource metadata. -- **Requirements**: PHP ^8.2, PHPStan ^2.1, Sylius ^2.0 (resource-bundle ^1.12, grid-bundle ^1.13). +- **Requirements**: PHP ^8.2, PHPStan ^2.1. Dev: resource-bundle ^1.12, grid-bundle **^1.15**. +- **grid-bundle support**: `>= 1.15`. CI exercises both `^1.15 <1.16` and `^1.16`; behaviour differs between them (see [Grid Bundle 1.16](#grid-bundle-116)). - **License**: MIT ### Installation & Usage @@ -48,7 +49,7 @@ src/ │ │ ├─ FilterRegistry.php # Interface │ │ ├─ FilterRegistryFactory.php # DI factory for filter registry │ │ ├─ DefaultFilterRegistry.php # Concrete registry - │ │ └─ *Filter.php # EntityFilter, EnumFilter, ExistsFilter, Filter, SelectFilter, StringFilter + │ │ └─ *.php # EntityFilter, EnumFilter, ExistsFilter, SelectFilter, StringFilter, BooleanFilter, DateFilter, MoneyFilter, Filter (catch-all) │ │ │ └─ Rule/ │ ├─ Grid/ @@ -63,6 +64,8 @@ src/ **Note**: `CollectRessourceClassForGridClass.php` contains a typo: `Ressource` (double `s`), which is preserved for historical compatibility. +**Note**: field nodes carry a `Node` suffix in their class name; filter nodes do **not**. `Field/StringFieldNode.php` vs `Filter/StringFilter.php`. + --- ## Key Conventions @@ -100,11 +103,17 @@ PHPStan extensions in this project follow a two-phase approach: This separation improves performance (collectors can run in parallel) and keeps rules focused on analysis rather than parsing. +> **Caution**: `getNodeType()` is authoritative. `GridBuilderFieldIsPartOfResourceClass` and +> `GridBuilderFilterIsPartOfResourceClass` both return `CollectedDataNode::class` and guard +> `processNode()` with `if (!$node instanceof CollectedDataNode)`. Their `@implements Rule` +> docblocks are **wrong leftovers** — PHPStan cannot catch this because `processNode(Node $node, ...)` +> takes the broad `Node` type. Trust `getNodeType()`, not the docblock. + ### Rule Execution Flow | Rule | Node Type | Key Logic | |------|-----------|-----------| -| `ResourceAwareGridNeedsResourceClass` | `MethodReturnStatementsNode` | Checks `getResourceClass()` or `#AsGrid(resourceClass:)` for existing class. | +| `ResourceAwareGridNeedsResourceClass` | `InClassNode` | Checks `#AsGrid(resourceClass:)` first, then `getResourceClass()` / `ResourceAwareGridInterface`, for an existing class. | | `GridBuilderFieldIsPartOfResourceClass` | `CollectedDataNode` | Validates every grid field exists as a property/getter on the resource class. Supports recursive fields (`address.city`). | | `GridBuilderFilterIsPartOfResourceClass` | `CollectedDataNode` | Validates every grid filter field exists on resource. Does **not** support recursive checks (dots are skipped). | | `IndexOperationNeedsGridClassRule` | `InClassNode` | Validates `#[Index(grid: '...')]` refers to an existing grid class. | @@ -117,12 +126,13 @@ This separation improves performance (collectors can run in parallel) and keeps ### `ResourceAwareGridNeedsResourceClass` - **Namespace**: `bitExpert\PHPStan\Sylius\Rule\Grid` -- **Node type**: `MethodReturnStatementsNode` -- **Scope**: Only subclasses of `Sylius\Bundle\GridBundle\Grid\AbstractGrid` or classes with `#AsGrid` attribute. -- **Checks**: - - **New API**: `#[Sylius\Component\Grid\Attribute\AsGrid(resourceClass: 'App\Entity\Supplier')]` - - **Old API**: `public function getResourceClass(): string { return Supplier::class; }` +- **Node type**: `InClassNode` +- **Checks, in order**: + 1. `#[Sylius\Component\Grid\Attribute\AsGrid(resourceClass: 'App\Entity\Supplier')]` — the attribute wins over any method, because 1.16 grids may declare no methods at all. + 2. Legacy `public function getResourceClass(): string { return Supplier::class; }` (also the `ResourceAwareGridInterface` contract). + 3. Class/subclass of `Sylius\Bundle\GridBundle\Grid\AbstractGrid`. - **Error**: `sylius.grid.resourceClassRequired` → `Resource class "%s" not found!` +- **Note**: reads the attribute argument from the AST, not the PHPDoc-resolved value, so it also fires under 1.16 where the class exposes no `getResourceClass()`. --- @@ -145,6 +155,18 @@ This separation improves performance (collectors can run in parallel) and keeps - `sylius.grid.resourceClassPropertyMissingType`: Unable to identify type for recursive path. - **Identifier note**: Grammar intentionally uses "needs to exists" (not "need to exist") in error messages. +#### Invariant: re-resolve the resource class per field + +`$resourceClass` is re-resolved from `$resourceClassName` at the **top of every field iteration**, and +the recursive walk keeps it a `ClassReflection` (`resolveNextClass()` / `toClassReflection()`). Both are load-bearing: + +- The recursive branch reassigns `$resourceClass` to the last segment's type. Without the re-resolve, a grid + with `address.city` followed by any other field validates the rest of the grid against `App\Entity\Address`. +- `Type::hasProperty()` is declared on `PHPStan\Type\Type` and returns **`TrinaryLogic`**. Casting that object + to bool is *always true*, so a `Type` in that position makes `!$type->hasProperty($x)` always `false` and + every remaining segment silently passes. This is a silent-pass bug, not a crash — it only shows up as + missing expected errors. + --- ### `GridBuilderFilterIsPartOfResourceClass` @@ -193,6 +215,8 @@ This separation improves performance (collectors can run in parallel) and keeps - `isSubtypeOf(Type $type, string $superType): bool` - Returns `true` if `$type` is a subtype of `$superType`. - **Usage**: All grid collectors extend this. +- **Note**: `scopeIsGrid()` does not match traits analysed on their own, because the scope class is the + trait itself. Only fields declared in a class/trait that a real grid uses are collected. --- @@ -212,7 +236,8 @@ This separation improves performance (collectors can run in parallel) and keeps - **Type**: `Collector` - **Validates node**: - - Must be a static `create()` call. + - Must be a static **`create()` or `createForService()`** call (`FACTORY_METHODS`). + `CallableField::createForService()` is the only alternative factory in grid-bundle. - Must be inside grid scope (`scopeIsGrid`). - Return type must be subtype of `Sylius\Component\Grid\Builder\Field\FieldInterface` (new) **or** `Sylius\Bundle\GridBundle\Builder\Field\FieldInterface` (old). - **Field resolution**: @@ -225,7 +250,7 @@ This separation improves performance (collectors can run in parallel) and keeps ### `CollectFilterForGridClass` -- **Type**: `Collector, int<1, max>}>` (doc declares `-1|positive int` but actual line numbers are positive) +- **Type**: `Collector, -1|int<1, max>}>` (doc declares `-1|positive int` but actual line numbers are positive) - **Validates node**: - Static `create()` call. - Grid scope. @@ -269,8 +294,9 @@ interface FieldNode { #### Important Notes -- **All nodes only support the old `Sylius\Bundle\GridBundle` namespace**, even though collectors accept both old and new (`Sylius\Component\Grid`) interfaces. -- If a project uses the new `Sylius\Component\Grid\Builder\Field\Field` classes, they will **not** be recognized by any field node unless custom nodes are registered. +- Matching the **bundle** namespace is correct on *both* grid-bundle 1.15 and 1.16 — see + [Grid Bundle 1.16](#grid-bundle-116). Do not "fix" these to the `Component` namespace; there is + nothing there to match. - The conversion helper `PropertyName::convertSnakeToCamelCase()` lowercases the entire string before shifting, so `My_Field` → `myField`. --- @@ -295,23 +321,53 @@ interface FilterNode { #### Built-in Filter Nodes -| Class | Sylius Class Name | Argument Logic | Notes | -|-------|-------------------|----------------|-------| -| `EntityFilter` | `Sylius\Bundle\GridBundle\Builder\Filter\EntityFilter` | args[3] if array of strings; fallback to args[0] | Signature likely `create(string, string, bool, ?array)` | -| `EnumFilter` | `Sylius\Bundle\GridBundle\Builder\Filter\EnumFilter` | args[3] if string; fallback to args[0] | Signature likely `create(string, string, bool, ?string)` | -| `ExistsFilter` | `Sylius\Bundle\GridBundle\Builder\Filter\ExistsFilter` | args[1] if string; fallback to args[0] | Signature likely `create(string, string)` | -| `Filter` | `Sylius\Bundle\GridBundle\Builder\Filter\FilterInterface` | args[0] only | **Bug?**: `isSuperTypeOf` check appears inverted (should be `$filterType->isSuperTypeOf($nodeClassType)`). Practically only supports direct interface calls. | -| `SelectFilter` | `Sylius\Bundle\GridBundle\Builder\Filter\SelectFilter` | args[3] if string; fallback to args[0] | | -| `StringFilter` | `Sylius\Bundle\GridBundle\Builder\Filter\StringFilter` | args[1] if array of strings; fallback to args[0] | Signature likely `create(string, array|callable)` | +Signatures below are the real grid-bundle ones; the `create()` first argument is always the filter +name, and every node falls back to it. + +| Class | Sylius Class Name | `create()` signature | Field Argument | +|-------|-------------------|----------------------|----------------| +| `EntityFilter` | `...Builder\Filter\EntityFilter` | `create(string $name, string $resourceClass, ?bool $multiple = null, ?array $fields = null)` | args[3] if array of strings; fallback args[0] | +| `EnumFilter` | `...Builder\Filter\EnumFilter` | `create(string $name, string $enumClass, ?bool $multiple = null, ?string $field = null)` | args[3] if string; fallback args[0] | +| `BooleanFilter` | `...Builder\Filter\BooleanFilter` | `create(string $name)` | args[0] | +| `DateFilter` | `...Builder\Filter\DateFilter` | `create(string $name)` | args[0] | +| `MoneyFilter` | `...Builder\Filter\MoneyFilter` | `create(string $name, string $currencyCode, ?int $scale = null)` | args[0] (currency + scale ignored) | +| `ExistsFilter` | `...Builder\Filter\ExistsFilter` | `create(string $name, ?string $field = null)` | args[1] if string; fallback args[0] | +| `SelectFilter` | `...Builder\Filter\SelectFilter` | `create(string $name, array $choices, ?bool $multiple = null, ?string $field = null)` | args[3] if string; fallback args[0] | +| `StringFilter` | `...Builder\Filter\StringFilter` | `create(string $name, ?array $fields = null, $type = null)` | args[1] if array of strings; fallback args[0] | +| `Filter` (catch-all) | matches anything that **implements** `FilterInterface` (old *or* new) | `create(string $name, string $type = null)` | args[0] only | #### Important Notes -- Same limitation as field nodes: **only old bundle namespace** recognized. -- `Filter` class likely broken: its `supports()` logic is reversed, making it effectively unused. +- **Registry order matters: keep the catch-all `Filter` node last.** First `supports()` match wins, so an + earlier catch-all shadows any node registered after it — in particular a user's custom node. Only the + catch-all is interface-based; the others match one concrete class name each, so the built-in set of + outcomes is order-independent. +- The catch-all is the *only* interface-based node, and only `Filter` itself implements `FilterInterface`. + `StringFilter`, `BooleanFilter` etc. are plain `final class`es with a static `create()` returning the + interface — they are matched by name, never by the catch-all. Anything without a dedicated node is + silently unvalidated, which is exactly how `BooleanFilter`/`DateFilter`/`MoneyFilter` went unnoticed. - Recursive filter fields (e.g., `address.city`) are **skipped** by the rule, not by the collector. --- +## Grid Bundle 1.16 + +grid-bundle 1.16 moved the *interfaces* into `Sylius\Component\Grid`, but **every concrete +`create()` factory class still lives in `Sylius\Bundle\GridBundle\Builder\...`** and returns the new +interface. In `Sylius\Component\Grid\Builder\Field\` and `...\Filter\` there are only the +`*Interface` files — no concrete field or filter classes exist there at all. + +Consequences: +- Name-based nodes must keep matching the **bundle** namespace on both lanes. +- The catch-all `Filter` node must check **both** interfaces, because a legacy `Filter::create()` call + on 1.16 returns the new `Sylius\Component\Grid\Builder\Filter\FilterInterface`. With only the legacy + interface it matches nothing on 1.16. +- Fixtures using new-namespace concrete classes would not even parse, so + `grid_needs_resource_model_native_interface.php` is excluded from PHPStan in `phpstan.dist.neon` + (it only parses on the `>= 1.16` lane). + +--- + ## Utility ### `PropertyName::convertSnakeToCamelCase` @@ -338,12 +394,12 @@ interface FilterNode { - bitExpert\PHPStan\Sylius\Rule\Resource\IndexOperationNeedsGridClassRule - bitExpert\PHPStan\Sylius\Rule\Resource\ResourceAttributeNeedsFormTypeRule ``` - - **Services**: - `FieldRegistryFactory` → `syliusFieldTypeRegistry` via `createRegistry()`. - `FilterRegistryFactory` → `syliusFilterTypeRegistry`. - Collectors (`CollectRessourceClassForGridClass`, `CollectFieldsForGridClass`, `CollectFilterForGridClass`) tagged `phpstan.collector`. - All field/filter nodes tagged with `phpstan.sylius.grid.field` / `phpstan.sylius.grid.filter`. + - Filter nodes are registered specific-first; the catch-all `Filter` node is deliberately last. ### Custom Field/Filter Types @@ -351,15 +407,19 @@ To add custom types: 1. Implement `FieldNode` or `FilterNode`. 2. Register service in `phpstan.neon` with the appropriate tag. +3. Register it **before** the catch-all `Filter` node if you are also adding an interface-based node. -Example (custom field node): +Example (custom filter node): ```neon services: - - class: App\PHPStan\CustomFieldNode + - class: App\PHPStan\CustomFilterNode tags: - - phpstan.sylius.grid.field + - phpstan.sylius.grid.filter ``` +`supports()` should match on the concrete class name (`$nodeClass->name`) like the built-in nodes do. +Prefer that over an interface check: interface-based nodes compete with the catch-all. + --- ## Testing @@ -370,32 +430,53 @@ services: - **Configuration**: `phpunit.xml.dist` (suffix `UnitTest.php`, bootstrap `tests/bootstrap.php`). - **Rules** use `PHPStan\Testing\RuleTestCase`. - **Utility/Registry** use standard `PHPUnit\Framework\TestCase`. +- 17 tests total across both lanes. + +#### Gotchas that make tests lie + +- **`RuleTestCase` asserts only `line: message`, never the file.** A wrong `->file()` in a rule cannot be + caught by these tests. Do not add a test that appears to cover file attribution. +- **`phpunit.xml.dist` sets `stopOnFailure="true"`.** A failing run reports only the tests up to the first + failure, so a low test count is not a discovery problem — confirm with `--list-tests`. +- **Test registries are hand-built.** Each grid rule test overrides `getCollectors()` and assembles its own + node list. Registering a node in `extension.neon` alone changes nothing in the tests, so a green suite can + pass while the new node is never exercised. Update both, and prove the node is load-bearing by pointing + one `FILTER_TYPE`/`FIELD_TYPE` at a wrong class name and confirming the expectation disappears. +- **Do not bulk-shift fixture line numbers with sequential `str.replace`.** Replacing `46→49` and then + `49→52` re-edits the value just written and shifts the wrong entries. Re-derive line numbers from the file. ### Test Files | Test File | Purpose | Fixtures | |-----------|---------|----------| | `PropertyNameUnitTest` | Unit tests for snake → camel conversion | None | -| `ResourceAttributeNeedsFormTypeUnitTest` | Validates `AsResource` form type existence | `tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity.php` | -| `ResourceAwareGridNeedsResourceClassUnitTest` | Tests old + new grid API with missing resource class | `grid_needs_resource_model.php`, `grid_needs_resource_model_attr.php` | +| `ResourceAttributeNeedsFormTypeUnitTest` | Validates `AsResource` form type existence | `Rule/Resource/data/entity.php`, `entity_non_constant_attribute.php` | +| `IndexOperationNeedsGridClassUnitTest` | Validates `Index` grid reference | `Rule/Resource/data/entity_index.php` | +| `ResourceAwareGridNeedsResourceClassUnitTest` | Missing resource class (old, attribute, 1.16-native, method-less) | `grid_needs_resource_model.php`, `grid_needs_resource_model_attr.php`, `grid_needs_resource_model_native_interface.php`, `grid_needs_resource_model_no_methods.php` | | `ResourceAwareGridNeedsResourceClassValidUnitTest` | Validates correct configurations | `grid_valid.php`, `grid_valid_attr.php` | -| `GridBuilderFieldIsPartOfResourceClassUnitTest` | Tests invalid field detection | `grid.php` | +| `GridBuilderFieldIsPartOfResourceClassUnitTest` | Invalid field detection (incl. `createForService`) | `grid.php` | | `GridBuilderFieldIsPartOfResourceClassValidUnitTest` | Validates correct grids | `grid_valid.php` | -| `GridBuilderFilterIsPartOfResourceClassUnitTest` | Tests invalid filter detection | `grid.php` | +| `GridBuilderFilterIsPartOfResourceClassUnitTest` | Invalid filter detection | `grid.php` | | `GridBuilderFilterIsPartOfResourceClassValidUnitTest` | Validates correct grids | `grid_valid.php` | | `DefaultFilterRegistryUnitTest` | Registry functionality | None | ### Fixtures -All fixtures (for analysis) reside under `tests/bitExpert/PHPStan/Sylius/Rule/*/data/`: -- `entity.php` (Resource): `App\Entity\Status` enum, `Address`, `Supplier` (implements `ResourceInterface`, missing `name` property). -- `grid.php` (Grid): `AdminSupplierGrid` with invalid field/filter definitions. -- `grid_valid.php`: Correct grid configuration. -- `grid_valid_attr.php`: Correct `#AsGrid` usage. -- `grid_needs_resource_model.php`: Old API with one missing class. -- `grid_needs_resource_model_attr.php`: `#AsGrid` with one missing class. +All fixtures (for analysis) reside under `tests/bitExpert/PHPStan/Sylius/Rule/*/data/`. + +`Rule/Grid/data/` (namespace `App\Entity` / `App\Grid`): +- `entity.php`: `App\Entity\Status` (enum), `Country`, `Address`, `Supplier` (implements `ResourceInterface`, missing `name`). `Country` and `Address::$country`/`getCountry()` back the three-segment field case. +- `grid.php`: `AdminSupplierGrid` with invalid field/filter definitions, plus `SomeOtherClass`. Shared by the field *and* filter tests, so any edit shifts expectations in both. +- `grid_valid.php`, `grid_valid_attr.php`: correct grid configurations. +- `grid_needs_resource_model.php` / `_attr.php` / `_no_methods.php` / `_native_interface.php`: resource-class detection cases. -These files are loaded via `composer.json` `autoload-dev.files` so classes exist during analysis. +`Rule/Resource/data/` (namespace `App\Entity`): +- `entity.php`: declares a class literally named `entity` (lowercase) implementing `ResourceInterface`. +- `entity_index.php`: missing `Index(grid:)`. +- `entity_non_constant_attribute.php`: non-constant attribute argument. + +These files are loaded via `composer.json` `autoload-dev.files` so classes exist during analysis, and +`Rule/Grid/data/grid.php` + `entity.php` are excluded from PHPStan analysis in `phpstan.dist.neon`. --- @@ -413,13 +494,20 @@ From `composer.json`: ### CI Pipeline (`.github/workflows/ci.yml`) +Matrix over PHP version × OS × grid-bundle constraint. Each lane runs: + 1. Checkout repo -2. Setup PHP 8.2 -3. Install dependencies -4. License check -5. Coding standards -6. Static analysis -7. Unit tests +2. Configure PHP (`shivammathur/setup-php`) +3. `composer require --dev "sylius/grid-bundle:" --no-update` + `composer update` +4. Show resolved versions — **one `composer show ` call per package**; `composer show` takes a single + package plus an optional version, so passing several names exits `1` +5. `composer check-license` +6. `composer cs` +7. `composer static-analysis` +8. `composer test` + +The two grid-bundle lanes are `^1.15 <1.16` and `^1.16`. Keep both green; a change that only passes the +second lane is not done. ### Coding Standards @@ -436,28 +524,39 @@ From `composer.json`: - **File**: `CollectRessourceClassForGridClass.php` (`Ressource` double `s`). - **Impact**: Must be preserved for BC; do not rename. -### 2. Stale PHPStan Ignore -- `phpstan.dist.neon` ignores errors in `AbstractGridBuilderRule.php`, but this file **does not exist** in the codebase. -- **Action**: Can likely be removed. - -### 3. Field/Filter Support Limitation -- **All built-in nodes only support `Sylius\Bundle\GridBundle\*` namespace**, even though collectors accept both old and new (`Sylius\Component\Grid`) interfaces. -- **Consequence**: Projects using the new component interfaces must register custom nodes. -- **Recommendation**: Update field/filter nodes to support both namespaces. - -### 4. `Filter` Node Bug -- `Filter::supports()` logic uses `$nodeClassType->isSuperTypeOf($filterType)` when it should be the reverse. -- **Consequence**: The generic `Filter` node likely never matches any concrete filter class. -- **Impact**: May cause some custom filters to be silently skipped. - -### 5. Recursive Filter Fields Skipped +### 2. Stale `@implements` docblocks on the two grid rules +- Both `GridBuilder*IsPartOfResourceClass` rules declare `@implements Rule` but actually + `return CollectedDataNode::class` from `getNodeType()`. +- **Impact**: Misleads readers about the architecture; harmless at runtime. PHPStan cannot flag it because + `processNode(Node $node, ...)` takes the broad `Node` type. +- **Action**: correct the docblocks (and the now-redundant `instanceof CollectedDataNode` guards). + +### 3. Node/collector coverage is opt-in +- A field or filter class with **no** matching node is silently never validated — no error, no warning. + The catch-all only covers classes that implement `FilterInterface`, and only `Filter` itself does. +- **Impact**: `BooleanFilter`, `DateFilter` and `MoneyFilter` were unvalidated until nodes were added for + them. When a new grid-bundle factory appears, add a node in the same change, and check the grid-bundle + changelog when bumping the floor. + +### 4. Recursive Filter Fields Skipped - The rule `GridBuilderFilterIsPartOfResourceClass` skips any filter field containing `.` (e.g., `address.city`). - **Rationale**: Filters typically do not need recursive access, but this may be overly restrictive. - **Consequence**: Users cannot validate recursive filter fields. -### 6. Case Sensitivity in Fixtures -- Test fixtures use non-standard naming: `tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/entity.php` defines class `App\Entity\entity` (lowercase `entity`). -- **Impact**: Avoid confusion; stick to PSR-1 naming in new code. +### 5. Field errors are attributed to the grid's file, not the node's +- Both grid rules use `$gridFilesMap[$gridClass]`, which is **last-write-wins per grid class** and shared + by all fields of that grid. +- **Impact**: A grid split across multiple files reports every field error against whichever file was seen + last. Fields inside a trait are always attributed to the using class, because the node's real file is not + recoverable: PHP-Parser 5 exposes only an integer `startFilePos` (no path), and `Scope::getFile()`, + `getFunction()->getFileName()` and `->getDeclaringClass()->getFileName()` all return the using class file. +- **Action**: not fixable without a broader file-identity design; `RuleTestCase` cannot even assert it. + +### 6. Lowercase fixture class name +- `tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity.php` declares a class named `entity` (lowercase) + in namespace `App\Entity`. +- **Impact**: Avoid confusion; stick to PSR-1 naming in new fixtures. (The `Rule/Grid/data/entity.php` + fixture is fine — it declares `Status`, `Country`, `Address`, `Supplier`.) --- @@ -466,7 +565,7 @@ From `composer.json`: ### Adding a New Rule 1. Create rule class in `src/bitExpert/PHPStan/Sylius/Rule/...`. -2. Implement `PHPStan\Rules\Rule`. +2. Implement `PHPStan\Rules\Rule` — make `@implements` and `getNodeType()` agree. 3. Register in `extension.neon` under `rules`. 4. Add test in `tests/.../Rule/.../...UnitTest.php`. 5. Add fixture if needed. @@ -482,8 +581,10 @@ From `composer.json`: 1. Implement `FieldNode` or `FilterNode`. 2. Tag service in `extension.neon` with `phpstan.sylius.grid.field` or `phpstan.sylius.grid.filter`. -3. Ensure `supports()` correctly checks class name. -4. Test with appropriate fixtures. +3. Ensure `supports()` correctly checks the **concrete** class name. +4. Register before the catch-all `Filter` node. +5. Add the node to the affected tests' `getCollectors()` too, then prove it is load-bearing with a + deliberately wrong class name. --- From d73d56a4760bbe7bc99bd2b794366b09e1ffbc93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 21:53:12 +0200 Subject: [PATCH 14/18] Correct the @implements annotation on both grid rules Both rules declared @implements Rule 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. --- .../Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php | 7 +------ .../Rule/Grid/GridBuilderFilterIsPartOfResourceClass.php | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php b/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php index d74763a..3d84d45 100644 --- a/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php +++ b/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php @@ -15,7 +15,6 @@ use bitExpert\PHPStan\Sylius\Collector\Grid\CollectFieldsForGridClass; use bitExpert\PHPStan\Sylius\Collector\Grid\CollectRessourceClassForGridClass; use PhpParser\Node; -use PhpParser\Node\Expr\StaticCall; use PHPStan\Analyser\Scope; use PHPStan\Node\CollectedDataNode; use PHPStan\Reflection\ClassReflection; @@ -26,7 +25,7 @@ use PHPStan\Type\Type; /** - * @implements Rule + * @implements Rule */ readonly class GridBuilderFieldIsPartOfResourceClass implements Rule { @@ -44,10 +43,6 @@ public function getNodeType(): string public function processNode(Node $node, Scope $scope): array { - if (!$node instanceof CollectedDataNode) { - return []; - } - $gridResourceMap = []; $gridFilesMap = []; $gridFieldsMap = []; diff --git a/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClass.php b/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClass.php index 4284ee7..e44fdc3 100644 --- a/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClass.php +++ b/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClass.php @@ -15,7 +15,6 @@ use bitExpert\PHPStan\Sylius\Collector\Grid\CollectFilterForGridClass; use bitExpert\PHPStan\Sylius\Collector\Grid\CollectRessourceClassForGridClass; use PhpParser\Node; -use PhpParser\Node\Expr\StaticCall; use PHPStan\Analyser\Scope; use PHPStan\Node\CollectedDataNode; use PHPStan\Reflection\ReflectionProvider; @@ -23,7 +22,7 @@ use PHPStan\Rules\RuleErrorBuilder; /** - * @implements Rule + * @implements Rule */ readonly class GridBuilderFilterIsPartOfResourceClass implements Rule { @@ -41,10 +40,6 @@ public function getNodeType(): string public function processNode(Node $node, Scope $scope): array { - if (!$node instanceof CollectedDataNode) { - return []; - } - $gridResourceMap = []; $gridFilesMap = []; $gridFilterFieldsMap = []; From dc1f237bffa30f6be6a611b5a4c1b0cb355204e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 21:53:53 +0200 Subject: [PATCH 15/18] Record the instanceof/@implements trap that the guard was hiding 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. --- AGENTS.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ee69825..e6995e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,11 +103,11 @@ PHPStan extensions in this project follow a two-phase approach: This separation improves performance (collectors can run in parallel) and keeps rules focused on analysis rather than parsing. -> **Caution**: `getNodeType()` is authoritative. `GridBuilderFieldIsPartOfResourceClass` and -> `GridBuilderFilterIsPartOfResourceClass` both return `CollectedDataNode::class` and guard -> `processNode()` with `if (!$node instanceof CollectedDataNode)`. Their `@implements Rule` -> docblocks are **wrong leftovers** — PHPStan cannot catch this because `processNode(Node $node, ...)` -> takes the broad `Node` type. Trust `getNodeType()`, not the docblock. +> **Note**: `getNodeType()` is authoritative and must agree with the `@implements` generic. Both grid +> rules return `CollectedDataNode::class`. The `processNode()` parameter stays typed as `Node` because +> that is what `Rule::processNode()` declares, and narrowing a parameter is a contravariance +> violation. Do **not** add a defensive `instanceof` guard to compensate — see +> [Known Issue 2](#2-a-redundant-instanceof-guard-can-hide-a-wrong-implements). ### Rule Execution Flow @@ -524,12 +524,15 @@ second lane is not done. - **File**: `CollectRessourceClassForGridClass.php` (`Ressource` double `s`). - **Impact**: Must be preserved for BC; do not rename. -### 2. Stale `@implements` docblocks on the two grid rules -- Both `GridBuilder*IsPartOfResourceClass` rules declare `@implements Rule` but actually - `return CollectedDataNode::class` from `getNodeType()`. -- **Impact**: Misleads readers about the architecture; harmless at runtime. PHPStan cannot flag it because - `processNode(Node $node, ...)` takes the broad `Node` type. -- **Action**: correct the docblocks (and the now-redundant `instanceof CollectedDataNode` guards). +### 2. A redundant `instanceof` guard can hide a wrong `@implements` +- `processNode()` must declare `Node $node` because `Rule::processNode()` does, so the generic in + `@implements` is the only thing that narrows `$node` inside the method body. +- Both grid rules used to open with `if (!$node instanceof CollectedDataNode) { return []; }`. That + re-narrowing is exactly what **suppressed** their wrong `@implements Rule` docblocks from + ever reaching PHPStan. Deleting the guard without fixing the docblock turns + `$node->get()` into `Call to an undefined method PhpParser\Node::get()`. +- **Action**: when adding a rule, keep `@implements` and `getNodeType()` in agreement, and skip the + defensive `instanceof` guard — it can mask a real annotation mistake instead of catching one. ### 3. Node/collector coverage is opt-in - A field or filter class with **no** matching node is silently never validated — no error, no warning. From 9f8359931bdfd74bfbb324c08f2cc37e1b67e0db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 21:58:13 +0200 Subject: [PATCH 16/18] Narrow every getNodeType() return to its concrete node type PHPStan's Rule interface declares `@return class-string`. 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` or `class-string`. 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. --- AGENTS.md | 4 +++- .../Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php | 4 ++-- .../Rule/Grid/GridBuilderFilterIsPartOfResourceClass.php | 2 +- .../Sylius/Rule/Grid/ResourceAwareGridNeedsResourceClass.php | 2 +- .../Sylius/Rule/Resource/IndexOperationNeedsGridClassRule.php | 3 +++ .../Rule/Resource/ResourceAttributeNeedsFormTypeRule.php | 3 +++ 6 files changed, 13 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e6995e6..9efa293 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -532,7 +532,9 @@ second lane is not done. ever reaching PHPStan. Deleting the guard without fixing the docblock turns `$node->get()` into `Call to an undefined method PhpParser\Node::get()`. - **Action**: when adding a rule, keep `@implements` and `getNodeType()` in agreement, and skip the - defensive `instanceof` guard — it can mask a real annotation mistake instead of catching one. + defensive `instanceof` guard — it can mask a real annotation mistake instead of catching one. Every + rule's `getNodeType()` now carries `@return class-string`, so PHPStan rejects a + mismatch between the two; do not widen that back to a bare `class-string`, or the safety net goes away. ### 3. Node/collector coverage is opt-in - A field or filter class with **no** matching node is silently never validated — no error, no warning. diff --git a/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php b/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php index 3d84d45..05d5159 100644 --- a/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php +++ b/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFieldIsPartOfResourceClass.php @@ -34,7 +34,7 @@ public function __construct(protected ReflectionProvider $broker) } /** - * @return class-string + * @return class-string */ public function getNodeType(): string { @@ -187,7 +187,7 @@ private function resolveNextClass( if ($property->hasPHPDocType()) { return $this->toClassReflection($property->getPhpDocType()); } - } catch (MissingPropertyFromReflectionException $e) { + } catch (MissingPropertyFromReflectionException) { // Reported by the caller as a missing property on the parent segment. } diff --git a/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClass.php b/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClass.php index e44fdc3..3fe5016 100644 --- a/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClass.php +++ b/src/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClass.php @@ -31,7 +31,7 @@ public function __construct(protected ReflectionProvider $broker) } /** - * @return class-string + * @return class-string */ public function getNodeType(): string { diff --git a/src/bitExpert/PHPStan/Sylius/Rule/Grid/ResourceAwareGridNeedsResourceClass.php b/src/bitExpert/PHPStan/Sylius/Rule/Grid/ResourceAwareGridNeedsResourceClass.php index 7e00ad4..1e55954 100644 --- a/src/bitExpert/PHPStan/Sylius/Rule/Grid/ResourceAwareGridNeedsResourceClass.php +++ b/src/bitExpert/PHPStan/Sylius/Rule/Grid/ResourceAwareGridNeedsResourceClass.php @@ -44,7 +44,7 @@ public function __construct(private ReflectionProvider $broker) } /** - * @return class-string + * @return class-string */ public function getNodeType(): string { diff --git a/src/bitExpert/PHPStan/Sylius/Rule/Resource/IndexOperationNeedsGridClassRule.php b/src/bitExpert/PHPStan/Sylius/Rule/Resource/IndexOperationNeedsGridClassRule.php index e735aff..fdaa9b7 100644 --- a/src/bitExpert/PHPStan/Sylius/Rule/Resource/IndexOperationNeedsGridClassRule.php +++ b/src/bitExpert/PHPStan/Sylius/Rule/Resource/IndexOperationNeedsGridClassRule.php @@ -29,6 +29,9 @@ public function __construct(private ReflectionProvider $broker) { } + /** + * @return class-string + */ public function getNodeType(): string { return InClassNode::class; diff --git a/src/bitExpert/PHPStan/Sylius/Rule/Resource/ResourceAttributeNeedsFormTypeRule.php b/src/bitExpert/PHPStan/Sylius/Rule/Resource/ResourceAttributeNeedsFormTypeRule.php index 21d8ec8..c9ba180 100644 --- a/src/bitExpert/PHPStan/Sylius/Rule/Resource/ResourceAttributeNeedsFormTypeRule.php +++ b/src/bitExpert/PHPStan/Sylius/Rule/Resource/ResourceAttributeNeedsFormTypeRule.php @@ -29,6 +29,9 @@ public function __construct(private ReflectionProvider $broker) { } + /** + * @return class-string + */ public function getNodeType(): string { return InClassNode::class; From 5159bcc7a15b793c4d5f824b00909a4539e57cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sat, 26 Sep 2026 22:10:09 +0200 Subject: [PATCH 17/18] Cover filter registry ordering with regression tests 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. --- AGENTS.md | 32 ++- composer.json | 2 + phpstan.dist.neon | 1 + ...derFilterIsPartOfResourceClassUnitTest.php | 189 +++++++++++++++++- .../Sylius/Rule/Grid/data/CustomFilter.php | 161 +++++++++++++++ .../Rule/Grid/data/grid_custom_filter.php | 48 +++++ 6 files changed, 419 insertions(+), 14 deletions(-) create mode 100644 tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/CustomFilter.php create mode 100644 tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_custom_filter.php diff --git a/AGENTS.md b/AGENTS.md index 9efa293..9f020ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -341,7 +341,9 @@ name, and every node falls back to it. - **Registry order matters: keep the catch-all `Filter` node last.** First `supports()` match wins, so an earlier catch-all shadows any node registered after it — in particular a user's custom node. Only the catch-all is interface-based; the others match one concrete class name each, so the built-in set of - outcomes is order-independent. + outcomes is order-independent. This is enforced by + `GridBuilderFilterIsPartOfResourceClassUnitTest`, which drives both orders against `grid_custom_filter.php` + *and* parses `extension.neon` to assert the shipped order. - The catch-all is the *only* interface-based node, and only `Filter` itself implements `FilterInterface`. `StringFilter`, `BooleanFilter` etc. are plain `final class`es with a static `create()` returning the interface — they are matched by name, never by the catch-all. Anything without a dedicated node is @@ -441,7 +443,9 @@ Prefer that over an interface check: interface-based nodes compete with the catc - **Test registries are hand-built.** Each grid rule test overrides `getCollectors()` and assembles its own node list. Registering a node in `extension.neon` alone changes nothing in the tests, so a green suite can pass while the new node is never exercised. Update both, and prove the node is load-bearing by pointing - one `FILTER_TYPE`/`FIELD_TYPE` at a wrong class name and confirming the expectation disappears. + one `FILTER_TYPE`/`FIELD_TYPE` at a wrong class name and confirming the expectation disappears. For the + same reason the filter test also parses `extension.neon` itself: a hand-built registry can only prove the + *mechanism*, never that the shipped config is ordered correctly. - **Do not bulk-shift fixture line numbers with sequential `str.replace`.** Replacing `46→49` and then `49→52` re-edits the value just written and shifts the wrong entries. Re-derive line numbers from the file. @@ -456,7 +460,7 @@ Prefer that over an interface check: interface-based nodes compete with the catc | `ResourceAwareGridNeedsResourceClassValidUnitTest` | Validates correct configurations | `grid_valid.php`, `grid_valid_attr.php` | | `GridBuilderFieldIsPartOfResourceClassUnitTest` | Invalid field detection (incl. `createForService`) | `grid.php` | | `GridBuilderFieldIsPartOfResourceClassValidUnitTest` | Validates correct grids | `grid_valid.php` | -| `GridBuilderFilterIsPartOfResourceClassUnitTest` | Invalid filter detection | `grid.php` | +| `GridBuilderFilterIsPartOfResourceClassUnitTest` | Invalid filter detection, plus registry-order (catch-all vs. dedicated node) and `extension.neon` order | `grid.php`, `grid_custom_filter.php`, `CustomFilter.php` | | `GridBuilderFilterIsPartOfResourceClassValidUnitTest` | Validates correct grids | `grid_valid.php` | | `DefaultFilterRegistryUnitTest` | Registry functionality | None | @@ -469,6 +473,8 @@ All fixtures (for analysis) reside under `tests/bitExpert/PHPStan/Sylius/Rule/*/ - `grid.php`: `AdminSupplierGrid` with invalid field/filter definitions, plus `SomeOtherClass`. Shared by the field *and* filter tests, so any edit shifts expectations in both. - `grid_valid.php`, `grid_valid_attr.php`: correct grid configurations. - `grid_needs_resource_model.php` / `_attr.php` / `_no_methods.php` / `_native_interface.php`: resource-class detection cases. +- `CustomFilter.php`: namespace `App\Filter`; a user-style filter that implements `FilterInterface`, so the catch-all matches it by interface. Implements every method for real so PHPStan verifies the signatures on both lanes. +- `grid_custom_filter.php`: `CustomFilterGrid`, holding the one `CustomFilter::create()` call whose extracted field differs depending on which node wins. `Rule/Resource/data/` (namespace `App\Entity`): - `entity.php`: declares a class literally named `entity` (lowercase) implementing `ResourceInterface`. @@ -476,7 +482,9 @@ All fixtures (for analysis) reside under `tests/bitExpert/PHPStan/Sylius/Rule/*/ - `entity_non_constant_attribute.php`: non-constant attribute argument. These files are loaded via `composer.json` `autoload-dev.files` so classes exist during analysis, and -`Rule/Grid/data/grid.php` + `entity.php` are excluded from PHPStan analysis in `phpstan.dist.neon`. +`Rule/Grid/data/grid.php` + `entity.php` + `grid_custom_filter.php` are excluded from PHPStan analysis +in `phpstan.dist.neon`. `CustomFilter.php` is deliberately **not** excluded: it declares no invalid +references, so analysing it is what proves the interface implementation still holds on grid-bundle 1.16. --- @@ -563,6 +571,22 @@ second lane is not done. - **Impact**: Avoid confusion; stick to PSR-1 naming in new fixtures. (The `Rule/Grid/data/entity.php` fixture is fine — it declares `Status`, `Country`, `Address`, `Supplier`.) +### 6a. PHP-CS-Fixer rewrites a fixture class to the file's basename +- `@Symfony` enables `psr_autoloading`, and its class-name matcher compares **case-insensitively**. A + snake_case data file that declares exactly one class whose name differs only by case gets silently + rewritten to the file's snake_case basename by `composer cs-fix`. Naming the file `custom_filter.php` + turned `class CustomFilter` into `class custom_filter`, which broke the fixture at runtime: the call + site `CustomFilter::create()` then named a non-existent class, `CollectFilterForGridClass` could no + longer resolve a `FilterInterface` return type, the call was silently skipped, and the test failed + only on the *missing* expected error. +- Files whose class name shares no letters with the file name are left alone, which is why `grid.php` + (class `AdminSupplierGrid`) has never been touched. +- **Action**: give any single-class fixture a file name matching its class in PSR-1 style + (`CustomFilter.php` → `class CustomFilter`). `grid_custom_filter.php` survives only because of the + no-letter-overlap accident above — do not rely on that if its class is ever renamed. +- **Detection tip**: this failure mode is a *missing* error, not a wrong one, so a green-looking + `composer cs-fix` followed by a red test usually means the fixer edited a fixture, not the test. + --- ## Extending the Extension diff --git a/composer.json b/composer.json index 3e82d03..532d580 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,8 @@ "files": [ "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/entity.php", "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php", + "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/CustomFilter.php", + "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_custom_filter.php", "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model.php", "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_attr.php", "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_no_methods.php", diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 40a4f6f..f311302 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -7,6 +7,7 @@ parameters: excludePaths: - tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/entity.php - tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php + - tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_custom_filter.php - tests/bitExpert/PHPStan/Sylius/Rule/Grid//data/grid_needs_resource_model.php - tests/bitExpert/PHPStan/Sylius/Rule/Grid//data/grid_needs_resource_model_attr.php - tests/bitExpert/PHPStan/Sylius/Rule/Grid//data/grid_needs_resource_model_no_methods.php diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php index c8bead2..427506b 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php @@ -21,9 +21,14 @@ use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\EnumFilter; use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\ExistsFilter; use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\Filter; +use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\FilterNode; use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\MoneyFilter; use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\SelectFilter; use bitExpert\PHPStan\Sylius\Collector\Grid\Filter\StringFilter; +use PhpParser\Node; +use PhpParser\Node\Expr\StaticCall; +use PhpParser\Node\Name\FullyQualified; +use PHPStan\Collectors\Collector; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; @@ -37,27 +42,68 @@ protected function getRule(): Rule return new GridBuilderFilterIsPartOfResourceClass($this->createReflectionProvider()); } + /** + * Overrides the registry for the test currently running. Left null the + * production order from extension.neon is used. + * + * @var list|null + */ + private ?array $filterNodes = null; + protected function getCollectors(): array { - $filters = []; + if (null !== $this->filterNodes) { + return $this->collectorsFor($this->filterNodes); + } + // Mirrors the registration order in extension.neon: the catch-all has to // come last so it cannot shadow a node for a more specific class. - $filters[] = new EntityFilter(); - $filters[] = new EnumFilter(); - $filters[] = new BooleanFilter(); - $filters[] = new DateFilter(); - $filters[] = new MoneyFilter(); - $filters[] = new ExistsFilter(); - $filters[] = new SelectFilter(); - $filters[] = new StringFilter(); - $filters[] = new Filter(); + return $this->collectorsFor([ + new EntityFilter(), + new EnumFilter(), + new BooleanFilter(), + new DateFilter(), + new MoneyFilter(), + new ExistsFilter(), + new SelectFilter(), + new StringFilter(), + new Filter(), + ]); + } + /** + * @param list $filters + * + * @return array> + */ + private function collectorsFor(array $filters): array + { return [ new CollectRessourceClassForGridClass(), new CollectFilterForGridClass(new DefaultFilterRegistry($filters)), ]; } + /** + * A node dedicated to App\Filter\CustomFilter, i.e. what a user would + * register for their own filter class. It reports a different field than + * the catch-all would, so the two are distinguishable in the output. + */ + private function dedicatedCustomFilterNode(): FilterNode + { + return new class implements FilterNode { + public function supports(FullyQualified $nodeClass): bool + { + return 'App\\Filter\\CustomFilter' === $nodeClass->name; + } + + public function getFilterFields(StaticCall $node): array + { + return ['customNodeField']; + } + }; + } + public function testRule(): void { $this->analyse( @@ -100,4 +146,127 @@ public function testRule(): void ], ); } + + /** + * Baseline: with no dedicated node registered, the catch-all resolves the + * user-defined filter class purely because it implements FilterInterface. + * This also proves the fixture is actually wired into grid scope. + */ + public function testCatchAllResolvesAUserFilterByInterface(): void + { + $this->analyse( + [__DIR__ . '/data/grid_custom_filter.php'], + [ + [ + 'The filter field "catchAllOnlyField" needs to exists as property in resource class "App\Entity\Supplier".', + 41, + ], + [ + 'The filter field "catchAllField" needs to exists as property in resource class "App\Entity\Supplier".', + 45, + ], + ], + ); + } + + /** + * The documented registration order: a dedicated node comes before the + * catch-all, so it wins for the class it targets. + */ + public function testDedicatedNodeWinsWhenRegisteredBeforeTheCatchAll(): void + { + $this->filterNodes = [ + new StringFilter(), + $this->dedicatedCustomFilterNode(), + new Filter(), + ]; + + $this->analyse( + [__DIR__ . '/data/grid_custom_filter.php'], + [ + [ + 'The filter field "catchAllOnlyField" needs to exists as property in resource class "App\Entity\Supplier".', + 41, + ], + [ + // customNodeField comes from the dedicated node, not from + // the create() argument, so the node clearly won. + 'The filter field "customNodeField" needs to exists as property in resource class "App\Entity\Supplier".', + 45, + ], + ], + ); + } + + /** + * The inverse order, which is the bug the registration order guards + * against: the catch-all is interface-based, so it matches the user's + * filter class and the dedicated node is never consulted. The dedicated + * field never appears in the output. + */ + public function testCatchAllShadowsADedicatedNodeRegisteredAfterIt(): void + { + $this->filterNodes = [ + new StringFilter(), + new Filter(), + $this->dedicatedCustomFilterNode(), + ]; + + $this->analyse( + [__DIR__ . '/data/grid_custom_filter.php'], + [ + [ + 'The filter field "catchAllOnlyField" needs to exists as property in resource class "App\Entity\Supplier".', + 41, + ], + [ + // catchAllField is the create() argument, which is what the + // catch-all extracts. The dedicated node was shadowed. + 'The filter field "catchAllField" needs to exists as property in resource class "App\Entity\Supplier".', + 45, + ], + ], + ); + } + + /** + * The three ordering tests above drive their own registries, so on their + * own they cannot notice extension.neon being reordered. This one reads + * the real config and asserts the invariant that actually ships: the + * catch-all is registered after every concrete node. + */ + public function testCatchAllIsRegisteredLastInExtensionNeon(): void + { + $config = \file_get_contents(\dirname(__DIR__, 6) . '/extension.neon'); + self::assertIsString($config); + + $filterNodes = []; + foreach (\preg_split('/^\t-$/m', $config) ?: [] as $block) { + if (!\str_contains($block, 'phpstan.sylius.grid.filter')) { + continue; + } + + if (1 !== \preg_match('/^\s*class:\s*(\S+)/m', $block, $matches)) { + self::fail('Every filter node service must declare a class.'); + } + // NEON leaves namespace separators unescaped, so shorten by hand + // rather than fighting backslashes in a regex. + $filterNodes[] = \substr((string) \strrchr($matches[1], '\\'), 1); + } + + self::assertContains('Filter', $filterNodes, 'The catch-all node must be registered.'); + + $duplicates = \array_keys(\array_filter( + \array_count_values($filterNodes), + static fn (int $count): bool => 1 < $count, + )); + self::assertSame([], $duplicates, 'No filter node may be registered twice.'); + + self::assertSame( + 'Filter', + \end($filterNodes), + 'The catch-all Filter node must stay last in extension.neon, otherwise it shadows ' + . 'every node registered after it, including user-supplied ones.', + ); + } } diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/CustomFilter.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/CustomFilter.php new file mode 100644 index 0000000..5bc60f8 --- /dev/null +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/CustomFilter.php @@ -0,0 +1,161 @@ + */ + private array $options = []; + + /** @var array */ + private array $formOptions = []; + + public static function create(string $name, string $type): self + { + return new self(); + } + + public function getName(): string + { + return ''; + } + + public function getLabel(): string|bool|null + { + return null; + } + + public function setLabel(string|bool|null $label): self + { + return $this; + } + + public function isEnabled(): bool + { + return true; + } + + public function setEnabled(bool $enabled): self + { + return $this; + } + + public function getTemplate(): ?string + { + return null; + } + + public function setTemplate(?string $template): self + { + return $this; + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @param array $options + */ + public function setOptions(array $options): self + { + $this->options = $options; + + return $this; + } + + /** + * @param mixed $value + */ + public function addOption(string $option, $value): self + { + $this->options[$option] = $value; + + return $this; + } + + public function removeOption(string $option): self + { + unset($this->options[$option]); + + return $this; + } + + /** + * @return array + */ + public function getFormOptions(): array + { + return $this->formOptions; + } + + /** + * @param array $formOptions + */ + public function setFormOptions(array $formOptions): self + { + $this->formOptions = $formOptions; + + return $this; + } + + /** + * @param mixed $value + */ + public function addFormOption(string $option, $value): self + { + $this->formOptions[$option] = $value; + + return $this; + } + + public function removeFormOption(string $option): self + { + unset($this->formOptions[$option]); + + return $this; + } + + /** + * @param array $criteria + */ + public function setCriteria(array $criteria): self + { + return $this; + } + + /** + * @return array + */ + public function toArray(): array + { + return []; + } +} diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_custom_filter.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_custom_filter.php new file mode 100644 index 0000000..c0dab52 --- /dev/null +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_custom_filter.php @@ -0,0 +1,48 @@ +addFilter( + Filter::create('catchAllOnlyField', 'string'), + ); + + $gridBuilder->addFilter( + CustomFilter::create('catchAllField', 'string'), + ); + } +} From 71e77d74c9b182c4353dd973e9a89d2ad772a83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Hochd=C3=B6rfer?= Date: Sun, 27 Sep 2026 08:21:03 +0200 Subject: [PATCH 18/18] Clean up test data used in the unit tests --- .php-cs-fixer.dist.php | 11 +++- AGENTS.md | 66 +++++++++---------- composer.json | 4 +- ...derFilterIsPartOfResourceClassUnitTest.php | 41 ------------ .../{CustomFilter.php => custom_filter.php} | 8 --- .../Rule/Grid/data/grid_custom_filter.php | 2 +- .../grid_needs_resource_model_no_methods.php | 2 +- .../Sylius/Rule/Grid/data/grid_valid.php | 2 +- .../Sylius/Rule/Grid/data/grid_valid_attr.php | 2 +- .../Sylius/Rule/Resource/data/entity.php | 2 +- .../Rule/Resource/data/entity_index.php | 2 +- .../data/entity_non_constant_attribute.php | 12 ++-- 12 files changed, 55 insertions(+), 99 deletions(-) rename tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/{CustomFilter.php => custom_filter.php} (93%) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 1fee96a..bbf3138 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -4,7 +4,16 @@ $finder = (new PhpCsFixer\Finder()) ->in(__DIR__) - ->exclude(['vendor']); + ->exclude([ + 'vendor', + // Analysis fixtures are loaded through composer autoload-dev.files, not + // by class name, so the psr_autoloading rule that @Symfony enables does + // not apply to them: their file name is snake_case while their classes + // are PSR-1. Without this the fixer would rewrite every fixture class to + // its file's basename, which breaks the references between fixtures. + 'tests/bitExpert/PHPStan/Sylius/Rule/Grid/data', + 'tests/bitExpert/PHPStan/Sylius/Rule/Resource/data', + ]); return (new PhpCsFixer\Config()) ->setRiskyAllowed(true) diff --git a/AGENTS.md b/AGENTS.md index 9f020ae..65dd1dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -341,9 +341,9 @@ name, and every node falls back to it. - **Registry order matters: keep the catch-all `Filter` node last.** First `supports()` match wins, so an earlier catch-all shadows any node registered after it — in particular a user's custom node. Only the catch-all is interface-based; the others match one concrete class name each, so the built-in set of - outcomes is order-independent. This is enforced by - `GridBuilderFilterIsPartOfResourceClassUnitTest`, which drives both orders against `grid_custom_filter.php` - *and* parses `extension.neon` to assert the shipped order. + outcomes is order-independent. `GridBuilderFilterIsPartOfResourceClassUnitTest` drives both orders + against `grid_custom_filter.php` to show which node wins; keeping the shipped order in + `extension.neon` correct is a manual job. - The catch-all is the *only* interface-based node, and only `Filter` itself implements `FilterInterface`. `StringFilter`, `BooleanFilter` etc. are plain `final class`es with a static `create()` returning the interface — they are matched by name, never by the catch-all. Anything without a dedicated node is @@ -443,9 +443,8 @@ Prefer that over an interface check: interface-based nodes compete with the catc - **Test registries are hand-built.** Each grid rule test overrides `getCollectors()` and assembles its own node list. Registering a node in `extension.neon` alone changes nothing in the tests, so a green suite can pass while the new node is never exercised. Update both, and prove the node is load-bearing by pointing - one `FILTER_TYPE`/`FIELD_TYPE` at a wrong class name and confirming the expectation disappears. For the - same reason the filter test also parses `extension.neon` itself: a hand-built registry can only prove the - *mechanism*, never that the shipped config is ordered correctly. + one `FILTER_TYPE`/`FIELD_TYPE` at a wrong class name and confirming the expectation disappears. Keep the + hand-built list in the same order as `extension.neon`, since that is the order the collector really uses. - **Do not bulk-shift fixture line numbers with sequential `str.replace`.** Replacing `46→49` and then `49→52` re-edits the value just written and shifts the wrong entries. Re-derive line numbers from the file. @@ -460,7 +459,7 @@ Prefer that over an interface check: interface-based nodes compete with the catc | `ResourceAwareGridNeedsResourceClassValidUnitTest` | Validates correct configurations | `grid_valid.php`, `grid_valid_attr.php` | | `GridBuilderFieldIsPartOfResourceClassUnitTest` | Invalid field detection (incl. `createForService`) | `grid.php` | | `GridBuilderFieldIsPartOfResourceClassValidUnitTest` | Validates correct grids | `grid_valid.php` | -| `GridBuilderFilterIsPartOfResourceClassUnitTest` | Invalid filter detection, plus registry-order (catch-all vs. dedicated node) and `extension.neon` order | `grid.php`, `grid_custom_filter.php`, `CustomFilter.php` | +| `GridBuilderFilterIsPartOfResourceClassUnitTest` | Invalid filter detection, plus registry-order (catch-all vs. dedicated node) | `grid.php`, `grid_custom_filter.php`, `custom_filter.php` | | `GridBuilderFilterIsPartOfResourceClassValidUnitTest` | Validates correct grids | `grid_valid.php` | | `DefaultFilterRegistryUnitTest` | Registry functionality | None | @@ -471,19 +470,19 @@ All fixtures (for analysis) reside under `tests/bitExpert/PHPStan/Sylius/Rule/*/ `Rule/Grid/data/` (namespace `App\Entity` / `App\Grid`): - `entity.php`: `App\Entity\Status` (enum), `Country`, `Address`, `Supplier` (implements `ResourceInterface`, missing `name`). `Country` and `Address::$country`/`getCountry()` back the three-segment field case. - `grid.php`: `AdminSupplierGrid` with invalid field/filter definitions, plus `SomeOtherClass`. Shared by the field *and* filter tests, so any edit shifts expectations in both. -- `grid_valid.php`, `grid_valid_attr.php`: correct grid configurations. +- `grid_valid.php` / `grid_valid_attr.php`: `GridValid` / `GridValidAttr`, correct grid configurations. - `grid_needs_resource_model.php` / `_attr.php` / `_no_methods.php` / `_native_interface.php`: resource-class detection cases. -- `CustomFilter.php`: namespace `App\Filter`; a user-style filter that implements `FilterInterface`, so the catch-all matches it by interface. Implements every method for real so PHPStan verifies the signatures on both lanes. -- `grid_custom_filter.php`: `CustomFilterGrid`, holding the one `CustomFilter::create()` call whose extracted field differs depending on which node wins. +- `custom_filter.php`: namespace `App\Filter`, declaring `class CustomFilter`; a user-style filter that implements `FilterInterface`, so the catch-all matches it by interface. Implements every method for real so PHPStan verifies the signatures on both lanes. +- `grid_custom_filter.php`: `GridCustomFilter`, holding the one `CustomFilter::create()` call whose extracted field differs depending on which node wins. `Rule/Resource/data/` (namespace `App\Entity`): -- `entity.php`: declares a class literally named `entity` (lowercase) implementing `ResourceInterface`. -- `entity_index.php`: missing `Index(grid:)`. -- `entity_non_constant_attribute.php`: non-constant attribute argument. +- `entity.php`: `Entity`, implementing `ResourceInterface`. +- `entity_index.php`: `EntityIndex`, missing `Index(grid:)`. +- `entity_non_constant_attribute.php`: `EntityWithNonConstantFormType`, `EntityWithArrayFormType`, `EntityWithIntFormType`, `EntityWithNonConstantGrid`, `EntityWithArrayGrid`, `EntityWithConstantFormType` — non-constant attribute arguments, which the rules must skip silently. These files are loaded via `composer.json` `autoload-dev.files` so classes exist during analysis, and `Rule/Grid/data/grid.php` + `entity.php` + `grid_custom_filter.php` are excluded from PHPStan analysis -in `phpstan.dist.neon`. `CustomFilter.php` is deliberately **not** excluded: it declares no invalid +in `phpstan.dist.neon`. `custom_filter.php` is deliberately **not** excluded: it declares no invalid references, so analysing it is what proves the interface implementation still holds on grid-bundle 1.16. --- @@ -565,27 +564,24 @@ second lane is not done. `getFunction()->getFileName()` and `->getDeclaringClass()->getFileName()` all return the using class file. - **Action**: not fixable without a broader file-identity design; `RuleTestCase` cannot even assert it. -### 6. Lowercase fixture class name -- `tests/bitExpert/PHPStan/Sylius/Rule/Resource/data/entity.php` declares a class named `entity` (lowercase) - in namespace `App\Entity`. -- **Impact**: Avoid confusion; stick to PSR-1 naming in new fixtures. (The `Rule/Grid/data/entity.php` - fixture is fine — it declares `Status`, `Country`, `Address`, `Supplier`.) - -### 6a. PHP-CS-Fixer rewrites a fixture class to the file's basename -- `@Symfony` enables `psr_autoloading`, and its class-name matcher compares **case-insensitively**. A - snake_case data file that declares exactly one class whose name differs only by case gets silently - rewritten to the file's snake_case basename by `composer cs-fix`. Naming the file `custom_filter.php` - turned `class CustomFilter` into `class custom_filter`, which broke the fixture at runtime: the call - site `CustomFilter::create()` then named a non-existent class, `CollectFilterForGridClass` could no - longer resolve a `FilterInterface` return type, the call was silently skipped, and the test failed - only on the *missing* expected error. -- Files whose class name shares no letters with the file name are left alone, which is why `grid.php` - (class `AdminSupplierGrid`) has never been touched. -- **Action**: give any single-class fixture a file name matching its class in PSR-1 style - (`CustomFilter.php` → `class CustomFilter`). `grid_custom_filter.php` survives only because of the - no-letter-overlap accident above — do not rely on that if its class is ever renamed. -- **Detection tip**: this failure mode is a *missing* error, not a wrong one, so a green-looking - `composer cs-fix` followed by a red test usually means the fixer edited a fixture, not the test. +### 6. `data/` fixtures are not PSR-4 and are excluded from PHP-CS-Fixer +- Fixtures under `tests/**/data/` carry **no license header** (only `src/` is header-checked) and are + registered in `composer.json` `autoload-dev.files`, so they are loaded by inclusion, not by class name. + PSR-4 file-name/class-name pairing therefore does not apply, and the file name deliberately stays + snake_case (`grid_valid.php`) while the class is PSR-1 (`GridValid`). +- That combination is exactly what `@Symfony`'s `psr_autoloading` rule "fixes": its matcher compares + class and file name **case-insensitively**, so `class GridValid` in `grid_valid.php` gets rewritten + to `class grid_valid`. `.php-cs-fixer.dist.php` therefore excludes both `Rule/Grid/data` and + `Rule/Resource/data` from the finder. Do not drop those exclusions: `composer cs` is a CI step, and a + local `cs-fix` would rewrite fixture classes, breaking the references between fixtures. +- The failure mode is a *missing* error, not a wrong one. A renamed fixture class makes the call site + name a non-existent class, `CollectFilterForGridClass` can no longer resolve a `FilterInterface` + return type, the call is silently skipped, and the test fails only on the missing expected error. So a + green-looking `composer cs-fix` followed by a red test usually means the fixer edited a fixture. +- **Action**: fixture file names stay snake_case and lowercase, fixture classes are always PSR-1. When + renaming either one, update all of them together — the class declaration, every call site and `use` + statement in sibling fixtures, and any class name a test asserts or matches on + (e.g. `'App\Filter\CustomFilter'` in the filter ordering tests). --- diff --git a/composer.json b/composer.json index 532d580..a34d55d 100644 --- a/composer.json +++ b/composer.json @@ -34,8 +34,8 @@ "files": [ "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/entity.php", "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid.php", - "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/CustomFilter.php", - "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_custom_filter.php", + "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/custom_filter.php", + "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_custom_filter.php", "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model.php", "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_attr.php", "tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/grid_needs_resource_model_no_methods.php", diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php index 427506b..2f0a3da 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/GridBuilderFilterIsPartOfResourceClassUnitTest.php @@ -228,45 +228,4 @@ public function testCatchAllShadowsADedicatedNodeRegisteredAfterIt(): void ], ); } - - /** - * The three ordering tests above drive their own registries, so on their - * own they cannot notice extension.neon being reordered. This one reads - * the real config and asserts the invariant that actually ships: the - * catch-all is registered after every concrete node. - */ - public function testCatchAllIsRegisteredLastInExtensionNeon(): void - { - $config = \file_get_contents(\dirname(__DIR__, 6) . '/extension.neon'); - self::assertIsString($config); - - $filterNodes = []; - foreach (\preg_split('/^\t-$/m', $config) ?: [] as $block) { - if (!\str_contains($block, 'phpstan.sylius.grid.filter')) { - continue; - } - - if (1 !== \preg_match('/^\s*class:\s*(\S+)/m', $block, $matches)) { - self::fail('Every filter node service must declare a class.'); - } - // NEON leaves namespace separators unescaped, so shorten by hand - // rather than fighting backslashes in a regex. - $filterNodes[] = \substr((string) \strrchr($matches[1], '\\'), 1); - } - - self::assertContains('Filter', $filterNodes, 'The catch-all node must be registered.'); - - $duplicates = \array_keys(\array_filter( - \array_count_values($filterNodes), - static fn (int $count): bool => 1 < $count, - )); - self::assertSame([], $duplicates, 'No filter node may be registered twice.'); - - self::assertSame( - 'Filter', - \end($filterNodes), - 'The catch-all Filter node must stay last in extension.neon, otherwise it shadows ' - . 'every node registered after it, including user-supplied ones.', - ); - } } diff --git a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/CustomFilter.php b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/custom_filter.php similarity index 93% rename from tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/CustomFilter.php rename to tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/custom_filter.php index 5bc60f8..81806d4 100644 --- a/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/CustomFilter.php +++ b/tests/bitExpert/PHPStan/Sylius/Rule/Grid/data/custom_filter.php @@ -1,13 +1,5 @@