Latest Results
fix(analyzer): track hook-property writes against the read type
A property with a `set` hook whose parameter type is wider than the
property's own type accepts values on write that a read never produces:
the write goes through `set`, but a read goes through `get`, which yields
the property (read) type. Writing such a value memoized it verbatim, so a
later read of the same access saw the write-only type — e.g. after
`$obj->foo = 'x'` on an `int|false` property with a `string|int|false`
set parameter, a read reported `string('x')` instead of `int|false`.
This is the same divergence the previous commit fixed for magic
`@property-read`/`@property-write` properties. Extend that fix: when an
assignment resolves through a `set` hook parameter, record the declared
(read) type as the resolved property's `read_type` so memoization clamps
the written value to what survives a read, falling back to the full read
type when the write shares nothing with it (the hook converts).
The read type must be expanded and localized the same way the write type
already is, or a generic (`T`), `self`, or `static` read type stays an
unresolved placeholder and the clamp never fires — the write-only value
leaks through as before. This gap also affected the magic path, whose
`read_type` was likewise never expanded; both now share the resolution.dotdash:fix_analyzer_track_hook_property_writes_against_the_read_type fix(analyzer): support split @property-read/@property-write types
A magic property can document asymmetric access with separate
`@property-read` and `@property-write` tags — normally a `__set()` that
converts, so the write type is wider than the read type (e.g.
`@property-read int|false` with `@property-write int|string`). A split
declaration exists precisely to express that difference; a symmetric one
would just be `@property`. Making it work takes three steps, none of
which is correct without the others:
Each `@property*` tag creates a fresh magic-property entry keyed by the
property name, so a second tag for the same name replaces the first: the
property ends up read-only or write-only depending on tag order. Reading
a write-only property is then reported as an invalid read, and writing a
read-only one as an invalid write — half the documented interface
disappears, and which half is arbitrary. Combine the tags into one
readable-and-writable entry instead.
That entry has a single type slot, so combining alone is not enough: the
read type wins the slot and every write the `@property-write` tag allows
becomes an invalid-property-assignment-value false positive. Store the
write tag's type in a dedicated write slot on PropertyMetadata (`None`
means writes accept the read type) and resolve assignments against it,
including the require-extends, mixin, and intersection fallback paths.
Property accesses are also memoized: an assignment caches the written
value against the access id, and a later read of the same expression
returns it instead of re-resolving. A read of a magic property goes
through `__get`, which yields the `@property-read` type regardless of
what was written, so the cached write-side value makes the read report a
type the property can never produce. Clamp the memoized type of a magic
property to what a read can observe: intersect the written value with
the `@property-read` type, falling back to the full read type when the
two are disjoint (the write converts). A write already assignable to the
read type is tracked precisely — an `int` write above narrows a later
read to `int`, while a `string` write leaves it `int|false`.
Real and dynamic-property (`stdClass`) accesses have no distinct read
type and round-trip, so they keep memoizing the written value unchanged;
the new `read_type` slot on ResolvedProperty carries the `@property-read`
type only for magic properties resolved for assignment.
Fixes #2079dotdash:fix_analyzer_support_split_property_read_property_write_types fix(analyzer): resolve @property tags by call-site visibility
A `@property`/`@property-read`/`@property-write` tag documents the
external interface a class exposes through `__get`/`__set`. A real
property of the same name may coexist with it; the standard example is
a private property that the class mutates internally but exposes
read-only via `__get()`. Which of the two governs an access follows
PHP's runtime rule: the real property wherever it is visible, the
magic methods (and thus the tag) everywhere else. That decision
depends on the call site, so it cannot be baked into the stored
metadata.
The scanner nevertheless stores tags as regular entries in the class's
property map (one entry per name) and resolves collisions while
scanning: a real property declared next to a tag inherits the tag's
public visibility and, for `@property-read`, its readonly flag. As a
result, internal writes to such a private property are reported as
writes to a readonly property, internal accesses use the tag's type
instead of the real one, and a real property inherited from a parent
or trait either masks the tag or is masked by it, depending on
populator order.
Store tags separately in `ClassLikeMetadata::magic_properties`, keep
`properties` for real declarations only, and decide per usage in the
new `resolve_declared_property` which one governs. A tag whose type
narrows a visible real property's type refines it (a wide inherited
`public $db` narrowed by `@property` on a subclass); a conflicting tag
type is ignored wherever the real property is visible. The unused and
write-only property analyses skip real properties shadowed by a tag:
they are reachable through `__get`/`__set` from outside the class,
which those analyses cannot see.
Type-level consumers follow suit: structural `object{...}` containment
consults tags alongside real declarations, while `properties-of<T>` now
excludes them (they are not real properties), matching Psalm.dotdash:fix_analyzer_resolve_property_tags_by_call_site_visibility Latest Branches
0%
dotdash:fix_analyzer_track_hook_property_writes_against_the_read_type -18%
dotdash:fix_analyzer_support_split_property_read_property_write_types 0%
dotdash:fix_analyzer_resolve_property_tags_by_call_site_visibility © 2026 CodSpeed Technology