Skip to content

Commit dec5a3e

Browse files
committed
Fix array shape tracking from keyed assignments in conditional loops
1 parent 935a5e6 commit dec5a3e

8 files changed

Lines changed: 228 additions & 37 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- **`instanceof` narrowing with unresolvable target class.** When the `instanceof` target class cannot be loaded (e.g. it lives inside a phar archive), the variable's type is now treated as unknown instead of keeping the un-narrowed base type. This eliminates false-positive "unknown member" diagnostics for members that only exist on the narrowed subclass. Applies to all narrowing contexts: if-bodies, ternary expressions, `assert()` statements, and inline `&&` chains.
1414
- **`DB::select()` return type.** `DB::select()`, `DB::selectFromWriteConnection()`, and `DB::selectResultSets()` now return `array<int, stdClass>` instead of bare `array`, and `DB::selectOne()` returns `?stdClass` instead of `mixed`. Property access on query results (e.g. `$result[0]->column_name`) no longer produces false-positive diagnostics. The same fix applies to the underlying `Illuminate\Database\Connection` class.
1515
- **Redis `Connection` method resolution.** `Illuminate\Redis\Connections\Connection` now has `@mixin \Redis` applied automatically, so Redis commands like `del()`, `get()`, `set()`, etc. resolve through the phpredis stubs instead of producing false-positive diagnostics.
16+
- **Array shape tracking from keyed assignments inside conditional branches.** When an array is built incrementally with variable keys inside a loop that contains if/else branching (e.g. `$arr[$id] = ['bundle' => $obj, 'count' => 1]` in an else branch), the shape type is now preserved through foreach iteration. Previously the variable resolution depth limit was too low, causing intermediate variables in the shape value to resolve as `mixed` instead of their actual class type.
1617

1718
### Added
1819

docs/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ within the same impact tier.
2323

2424
| # | Item | Impact | Effort |
2525
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------- |
26-
| B13 | [Array shape tracking from keyed literal assignments in loops](todo/bugs.md#b13-array-shape-tracking-from-keyed-literal-assignments-in-loops) | Low | High |
2726
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
2827
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |
2928
| | **Release 0.7.0** | | |
@@ -90,6 +89,7 @@ unlikely to move the needle for most users.
9089
| C6 | `#[ExpectedValues]` parameter value suggestions | Low | Medium |
9190
| C10 | [Deprecation markers on class-name completions from all sources](todo/completion.md#c10-deprecation-markers-on-class-name-completions-from-all-sources) | Low | Low |
9291
| | **[Type Inference](todo/type-inference.md)** | | |
92+
| T25 | [Forward-walking scope model](todo/type-inference.md#t25-forward-walking-scope-model-for-variable-type-resolution) (eliminate backward-scanning depth limit) | High | Very High |
9393
| T19 | [Structured type representation](todo/type-inference.md#t19-structured-type-representation) (replace string-based types with `PhpType` enum) | High | Very High |
9494
| T20 | [Type narrowing reconciliation engine](todo/type-inference.md#t20-type-narrowing-reconciliation-engine) (sure/sureNot tracking, AND/OR algebra) | Medium-High | High |
9595
| T6 | `Closure::bind()` / `Closure::fromCallable()` return type preservation | Low-Medium | Low-Medium |

docs/todo/bugs.md

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,3 @@
11
# PHPantom — Bug Fixes
22

3-
## B13. Array shape tracking from keyed literal assignments in loops
4-
**Impact: Low · Effort: High**
5-
6-
Pattern:
7-
```php
8-
$bundleProductCounts = [];
9-
foreach ($items as $item) {
10-
$bundleProductCounts[$item->id] = [
11-
'bundle' => $item->productBundle,
12-
'count' => 1,
13-
];
14-
}
15-
foreach ($bundleProductCounts as $entry) {
16-
$entry['bundle']->parentProduct(); // unresolved
17-
}
18-
```
19-
20-
PHPantom tracks array value types from variable-key assignments
21-
(`$arr[$key] = $value`), but when the value is an array literal with
22-
string keys (a shape), the element type is not preserved as a shape.
23-
Subsequent access like `$entry['bundle']->method()` requires knowing
24-
that `'bundle'` maps to a specific class type.
25-
26-
**Observed in:** `ProductSupplyAmountChangeListener:58` — array built
27-
with `['bundle' => $productBundle, 'count' => 1]` in a loop, then
28-
iterated; `$bundleProductCount['bundle']->parentProduct()` is
29-
unresolvable because the shape is lost.
30-
31-
**Depends on:** T19 (structured type representation) or at minimum
32-
a basic array shape inference that preserves `array{key: Type}` from
33-
literal array constructors and propagates it through foreach.
3+
No outstanding items.

docs/todo/type-inference.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,3 +527,80 @@ with `stdClass` property access suppression, but the general gap
527527
remains for any `$arr[$i] instanceof Foo` pattern.
528528

529529
---
530+
531+
## T25. Forward-walking scope model for variable type resolution
532+
**Impact: High · Effort: Very High**
533+
534+
PHPantom resolves variable types lazily: when the user triggers
535+
completion on `$x->`, it walks backward from the cursor to find
536+
where `$x` was assigned, then recursively resolves any variables
537+
referenced in the RHS. Each level of indirection adds a call to
538+
`resolve_variable_types`, which re-parses the file and re-walks the
539+
AST. A global depth counter (`MAX_VAR_RESOLUTION_DEPTH`, currently 4)
540+
caps the recursion to prevent stack overflows.
541+
542+
PHPStan, Psalm, and Mago all use the opposite strategy: an eager,
543+
single-pass, forward-walking scope model. They walk statements
544+
top-to-bottom, carrying a mutable type map (`expressionTypes` in
545+
PHPStan, `locals` in Mago). When they encounter `$a = $b->prop`,
546+
they look up `$b` in the already-populated map, resolve the property,
547+
and store `$a`'s type. Variable lookup is a flat O(1) map fetch with
548+
zero recursion regardless of assignment chain depth.
549+
550+
The backward-scanning approach causes several problems:
551+
552+
- **Depth limit fragility.** Every real-world pattern that adds one
553+
more level of indirection (e.g. array shape literals referencing
554+
variables assigned from foreach bindings inside conditional
555+
branches) requires bumping the limit. The limit was 3, then 4;
556+
it will need to grow again.
557+
- **Redundant work.** Each recursive call re-parses the source and
558+
re-walks the AST from the top. A forward pass would parse once and
559+
walk once.
560+
- **No scope threading.** Narrowing, guard clauses, and branch-aware
561+
resolution are bolted on as post-hoc corrections rather than
562+
flowing naturally through the scope.
563+
564+
**Depth limits eliminated by this item:**
565+
566+
| Constant | Value | Location | Why it exists |
567+
|---|---|---|---|
568+
| `MAX_VAR_RESOLUTION_DEPTH` | 4 | `completion/variable/resolution.rs` | Each variable in an assignment chain triggers a full re-parse and re-walk. Was 3, bumped to 4 for array shapes in conditional loop branches. |
569+
| `MAX_CLOSURE_INFER_DEPTH` | 4 | `completion/variable/closure_resolution.rs` | `infer_callable_params_from_receiver` resolves the receiver type to infer closure parameter types, which can trigger another variable resolution cycle. |
570+
571+
Both share the same root cause: resolving a variable's type triggers
572+
a full re-parse and re-walk from scratch, which recurses when the RHS
573+
references another variable. In a forward-walking model, both would
574+
be flat map lookups with zero recursion.
575+
576+
The remaining depth limits (`MAX_INHERITANCE_DEPTH`,
577+
`MAX_TRAIT_DEPTH`, `MAX_MIXIN_DEPTH`, `MAX_ALIAS_DEPTH`) guard
578+
against walking PHP class/type hierarchies and are unrelated to the
579+
resolution architecture. They mirror limits that PHPStan and Mago
580+
also have and should stay as-is.
581+
582+
A forward-walking scope model would:
583+
584+
1. Parse the enclosing function body once.
585+
2. Walk statements in order, maintaining a `HashMap<VarName, PhpType>`
586+
scope that is updated on each assignment, foreach binding, catch
587+
clause, and narrowing point.
588+
3. At the cursor position, read the variable's type from the map.
589+
590+
This eliminates the depth limit entirely and makes the resolution
591+
cost proportional to the number of statements before the cursor,
592+
not the depth of the assignment chain.
593+
594+
**Depends on:** T19 (structured type representation) should land
595+
first so the scope map stores `PhpType` values instead of strings.
596+
597+
**Migration path:** Start with a parallel implementation behind a
598+
feature flag. The existing backward-scanning resolver stays as a
599+
fallback. Migrate one resolution context at a time (completion,
600+
hover, diagnostics) once the forward walker covers enough cases.
601+
602+
**Reference:** PHPStan's `MutatingScope.expressionTypes` +
603+
`NodeScopeResolver.processStmtNode`, Mago's `BlockContext.locals` +
604+
statement analyzers. Both converge on the same architecture: the
605+
scope is the single source of truth, populated eagerly as the walk
606+
progresses.

example.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3286,6 +3286,34 @@ public function demo(array $pens): void
32863286
}
32873287
}
32883288

3289+
class ConditionalLoopShapeDemo
3290+
{
3291+
/** @param list<Pen> $pens */
3292+
public function demo(array $pens): void
3293+
{
3294+
// Array built with variable keys inside a loop where the assignment
3295+
// is inside a conditional branch (if/else). The shape type from
3296+
// the array literal is preserved through foreach iteration.
3297+
$grouped = [];
3298+
foreach ($pens as $pen) {
3299+
$key = $pen->color();
3300+
if (array_key_exists($key, $grouped)) {
3301+
$grouped[$key]['count']++;
3302+
} else {
3303+
$grouped[$key] = [
3304+
'tool' => $pen,
3305+
'count' => 1,
3306+
];
3307+
}
3308+
}
3309+
3310+
// Foreach over the built array resolves shape keys
3311+
foreach ($grouped as $entry) {
3312+
$entry['tool']->write(); // Pen method via shape tracking
3313+
}
3314+
}
3315+
}
3316+
32893317

32903318
// ═══════════════════════════════════════════════════════════════════════════
32913319
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
@@ -5968,6 +5996,24 @@ function runDemoAssertions(): void
59685996
$labFound = $labIndexed['blue'] ?? null;
59695997
assert($labFound instanceof Pen, 'Null-coalesce on variable-key array must resolve to Pen');
59705998

5999+
// ── Conditional loop shape (keyed assignment in if/else) ────────────
6000+
$shapePens = [new Pen('red'), new Pen('blue'), new Pen('red')];
6001+
$shapeGrouped = [];
6002+
foreach ($shapePens as $shapePen) {
6003+
$shapeKey = $shapePen->color();
6004+
if (array_key_exists($shapeKey, $shapeGrouped)) {
6005+
$shapeGrouped[$shapeKey]['count']++;
6006+
} else {
6007+
$shapeGrouped[$shapeKey] = [
6008+
'tool' => $shapePen,
6009+
'count' => 1,
6010+
];
6011+
}
6012+
}
6013+
foreach ($shapeGrouped as $shapeEntry) {
6014+
assert($shapeEntry['tool'] instanceof Pen, 'Shape key from conditional loop must resolve to Pen');
6015+
}
6016+
59716017
echo "All assertions passed.\n";
59726018
}
59736019

src/completion/variable/resolution.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,18 @@ thread_local! {
6969

7070
/// Maximum nesting depth for `resolve_variable_types` calls.
7171
///
72-
/// Three levels covers legitimate nested resolution such as:
73-
/// depth 0: resolve `$item` in a foreach body
74-
/// depth 1: resolve `$collection` (the foreach iterator expression)
75-
/// depth 2: resolve `$node` (RHS of `$collection = $node->method()`)
72+
/// Four levels covers legitimate nested resolution such as:
73+
/// depth 0: resolve `$arr` built via `$arr[$key] = ['k' => $var]`
74+
/// depth 1: resolve `$var` (array element variable in the shape literal)
75+
/// depth 2: resolve `$item->prop` (RHS of `$var = $item->prop`)
76+
/// depth 3: resolve `$item` (foreach value binding → iterable param)
7677
///
7778
/// Cycles like `foreach ($x->method() as $x)` are caught by a
7879
/// targeted check in `try_resolve_foreach_value_type` (which skips
7980
/// resolution when the value variable shadows the iterator receiver)
8081
/// rather than by this depth limit alone. The depth limit is a
8182
/// safety net for any remaining recursive patterns.
82-
const MAX_VAR_RESOLUTION_DEPTH: u8 = 3;
83+
const MAX_VAR_RESOLUTION_DEPTH: u8 = 4;
8384

8485
/// Returns `true` when any `resolve_variable_types` call on the
8586
/// current stack has returned empty because the depth guard fired.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// test: foreach over array built with variable-keyed shape assignments in conditional loop
2+
// feature: completion
3+
// Array built inside a loop with if/else branching (one branch increments,
4+
// the other assigns a shape literal) should preserve the shape type through
5+
// foreach iteration. Reproduces the real-world pattern from B13.
6+
// expect: parentProduct(
7+
---
8+
<?php
9+
10+
class Product
11+
{
12+
public function parentProduct(): self { return $this; }
13+
}
14+
15+
class ProductBundle
16+
{
17+
public int $id = 1;
18+
public Product $product;
19+
20+
public function parentProduct(): Product { return $this->product; }
21+
}
22+
23+
class Item
24+
{
25+
public int $id = 0;
26+
public ProductBundle $productBundle;
27+
}
28+
29+
class Listener
30+
{
31+
/** @param Item[] $items */
32+
public function handle(array $items): void
33+
{
34+
$bundleProductCounts = [];
35+
foreach ($items as $item) {
36+
$productBundle = $item->productBundle;
37+
38+
if (array_key_exists($productBundle->id, $bundleProductCounts)) {
39+
$bundleProductCounts[$productBundle->id]['count']++;
40+
} else {
41+
$bundleProductCounts[$productBundle->id] = [
42+
'bundle' => $productBundle,
43+
'count' => 1,
44+
];
45+
}
46+
}
47+
48+
foreach ($bundleProductCounts as $bundleProductCount) {
49+
$bundleProductCount['bundle']-><>
50+
}
51+
}
52+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// test: foreach over array built with variable-keyed shape assignments resolves chained method
2+
// feature: completion
3+
// The shape value type ('bundle' => ProductBundle) must survive the
4+
// variable-key merge and foreach extraction so that chaining
5+
// $entry['bundle']->parentProduct()-> resolves Product members.
6+
// expect: getName(
7+
---
8+
<?php
9+
10+
class Product
11+
{
12+
public function getName(): string { return ''; }
13+
}
14+
15+
class ProductBundle
16+
{
17+
public int $id = 1;
18+
19+
public function parentProduct(): Product { return new Product(); }
20+
}
21+
22+
class Item
23+
{
24+
public int $id = 0;
25+
public ProductBundle $productBundle;
26+
}
27+
28+
class Listener
29+
{
30+
/** @param Item[] $items */
31+
public function handle(array $items): void
32+
{
33+
$bundleProductCounts = [];
34+
foreach ($items as $item) {
35+
$bundleProductCounts[$item->id] = [
36+
'bundle' => $item->productBundle,
37+
'count' => 1,
38+
];
39+
}
40+
foreach ($bundleProductCounts as $entry) {
41+
$entry['bundle']->parentProduct()-><>
42+
}
43+
}
44+
}

0 commit comments

Comments
 (0)