Skip to content

GH-85: Refuse a value the constructor parameter rejects - #140

Merged
magicsunday merged 1 commit into
mainfrom
GH-85
Jul 21, 2026
Merged

GH-85: Refuse a value the constructor parameter rejects#140
magicsunday merged 1 commit into
mainfrom
GH-85

Conversation

@magicsunday

Copy link
Copy Markdown
Owner

Fixes #85.

Property types are resolved docblock-first, which is right when the docblock refines the native declaration — array narrowed to string[] is the library's core capability — and wrong when it widens it. A docblock cannot grant a value the target itself rejects.

The defect

The two lanes failed differently:

  • Property assignment goes through the accessor, and the Test coverage gaps: lenient error paths, union failure, converter edge cases #77 write guard already turns the refusal into a reported TypeMismatchException — that lane never escaped.
  • A constructor parameter has no assignment to intercept: a widened value reached new $className(...) and raised a native TypeError outside the mapping report — the contract break AGENTS.md §4 names (a native TypeError/ValueError/ArgumentCountError reaching the caller is invisible to error collection).

The fix

A new NativeTypeMatcher checks each constructor argument against the parameter's own native declaration at the hand-over, before the spread. A proven violation is recorded as a TypeMismatchException and then falls through to the lanes an absent value takes — a defaulted parameter takes its default, a required one records MissingConstructorArgumentException and the object is not built (the same two-error, no-object outcome a required parameter already produces when its value fails ordinary conversion).

It is not a null check: it refuses every proven widening, verified across three escaping shapes, each pinned by a test that fails without the guard with exactly the native TypeError it describes:

  • @var int|null over a promoted int, given null
  • @var int|string over a promoted int, given a string
  • a property with no type metadata at all, feeding a natively typed parameter — nothing contradicts anything here, which is why keying on the docblock would have missed it

Further behaviour:

  • A refused variadic element is dropped individually, keeping its valid siblings and reporting under its own index, mirroring the collection element loop's "one bad entry must not discard the rest" rule.
  • The matcher is one-sided: false only for a proven violation, true for anything it cannot judge. A missed violation degrades to the error PHP would have raised anyway; a fabricated one would refuse a valid value with no way around it.
  • It resolves self/parent/static against the declaring class, because PHP 8.5 changed whether reflection spells a relative type as the keyword or the class it stands for — keying on the spelling would wave every value through on 8.3/8.4, where reflection still reports the literal keyword.

Cross-checking nullability inside the type resolver was the rejected alternative: ReflectionProperty::getType() is the storage type, not the type the write must satisfy — a setter or a set hook may accept more than the field it writes, so narrowing there would refuse values the class documents itself as taking. A regression test pins that direction.

Verification

composer ci:test green across the 8.3, 8.4 and 8.5 legs (549 tests). NativeTypeMatcher is unit-tested per type category (builtins in both directions, nullable, union, intersection, DNF, describe() rendering); the constructor-guard behaviour is pinned end-to-end.

🤖 Generated with Claude Code

Types are resolved docblock-first, which is right when the docblock REFINES the
native declaration - `array` narrowed to `string[]` is the library's core
capability - and wrong when it WIDENS it. A docblock cannot grant a value the
target itself rejects.

The two lanes failed differently. Assigned to a property, the value goes through
the accessor and the write guard already turns the refusal into a reported
mismatch, so that lane never escaped. Passed to a constructor parameter there is
no assignment to intercept: the value reached `new $className()` and raised a
native TypeError outside the report - the contract break §4 names.

So the check sits at the hand-over, against each parameter's own declaration,
through the new NativeTypeMatcher. It is not a null check: it refuses every proven
violation of the native type, covering three shapes verified to escape today, each
pinned by a test that fails without the guard with exactly the TypeError it
describes:

  - `@var int|null` over a promoted `int`, given null
  - `@var int|string` over a promoted `int`, given a string
  - a property carrying no type metadata at all, feeding a natively typed
    parameter - nothing contradicts anything here, which is why keying on the
    docblock would have missed it

A refused VARIADIC element is dropped on its own, keeping its valid siblings and
reporting under its own index, matching the collection element loop's "one bad
entry must not discard the rest" rule. A required parameter whose value is refused
records the refusal and then falls through to the lanes an absent value takes -
the same two-error, no-object outcome it already produces when its value fails
ordinary conversion, so the guard adds a lane rather than a new failure shape.

The matcher answers one-sidedly by construction: only a proven violation refuses,
and a declaration it cannot judge passes. A missed violation degrades to the error
PHP would have raised anyway; a fabricated one would refuse a valid value with no
way around it. It resolves `self`/`parent`/`static` against the declaring class,
because whether reflection spells a relative type as `self` or as the class it
stands for changed in PHP 8.5 - keying on the spelling would wave every value
through on 8.3 and 8.4, where reflection still reports the literal keyword.

Cross-checking nullability inside the type resolver was the other candidate and is
wrong: `ReflectionProperty::getType()` is the storage type, not the type the write
has to satisfy. A setter, or a `set` hook, may accept more than the field it
writes, so narrowing there refuses values the class documents itself as taking. A
regression test pins that direction.

The ci:test suite is green across the 8.3, 8.4 and 8.5 legs (549 tests, exit 0).
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@magicsunday
magicsunday merged commit d02a01e into main Jul 21, 2026
18 checks passed
@magicsunday
magicsunday deleted the GH-85 branch July 21, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Define docblock-vs-native type precedence: refine yes, widen no

1 participant