Skip to content

Commit cfdf83a

Browse files
committed
fix name argument parsing mismatch cuasing false positive type-errors and document it.
1 parent b734de3 commit cfdf83a

14 files changed

Lines changed: 369 additions & 27 deletions

docs/advanced/liskov-and-inheritance.md

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -292,35 +292,42 @@ $service->update(10, 'Charlie');
292292

293293
---
294294

295-
## Parameter Renaming ($id $\rightarrow$ $userId$)
295+
## Parameter Renaming ($id $userId) & Position Shifts
296296

297-
PHP permits child classes to rename parameters when implementing an interface or extending a class. TypePHP maps inherited parameter contracts by **index position** (0, 1, 2...) rather than parameter name:
297+
When a child class or attribute constructor overrides a parent method, parameter positions or parameter names may shift. TypePHP resolves parameter contract inheritance using **Name-First Resolution**:
298+
299+
1. **Name Matching:** If a parameter name in the child method matches a parameter name in the parent class (e.g. `$api`), the parent's contract is inherited by that parameter regardless of its position index in the child.
300+
2. **Position Fallback:** If a parameter is renamed in the child class (e.g., `$id` $\rightarrow$ `$userId`), TypePHP falls back to matching by position index.
298301

299302
```php
300-
interface UserApiInterface
303+
class BaseField
301304
{
302305
/**
303-
* Interface uses parameter name $id
306+
* Parent constructor has $api at position #1
304307
*
305-
* @param positive-int $id
308+
* @param string $type
309+
* @param bool|array{admin-api: bool} $api
306310
*/
307-
public function find(int $id): bool;
311+
public function __construct(string $type, bool|array $api = false) {}
308312
}
309313

310-
class UserApi implements UserApiInterface
314+
class OneToManyRelation extends BaseField
311315
{
312-
// Child renames parameter $id to $userId
313-
public function find(int $userId): bool
314-
{
315-
return true;
316+
/**
317+
* Child inserts $entity, $ref, $onDelete BEFORE $api (position shift!)
318+
*/
319+
public function __construct(
320+
string $entity,
321+
string $ref,
322+
OnDeleteOption $onDelete = OnDeleteOption::NO_ACTION,
323+
bool|array $api = false
324+
) {
325+
parent::__construct('one-to-many', $api);
316326
}
317327
}
318328

319-
$api = new UserApi();
320-
321-
// $userId = -50 is checked at index 0 against interface's @param positive-int $id!
322-
$api->find(-50);
323-
// Throws: TypeError: UserApi::find(): Argument $userId must be of type positive-int
329+
// $onDelete (position #2 in child) is NOT overwritten by $api's type (position #1 in parent)!
330+
$attr = new OneToManyRelation('unit', 'unit_id', OnDeleteOption::CASCADE, true);
324331
```
325332

326333
---

docs/core-concepts/function-contracts.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,33 @@ registerUser(-5, 'Alice', 'admin');
3535

3636
> **Execution Order Note:** Native PHP type hints (e.g., `int $id`, `string $username`) are evaluated by PHP's C-engine *before* function execution begins. TypePHP's extended PHPDoc contracts (e.g., `positive-int`, `non-empty-string`) execute at the very start of the function/method body. If a native type hint fails, PHP throws its native `TypeError` before TypePHP's guard rails run.
3737
38+
---
39+
## PHP 8.0+ Named Arguments
40+
41+
TypePHP natively supports PHP 8.0+ Named Arguments. Because parameter contracts are mapped by parameter name rather than argument position index, you can pass named arguments in any order, and TypePHP will accurately validate each parameter:
42+
43+
```php
44+
<?php
45+
46+
declare(strict_types=1);
47+
48+
/**
49+
* @param positive-int $id
50+
* @param non-empty-string $username
51+
* @param int<1, 100> $age
52+
*/
53+
function registerUser(int $id, string $username, int $age): void
54+
{
55+
// ...
56+
}
57+
58+
// Valid Call: Arguments passed in completely reversed/swapped order
59+
registerUser(age: 25, username: 'Alice', id: 42);
60+
61+
// Invalid Call: $id (-5) passed as 3rd named argument
62+
registerUser(age: 25, username: 'Alice', id: -5);
63+
// Throws: TypeError: registerUser(): Argument $id must be of type positive-int, negative int (-5) given
64+
```
3865
---
3966

4067
## Class Methods (Instance & Static)

src/Contract/ContractParser.php

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -332,10 +332,12 @@ private static function parseMethodHierarchyDocs(
332332
$hierarchy = HierarchyResolver::getMethodHierarchy($ref);
333333
$baseParams = $ref->getParameters();
334334
$baseParamNames = [];
335+
$baseParamSet = [];
335336
$baseParamVariadic = [];
336337

337338
foreach ($baseParams as $idx => $p) {
338339
$baseParamNames[$idx] = $p->getName();
340+
$baseParamSet[$p->getName()] = $idx;
339341
$baseParamVariadic[$p->getName()] = $p->isVariadic();
340342
}
341343

@@ -369,20 +371,24 @@ private static function parseMethodHierarchyDocs(
369371

370372
foreach ($phpDocNode->getParamTagValues() as $paramTag) {
371373
$paramName = ltrim($paramTag->parameterName, '$');
372-
$paramIndex = $hierNameToIndex[$paramName] ?? null;
373374

374-
if ($paramIndex !== null && isset($baseParamNames[$paramIndex])) {
375-
$baseParamName = $baseParamNames[$paramIndex];
375+
if (isset($baseParamSet[$paramName])) {
376+
$targetParamName = $paramName;
377+
} else {
378+
$paramIndex = $hierNameToIndex[$paramName] ?? null;
379+
$targetParamName = ($paramIndex !== null && isset($baseParamNames[$paramIndex]))
380+
? $baseParamNames[$paramIndex]
381+
: null;
382+
}
376383

377-
if (! isset($types[$baseParamName])) {
378-
$type = $paramTag->type;
379-
$isVariadic = $paramTag->isVariadic || $baseParamVariadic[$baseParamName];
380-
if ($isVariadic) {
381-
$type = new ArrayTypeNode($type);
382-
}
383-
$substitutedType = self::substituteAliases($type, $aliases);
384-
$types[$baseParamName] = SpecialTypeResolver::resolve($substitutedType, $hierRef);
384+
if ($targetParamName !== null && ! isset($types[$targetParamName])) {
385+
$type = $paramTag->type;
386+
$isVariadic = $paramTag->isVariadic || ($baseParamVariadic[$targetParamName] ?? false);
387+
if ($isVariadic) {
388+
$type = new ArrayTypeNode($type);
385389
}
390+
$substitutedType = self::substituteAliases($type, $aliases);
391+
$types[$targetParamName] = SpecialTypeResolver::resolve($substitutedType, $hierRef);
386392
}
387393
}
388394

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Attributes;
6+
7+
class BaseField
8+
{
9+
/**
10+
* Parent constructor has $api at index 2
11+
*
12+
* @param string $type
13+
* @param string|null $storageName
14+
* @param bool|array{admin-api: bool, store-api: bool} $api
15+
*/
16+
public function __construct(
17+
string $type,
18+
?string $storageName = null,
19+
bool|array $api = false
20+
) {
21+
}
22+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Attributes;
6+
7+
class DeepMultiLevelField extends ParentField
8+
{
9+
/**
10+
* Child inserts $id at index 0!
11+
*
12+
* @param positive-int $id
13+
*/
14+
public function __construct(
15+
public int $id,
16+
string $type,
17+
string $name,
18+
bool $api = false
19+
) {
20+
parent::__construct($type, $name, $api);
21+
}
22+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Attributes;
6+
7+
abstract class GrandParentField
8+
{
9+
/**
10+
* @param non-empty-string $type
11+
* @param bool $api
12+
*/
13+
public function __construct(
14+
string $type,
15+
bool $api = false
16+
) {
17+
}
18+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Attributes;
6+
7+
class MeasurementSystemEntity
8+
{
9+
/**
10+
* @var array<string, mixed>|null
11+
*/
12+
#[OneToManyRelation(
13+
entity: 'measurement_display_unit',
14+
ref: 'measurement_system_id',
15+
onDelete: OnDeleteOption::CASCADE,
16+
api: true
17+
)]
18+
public ?array $units = null;
19+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Attributes;
6+
7+
enum OnDeleteOption
8+
{
9+
case CASCADE;
10+
case NO_ACTION;
11+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Attributes;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_PROPERTY)]
10+
class OneToManyRelation extends BaseField
11+
{
12+
/**
13+
* Child constructor has $onDelete at index 2
14+
*/
15+
public function __construct(
16+
public string $entity,
17+
public string $ref,
18+
public OnDeleteOption $onDelete = OnDeleteOption::NO_ACTION,
19+
public bool|array $api = false
20+
) {
21+
parent::__construct('one-to-many', null, $api);
22+
}
23+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Attributes;
6+
7+
abstract class ParentField extends GrandParentField
8+
{
9+
/**
10+
* ParentField inserts $name at index 1
11+
*
12+
* @param non-empty-string $name
13+
*/
14+
public function __construct(
15+
string $type,
16+
string $name,
17+
bool $api = false
18+
) {
19+
parent::__construct($type, $api);
20+
}
21+
}

0 commit comments

Comments
 (0)