Skip to content

Commit 02f1a0d

Browse files
committed
Fix a few issues
1 parent 0dc8de0 commit 02f1a0d

10 files changed

Lines changed: 212 additions & 44 deletions

File tree

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
119119

120120
### Fixed
121121

122+
- **A `@property` tag beats an inherited property nobody can reach.** PHP only calls `__get()` when no *accessible* property of that name exists, so a `protected` declaration up the chain is never what the read yields. PHPantom reported its type anyway: an Eloquent model documenting `@property string $connection` still resolved `$model->connection` through `Model`'s own `\UnitEnum|string|null`, and the same happened for `$table` and `$keyType`, which models shadow routinely. The tag now describes the read it was written for. A property the class declares *itself* is a different matter and keeps its own type, since it is in scope everywhere the tag is, and so does an accessible inherited one.
123+
- **An accumulator that starts as `[]` counts in whole numbers.** `$totals[$k] = ($totals[$k] ?? 0) + $n` is the standard way to tally by key, and the first pass read the empty array as an unknown value rather than a miss, so the sum came out `int|float` and failed every `array<string, int>` it was declared as. An offset read on `[]` now yields `null`, which is what PHP produces and what the `??` was written to catch. The empty array also stops trailing along beside the array a later write produced: a variable seeded with `[]` and appended to in a loop, or captured by reference and filled in by a closure, reports the array it ends up holding instead of that alternative plus the empty one it started from, so reading an element out of it no longer carries a `null` from the empty half.
122124
- **A ternary's arms see what its condition proved.** `is_string($req) ? $req : 'today'` handed both arms the raw `string|array|null`, so a value the condition had just established was still reported against every `string` the ternary fed. Each arm is now resolved under its own polarity of the condition, using the same narrowing an `if`/`else` body gets, and it happens wherever the ternary is written rather than only in some positions: assignment, argument, and return all behave the same. That covers the whole family of conditions rather than a list of recognised shapes, so a type guard, a null or falsy check, `instanceof`, a member-existence proof, and anything added to narrowing later all reach the arms. A nested ternary's else arm carries the outer conditions' inverse narrowing as well as its own, and `?:` still yields the truthy half of its subject.
123125
- **A negated compound guard narrows by every conjunct.** `if (!is_string($payload) || $payload === '') { return; }` is the standard way to reject everything a function cannot handle, and the code after it was left with the un-narrowed union: the guard proved nothing. Falling through an `||` means every operand was false, so each operand's own inverse now applies, whatever kind of check it is. Previously only `instanceof` and member-existence checks were read one operand at a time, and the rest were matched against the whole `||` expression, which never matched. Every exit form works (`return`, `throw`, `continue`, `abort()`), so does the `else` branch, and so do chains of more than two conjuncts. `is_resource()` joins the `is_*` family it was missing from, and `!== ''` / `!== []` now refine to `non-empty-string` / `non-empty-array` rather than only removing a literal that was never in the union.
124126
- **An array written under a `string` key stays keyed by `string`.** Every non-literal string key widened to `int|string`, on the grounds that a numeric string becomes an int key at runtime. Only a *literal* decimal-integer string does, so a function building `array<string, string>` reported `array<int|string, string>` and failed its own declared return type, including after an explicit `(string)` cast, a backed enum's `->value`, and `ReflectionProperty::getName()`. A key expression now keeps its own domain: `string` stays `string`, `int` stays `int`, and the int conversion applies to literal decimal keys alone. `++$i` and `$i++` resolve as well, so a counter used as a write key no longer falls back to `array-key`.

docs/todo.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,8 @@ within the same impact tier.
2525

2626
| # | Item | Impact | Effort |
2727
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------- |
28-
| B140 | [Interface phpDoc is not inherited by an implementation without its own docblock](todo/bugs.md#b140-interface-phpdoc-is-not-inherited-by-an-implementation-without-its-own-docblock) | High | Low-Medium |
2928
| B167 | [Factory `create()`/`make()` keep the collection half on single-model chains](todo/bugs.md#b167-factory-createmake-keep-the-collection-half-on-single-model-chains) | Medium-High | Low-Medium |
30-
| B171 | [A subclass `@property` tag loses to an inherited real property](todo/bugs.md#b171-a-subclass-property-tag-loses-to-an-inherited-real-property) | Medium | Low-Medium |
31-
| B163 | [Residual `int` arithmetic and assignment widenings](todo/bugs.md#b163-residual-int-arithmetic-and-assignment-widenings) | Low-Medium | Low-Medium |
29+
| B163 | [An `int` assigned to a `float` property is reported](todo/bugs.md#b163-an-int-assigned-to-a-float-property-is-reported) | Low | Low-Medium |
3230
| B139 | [Conditional return types are not evaluated against argument types](todo/bugs.md#b139-conditional-return-types-are-not-evaluated-against-argument-types) | High | Medium |
3331
| B142 | [Builtins with argument-dependent return types, round two](todo/bugs.md#b142-builtins-with-argument-dependent-return-types-round-two) | High | Medium |
3432
| B144 | [`preg_match` `$matches` is nullable and shapeless](todo/bugs.md#b144-preg_match-matches-is-nullable-and-shapeless) | High | Medium |

docs/todo/bugs.md

Lines changed: 14 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ $order = Order::find(7); // reported Collection<int, Order>|Order|null
3737
Undecided type conditions from the sweep: `$id is array<mixed>|Arrayable`
3838
(Eloquent `find`/`findOrFail`, ~24 sites), `$items is EloquentCollection`
3939
/ `$into is class-string<…>` (spatie/laravel-data `Data::collect()`,
40-
~22 sites once B140 lands), `$callback is null` (`tap()`, which also
40+
~22 sites), `$callback is null` (`tap()`, which also
4141
leaks the raw template name `TValue` into the union), and Symfony's
4242
`ContainerInterface::get()` (`B is 0|1` against the omitted argument's
4343
default `1`). Two cosmetic side effects to clear with it: a model's
@@ -51,24 +51,6 @@ resolved argument type (falling back to the declared default's type
5151
when the argument is omitted), recursing into nested conditionals, and
5252
only union the branches when the condition is genuinely undecidable.
5353

54-
### B140. Interface phpDoc is not inherited by an implementation without its own docblock
55-
56-
**Impact: High · Effort: Low-Medium**
57-
58-
`Spatie\LaravelData\Concerns\BaseData::collect()` (a trait method) has
59-
no docblock; the conditional `@return` lives on the interface
60-
`Spatie\LaravelData\Contracts\BaseData` that the `Data` base class
61-
implements. PHPStan inherits phpDoc from implemented interfaces when
62-
the implementation (including one supplied by a trait) has none;
63-
PHPantom reads only the native signature, so every `X::collect(...)`
64-
returns the raw eleven-member union. ~22 sites across three Laravel
65-
projects, always as `array`/`Collection` inputs whose result feeds a
66-
declared `array<X>`/`Collection<int, X>`.
67-
68-
**Fix:** when a method has no own docblock, look it up on the
69-
interfaces the declaring class (transitively) implements, the same way
70-
parent-class docblocks are already inherited.
71-
7254
### B141. A `never` conditional branch does not assert the condition
7355

7456
**Impact: Medium-High · Effort: Medium**
@@ -233,10 +215,9 @@ Several forms of the same weakness (~7 sites):
233215
- `$a[$k][] = $v` never updates the inner element type: a value
234216
initialised as `[]` stays `array{}` in the outgoing type even
235217
though every loop iteration appends strings.
236-
- The intermediate empty-array state from
237-
`if (!isset($a[$k])) { $a[$k] = []; } $a[$k][$id] = $x;` survives
238-
the loop fix-point, leaving `array{}|array<int, string>` where
239-
PHPStan reports `non-empty-array<int, string>`.
218+
- A key written on every path through a loop body leaves
219+
`array<int, string>` where PHPStan reports
220+
`non-empty-array<int, string>`.
240221
- `$a += ['slot' => $obj]` degrades to unconstrained `array`.
241222
- A constant shape `array{item: string, qty: int}` fails the subtype
242223
check against `array<string, mixed>`, so shaped rows are rejected
@@ -393,14 +374,19 @@ is also overridden by the annotation. The `@var` should seed the
393374
assignment it documents and then submit to normal flow narrowing
394375
(3 sites).
395376

396-
### B163. Residual `int` arithmetic and assignment widenings
377+
### B163. An `int` assigned to a `float` property is reported
397378

398-
**Impact: Low-Medium · Effort: Low-Medium**
379+
**Impact: Low · Effort: Low-Medium**
399380

400-
Two leftovers from the shipped arithmetic-precision work:
401-
`($a[$k] ?? 0) + $int` widens to `int|float`, and an `int` value
381+
A leftover from the shipped arithmetic-precision work: an `int` value
402382
assigned to a `float`-typed property is reported instead of accepting
403-
the standard numeric widening.
383+
the standard numeric widening PHP performs even under
384+
`declare(strict_types=1)`.
385+
386+
No site for this reproduces any more — not in the ten-project sweep,
387+
and not in the promoted-constructor, static-property, `+=`,
388+
`@var`-only, nullable, and array-element forms. Confirm it still
389+
happens before working on it.
404390

405391
### B174. A `break` that leaves a loop early is missing from the post-loop join
406392

@@ -501,18 +487,6 @@ virtual PHP already contains the real `if`, so the walker has the
501487
narrowing; it is the include-contract check that reads the wrong
502488
scope.
503489

504-
### B171. A subclass `@property` tag loses to an inherited real property
505-
506-
**Impact: Medium · Effort: Low-Medium**
507-
508-
A model declaring `@property string $connection` still resolves
509-
`$model->connection` through `Illuminate\Database\Eloquent\Model`'s
510-
inherited `protected $connection` (`string|null`) and the generic
511-
attribute fallback (`UnitEnum|string|null`). A magic read must prefer
512-
the class's own `@property` tag over a non-public inherited property.
513-
1 site, but the shadowing pattern (`$connection`, `$table`, `$keyType`)
514-
is common on Eloquent models.
515-
516490
## Miscellaneous
517491

518492
### B173. Classes shipped inside a dependency's phar are invisible

src/inheritance/mod.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,42 @@ pub(crate) fn resolve_class_with_inheritance(
488488
}
489489
}
490490

491+
// Retype an inherited non-public property that the class documents
492+
// with a `@property` tag of its own.
493+
//
494+
// A tag documents a magic read, and PHP only reaches `__get()` when no
495+
// *accessible* property of that name exists. An ancestor's
496+
// `protected` / `private` declaration is invisible from outside, so it
497+
// is not what the read yields and its type must not describe it — an
498+
// Eloquent model declaring `@property string $connection` means
499+
// `string`, not `Model::$connection`'s `\UnitEnum|string|null`. The
500+
// same shadowing is routine for `$table` and `$keyType`.
501+
//
502+
// A property the class declares *itself* is a different matter: it is
503+
// in scope everywhere the tag is, so it keeps its own type (the tag is
504+
// then a contradiction, and the real declaration is the truth).
505+
if let Some(doc) = class.doc_members.as_deref() {
506+
for (name, type_hint) in &doc.properties {
507+
let Some(hint) = type_hint else {
508+
continue;
509+
};
510+
if class.properties.iter().any(|p| p.name == *name) {
511+
continue;
512+
}
513+
// Look the index up immutably so a class with nothing to
514+
// retype keeps sharing its property vector.
515+
let Some(idx) = merged
516+
.properties
517+
.iter()
518+
.position(|p| p.name == *name && p.visibility != Visibility::Public)
519+
else {
520+
continue;
521+
};
522+
let prop = &mut merged.properties.make_mut()[idx];
523+
Arc::make_mut(prop).type_hint = Some(hint.clone());
524+
}
525+
}
526+
491527
// Refine the `value` property on backed enums. The `BackedEnum`
492528
// interface declares `public readonly int|string $value`, but each
493529
// concrete backed enum knows its specific backing type. Replace

src/php_type/mod.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1445,6 +1445,36 @@ impl PhpType {
14451445
}
14461446
}
14471447

1448+
/// Returns `true` for the shape with no entries — `array{}`, the type
1449+
/// of an `[]` literal.
1450+
///
1451+
/// It is the one array type whose value set is a single value, which
1452+
/// makes it a member of every array type that does not demand an
1453+
/// entry, and makes every offset read on it a guaranteed miss.
1454+
pub fn is_empty_array_shape(&self) -> bool {
1455+
matches!(self.kind(), TypeKind::ArrayShape(entries) if entries.is_empty())
1456+
}
1457+
1458+
/// Returns `true` when this array type has the empty array among its
1459+
/// values, so an `array{}` alternative beside it is redundant.
1460+
///
1461+
/// `array`, `array<K, V>`, `list<V>`, `T[]` and `iterable` all do. The
1462+
/// `non-empty-*` family does not, and neither does a shape: `array{}`
1463+
/// is the only shape the empty array satisfies, and every other one
1464+
/// names an entry it lacks.
1465+
pub fn accepts_empty_array(&self) -> bool {
1466+
let name = match self.kind() {
1467+
TypeKind::Array(_) => return true,
1468+
TypeKind::Named(name) => name,
1469+
TypeKind::Generic(generic) => &generic.name,
1470+
_ => return false,
1471+
};
1472+
matches!(
1473+
crate::php_type::keywords::keyword_lowercase(name).as_str(),
1474+
"array" | "list" | "iterable"
1475+
)
1476+
}
1477+
14481478
/// Returns `true` when this type is exactly the bare, unparameterised
14491479
/// `array` keyword — i.e. `PhpType::named("array")`.
14501480
///

src/php_type/normalize.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,17 @@ impl PhpType {
386386
///
387387
/// Only scalar value domains take part; see [`is_runtime_scalar_value_domain`].
388388
pub(crate) fn is_runtime_value_subtype(subtype: &PhpType, supertype: &PhpType) -> bool {
389+
// `array{}` is the empty array, and every array type that does not
390+
// demand an entry has it as a member value. That is real value
391+
// containment rather than the variance/coercion kind
392+
// [`is_runtime_scalar_value_domain`] rules out, so a branch that only
393+
// produced `[]` adds nothing beside a branch that produced an array of
394+
// the same family — which is what keeps the `[]` a loop or a by-ref
395+
// closure capture starts from out of the joined result.
396+
if subtype.is_empty_array_shape() && supertype.accepts_empty_array() {
397+
return true;
398+
}
399+
389400
if !is_runtime_scalar_value_domain(subtype) || !is_runtime_scalar_value_domain(supertype) {
390401
return false;
391402
}

src/type_engine/variable/rhs_resolution/array_access.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,15 @@ fn index_segment(
219219
return Some(element);
220220
}
221221

222+
// An empty shape has no entry any key could address, so the read is a
223+
// guaranteed miss and yields `null`, exactly like an offset read on a
224+
// non-array. Widening to `mixed` instead loses the answer to
225+
// `$a[$k] ?? 0` on the `[]` a loop is about to fill, which then makes
226+
// every accumulated `+` an `int|float`.
227+
if base.is_empty_array_shape() {
228+
return Some(PhpType::null());
229+
}
230+
222231
// Fallback: when the base type is a plain class name (e.g.
223232
// `OpeningHours`), resolve the class and check its iterable generics
224233
// (`@extends`, `@implements`) for the element type. This handles

src/types/resolved_type.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,18 @@ impl ResolvedType {
525525
_ => members.push(rt.type_string.clone()),
526526
}
527527
}
528+
// The `[]` a variable was initialised to says nothing
529+
// beside the array a later write produced: `array{}` is the
530+
// empty array, and an array alternative that demands no
531+
// entry already contains it. Dropping it is what keeps a
532+
// loop accumulator and a by-ref closure capture from
533+
// reporting `array{}|list<string>`, whose offset reads then
534+
// carry a spurious `null` from the empty half.
535+
if members.iter().any(PhpType::is_empty_array_shape)
536+
&& members.iter().any(PhpType::accepts_empty_array)
537+
{
538+
members.retain(|m| !m.is_empty_array_shape());
539+
}
528540
PhpType::union(members)
529541
}
530542
}

tests/integration/docblock_types.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,6 +1515,59 @@ async fn test_real_property_overrides_property_tag() {
15151515
);
15161516
}
15171517

1518+
/// Test: a `@property` tag beats a *non-public inherited* property of the
1519+
/// same name. PHP only reaches `__get()` when no accessible property
1520+
/// exists, so an ancestor's `protected` declaration is not what the read
1521+
/// yields and its type must not describe it. This is the Eloquent
1522+
/// `@property string $connection` / `$table` pattern.
1523+
#[tokio::test]
1524+
async fn test_property_tag_overrides_inherited_non_public_property() {
1525+
let backend = create_test_backend();
1526+
let base = backend.parse_php(concat!(
1527+
"<?php\n",
1528+
"class Base {\n",
1529+
" /** @var \\UnitEnum|string|null */\n",
1530+
" protected $connection;\n",
1531+
" /** @var string|null */\n",
1532+
" public $label;\n",
1533+
"}\n",
1534+
));
1535+
let child = backend.parse_php(concat!(
1536+
"<?php\n",
1537+
"/**\n",
1538+
" * @property string $connection\n",
1539+
" * @property string $label\n",
1540+
" */\n",
1541+
"class Child extends Base {}\n",
1542+
));
1543+
1544+
let base = std::sync::Arc::new(base.into_iter().next().unwrap());
1545+
let loader = move |name: &str| -> Option<std::sync::Arc<phpantom_lsp::ClassInfo>> {
1546+
(name == "Base").then(|| std::sync::Arc::clone(&base))
1547+
};
1548+
let merged = phpantom_lsp::resolve_class_fully(&child[0], &loader);
1549+
1550+
let type_of = |name: &str| {
1551+
merged
1552+
.properties
1553+
.iter()
1554+
.find(|p| p.name == name)
1555+
.and_then(|p| p.type_hint_str())
1556+
};
1557+
assert_eq!(
1558+
type_of("connection").as_deref(),
1559+
Some("string"),
1560+
"@property should retype the inherited protected property"
1561+
);
1562+
// A public inherited property is reachable without `__get()`, so the
1563+
// tag documents nothing new and the declaration keeps its own type.
1564+
assert_eq!(
1565+
type_of("label").as_deref(),
1566+
Some("string|null"),
1567+
"@property must not override an accessible inherited property"
1568+
);
1569+
}
1570+
15181571
/// Test: `@property-read` tags are provided lazily via `resolve_class_fully`.
15191572
#[tokio::test]
15201573
async fn test_parse_php_property_read_tag() {

tests/phpstan_nsrt/array-shapes.php

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,47 @@ public function optionalKey(array $opt)
8787
// assertType('string', $opt['nullable']);
8888
}
8989

90-
}
90+
91+
/**
92+
* The empty shape has no entry any key could address, so an offset
93+
* read on it is a guaranteed miss.
94+
*
95+
* @param array<string, int> $ints
96+
*/
97+
public function emptyShapeOffsets(array $ints, string $k, int $n)
98+
{
99+
$empty = [];
100+
assertType('null', $empty['missing']);
101+
assertType('0', $empty[$k] ?? 0);
102+
assertType('int', ($empty[$k] ?? 0) + $n);
103+
104+
$totals = [];
105+
foreach ($ints as $key => $value) {
106+
$totals[$key] = ($totals[$key] ?? 0) + $value;
107+
}
108+
assertType('array<string, int>', $totals);
109+
}
110+
111+
/**
112+
* `array{}` is the empty array, which every array alternative that
113+
* demands no entry already contains, so it drops out of the join.
114+
*
115+
* @param list<string> $names
116+
*/
117+
public function emptyShapeAbsorbedByArraySibling(array $names, bool $c)
118+
{
119+
$collected = [];
120+
$append = function (string $name) use (&$collected): void {
121+
$collected[] = $name;
122+
};
123+
assertType('list<string>', $collected);
124+
assertType('string', $collected[0]);
125+
126+
$maybe = [];
127+
if ($c) {
128+
$maybe = $names;
129+
}
130+
assertType('list<string>', $maybe);
131+
}
132+
133+
}

0 commit comments

Comments
 (0)