|
1 | 1 | # PHPantom — Bug Fixes |
2 | 2 |
|
3 | | -No outstanding items. |
| 3 | +## B10. `instanceof` ternary narrowing fails when target class is in a phar |
| 4 | +**Impact: Low · Effort: Low-Medium** |
| 5 | + |
| 6 | +Pattern: |
| 7 | +```php |
| 8 | +$types = $argType instanceof UnionType ? $argType->getTypes() : [$argType]; |
| 9 | +``` |
| 10 | + |
| 11 | +The type engine is unified: completion and diagnostics both use |
| 12 | +`resolve_variable_types` → `walk_statements_for_assignments` → |
| 13 | +`try_apply_ternary_instanceof_narrowing`. A local-class test |
| 14 | +(assignment RHS context, interface parameter, `instanceof` subclass |
| 15 | +in ternary condition) passes with zero diagnostics. |
| 16 | + |
| 17 | +The failure in the shared project is specific to PHPStan phar classes. |
| 18 | +`PHPStan\Type\UnionType` lives inside `phpstan.phar` and may not be |
| 19 | +loadable at resolution time, so `apply_instanceof_inclusion` silently |
| 20 | +fails (cannot resolve the class name to a `ClassInfo`), and the |
| 21 | +variable keeps its un-narrowed type (`Type`). |
| 22 | + |
| 23 | +**Observed in:** `DecimalDivThrowTypeExtension:54` — `$argType` is |
| 24 | +typed as `PHPStan\Type\Type`, `getTypes()` exists on `UnionType` but |
| 25 | +not on the `Type` interface. The `instanceof UnionType` check in the |
| 26 | +ternary condition should narrow the type in the then-branch. |
| 27 | + |
| 28 | +**Root cause:** The `instanceof` target class (`UnionType`) cannot be |
| 29 | +loaded from the phar, so `apply_instanceof_inclusion` has no |
| 30 | +`ClassInfo` to narrow to. The narrowing architecture itself is correct |
| 31 | +and unified across completion and diagnostics. The fix is either |
| 32 | +phar class indexing or suppressing diagnostics when the `instanceof` |
| 33 | +target class is unresolvable (since the developer clearly expects |
| 34 | +narrowing to occur). |
| 35 | + |
| 36 | +--- |
| 37 | + |
| 38 | +## B11. `static` return type not resolved through `class-string<T>` context |
| 39 | +**Impact: Low · Effort: Medium** |
| 40 | + |
| 41 | +Pattern: |
| 42 | +```php |
| 43 | +/** @param class-string<BackedEnum> $class */ |
| 44 | +function enum(BackedEnum $value, string $class): void { |
| 45 | + foreach ($class::cases() as $item) { |
| 46 | + $name = $item->name; // unresolved |
| 47 | + $val = $item->value; // unresolved |
| 48 | + } |
| 49 | +} |
| 50 | +``` |
| 51 | + |
| 52 | +`UnitEnum::cases()` returns `static[]`. When called on a variable |
| 53 | +typed as `class-string<BackedEnum>`, the `static` return type should |
| 54 | +resolve to `BackedEnum[]`, making `$item` typed as `BackedEnum`. |
| 55 | +Currently `static` is not substituted through the `class-string<T>` |
| 56 | +context, so `$item` has no type and `->name` / `->value` are |
| 57 | +unresolvable. |
| 58 | + |
| 59 | +**Observed in:** `OptionList:27,30` — `$class::cases()` where |
| 60 | +`$class` is `class-string<BackedEnum>`. |
| 61 | + |
| 62 | +**Root cause:** The `class-string<T>` static dispatch fix (`caa6163`) |
| 63 | +resolves method signatures and return types for static calls on |
| 64 | +`class-string`-typed variables, but does not substitute `static` in |
| 65 | +the return type with the bound type `T`. The substitution |
| 66 | +`static → BackedEnum` needs to happen when the call target is |
| 67 | +resolved through a `class-string<BackedEnum>` context. |
| 68 | + |
| 69 | +--- |
| 70 | + |
| 71 | +## B12. `Collection::reduce()` generic return type not inferred |
| 72 | +**Impact: Low · Effort: Medium** |
| 73 | + |
| 74 | +Pattern: |
| 75 | +```php |
| 76 | +$result = $collection |
| 77 | + ->reduce(fn(Decimal $carry, OrderProduct $p): Decimal => $carry->add($p->price), new Decimal('0')) |
| 78 | + ->add($total); // unresolved |
| 79 | +``` |
| 80 | + |
| 81 | +The `reduce()` method on Laravel collections has this signature: |
| 82 | +``` |
| 83 | +@template TReduceInitial |
| 84 | +@template TReduceReturnType |
| 85 | +@param callable(TReduceInitial|TReduceReturnType, TValue, TKey): TReduceReturnType $callback |
| 86 | +@param TReduceInitial $initial |
| 87 | +@return TReduceReturnType |
| 88 | +``` |
| 89 | + |
| 90 | +PHPantom should infer: |
| 91 | +- `TReduceInitial = Decimal` (from the `$initial` argument) |
| 92 | +- `TReduceReturnType = Decimal` (from the closure return type hint) |
| 93 | +- Therefore `reduce()` returns `Decimal` |
| 94 | + |
| 95 | +The bidirectional template inference (`4329efe`) partially addresses |
| 96 | +this, but `reduce()` still returns unresolved. The likely gap is the |
| 97 | +union `TReduceInitial|TReduceReturnType` in the callable's first |
| 98 | +parameter position: the inference engine may not decompose the union |
| 99 | +to extract individual template bindings when both templates appear |
| 100 | +in the same callable parameter type. |
| 101 | + |
| 102 | +**Observed in:** `FlowService:517` — `->reduce(fn(Decimal $c, ...): |
| 103 | +Decimal => ..., new Decimal('0'))->add($total)`. |
| 104 | + |
| 105 | +--- |
| 106 | + |
| 107 | +## B13. Array shape tracking from keyed literal assignments in loops |
| 108 | +**Impact: Low · Effort: High** |
| 109 | + |
| 110 | +Pattern: |
| 111 | +```php |
| 112 | +$bundleProductCounts = []; |
| 113 | +foreach ($items as $item) { |
| 114 | + $bundleProductCounts[$item->id] = [ |
| 115 | + 'bundle' => $item->productBundle, |
| 116 | + 'count' => 1, |
| 117 | + ]; |
| 118 | +} |
| 119 | +foreach ($bundleProductCounts as $entry) { |
| 120 | + $entry['bundle']->parentProduct(); // unresolved |
| 121 | +} |
| 122 | +``` |
| 123 | + |
| 124 | +PHPantom tracks array value types from variable-key assignments |
| 125 | +(`$arr[$key] = $value`), but when the value is an array literal with |
| 126 | +string keys (a shape), the element type is not preserved as a shape. |
| 127 | +Subsequent access like `$entry['bundle']->method()` requires knowing |
| 128 | +that `'bundle'` maps to a specific class type. |
| 129 | + |
| 130 | +**Observed in:** `ProductSupplyAmountChangeListener:58` — array built |
| 131 | +with `['bundle' => $productBundle, 'count' => 1]` in a loop, then |
| 132 | +iterated; `$bundleProductCount['bundle']->parentProduct()` is |
| 133 | +unresolvable because the shape is lost. |
| 134 | + |
| 135 | +**Depends on:** T19 (structured type representation) or at minimum |
| 136 | +a basic array shape inference that preserves `array{key: Type}` from |
| 137 | +literal array constructors and propagates it through foreach. |
| 138 | + |
| 139 | +--- |
| 140 | + |
| 141 | +## B14. `DB::select()` returns bare `array`, element type unresolvable |
| 142 | +**Impact: Low · Effort: Low** |
| 143 | + |
| 144 | +Pattern: |
| 145 | +```php |
| 146 | +$result = DB::select('select sum(...) as incoming_qty from ...'); |
| 147 | +if ($result[0] instanceof stdClass && $result[0]->incoming_qty) { |
| 148 | + return Convert::toInt($result[0]->incoming_qty); // unresolved |
| 149 | +} |
| 150 | +``` |
| 151 | + |
| 152 | +The `DB` facade's `@method` annotation declares `select()` as |
| 153 | +returning bare `array` rather than `array<int, stdClass>`. Since the |
| 154 | +return type has no element type, `$result[0]` is untyped and |
| 155 | +`->incoming_qty` is unresolvable. |
| 156 | + |
| 157 | +Two compounding issues: |
| 158 | + |
| 159 | +1. **Stub/facade return type gap:** `DB::select()` should return |
| 160 | + `array<int, stdClass>` (which is what the underlying |
| 161 | + `Connection::select()` actually returns), but the facade |
| 162 | + annotation only says `array`. |
| 163 | + |
| 164 | +2. **`instanceof` narrowing on array element access:** Even if the |
| 165 | + array element type were known, `$result[0] instanceof stdClass` |
| 166 | + should narrow `$result[0]` to `stdClass` within the condition. |
| 167 | + The narrowing system only matches bare variable names (`$var`), |
| 168 | + not subscript expressions (`$var[0]`). This is a T20 concern. |
| 169 | + |
| 170 | +**Observed in:** `PurchaseFileService:1087,1089`. |
| 171 | + |
| 172 | +**Partial workaround:** PHPantom could patch `DB::select()` to return |
| 173 | +`array<int, stdClass>` via framework-specific return type overrides |
| 174 | +(similar to how scope methods are handled). The `instanceof` |
| 175 | +narrowing gap (issue 2) would remain but would no longer trigger |
| 176 | +here because `stdClass` property access is already suppressed. |
0 commit comments