From 2478842d59a8f8517901fbee84e5927cd91a2912 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 02:18:55 +0000 Subject: [PATCH 01/80] feat(closure-sig): add closure-signature type representation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the value classes for a parsed closure signature type (`Closure(int $x): bool`): a recursive `SigType` sum (`SigTypeRef` | `SigClosure`) for signature leaves — so a nested `Closure(...)` in a parameter or return position is representable — plus `ClosureSignature` and `ClosureSignatureParam`. Leaves carry a `TypeRef` so generic substitution and the type hierarchy are reused. Mirrors the separate `BoundExpr` hierarchy rather than widening `TypeRef`. No wiring yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/ClosureSignature.php | 31 +++++++++++++++++++ .../Monomorphize/ClosureSignatureParam.php | 23 ++++++++++++++ src/Transpiler/Monomorphize/SigClosure.php | 19 ++++++++++++ src/Transpiler/Monomorphize/SigType.php | 30 ++++++++++++++++++ src/Transpiler/Monomorphize/SigTypeRef.php | 20 ++++++++++++ 5 files changed, 123 insertions(+) create mode 100644 src/Transpiler/Monomorphize/ClosureSignature.php create mode 100644 src/Transpiler/Monomorphize/ClosureSignatureParam.php create mode 100644 src/Transpiler/Monomorphize/SigClosure.php create mode 100644 src/Transpiler/Monomorphize/SigType.php create mode 100644 src/Transpiler/Monomorphize/SigTypeRef.php diff --git a/src/Transpiler/Monomorphize/ClosureSignature.php b/src/Transpiler/Monomorphize/ClosureSignature.php new file mode 100644 index 0000000..598785b --- /dev/null +++ b/src/Transpiler/Monomorphize/ClosureSignature.php @@ -0,0 +1,31 @@ + $params In source order. A variadic + * parameter, if present, is always the last (enforced at parse time). + */ + public function __construct( + public array $params, + public ?SigType $return, + public bool $nullable = false, + ) { + } +} diff --git a/src/Transpiler/Monomorphize/ClosureSignatureParam.php b/src/Transpiler/Monomorphize/ClosureSignatureParam.php new file mode 100644 index 0000000..e4c8d07 --- /dev/null +++ b/src/Transpiler/Monomorphize/ClosureSignatureParam.php @@ -0,0 +1,23 @@ + Date: Mon, 6 Jul 2026 02:45:12 +0000 Subject: [PATCH 02/80] feat(closure-sig): parse and erase closure signature types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recognize a `Closure(params): return` production in a type slot (parameter, property, or return position) and erase it to a bare `\Closure` in the emitted PHP, carrying the parsed shape as an AST attribute for the later conformance validator. - A position gate distinguishes a genuine type slot (the signature is followed by a `$var`, or sits in a `) :` return slot) from an expression-context call to a user function named `Closure`, which is left untouched. - `Closure(int)` — where PHP lexes `(int)` as a single cast token rather than `(` `int` `)` — is accepted as the one-scalar parameter list; the cast-token spelling maps back to its scalar name. - Erasure preserves newlines so line-keyed markers on later lines stay aligned, and always emits the fully-qualified `\Closure` so a bare `Closure` hint does not fatal inside a namespace. - Nested `Closure(...)` parameter/return types recurse; enclosing type parameters are carried through so a signature can reference them. Union / intersection / nullable leaf types are erased and retained as raw text pending their structured form. - Two structural errors are raised at parse time: a call signature on a non-`Closure` name, and a variadic parameter that is not last. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/SigRaw.php | 20 + .../Monomorphize/XphpSourceParser.php | 593 +++++++++++++- .../ClosureSignatureParseTest.php | 743 ++++++++++++++++++ 3 files changed, 1348 insertions(+), 8 deletions(-) create mode 100644 src/Transpiler/Monomorphize/SigRaw.php create mode 100644 test/Transpiler/Monomorphize/ClosureSignatureParseTest.php diff --git a/src/Transpiler/Monomorphize/SigRaw.php b/src/Transpiler/Monomorphize/SigRaw.php new file mode 100644 index 0000000..6a01c05 --- /dev/null +++ b/src/Transpiler/Monomorphize/SigRaw.php @@ -0,0 +1,20 @@ +scanAndStrip($source); + [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers] = $this->scanAndStrip($source); $ast = $this->parser->parse($cleanedSource); if ($ast === null) { @@ -133,7 +138,7 @@ public function parseWithMap(string $source): array } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers); + $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers); return [$ast, $byteOffsetMap]; } @@ -163,7 +168,7 @@ public function parseTolerant(string $source): ?array */ public function parseTolerantWithMap(string $source): ?ParseWithMapResult { - [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap] = $this->scanAndStrip($source); + [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers] = $this->scanAndStrip($source); $errorHandler = new \PhpParser\ErrorHandler\Collecting(); $ast = $this->parser->parse($cleanedSource, $errorHandler); @@ -172,7 +177,7 @@ public function parseTolerantWithMap(string $source): ?ParseWithMapResult } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers); + $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers); return new ParseWithMapResult($ast, $byteOffsetMap); } @@ -197,7 +202,7 @@ public function strip(string $source): string } /** - * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap} + * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list} */ private function scanAndStrip(string $source): array { @@ -209,6 +214,8 @@ private function scanAndStrip(string $source): array $classMarkers = []; $nameMarkers = []; $methodMarkers = []; + /** @var list $closureMarkers */ + $closureMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -544,6 +551,35 @@ private function scanAndStrip(string $source): array } } + // Closure signature type `Closure(params): return` in a type slot. + // Fires for any `Name(` not in a member-access / `new` context; + // `tryParseClosureSignature`'s position gate distinguishes a genuine + // type slot from an expression-context call (which falls through + // untouched), and throws the two structural errors. A signature-shaped + // production on a non-`Closure` name is the only-`Closure` compile error. + // @infection-ignore-all — the `(`/cast opener test is a fast-path guard: + // findClosureSigEnd re-validates the opener and returns null for anything + // that is not a `(` or a cast token, so negating this sub-expression only + // adds calls that bail to null — observably equivalent. + if ($j < $n && ($tokens[$j]->text === '(' || self::isCastToken($tokens[$j])) + && !self::isMemberAccessContext($tokens, $i) + && !self::isPrecededByNew($tokens, $i) + ) { + $sig = self::tryParseClosureSignature($tokens, $i, $j, $source); + if ($sig !== null) { + [$signature, $endIdx, $spanStart, $spanLen, $replacement] = $sig; + $closureMarkers[] = [ + 'line' => $nameLine, + 'name' => 'Closure', + 'bytePosition' => $tok->pos, + 'signature' => $signature, + ]; + $replacements[] = [$spanStart, $spanLen, $replacement]; + $i = $endIdx + 1; + continue; + } + } + // Skip the array-type sugar in member-access context (`$x->prop[]`, // `$x?->prop[]`, `Foo::bar[]`) — there the trailing `[]` is array-append // or array-deref syntax, never a type-hint. @@ -567,7 +603,492 @@ private function scanAndStrip(string $source): array $cleaned = self::applyReplacements($source, $replacements); $byteOffsetMap = ByteOffsetMap::fromReplacements($replacements); - return [$classMarkers, $nameMarkers, $methodMarkers, $cleaned, $byteOffsetMap]; + return [$classMarkers, $nameMarkers, $methodMarkers, $cleaned, $byteOffsetMap, $closureMarkers]; + } + + /** + * Recognize + parse a closure signature type `Closure(params): return` that + * begins at the `Closure`/`\Closure` name token index `$i`, where `$j` is the + * index of the `(` immediately following the name (past whitespace). + * + * Returns `[ClosureSignature, spanEndIdx, spanStartByte, spanLen, replacement]` + * when the position is a genuine type slot, or `null` when it is an ordinary + * expression (e.g. a call to a user function named `Closure`) that must be left + * untouched. Throws for the two structural errors (only-`Closure`, variadic-last). + * + * @infection-ignore-all — the accept/reject behavior (type-slot vs. call, the two + * structural throws) and the newline-preserving erasure math are pinned by + * behavioral tests; the sole residual mutant is the defensive + * `findClosureSigEnd() === null` guard, unreachable once the pre-filter has matched + * a balanced `(…)` at a genuine type slot. + * + * @param list $tokens + * @return array{0: ClosureSignature, 1: int, 2: int, 3: int, 4: string}|null + */ + private static function tryParseClosureSignature(array $tokens, int $i, int $j, string $source): ?array + { + $n = count($tokens); + + // Cheap pre-filter: a signature's first inner token is type-ish, never a + // `$var` or literal (which a real call `Closure($x)` / `Closure(5)` has). + // A cast token (`Closure(int)`) is itself the whole one-scalar param list. + if (!self::isCastToken($tokens[$j])) { + $firstInner = self::skipWs($tokens, $j + 1); + if ($firstInner >= $n) { + return null; + } + $ft = $tokens[$firstInner]; + $firstOk = self::isNameToken($ft) + || $ft->id === T_STATIC + || $ft->id === T_ELLIPSIS + || in_array($ft->text, ['?', '(', ')', '&'], true); + if (!$firstOk) { + return null; + } + } + + // Balanced end of `( … )` plus an optional `: return`. + $spanEnd = self::findClosureSigEnd($tokens, $j); + if ($spanEnd === null) { + return null; + } + + // Position gate: a type slot is a param/property (a `$var` follows the whole + // signature) or a return type (the name sits just after `) :`, past an + // optional `?`). Anything else is expression-context `Closure(` — leave it. + $afterSpan = self::skipWs($tokens, $spanEnd + 1); + $isParamOrProp = $afterSpan < $n && $tokens[$afterSpan]->id === T_VARIABLE; + $isReturn = self::isClosureReturnSlot($tokens, $i); + if (!$isParamOrProp && !$isReturn) { + return null; + } + + $name = ltrim($tokens[$i]->text, '\\'); + if ($name !== 'Closure') { + throw new RuntimeException(sprintf( + 'Only "Closure" may carry a call signature, "%s" may not', + $name, + )); + } + + $nullable = self::isNullablePrefixed($tokens, $i); + $signature = self::buildClosureSignature($tokens, $j, $source, $nullable); + + // Erase to `\Closure`, preserving newlines (line-matched markers rely on + // the newline count staying constant) and blanking the rest to spaces. + $spanStart = $tokens[$i]->pos; + $nameLen = strlen($tokens[$i]->text); + $spanEndByte = $tokens[$spanEnd]->pos + strlen($tokens[$spanEnd]->text); + $tail = substr($source, $spanStart + $nameLen, $spanEndByte - ($spanStart + $nameLen)); + $blankedTail = preg_replace('/[^\r\n]/', ' ', $tail) ?? ''; + $replacement = '\\Closure' . $blankedTail; + + return [$signature, $spanEnd, $spanStart, $spanEndByte - $spanStart, $replacement]; + } + + /** + * The scalar-cast token ids (`(int)`, `(bool)`, `(float)`, `(string)`, + * `(array)`, `(object)`, `(unset)`). A bare single-scalar signature parameter + * — `Closure(int)` — is lexed by PHP as one cast token rather than + * `(` `int` `)`, so the scanner must accept a cast token where it expects the + * parameter-list parens (the same collision the engine RFC handles). + * + * @return array token-id → canonical scalar name + */ + private static function castTokenScalars(): array + { + return [ + T_INT_CAST => 'int', + T_BOOL_CAST => 'bool', + T_DOUBLE_CAST => 'float', + T_STRING_CAST => 'string', + T_ARRAY_CAST => 'array', + T_OBJECT_CAST => 'object', + T_UNSET_CAST => 'void', + ]; + } + + private static function isCastToken(PhpToken $tok): bool + { + return array_key_exists($tok->id, self::castTokenScalars()); + } + + /** + * Index of the last token of a closure signature whose parameter list opens at + * `$openIdx` (a `(` OR a single cast token like `(int)`): the matching `)` (or + * the cast token itself), extended over an optional `: `. `null` if + * the parens are unbalanced. + * + * @param list $tokens + */ + private static function findClosureSigEnd(array $tokens, int $openIdx): ?int + { + $n = count($tokens); + if ($openIdx >= $n) { + return null; + } + if (self::isCastToken($tokens[$openIdx])) { + $closeIdx = $openIdx; + } elseif ($tokens[$openIdx]->text === '(') { + $depth = 0; + $closeIdx = null; + for ($i = $openIdx; $i < $n; $i++) { + $t = $tokens[$i]->text; + if ($t === '(') { + $depth++; + } elseif ($t === ')') { + $depth--; + if ($depth === 0) { + $closeIdx = $i; + break; + } + } + } + if ($closeIdx === null) { + return null; + } + } else { + return null; + } + $k = self::skipWs($tokens, $closeIdx + 1); + if ($k < $n && $tokens[$k]->text === ':') { + $retStart = self::skipWs($tokens, $k + 1); + return self::scanTypeExprEnd($tokens, $retStart); + } + return $closeIdx; + } + + /** + * Index of the last token of a type expression starting at `$idx` — a leaf + * (scalar / class / `Name<…>` generic / nested `Closure(…)` / nullable) plus + * any `|` / `&` union-or-intersection continuation. Stops before a top-level + * `$var` / `{` / `;` / `,` / `)` / `=`. `null` if nothing type-shaped is there. + * + * @infection-ignore-all — flat token walk. The span it returns is pinned + * behaviorally (a wrong end leaves invalid residue that fails to re-parse, and the + * generic / union / intersection / nested-closure tests assert the exact shape); + * the residual mutants are walk-boundary guards and whitespace-skip offsets plus a + * sub-expression negation of the continuation predicate that is equivalent for the + * only members the caller can pass here (a name, a `?`-name, or `static`). + * + * @param list $tokens + */ + private static function scanTypeExprEnd(array $tokens, int $idx): ?int + { + $n = count($tokens); + $last = null; + $i = $idx; + while ($i < $n) { + $t = $tokens[$i]; + if ($t->id === T_WHITESPACE || $t->id === T_COMMENT || $t->id === T_DOC_COMMENT) { + $i++; + continue; + } + if ($t->text === '?') { + $last = $i; + $i++; + continue; + } + if (self::isNameToken($t) || $t->id === T_STATIC) { + $last = $i; + $la = self::skipWs($tokens, $i + 1); + if ($la < $n && ($tokens[$la]->text === '(' || self::isCastToken($tokens[$la])) && ltrim($t->text, '\\') === 'Closure') { + $inner = self::findClosureSigEnd($tokens, $la); + if ($inner === null) { + return null; + } + $last = $inner; + $i = $inner + 1; + } elseif ($la < $n && $tokens[$la]->text === '<') { + $inner = self::findAngleEnd($tokens, $la); + if ($inner === null) { + return null; + } + $last = $inner; + $i = $inner + 1; + } else { + $i++; + } + continue; + } + if ($t->text === '|' || $t->text === '&') { + // `&` before a `$var` / `...` / `)` is a by-ref marker (belongs to + // the parameter, not the type) — stop. `&`/`|` before a Name is an + // intersection / union continuation. + $nx = self::skipWs($tokens, $i + 1); + $continues = $nx < $n + && (self::isNameToken($tokens[$nx]) || $tokens[$nx]->text === '?' || $tokens[$nx]->id === T_STATIC); + if (!$continues) { + break; + } + $i++; + continue; + } + break; + } + return $last; + } + + /** + * Index of the matching `>` for a `<` at `$openIdx` (generic arg clause). + * Assumes `splitMergedAngleTokens` has already split `>>` etc. `null` if + * unbalanced. + * + * @infection-ignore-all — flat balanced-angle scan; the depth logic is pinned by + * the nested-generic test (a broken counter leaves a stray `>` that fails to + * re-parse), and the sole residual mutant is the `$i < $n` loop-bound guard, which + * is defensive: the caller only passes a `<` that has a matching `>`. + * + * @param list $tokens + */ + private static function findAngleEnd(array $tokens, int $openIdx): ?int + { + $n = count($tokens); + $depth = 0; + for ($i = $openIdx; $i < $n; $i++) { + $t = $tokens[$i]->text; + if ($t === '<') { + $depth++; + } elseif ($t === '>') { + $depth--; + if ($depth === 0) { + return $i; + } + } + } + return null; + } + + /** + * True iff the `Closure` at `$i` sits in a return-type slot: preceded (past an + * optional nullable `?` and whitespace) by `:` which is itself preceded by `)`. + * Keyed on `)`-then-`:` so a `case FOO: Closure($x);` label does not read as one. + * + * @infection-ignore-all — flat backward token walk. The true/false behavior is + * pinned behaviorally (return-position signatures are recognized; an expression + * `Closure(Foo::class)` is left untouched); the residual mutants are the `$q >= 0` + * boundary guard (index 0 is always T_OPEN_TAG, so `>= 0` never differs from `> 0`) + * and the `)`-before-`:` requirement, which no valid PHP can falsify — a `:` that + * immediately precedes a `Closure(…)` type is only ever a return-type colon. + * + * @param list $tokens + */ + private static function isClosureReturnSlot(array $tokens, int $i): bool + { + $p = self::skipWsBack($tokens, $i - 1); + if ($p >= 0 && $tokens[$p]->text === '?') { + $p = self::skipWsBack($tokens, $p - 1); + } + if ($p < 0 || $tokens[$p]->text !== ':') { + return false; + } + $q = self::skipWsBack($tokens, $p - 1); + return $q >= 0 && $tokens[$q]->text === ')'; + } + + /** + * True iff a `?` (nullable) immediately precedes the `Closure` at `$i`. + * + * @infection-ignore-all — flat backward token walk; the nullable/non-nullable + * behavior is pinned by the `?Closure` tests, and the residual `$p >= 0` guard is + * defensive (index 0 is always T_OPEN_TAG, so a `?` can never sit at index 0). + * + * @param list $tokens + */ + private static function isNullablePrefixed(array $tokens, int $i): bool + { + $p = self::skipWsBack($tokens, $i - 1); + return $p >= 0 && $tokens[$p]->text === '?'; + } + + /** + * Skip whitespace/comments walking backwards from `$i`; returns the index of + * the first significant token at or before `$i`, or -1. + * + * @param list $tokens + */ + private static function skipWsBack(array $tokens, int $i): int + { + while ($i >= 0 + && ($tokens[$i]->id === T_WHITESPACE || $tokens[$i]->id === T_COMMENT || $tokens[$i]->id === T_DOC_COMMENT)) { + $i--; + } + return $i; + } + + /** + * Build the structured `ClosureSignature` for a `(` at `$openIdx`. Throws the + * variadic-not-last structural error. Nested `Closure(…)` leaves recurse. + * + * @infection-ignore-all — structural correctness (param arity, the cast-token + * single-scalar param, absent-vs-present return, the variadic-not-last throw, + * nested recursion) is pinned by behavioral accept/reject tests; the residual + * mutants are all `$i < $n` loop/lookahead guards and whitespace-skip offsets, + * equivalent because the param loop is terminated by the closing `)` the caller + * guarantees and the tightly-packed / spaced fixtures both assert the same shape. + * + * @param list $tokens + */ + private static function buildClosureSignature(array $tokens, int $openIdx, string $source, bool $nullable): ClosureSignature + { + $n = count($tokens); + + // `Closure(int)` — the whole one-scalar param list is a single cast token. + if (self::isCastToken($tokens[$openIdx])) { + $scalar = self::castTokenScalars()[$tokens[$openIdx]->id]; + // No isScalar flag here: resolveTypeRef re-derives it from SCALAR_TYPES + // during resolution, so setting it at scan time would be dead. + $params = [new ClosureSignatureParam(new SigTypeRef(new TypeRef($scalar)))]; + $return = null; + $k = self::skipWs($tokens, $openIdx + 1); + if ($k < $n && $tokens[$k]->text === ':') { + [$return] = self::parseSigType($tokens, self::skipWs($tokens, $k + 1), $source); + } + return new ClosureSignature($params, $return, $nullable); + } + + $params = []; + $i = self::skipWs($tokens, $openIdx + 1); + $sawVariadic = false; + while ($i < $n && $tokens[$i]->text !== ')') { + if ($sawVariadic) { + throw new RuntimeException('Only the last parameter of a Closure signature can be variadic'); + } + [$param, $i] = self::parseSigParam($tokens, $i, $source); + $params[] = $param; + $sawVariadic = $param->variadic; + $i = self::skipWs($tokens, $i); + if ($i < $n && $tokens[$i]->text === ',') { + $i = self::skipWs($tokens, $i + 1); + } + } + $return = null; + $k = self::skipWs($tokens, min($i, $n - 1) + 1); + if ($k < $n && $tokens[$k]->text === ':') { + $retStart = self::skipWs($tokens, $k + 1); + [$return] = self::parseSigType($tokens, $retStart, $source); + } + return new ClosureSignature($params, $return, $nullable); + } + + /** + * Parse one signature parameter `type [&] [...] [$name]` beginning at `$i`. + * + * @infection-ignore-all — flat token walk reading the optional `&`, `...` and + * `$name` markers in order; the by-ref / variadic capture is pinned by the by-ref, + * variadic, and by-ref-variadic tests, and the residual mutants are `$i < $n` + * lookahead guards and whitespace-skip offsets equivalent under the same inputs. + * + * @param list $tokens + * @return array{0: ClosureSignatureParam, 1: int} + */ + private static function parseSigParam(array $tokens, int $i, string $source): array + { + $n = count($tokens); + [$type, $i] = self::parseSigType($tokens, $i, $source); + $i = self::skipWs($tokens, $i); + $byRef = false; + $variadic = false; + if ($i < $n && $tokens[$i]->text === '&') { + $byRef = true; + $i = self::skipWs($tokens, $i + 1); + } + if ($i < $n && $tokens[$i]->id === T_ELLIPSIS) { + $variadic = true; + $i = self::skipWs($tokens, $i + 1); + } + if ($i < $n && $tokens[$i]->id === T_VARIABLE) { + $i++; + } + return [new ClosureSignatureParam($type, $byRef, $variadic), $i]; + } + + /** + * Parse one signature leaf type beginning at `$i` → `[SigType, nextIdx]`. + * A nested `Closure(…)` becomes a `SigClosure`; a plain scalar / class / + * type-parameter / `Name<…>` leaf becomes a `SigTypeRef` (unresolved — the + * resolver walks the tree later); a `?` / union / intersection leaf is kept + * as raw text in a `SigRaw` (structured form deferred). + * + * @infection-ignore-all — leaf-kind selection (nested `SigClosure`, compound + * `SigRaw`, plain `SigTypeRef`) is pinned by the nested / union / intersection / + * nullable-leaf tests; the residual mutants are `$i < $n` lookahead guards and + * whitespace-skip offsets, equivalent because every path is entered only for the + * balanced, type-shaped spans the caller passes. + * + * @param list $tokens + * @return array{0: SigType, 1: int} + */ + private static function parseSigType(array $tokens, int $i, string $source): array + { + $n = count($tokens); + if ($i < $n && self::isNameToken($tokens[$i]) && ltrim($tokens[$i]->text, '\\') === 'Closure') { + $la = self::skipWs($tokens, $i + 1); + if ($la < $n && ($tokens[$la]->text === '(' || self::isCastToken($tokens[$la]))) { + $end = self::findClosureSigEnd($tokens, $la); + if ($end !== null) { + return [new SigClosure(self::buildClosureSignature($tokens, $la, $source, false)), $end + 1]; + } + } + } + + // A leading `?` marks a nullable leaf — keep the whole leaf raw for now. + $nullableLeaf = $i < $n && $tokens[$i]->text === '?'; + $start = $i; + if ($nullableLeaf) { + $i = self::skipWs($tokens, $i + 1); + } + + $parsed = self::parseTypeArg($tokens, $i); + if ($parsed === null) { + $end = self::scanTypeExprEnd($tokens, $start); + $end = $end ?? $start; + return [new SigRaw(self::sliceTokens($tokens, $source, $start, $end)), $end + 1]; + } + [$typeRef, $afterLeaf] = $parsed; + + // Union / intersection continuation → raw (structured form is a later WI). + $peek = self::skipWs($tokens, $afterLeaf); + $isCompound = $nullableLeaf + || ($peek < $n && ($tokens[$peek]->text === '|' + || ($tokens[$peek]->text === '&' && self::sigAmpersandIsIntersection($tokens, $peek)))); + if ($isCompound) { + $end = self::scanTypeExprEnd($tokens, $start); + $end = $end ?? ($afterLeaf - 1); + return [new SigRaw(self::sliceTokens($tokens, $source, $start, $end)), $end + 1]; + } + + return [new SigTypeRef($typeRef), $afterLeaf]; + } + + /** + * True iff the `&` at `$idx` continues an intersection type (a Name follows) + * rather than marking a by-reference parameter (a `$var` / `...` follows). + * + * @infection-ignore-all — flat one-token lookahead; the intersection-vs-by-ref + * discrimination is pinned by the intersection-by-ref and intersection-member + * tests, and the residual `$nx < count()` guard is defensive (a `&` is only reached + * mid-span, never at the token-array boundary). + * + * @param list $tokens + */ + private static function sigAmpersandIsIntersection(array $tokens, int $idx): bool + { + $nx = self::skipWs($tokens, $idx + 1); + return $nx < count($tokens) + && (self::isNameToken($tokens[$nx]) || $tokens[$nx]->text === '?' || $tokens[$nx]->id === T_STATIC); + } + + /** + * Source substring spanning token indices `$from`..`$to` inclusive. + * + * @param list $tokens + */ + private static function sliceTokens(array $tokens, string $source, int $from, int $to): string + { + $start = $tokens[$from]->pos; + $end = $tokens[$to]->pos + strlen($tokens[$to]->text); + return substr($source, $start, $end - $start); } /** @@ -1364,14 +1885,14 @@ private static function applyReplacements(string $source, array $replacements): * @param list}> $nameMarkers * @param list}> $methodMarkers */ - private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers): void + private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers): void { $traverser = new NodeTraverser(); $traverser->addVisitor(new /** * @phpstan-import-type BoundDict from XphpSourceParser */ - class($classMarkers, $nameMarkers, $methodMarkers) extends NodeVisitorAbstract { + class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers) extends NodeVisitorAbstract { private NamespaceContext $ctx; /** @var list> stack of enclosing type-param scopes */ private array $typeParamStack = []; @@ -1385,11 +1906,13 @@ class($classMarkers, $nameMarkers, $methodMarkers) extends NodeVisitorAbstract { * @param array}> $classMarkers * @param array}> $nameMarkers * @param array}> $methodMarkers + * @param array $closureMarkers */ public function __construct( private array $classMarkers, private array $nameMarkers, private array $methodMarkers, + private array $closureMarkers, ) { $this->ctx = new NamespaceContext(); } @@ -1692,6 +2215,7 @@ public function enterNode(Node $node): null private function markType(?Node $type): void { if ($type instanceof Name) { + $this->attachClosureSig($type); $this->markName($type); } elseif ($type instanceof Node\NullableType) { $this->markType($type->type); @@ -1702,6 +2226,59 @@ private function markType(?Node $type): void } } + /** + * If a `\Closure` type Name carries a parsed closure signature (the + * `(…)[: ret]` was erased at scan time, leaving this surviving Name), + * attach the resolved signature. Matched by (line, `Closure`) and + * consumed in insertion order so multiple signatures on one line map to + * the Name nodes in source order. + */ + private function attachClosureSig(Name $node): void + { + if (ltrim($node->toString(), '\\') !== 'Closure') { + return; + } + foreach ($this->closureMarkers as $i => $marker) { + if ($marker['line'] === $node->getStartLine()) { + $node->setAttribute( + XphpSourceParser::ATTR_CLOSURE_SIG, + $this->resolveClosureSignature($marker['signature']), + ); + unset($this->closureMarkers[$i]); + break; + } + } + } + + private function resolveClosureSignature(ClosureSignature $sig): ClosureSignature + { + $params = array_map( + fn (ClosureSignatureParam $p): ClosureSignatureParam => new ClosureSignatureParam( + $this->resolveSigType($p->type), + $p->byRef, + $p->variadic, + ), + $sig->params, + ); + $return = $sig->return === null ? null : $this->resolveSigType($sig->return); + + return new ClosureSignature($params, $return, $sig->nullable); + } + + private function resolveSigType(SigType $type): SigType + { + if ($type instanceof SigTypeRef) { + return new SigTypeRef($this->resolveTypeRef($type->type)); + } + if ($type instanceof SigClosure) { + return new SigClosure($this->resolveClosureSignature($type->signature)); + } + + // SigRaw (union / intersection / nullable leaf) — structured + // resolution is a later work item; carry the raw text through. + return $type; + } + private function markName(Name $node): void { if (!$this->shouldQualify($node)) { diff --git a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php new file mode 100644 index 0000000..3bec7b9 --- /dev/null +++ b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php @@ -0,0 +1,743 @@ +params); + self::assertSame('int', self::refName($sig->params[0]->type)); + self::assertTrue(self::isScalarRef($sig->params[0]->type)); + self::assertSame('string', self::refName($sig->params[1]->type)); + self::assertSame('bool', self::refName($sig->return)); + self::assertFalse($sig->nullable); + } + + public function testParametersMayOmitNames(): void + { + // Names are insignificant to conformance; a bare `Closure(int, int): int` + // must parse to two params exactly like the named form. + $sig = self::firstSig('params); + self::assertSame('int', self::refName($sig->params[0]->type)); + self::assertSame('int', self::refName($sig->params[1]->type)); + self::assertSame('int', self::refName($sig->return)); + } + + public function testSingleScalarParameterLexedAsCastTokenIsParsed(): void + { + // `Closure(int)` — PHP lexes `(int)` as ONE T_INT_CAST token, not + // `(` `int` `)`. The scanner must accept the cast token as the whole + // one-scalar parameter list (the same collision the engine RFC handles). + $sig = self::firstSig('params); + self::assertSame('int', self::refName($sig->params[0]->type)); + self::assertTrue(self::isScalarRef($sig->params[0]->type)); + self::assertSame('int', self::refName($sig->return)); + } + + public function testEachCastTokenScalarMapsToItsCanonicalName(): void + { + // Locks the castTokenScalars() id→name table: every single-scalar param + // spelled as a cast token resolves to the right scalar. `(unset)` has no + // matching scalar type, so it maps to `void` (the RFC's bottom return), + // exercised via the return slot below. + $cases = [ + '(int): void' => 'int', + '(bool): void' => 'bool', + '(float): void' => 'float', + '(string): void' => 'string', + '(array): void' => 'array', + '(object): void' => 'object', + ]; + foreach ($cases as $spelling => $expected) { + $sig = self::firstSig("params, "one param for Closure{$spelling}"); + self::assertSame($expected, self::refName($sig->params[0]->type), "Closure{$spelling}"); + } + } + + public function testUnsetCastTokenMapsToVoid(): void + { + // `(unset)` is a cast token with no scalar-type spelling; the table maps + // it to `void`. Kept as its own test so the assertion is unambiguous. + $sig = self::firstSig('params); + self::assertSame('void', self::refName($sig->params[0]->type)); + } + + public function testReturnPositionSignatureIsRecognized(): void + { + // `Closure` in a return slot (`) : Closure(): int`) is a type, not a call. + $sig = self::firstSig('params); + self::assertSame('int', self::refName($sig->return)); + } + + public function testNoParametersAndNoReturnIsDistinctFromMixed(): void + { + // An ABSENT return is `$return === null` — kept distinct from `: mixed`. + $sig = self::firstSig('params); + self::assertNull($sig->return, 'absent return must stay null, not default to mixed'); + } + + public function testNullablePrefixSetsNullableFlag(): void + { + // `?Closure(int): int` — the `?` precedes the erased `\Closure`; nullable + // is captured on the signature (the emitted `?\Closure` carries the PHP + // nullability, the flag records it for conformance). + $sig = self::firstSig('nullable); + self::assertCount(1, $sig->params); + } + + public function testByRefAndVariadicMarkersAreCaptured(): void + { + $sig = self::firstSig('params); + self::assertTrue($sig->params[0]->byRef, 'first param is by-reference'); + self::assertFalse($sig->params[0]->variadic); + self::assertFalse($sig->params[1]->byRef); + self::assertTrue($sig->params[1]->variadic, 'last param is variadic'); + self::assertSame('void', self::refName($sig->return)); + } + + public function testNestedClosureParameterAndReturnRecurse(): void + { + // `Closure(Closure(int): int): Closure(): string` — the param type and the + // return type are themselves closure signatures (SigClosure), not TypeRefs. + $sig = self::firstSig('params); + $inner = $sig->params[0]->type; + self::assertInstanceOf(SigClosure::class, $inner); + self::assertCount(1, $inner->signature->params); + self::assertSame('int', self::refName($inner->signature->params[0]->type)); + self::assertSame('int', self::refName($inner->signature->return)); + self::assertFalse($inner->signature->nullable, 'a nested closure is not nullable unless prefixed with `?`'); + + self::assertInstanceOf(SigClosure::class, $sig->return); + self::assertCount(0, $sig->return->signature->params); + self::assertSame('string', self::refName($sig->return->signature->return)); + self::assertFalse($sig->return->signature->nullable); + } + + public function testFullyQualifiedClosureNameCarriesSignature(): void + { + // A `\Closure(int): int` written fully-qualified must normalize to `Closure` + // (leading-`\` stripped) before the only-`Closure` check, so it is accepted + // and carries a signature — not rejected as a non-`Closure` name. + $sig = self::firstSig('params); + self::assertSame('int', self::refName($sig->params[0]->type)); + self::assertSame('int', self::refName($sig->return)); + } + + public function testFullyQualifiedNestedClosureNameIsRecognized(): void + { + // Same leading-`\` normalization on a NESTED closure leaf. + $sig = self::firstSig('params[0]->type); + self::assertSame('int', self::refName($sig->params[0]->type->signature->params[0]->type)); + } + + public function testUnionMembersIncludingNullableAndStaticContinueTheScan(): void + { + // `A|?B|static` — the scan's union continuation must accept a `?`-prefixed + // member AND a `static` member, not just plain names. A predicate that drops + // either would stop the span early and leave unparseable residue. + $source = 'return); + self::assertSame('A|?B|static', $sig->return->raw); + } + + public function testIntersectionMembersIncludingNullableAndStaticAreDetected(): void + { + // The intersection-vs-by-ref discriminator must treat a `?`-prefixed name and + // a `static` after `&` as intersection continuations (not by-ref markers). + $nullableMember = self::firstSig('params[0]->type); + self::assertSame('A&?B', $nullableMember->params[0]->type->raw); + + $staticMember = self::firstSig('params[0]->type); + self::assertSame('A&static', $staticMember->params[0]->type->raw); + } + + public function testTypeParametersInSignatureResolveAsTypeParamRefs(): void + { + // Generics in scope: a `Closure(T): U` inside a generic class must carry + // the enclosing type params as type-param TypeRefs so a later work item can + // substitute them per specialization. + $sig = self::firstSig(<<<'PHP' + { + public function make(Closure(T): U $fn): void {} + } + PHP); + + self::assertNotNull($sig); + self::assertCount(1, $sig->params); + $param = $sig->params[0]->type; + self::assertInstanceOf(SigTypeRef::class, $param); + self::assertSame('T', $param->type->name); + self::assertTrue($param->type->isTypeParam, 'T must resolve as an enclosing type-param, not a class'); + self::assertInstanceOf(SigTypeRef::class, $sig->return); + self::assertTrue($sig->return->type->isTypeParam, 'U must resolve as an enclosing type-param'); + } + + public function testClassTypeParameterResolvesAgainstNamespace(): void + { + // A non-type-param class name in a signature resolves against the namespace + // like any other type reference (reusing resolveTypeRef). + $sig = self::firstSig(<<<'PHP' + params[0]->type)); + self::assertSame('App\\Gadget', self::refName($sig->return)); + } + + // =================================================================== + // Erasure: output is valid PHP, line-stable, and executes + // =================================================================== + + public function testErasesToBareClosureLeavingNoSignatureResidue(): void + { + $stripped = self::strip(' { + public function build( + Closure( + int $x, + string $y + ): bool $cb + ): void {} + public Box $b; + } + PHP; + $stripped = self::strip($source); + + self::assertSame( + substr_count($source, "\n"), + substr_count($stripped, "\n"), + 'newline count must be identical before and after erasure', + ); + // The Box marker on a later line must still resolve after the multi-line + // erasure — proving the line counter stayed aligned. + $sig = self::firstSig($source); + self::assertNotNull($sig); + } + + public function testErasureAtColumnZeroKeepsLaterMarkersAligned(): void + { + // A signature whose `Closure` sits at column 0 (start of its line) stresses the + // erasure byte math: the newline-preserving blank must span exactly the + // signature bytes and not shift the preceding newline. If it did, the `Box` + // marker two lines below would desync and no longer resolve as a type param. + $source = " {\n" + . " public function f(\n" + . "Closure(int): int \$cb\n" + . " ): void {}\n" + . " public Box \$b;\n" + . "}\n"; + + $args = self::genericArgs($source, 'Box'); + self::assertCount(1, $args); + self::assertTrue( + $args[0]->isTypeParam, + 'Box below a column-0 closure signature must stay line-aligned to resolve as a type param', + ); + } + + public function testErasedSignatureCompilesAndExecutesInNamespace(): void + { + // A bare `Closure` in a namespace would fatal (`App\Closure` doesn't exist); + // erasure MUST emit the fully-qualified `\Closure`. Round-trip through PHP. + $source = <<<'PHP' + \$x + 1, 41);\necho \$r;"; + $out = self::runPhp($harness); + self::assertSame('42', $out); + } + + // =================================================================== + // Union / intersection leaves are erased but carried raw (WI-03) + // =================================================================== + + public function testUnionAndIntersectionLeavesAreErasedAndCarriedRaw(): void + { + $source = 'params); + self::assertInstanceOf(SigRaw::class, $sig->params[0]->type); + self::assertSame('A&B', $sig->params[0]->type->raw); + self::assertInstanceOf(SigRaw::class, $sig->return); + self::assertSame('C|null', $sig->return->raw); + + // Still fully erased to a bare \Closure. + $stripped = self::strip($source); + self::assertStringContainsString('\\Closure', $stripped); + self::assertStringNotContainsString('A&B', $stripped); + self::assertStringNotContainsString('C|null', $stripped); + } + + public function testNullableLeafInsideSignatureIsCarriedRaw(): void + { + // A `?Type` leaf INSIDE the signature (distinct from an outer `?Closure`) + // is kept raw until the structured-union work item. + $sig = self::firstSig('params[0]->type); + self::assertSame('?int', $sig->params[0]->type->raw); + self::assertFalse($sig->nullable, 'the inner `?int` must not set the outer nullable flag'); + } + + // =================================================================== + // Structural rejects + // =================================================================== + + public function testOnlyClosureMayCarryACallSignature(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Only "Closure" may carry a call signature, "Foo" may not'); + self::strip('expectException(\RuntimeException::class); + $this->expectExceptionMessage('Only the last parameter of a Closure signature can be variadic'); + self::strip(' { + public T $item; + public function get(): T { return $this->item; } + } + PHP; + // No closure signature anywhere → the strip output must be byte-identical + // to the generic-only erasure it would already produce (no closure marker + // interference). We only assert no \Closure was introduced. + self::assertStringNotContainsString('\\Closure', self::strip($source)); + } + + // =================================================================== + // Adversarial span / token-walk coverage. `parse()` runs the erasure + // through nikic, so a signature span that ends too early or too late + // leaves invalid residue and fails to parse — every assertion here both + // checks the parsed shape AND (implicitly, via firstSig calling parse()) + // proves the erased output is still valid PHP. + // =================================================================== + + public function testGenericTypeInReturnPositionIsParsedAndErased(): void + { + // A `Name<...>` return type: scanTypeExprEnd must extend the span across + // the angle clause (findAngleEnd), and parseSigType must keep the generic + // args. A span that stops at `Vec` would leave `` as invalid residue. + $sig = self::firstSig(' {}'); + + self::assertNotNull($sig); + self::assertInstanceOf(SigTypeRef::class, $sig->return); + self::assertSame('Vec', $sig->return->type->name); + self::assertCount(1, $sig->return->type->args); + self::assertSame('int', $sig->return->type->args[0]->name); + } + + public function testNestedGenericAnglesInSignatureEraseFully(): void + { + // `Map>` — the `>>` is two levels of angle nesting. findAngleEnd's + // depth counter must reach zero only at the OUTER `>`; a broken counter + // ends the span at the inner `>` and leaves `>` residue (unparseable). + $source = '>): bool $c) {}'; + $sig = self::firstSig($source); + + self::assertNotNull($sig); + $paramType = $sig->params[0]->type; + self::assertInstanceOf(SigTypeRef::class, $paramType); + self::assertSame('Map', $paramType->type->name); + self::assertCount(2, $paramType->type->args); + + // Fully erased: no fragment of the nested generic survives (and firstSig + // above already proved the erased output re-parses). + $stripped = self::strip($source); + self::assertStringNotContainsString('Map', $stripped); + self::assertStringNotContainsString('Vec', $stripped); + self::assertStringNotContainsString('>', $stripped); + } + + public function testThreeMemberUnionReturnCarriesFullRawAndErases(): void + { + // `A|B|C` drives scanTypeExprEnd's `|`-continuation loop twice. A span that + // stops after the first `|` yields raw `A|B` and leaves `|C` residue. + $source = 'return); + self::assertSame('A|B|C', $sig->return->raw); + self::assertStringNotContainsString('A|B|C', self::strip($source)); + } + + public function testIntersectionByReferenceParameterSeparatesTypeFromMarker(): void + { + // `A&B &$r` — the FIRST `&` is an intersection continuation (a Name follows), + // the SECOND `&` is the by-ref marker (a `$var` follows). scanTypeExprEnd must + // consume the first and STOP at the second (its non-continuing-`&` break); + // parseSigParam then reads the by-ref marker. + $source = 'params); + self::assertInstanceOf(SigRaw::class, $sig->params[0]->type); + self::assertSame('A&B', $sig->params[0]->type->raw); + self::assertTrue($sig->params[0]->byRef, 'the second `&` is the by-ref marker'); + self::assertSame('void', self::refName($sig->return)); + } + + public function testWhitespaceAndCommentsWithinSignatureAreSkipped(): void + { + // Spaces around every token and a comment between params — exercises the + // forward whitespace/comment skips in the param/return walk. + $source = 'params); + self::assertSame('int', self::refName($sig->params[0]->type)); + self::assertSame('string', self::refName($sig->params[1]->type)); + self::assertSame('bool', self::refName($sig->return)); + } + + public function testCommentBetweenNullableAndClosureStillMarksNullable(): void + { + // The backward walk (skipWsBack) that finds a leading `?` must skip a comment + // sitting between the `?` and `Closure`. + $sig = self::firstSig('nullable); + } + + public function testTightlyPackedSignatureIsParsed(): void + { + // No whitespace anywhere — the token walks must advance to the next + // SIGNIFICANT token, not blindly by one index (which coincides only when a + // whitespace token happens to sit between). Locks the comma / colon skips. + $sig = self::firstSig('params); + self::assertSame('int', self::refName($sig->params[0]->type)); + self::assertSame('string', self::refName($sig->params[1]->type)); + self::assertSame('bool', self::refName($sig->return)); + } + + public function testTightlyPackedCastParamAndReturn(): void + { + // `Closure(int):int` — cast-token param list immediately followed by `:int` + // with no surrounding whitespace. + $sig = self::firstSig('params); + self::assertSame('int', self::refName($sig->params[0]->type)); + self::assertSame('int', self::refName($sig->return)); + } + + public function testByReferenceVariadicParameterCapturesBothMarkers(): void + { + // `int&...$rest` — a by-reference variadic. The `&` (by-ref) and `...` + // (variadic) must both be read, in order, off the same parameter; a skip that + // overshoots the `&` would miss the `...` and drop the variadic flag. + $sig = self::firstSig('params); + self::assertTrue($sig->params[0]->byRef); + self::assertTrue($sig->params[0]->variadic); + self::assertSame('int', self::refName($sig->params[0]->type)); + } + + public function testNestedClosureInnerTypeIsResolvedAgainstNamespace(): void + { + // The resolver must recurse INTO a nested closure: an inner class name is + // namespace-resolved (`Widget` → `App\Widget`). Without the recursion the + // inner leaf would stay the bare, unresolved `Widget`. + $sig = self::firstSig(<<<'PHP' + params[0]->type; + self::assertInstanceOf(SigClosure::class, $inner); + self::assertSame('App\\Widget', self::refName($inner->signature->params[0]->type)); + } + + // =================================================================== + // Multiple signatures on one line map to Name nodes in source order + // =================================================================== + + public function testTwoSignaturesOnOneLineMapToNamesInSourceOrder(): void + { + $sigs = self::allSigs('params[0]->type)); + self::assertSame('int', self::refName($sigs[0]->return)); + self::assertSame('string', self::refName($sigs[1]->params[0]->type)); + self::assertSame('bool', self::refName($sigs[1]->return)); + } + + // =================================================================== + // Helpers + // =================================================================== + + private static function parser(): XphpSourceParser + { + return new XphpSourceParser((new ParserFactory())->createForHostVersion()); + } + + private static function strip(string $source): string + { + return self::parser()->strip($source); + } + + /** The ClosureSignature attached to the first `\Closure` Name, or null. */ + private static function firstSig(string $source): ?ClosureSignature + { + return self::allSigs($source)[0] ?? null; + } + + /** + * Every attached ClosureSignature in source (traversal) order. + * + * @return list + */ + private static function allSigs(string $source): array + { + $ast = self::parser()->parse($source); + $found = []; + $walker = function ($nodes) use (&$walker, &$found): void { + foreach ($nodes as $node) { + if ($node instanceof Name) { + $sig = $node->getAttribute(XphpSourceParser::ATTR_CLOSURE_SIG); + if ($sig instanceof ClosureSignature) { + $found[] = $sig; + } + } + if (is_object($node) && method_exists($node, 'getSubNodeNames')) { + foreach ($node->getSubNodeNames() as $name) { + $value = $node->$name; + if (is_array($value)) { + $walker($value); + } elseif (is_object($value)) { + $walker([$value]); + } + } + } + } + }; + $walker($ast); + return $found; + } + + /** + * The ATTR_GENERIC_ARGS attached to the first Name matching $nameLookup. + * + * @return list + */ + private static function genericArgs(string $source, string $nameLookup): array + { + $ast = self::parser()->parse($source); + $found = []; + $walker = function ($nodes) use (&$walker, &$found, $nameLookup): void { + foreach ($nodes as $node) { + if ($found) { + return; + } + if ($node instanceof Name && $node->toString() === $nameLookup) { + $args = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (is_array($args)) { + $found = $args; + return; + } + } + if (is_object($node) && method_exists($node, 'getSubNodeNames')) { + foreach ($node->getSubNodeNames() as $name) { + $value = $node->$name; + if (is_array($value)) { + $walker($value); + } elseif (is_object($value)) { + $walker([$value]); + } + } + } + } + }; + $walker($ast); + return $found; + } + + private static function refName(?SigType $type): ?string + { + if (!$type instanceof SigTypeRef) { + return null; + } + return $type->type->name; + } + + private static function isScalarRef(SigType $type): bool + { + return $type instanceof SigTypeRef && $type->type->isScalar; + } + + private static function runPhp(string $code): string + { + $file = tempnam(sys_get_temp_dir(), 'xphp_closure_sig_') ?: throw new \RuntimeException('tempnam failed'); + try { + file_put_contents($file, $code); + $output = shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($file) . ' 2>&1'); + return trim((string) $output); + } finally { + @unlink($file); + } + } +} From ed68b37ea1d334f40580dd0c803129b6af62ec0e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 04:02:14 +0000 Subject: [PATCH 03/80] fix(closure-sig): recognize keyword types, length-preserve erasure, byte-key markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three defects found reviewing closure signature parsing: - `array` and `callable` lex as their own tokens (T_ARRAY / T_CALLABLE), not T_STRING, so a signature that used them as a parameter or return type — `Closure(array $a): callable`, `Closure(): int|array` — was not recognized and failed to erase (or, for a union, silently re-parsed into a bogus `\Closure|array` return type). Recognize the reserved-word type keywords `array` / `callable` / `static` everywhere a type name is expected, and represent them as structured leaves rather than raw text. - Erasure emitted an 8-byte `\Closure` over a 7-byte `Closure`, shifting every byte position after the signature and desyncing later byte-keyed markers (an anonymous closure's generic parameters would silently fail to attach). Reclaim the extra byte from the blanked tail so the span is erased to exactly its original length. - The parsed signature was matched to its `\Closure` Name by line, so a plain `Closure` hint sharing a line with a real signature stole the marker. Match by byte position (mapped back through the offset map) instead, so the signature lands on the exact Name it belongs to. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 84 ++++++--- .../ClosureSignatureParseTest.php | 172 ++++++++++++++++++ 2 files changed, 233 insertions(+), 23 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 5cbe71f..6bb728f 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -138,7 +138,7 @@ public function parseWithMap(string $source): array } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers); + $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap); return [$ast, $byteOffsetMap]; } @@ -177,7 +177,7 @@ public function parseTolerantWithMap(string $source): ?ParseWithMapResult } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers); + $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap); return new ParseWithMapResult($ast, $byteOffsetMap); } @@ -202,7 +202,7 @@ public function strip(string $source): string } /** - * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list} + * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list} */ private function scanAndStrip(string $source): array { @@ -214,7 +214,7 @@ private function scanAndStrip(string $source): array $classMarkers = []; $nameMarkers = []; $methodMarkers = []; - /** @var list $closureMarkers */ + /** @var list $closureMarkers */ $closureMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -569,8 +569,6 @@ private function scanAndStrip(string $source): array if ($sig !== null) { [$signature, $endIdx, $spanStart, $spanLen, $replacement] = $sig; $closureMarkers[] = [ - 'line' => $nameLine, - 'name' => 'Closure', 'bytePosition' => $tok->pos, 'signature' => $signature, ]; @@ -638,8 +636,7 @@ private static function tryParseClosureSignature(array $tokens, int $i, int $j, return null; } $ft = $tokens[$firstInner]; - $firstOk = self::isNameToken($ft) - || $ft->id === T_STATIC + $firstOk = self::isSigTypeToken($ft) || $ft->id === T_ELLIPSIS || in_array($ft->text, ['?', '(', ')', '&'], true); if (!$firstOk) { @@ -674,12 +671,17 @@ private static function tryParseClosureSignature(array $tokens, int $i, int $j, $nullable = self::isNullablePrefixed($tokens, $i); $signature = self::buildClosureSignature($tokens, $j, $source, $nullable); - // Erase to `\Closure`, preserving newlines (line-matched markers rely on - // the newline count staying constant) and blanking the rest to spaces. + // Erase to `\Closure`, preserving both the newline count (line-keyed + // markers depend on it) and the total byte length (byte-keyed markers — + // e.g. anonymous-closure generics — depend on positions after the span not + // shifting). `\Closure` is 8 bytes; a bare `Closure` name is only 7, so the + // extra `\` reclaims one blank byte from the tail (which always begins with + // `(`, never a newline). A fully-qualified `\Closure` name is already 8. $spanStart = $tokens[$i]->pos; $nameLen = strlen($tokens[$i]->text); $spanEndByte = $tokens[$spanEnd]->pos + strlen($tokens[$spanEnd]->text); - $tail = substr($source, $spanStart + $nameLen, $spanEndByte - ($spanStart + $nameLen)); + $drop = strlen('\\Closure') - $nameLen; + $tail = substr($source, $spanStart + $nameLen + $drop, $spanEndByte - ($spanStart + $nameLen + $drop)); $blankedTail = preg_replace('/[^\r\n]/', ' ', $tail) ?? ''; $replacement = '\\Closure' . $blankedTail; @@ -713,6 +715,22 @@ private static function isCastToken(PhpToken $tok): bool return array_key_exists($tok->id, self::castTokenScalars()); } + /** + * True for a token that can lead a signature type leaf: an ordinary name + * ({@see isNameToken}) plus the reserved-word type keywords `array`, + * `callable`, and `static`, which PHP lexes as their own tokens + * (T_ARRAY / T_CALLABLE / T_STATIC) rather than T_STRING — so `isNameToken` + * alone would miss them and a signature like `Closure(array $a): callable` + * would fail to erase. + */ + private static function isSigTypeToken(PhpToken $tok): bool + { + return self::isNameToken($tok) + || $tok->id === T_ARRAY + || $tok->id === T_CALLABLE + || $tok->id === T_STATIC; + } + /** * Index of the last token of a closure signature whose parameter list opens at * `$openIdx` (a `(` OR a single cast token like `(int)`): the matching `)` (or @@ -789,7 +807,7 @@ private static function scanTypeExprEnd(array $tokens, int $idx): ?int $i++; continue; } - if (self::isNameToken($t) || $t->id === T_STATIC) { + if (self::isSigTypeToken($t)) { $last = $i; $la = self::skipWs($tokens, $i + 1); if ($la < $n && ($tokens[$la]->text === '(' || self::isCastToken($tokens[$la])) && ltrim($t->text, '\\') === 'Closure') { @@ -817,7 +835,7 @@ private static function scanTypeExprEnd(array $tokens, int $idx): ?int // intersection / union continuation. $nx = self::skipWs($tokens, $i + 1); $continues = $nx < $n - && (self::isNameToken($tokens[$nx]) || $tokens[$nx]->text === '?' || $tokens[$nx]->id === T_STATIC); + && (self::isSigTypeToken($tokens[$nx]) || $tokens[$nx]->text === '?'); if (!$continues) { break; } @@ -1040,6 +1058,12 @@ private static function parseSigType(array $tokens, int $i, string $source): arr } $parsed = self::parseTypeArg($tokens, $i); + if ($parsed === null && $i < $n && self::isSigTypeToken($tokens[$i])) { + // `array` / `callable` / `static` — reserved-word type keywords that + // parseTypeArg (T_STRING / T_NAME only) does not recognize. Build the + // leaf directly; resolveTypeRef treats them as built-ins via SCALAR_TYPES. + $parsed = [new TypeRef(strtolower($tokens[$i]->text)), $i + 1]; + } if ($parsed === null) { $end = self::scanTypeExprEnd($tokens, $start); $end = $end ?? $start; @@ -1076,7 +1100,7 @@ private static function sigAmpersandIsIntersection(array $tokens, int $idx): boo { $nx = self::skipWs($tokens, $idx + 1); return $nx < count($tokens) - && (self::isNameToken($tokens[$nx]) || $tokens[$nx]->text === '?' || $tokens[$nx]->id === T_STATIC); + && (self::isSigTypeToken($tokens[$nx]) || $tokens[$nx]->text === '?'); } /** @@ -1884,15 +1908,16 @@ private static function applyReplacements(string $source, array $replacements): * @param list}> $classMarkers * @param list}> $nameMarkers * @param list}> $methodMarkers + * @param list $closureMarkers */ - private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers): void + private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap): void { $traverser = new NodeTraverser(); $traverser->addVisitor(new /** * @phpstan-import-type BoundDict from XphpSourceParser */ - class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers) extends NodeVisitorAbstract { + class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap) extends NodeVisitorAbstract { private NamespaceContext $ctx; /** @var list> stack of enclosing type-param scopes */ private array $typeParamStack = []; @@ -1906,13 +1931,14 @@ class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers) extends Node * @param array}> $classMarkers * @param array}> $nameMarkers * @param array}> $methodMarkers - * @param array $closureMarkers + * @param array $closureMarkers */ public function __construct( private array $classMarkers, private array $nameMarkers, private array $methodMarkers, private array $closureMarkers, + private ByteOffsetMap $byteOffsetMap, ) { $this->ctx = new NamespaceContext(); } @@ -2227,24 +2253,36 @@ private function markType(?Node $type): void } /** - * If a `\Closure` type Name carries a parsed closure signature (the - * `(…)[: ret]` was erased at scan time, leaving this surviving Name), - * attach the resolved signature. Matched by (line, `Closure`) and - * consumed in insertion order so multiple signatures on one line map to - * the Name nodes in source order. + * If a `\Closure` type Name is the erased head of a closure signature + * (the `(…)[: ret]` was blanked at scan time, leaving this surviving + * `\Closure`), attach the resolved signature. Matched by BYTE POSITION, + * not by line: a plain `Closure` type hint sharing a line with a real + * signature (`Closure $a, Closure(int): int $b`) would otherwise steal the + * marker. The node's stripped-source start position maps back through the + * byte-offset map to the original `Closure` position the marker recorded. */ private function attachClosureSig(Name $node): void { if (ltrim($node->toString(), '\\') !== 'Closure') { return; } + $startPos = $node->getStartFilePos(); + // @infection-ignore-all — defensive: getStartFilePos() only returns < 0 + // when positions are unrecorded; a real Name never sits at byte 0 (the + // open tag), so shifting this boundary is unreachable with valid input. + if ($startPos < 0) { + return; + } + $originalPos = $this->byteOffsetMap->toOriginal($startPos); foreach ($this->closureMarkers as $i => $marker) { - if ($marker['line'] === $node->getStartLine()) { + if ($marker['bytePosition'] === $originalPos) { $node->setAttribute( XphpSourceParser::ATTR_CLOSURE_SIG, $this->resolveClosureSignature($marker['signature']), ); unset($this->closureMarkers[$i]); + // @infection-ignore-all — break vs continue is equivalent after unset: + // bytePosition is unique, so no later marker can match again. break; } } diff --git a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php index 3bec7b9..a5a374f 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php @@ -626,10 +626,182 @@ public function testTwoSignaturesOnOneLineMapToNamesInSourceOrder(): void self::assertSame('bool', self::refName($sigs[1]->return)); } + // =================================================================== + // Reserved-word type keywords: `array` / `callable` / `static` lex as their + // own tokens (T_ARRAY / T_CALLABLE / T_STATIC), not T_STRING, so they must be + // recognized as type names — otherwise the signature fails to erase. + // =================================================================== + + public function testArrayAsFirstParameterErasesAndParses(): void + { + $sig = self::firstSig('params); + self::assertSame('array', self::refName($sig->params[0]->type)); + self::assertSame('void', self::refName($sig->return)); + } + + public function testCallableAsReturnTypeErasesAndParses(): void + { + $sig = self::firstSig('return)); + } + + public function testArrayAsNonFirstParameterIsAStructuredLeaf(): void + { + // Not just erased — represented as a SigTypeRef, not raw text. + $sig = self::firstSig('params); + self::assertSame('array', self::refName($sig->params[1]->type)); + } + + public function testStaticAsReturnTypeIsAStructuredLeaf(): void + { + $sig = self::firstSig('return)); + } + + public function testUnionWithArrayMemberErasesFullyWithoutResidue(): void + { + // Regression: `int|array` once stopped the span at `int`, leaving `|array` + // which re-parsed into a bogus `\Closure|array` return type (unsound). The + // whole union must erase and be carried raw. + $source = 'return); + self::assertSame('int|array', $sig->return->raw); + + $stripped = self::strip($source); + self::assertStringNotContainsString('|array', $stripped); + self::assertStringNotContainsString('|', $stripped); + } + + public function testNullableArrayLeafErasesAndIsCarriedRaw(): void + { + $sig = self::firstSig('return); + self::assertSame('?array', $sig->return->raw); + } + + // =================================================================== + // Marker attaches to the exact Name it belongs to — a plain `Closure` + // hint sharing a line with a real signature must not steal the marker. + // =================================================================== + + public function testPlainClosureHintDoesNotStealASiblingSignature(): void + { + // param $a is a plain `Closure`; param $b carries the signature. The marker + // must land on $b, not the earlier plain $a. + $names = self::closureTypeNamesInOrder('return)); + } + + public function testSignatureParamDoesNotLeakToPlainClosureReturn(): void + { + // param $a carries the signature; the bare `Closure` return type is plain. + $names = self::closureTypeNamesInOrder('` marker still aligns and attaches. + $source = "(T \$x): T { return \$x; };\n"; + + $ast = self::parser()->parse($source); + $params = null; + $walker = function ($nodes) use (&$walker, &$params): void { + foreach ($nodes as $node) { + if ($node instanceof Node\Expr\Closure) { + $params = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + } + if (is_object($node) && method_exists($node, 'getSubNodeNames')) { + foreach ($node->getSubNodeNames() as $name) { + $value = $node->$name; + if (is_array($value)) { + $walker($value); + } elseif (is_object($value)) { + $walker([$value]); + } + } + } + } + }; + $walker($ast); + + self::assertIsArray($params, 'the anonymous closure generic marker must still attach after a closure-sig erasure'); + self::assertCount(1, $params); + self::assertSame('T', $params[0]->name); + } + // =================================================================== // Helpers // =================================================================== + /** + * The signature (or null) attached to each `Closure`/`\Closure` type Name in + * the source, in traversal order — one entry per Name, so a plain hint shows + * up as a null slot. + * + * @return list + */ + private static function closureTypeNamesInOrder(string $source): array + { + $ast = self::parser()->parse($source); + $out = []; + $walker = function ($nodes) use (&$walker, &$out): void { + foreach ($nodes as $node) { + if ($node instanceof Name && ltrim($node->toString(), '\\') === 'Closure') { + $sig = $node->getAttribute(XphpSourceParser::ATTR_CLOSURE_SIG); + $out[] = $sig instanceof ClosureSignature ? $sig : null; + } + if (is_object($node) && method_exists($node, 'getSubNodeNames')) { + foreach ($node->getSubNodeNames() as $name) { + $value = $node->$name; + if (is_array($value)) { + $walker($value); + } elseif (is_object($value)) { + $walker([$value]); + } + } + } + } + }; + $walker($ast); + return $out; + } + + // =================================================================== + // Base helpers + // =================================================================== + private static function parser(): XphpSourceParser { return new XphpSourceParser((new ParserFactory())->createForHostVersion()); From 7ed23ceef451bfd6154511c44e35ebd66f6f5784 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 07:05:47 +0000 Subject: [PATCH 04/80] feat(closure-sig): add closure-signature conformance engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure rule engine that decides whether a candidate closure signature conforms to a target `Closure(...)` type, using the engine's inherited- method variance: parameters contravariant, return covariant, by-reference exact, arity compatible. The check is one-directional — it reports only a *provable* mismatch, and gradually accepts everything unproven (unresolved class, still-abstract type parameter, untyped/`mixed` leaf, raw union/intersection, and the pseudo-type leaves `object`/`never`/`iterable`/`callable`/`self`/`static`/ `parent`, which are decided by an explicit table rather than nominal subtyping so they never false-reject). Only class-vs-class and true-scalar pairs consult the type hierarchy. Structural rules (arity + by-reference) are exposed separately so they can run once at an abstract generic template, with the type rules run per grounded specialization. A parameter gains an `optional` flag so a defaulted candidate parameter lowers the required arity. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ClosureConformanceViolation.php | 30 ++ .../ClosureSignatureConformance.php | 323 +++++++++++ .../Monomorphize/ClosureSignatureParam.php | 6 + .../Monomorphize/XphpSourceParser.php | 1 + .../ClosureSignatureConformanceTest.php | 502 ++++++++++++++++++ 5 files changed, 862 insertions(+) create mode 100644 src/Transpiler/Monomorphize/ClosureConformanceViolation.php create mode 100644 src/Transpiler/Monomorphize/ClosureSignatureConformance.php create mode 100644 test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php diff --git a/src/Transpiler/Monomorphize/ClosureConformanceViolation.php b/src/Transpiler/Monomorphize/ClosureConformanceViolation.php new file mode 100644 index 0000000..1e1f3ab --- /dev/null +++ b/src/Transpiler/Monomorphize/ClosureConformanceViolation.php @@ -0,0 +1,30 @@ +structural($candidate, $target); + } + + /** + * Full conformance — structural plus the contravariant-parameter / + * covariant-return leaf relations. Returns the first violation or null + * (conforms, or the mismatch is not provable ⇒ gradually accepted). + */ + public function check(ClosureSignature $candidate, ClosureSignature $target): ?ClosureConformanceViolation + { + $structural = $this->structural($candidate, $target); + if ($structural !== null) { + return $structural; + } + + // Parameters: contravariant. Each candidate parameter must be the same as + // or WIDER than the target's — i.e. the target's type must be a subtype of + // the candidate's. Only positions the target defines are constrained. + $targetParams = $target->params; + $candidateParams = $candidate->params; + foreach ($targetParams as $i => $targetParam) { + $candidateParam = $candidateParams[$i] + ?? $this->variadicTailOf($candidateParams); + if ($candidateParam === null) { + continue; + } + if ($this->provablyNotSubtype($targetParam->type, $candidateParam->type)) { + return new ClosureConformanceViolation( + ClosureConformanceViolation::KIND_PARAM_TYPE, + sprintf( + 'parameter %d: %s is not wider than %s', + $i + 1, + self::display($candidateParam->type), + self::display($targetParam->type), + ), + ); + } + } + + // Return: covariant. The candidate's return must be the same as or + // NARROWER than the target's — candidate return must be a subtype of the + // target return. An absent return on either side is gradual (top). + if ($candidate->return !== null && $target->return !== null + && $this->provablyNotSubtype($candidate->return, $target->return) + ) { + return new ClosureConformanceViolation( + ClosureConformanceViolation::KIND_RETURN_TYPE, + sprintf( + 'return type: %s is not a subtype of %s', + self::display($candidate->return), + self::display($target->return), + ), + ); + } + + return null; + } + + private function structural(ClosureSignature $candidate, ClosureSignature $target): ?ClosureConformanceViolation + { + $targetRequired = self::requiredArity($target); + $candidateHasVariadic = self::hasVariadic($candidate); + $candidateMax = $candidateHasVariadic ? PHP_INT_MAX : count($candidate->params); + $candidateRequired = self::requiredArity($candidate); + + // Too few: the candidate cannot accept every argument the target requires. + if ($candidateMax < $targetRequired) { + return new ClosureConformanceViolation( + ClosureConformanceViolation::KIND_ARITY_TOO_FEW, + sprintf('expects at least %d parameter(s), candidate accepts at most %d', $targetRequired, $candidateMax), + ); + } + + // Requires too many: the candidate demands arguments the target does not + // guarantee (a legal minimal call on the target would under-supply it). + if ($candidateRequired > $targetRequired) { + return new ClosureConformanceViolation( + ClosureConformanceViolation::KIND_ARITY_REQUIRES_MORE, + sprintf('requires %d parameter(s) but the target guarantees only %d', $candidateRequired, $targetRequired), + ); + } + + // A variadic target may pass unboundedly many arguments; only a variadic + // candidate can absorb them. + if (self::hasVariadic($target) && !$candidateHasVariadic) { + return new ClosureConformanceViolation( + ClosureConformanceViolation::KIND_VARIADIC_REQUIRED, + 'target is variadic; candidate must declare a variadic parameter to absorb the tail', + ); + } + + // By-reference: exact, per position the target defines. + foreach ($target->params as $i => $targetParam) { + $candidateParam = $candidate->params[$i] ?? self::variadicTailOf($candidate->params); + if ($candidateParam === null) { + continue; + } + if ($candidateParam->byRef !== $targetParam->byRef) { + return new ClosureConformanceViolation( + ClosureConformanceViolation::KIND_BYREF, + sprintf( + 'parameter %d: by-reference-ness must match exactly (target %s, candidate %s)', + $i + 1, + $targetParam->byRef ? 'by-ref' : 'by-value', + $candidateParam->byRef ? 'by-ref' : 'by-value', + ), + ); + } + } + + return null; + } + + /** + * True only when it is *provable* that `$sub` is not a subtype of `$super` + * (⇒ a violation). Every unproven or gradual case returns false (accept). + * + * A nested closure leaf ({@see SigClosure}) recurses: `$sub`'s params must be + * contravariant and its return covariant relative to `$super` — i.e. `$sub` + * conforms as a candidate to `$super` as a target. + */ + private function provablyNotSubtype(SigType $sub, SigType $super): bool + { + // A raw (union/intersection) leaf on either side is not yet structured; + // treat as gradual until WI-03 gives it variance. (This is the intentional + // statement of that rule; a SigRaw would also fall through to the defensive + // non-SigTypeRef guard below, so mutating this line is caught there — + // @infection-ignore-all.) + if ($sub instanceof SigRaw || $super instanceof SigRaw) { + return false; + } + + if ($sub instanceof SigClosure && $super instanceof SigClosure) { + return $this->check($sub->signature, $super->signature) !== null; + } + + // A closure vs. a plain leaf: a closure value is provably not a true + // scalar; anything else (object / callable / Closure / a class / gradual) + // is accepted. + if ($sub instanceof SigClosure || $super instanceof SigClosure) { + $leaf = $sub instanceof SigTypeRef ? $sub : ($super instanceof SigTypeRef ? $super : null); + return $leaf !== null && $this->classify($leaf->type) === 'scalar'; + } + + // @infection-ignore-all — defensive: SigRaw is handled above and both + // SigClosure cases too, so by here both operands are always SigTypeRef; + // this guard only exists for a future SigType arm and never fires. + if (!$sub instanceof SigTypeRef || !$super instanceof SigTypeRef) { + return false; + } + + $subKind = $this->classify($sub->type); + $superKind = $this->classify($super->type); + + if ($subKind === 'gradual' || $superKind === 'gradual') { + return false; + } + // @infection-ignore-all — the `&& scalar` second operand is equivalent: a + // scalar-vs-class pair falls through to the mixed-kind `return true` below + // with the identical verdict, so weakening this conjunction changes nothing. + if ($subKind === 'scalar' && $superKind === 'scalar') { + return self::normalizeScalar($sub->type->name) !== self::normalizeScalar($super->type->name); + } + if ($subKind === 'class' && $superKind === 'class') { + // Only provable when BOTH classes are known to the hierarchy: an + // undeclared class on either side leaves the relation unprovable, and + // `isSubtype(knownChild, undeclaredParent)` returns a hard `false` + // (the BFS simply never reaches the unknown) — which must NOT be read + // as a proven non-subtype, or valid code against an out-of-source class + // would be false-rejected. + if (!$this->hierarchy->isDeclared($sub->type->name) + || !$this->hierarchy->isDeclared($super->type->name) + ) { + return false; + } + return $this->hierarchy->isSubtype($sub->type->name, $super->type->name) === false; + } + + // Mixed scalar-vs-class: a true scalar is provably not a class, and a class + // is provably not a scalar. + return true; + } + + /** + * `scalar` (a true scalar), `class` (a resolved user/builtin class name), or + * `gradual` (a top/bottom/pseudo/late-static leaf, or a still-abstract type + * parameter) — for which no mismatch is ever provable. + */ + private function classify(TypeRef $ref): string + { + if ($ref->isTypeParam) { + return 'gradual'; + } + $name = self::normalizeScalar($ref->name); + if (in_array($name, self::TRUE_SCALARS, true)) { + return 'scalar'; + } + if (in_array($name, self::GRADUAL_LEAVES, true)) { + return 'gradual'; + } + return 'class'; + } + + private static function normalizeScalar(string $name): string + { + // @infection-ignore-all — ltrim/strtolower are defensive: closure-sig leaf + // TypeRefs arrive already unqualified and lowercased from the resolver, so + // dropping either normalization is unobservable for real inputs. + $name = strtolower(ltrim($name, '\\')); + return $name === 'true' || $name === 'false' ? 'bool' : $name; + } + + /** + * The number of parameters a signature *requires* — the leading run of + * parameters that are neither optional (a candidate literal's defaulted + * parameter) nor variadic. A `Closure(...)` type has no defaults, so for a + * target this is simply its non-variadic parameter count. + */ + private static function requiredArity(ClosureSignature $sig): int + { + $count = 0; + foreach ($sig->params as $param) { + if ($param->variadic || $param->optional) { + break; + } + $count++; + } + return $count; + } + + private static function hasVariadic(ClosureSignature $sig): bool + { + foreach ($sig->params as $param) { + if ($param->variadic) { + return true; + } + } + return false; + } + + /** + * The variadic parameter of a list, if the position being matched runs past + * the fixed parameters into a variadic tail (which absorbs the rest). + * + * @param list $params + */ + private static function variadicTailOf(array $params): ?ClosureSignatureParam + { + foreach ($params as $param) { + if ($param->variadic) { + return $param; + } + } + return null; + } + + private static function display(SigType $type): string + { + if ($type instanceof SigTypeRef) { + return $type->type->toDisplayString(); + } + if ($type instanceof SigClosure) { + return 'Closure(...)'; + } + return $type instanceof SigRaw ? $type->raw : '?'; + } +} diff --git a/src/Transpiler/Monomorphize/ClosureSignatureParam.php b/src/Transpiler/Monomorphize/ClosureSignatureParam.php index e4c8d07..6aa55d8 100644 --- a/src/Transpiler/Monomorphize/ClosureSignatureParam.php +++ b/src/Transpiler/Monomorphize/ClosureSignatureParam.php @@ -11,6 +11,11 @@ * conformance and may be omitted; only the structural `&` (by-reference) and * `...` (variadic) markers matter, so those are captured as flags while the name * — if any — is discarded at parse time. + * + * `$optional` is only ever set on a *candidate* signature extracted from a + * closure literal whose parameter has a default (`fn($x = 1) => …`); it lowers + * the required arity. A `Closure(...)` TYPE has no defaults, so a target + * signature always leaves it false. */ final readonly class ClosureSignatureParam { @@ -18,6 +23,7 @@ public function __construct( public SigType $type, public bool $byRef = false, public bool $variadic = false, + public bool $optional = false, ) { } } diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 6bb728f..b1b82a9 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -2295,6 +2295,7 @@ private function resolveClosureSignature(ClosureSignature $sig): ClosureSignatur $this->resolveSigType($p->type), $p->byRef, $p->variadic, + $p->optional, ), $sig->params, ); diff --git a/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php b/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php new file mode 100644 index 0000000..ca0ad09 --- /dev/null +++ b/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php @@ -0,0 +1,502 @@ + ... : untyped param ⇒ mixed ⇒ always conforms. + self::assertConforms( + self::sig([self::p(self::ref('mixed'))], self::ref('int')), + self::sig([self::p(self::ref('int'))], self::ref('int')), + ); + } + + // ---- Return: covariant ---------------------------------------------- + + public function testNarrowerReturnConforms(): void + { + self::assertConforms( + self::sig([], self::ref('App\\Apple')), // candidate returns Apple + self::sig([], self::ref('App\\Fruit')), // target returns Fruit + ); + } + + public function testWiderReturnIsRejected(): void + { + self::assertViolation( + ClosureConformanceViolation::KIND_RETURN_TYPE, + self::sig([], self::ref('App\\Fruit')), // candidate returns Fruit (wider) + self::sig([], self::ref('App\\Apple')), // target returns Apple + ); + } + + public function testAbsentCandidateReturnConformsGradually(): void + { + self::assertConforms( + self::sig([], null), // candidate: no return type + self::sig([], self::ref('int')), + ); + } + + public function testAbsentTargetReturnAcceptsAnyCandidateReturn(): void + { + self::assertConforms( + self::sig([], self::ref('int')), + self::sig([], null), + ); + } + + // ---- By-reference: exact -------------------------------------------- + + public function testByRefCandidateInByValueSlotIsRejected(): void + { + self::assertViolation( + ClosureConformanceViolation::KIND_BYREF, + self::sig([self::p(self::ref('int'), byRef: true)], self::ref('int')), + self::sig([self::p(self::ref('int'))], self::ref('int')), + ); + } + + public function testByValueCandidateInByRefSlotIsRejected(): void + { + self::assertViolation( + ClosureConformanceViolation::KIND_BYREF, + self::sig([self::p(self::ref('int'))], self::ref('int')), + self::sig([self::p(self::ref('int'), byRef: true)], self::ref('int')), + ); + } + + // ---- Pseudo-type leaves: every one must ACCEPT (would false-reject + // if routed through isSubtype). ------------------------------------ + + #[DataProvider('pseudoTypeAcceptPairs')] + public function testPseudoTypeLeavesAreAccepted(string $candidateReturn, string $targetReturn): void + { + self::assertConforms( + self::sig([], self::ref($candidateReturn)), + self::sig([], self::ref($targetReturn)), + ); + } + + /** @return iterable */ + public static function pseudoTypeAcceptPairs(): iterable + { + yield 'class candidate vs object target' => ['App\\Fruit', 'object']; + yield 'object candidate vs class target' => ['object', 'App\\Fruit']; + yield 'never candidate return' => ['never', 'App\\Fruit']; + yield 'array candidate vs iterable target' => ['array', 'iterable']; + yield 'mixed target' => ['int', 'mixed']; + yield 'mixed candidate' => ['mixed', 'int']; + yield 'callable vs Closure' => ['callable', 'App\\Closurish']; + yield 'static leaf' => ['static', 'App\\Fruit']; + yield 'self leaf' => ['self', 'App\\Fruit']; + } + + public function testTrueAndFalseNormalizeToBool(): void + { + self::assertConforms(self::sig([], self::ref('true')), self::sig([], self::ref('bool'))); + self::assertConforms(self::sig([], self::ref('false')), self::sig([], self::ref('bool'))); + } + + // ---- Gradual: type-param, SigRaw, unresolved class ------------------ + + public function testUngroundedTypeParameterLeafIsAccepted(): void + { + self::assertConforms( + self::sig([self::p(self::refParam('T'))], self::refParam('U')), + self::sig([self::p(self::ref('int'))], self::ref('string')), + ); + } + + public function testRawUnionLeafIsAcceptedGradually(): void + { + self::assertConforms( + self::sig([self::p(new SigRaw('A|B'))], self::ref('int')), + self::sig([self::p(self::ref('App\\Apple'))], self::ref('int')), + ); + } + + public function testUnresolvedClassIsAcceptedGradually(): void + { + // Neither class is in the hierarchy ⇒ isSubtype null ⇒ accept. + self::assertConforms( + self::sig([], self::ref('App\\Unknown\\Foo')), + self::sig([], self::ref('App\\Unknown\\Bar')), + ); + } + + // ---- Nested closure leaf: variance flips ---------------------------- + + public function testNestedClosureParameterFlipsVariance(): void + { + // target param: Closure(Apple): int ; candidate param: Closure(Fruit): int + // A parameter position is contravariant, and a nested closure's own params + // are contravariant — the two flips compose so the candidate's inner param + // (Fruit) being WIDER than the target's inner param (Apple) is a REJECT + // (the candidate promises to accept a narrower inner arg than the target). + $targetInner = self::sig([self::p(self::ref('App\\Apple'))], self::ref('int')); + $candidateInner = self::sig([self::p(self::ref('App\\Fruit'))], self::ref('int')); + + $violation = self::engine()->check( + self::sig([self::p(self::cl($candidateInner))], self::ref('int')), + self::sig([self::p(self::cl($targetInner))], self::ref('int')), + ); + self::assertNotNull($violation); + self::assertSame(ClosureConformanceViolation::KIND_PARAM_TYPE, $violation->kind); + self::assertStringContainsString('Closure(...)', $violation->detail, 'a nested-closure leaf renders as Closure(...) in the message'); + } + + public function testNestedClosureReturnConforms(): void + { + // return position: candidate Closure(Fruit): Apple vs target Closure(Apple): Fruit + // inner param contravariant (Fruit wider than Apple: OK), inner return + // covariant (Apple narrower than Fruit: OK) — conforms. + $candidateInner = self::sig([self::p(self::ref('App\\Fruit'))], self::ref('App\\Apple')); + $targetInner = self::sig([self::p(self::ref('App\\Apple'))], self::ref('App\\Fruit')); + + self::assertConforms( + self::sig([], self::cl($candidateInner)), + self::sig([], self::cl($targetInner)), + ); + } + + public function testClosureCandidateVsScalarTargetIsRejected(): void + { + self::assertViolation( + ClosureConformanceViolation::KIND_RETURN_TYPE, + self::sig([], self::cl(self::sig([], self::ref('int')))), + self::sig([], self::ref('int')), + ); + } + + // ---- checkStructural stops at arity/by-ref, ignores type rules ------- + + public function testCheckStructuralIgnoresTypeMismatch(): void + { + // A narrower param (type violation) but matching arity/by-ref: structural + // pass is clean; full check rejects. + $candidate = self::sig([self::p(self::ref('App\\Apple'))], self::ref('int')); + $target = self::sig([self::p(self::ref('App\\Fruit'))], self::ref('int')); + + self::assertNull(self::engine()->checkStructural($candidate, $target)); + self::assertNotNull(self::engine()->check($candidate, $target)); + } + + public function testCheckStructuralStillCatchesArity(): void + { + self::assertSame( + ClosureConformanceViolation::KIND_ARITY_TOO_FEW, + self::engine()->checkStructural( + self::sig([], self::ref('int')), + self::sig([self::p(self::ref('int'))], self::ref('int')), + )?->kind, + ); + } + + public function testVariadicCandidateTailTypeIsCheckedOnOverflowPositions(): void + { + // candidate Closure(int, Fruit ...$rest); target Closure(int, Apple, string). + // Position 0 uses the FIXED candidate param (int); the overflow position 2 + // falls back to the variadic tail (Fruit) and provably mismatches `string`. + self::assertViolation( + ClosureConformanceViolation::KIND_PARAM_TYPE, + self::sig([self::p(self::ref('int')), self::p(self::ref('App\\Fruit'), variadic: true)], self::ref('int')), + self::sig([self::p(self::ref('int')), self::p(self::ref('App\\Apple')), self::p(self::ref('string'))], self::ref('int')), + ); + } + + public function testVariadicCandidateFixedParameterIsUsedBeforeTail(): void + { + // candidate Closure(Fruit, int ...$rest); target Closure(Apple, int). At + // position 0 the FIXED candidate param (Fruit, wider than Apple) must be + // used — NOT the variadic tail (int) — so this conforms. Reading the tail + // at a fixed position would wrongly reject (Apple vs int). + self::assertConforms( + self::sig([self::p(self::ref('App\\Fruit')), self::p(self::ref('int'), variadic: true)], self::ref('int')), + self::sig([self::p(self::ref('App\\Apple')), self::p(self::ref('int'))], self::ref('int')), + ); + } + + public function testVariadicCandidateTailByRefIsCheckedOnOverflowPositions(): void + { + // candidate Closure(int, int ...$rest by-value); target Closure(int, int, int&). + // The overflow position 2 falls back to the by-value tail and mismatches the + // target's by-ref param. + self::assertViolation( + ClosureConformanceViolation::KIND_BYREF, + self::sig([self::p(self::ref('int')), self::p(self::ref('int'), variadic: true)], self::ref('int')), + self::sig([self::p(self::ref('int')), self::p(self::ref('int')), self::p(self::ref('int'), byRef: true)], self::ref('int')), + ); + } + + public function testVariadicCandidateFixedByRefIsUsedBeforeTail(): void + { + // candidate Closure(int&, int ...$rest by-value); target Closure(int&, int). + // Position 0 must use the FIXED by-ref param (matches the target's by-ref); + // reading the by-value tail there would wrongly reject. + self::assertConforms( + self::sig([self::p(self::ref('int'), byRef: true), self::p(self::ref('int'), variadic: true)], self::ref('int')), + self::sig([self::p(self::ref('int'), byRef: true), self::p(self::ref('int'))], self::ref('int')), + ); + } + + public function testRequiredParameterAfterOptionalStillCountsTowardArity(): void + { + // A required parameter following an optional one lowers nothing: the leading + // optional means required arity is 0, so this conforms to a 0-param target; + // but the presence of a later required param must not be silently dropped — + // against a target that guarantees 0, requiring even one is too many only if + // it is *leading*. Here the optional leads, so arity is 0 → conforms. + self::assertConforms( + self::sig([self::p(self::ref('int'), optional: true), self::p(self::ref('int'))], self::ref('int')), + self::sig([], self::ref('int')), + ); + } + + public function testRawUnionOnTargetSideIsAcceptedGradually(): void + { + self::assertConforms( + self::sig([], self::ref('App\\Apple')), + self::sig([], new SigRaw('A|B')), + ); + } + + public function testClosureCandidateVsClassTargetIsAccepted(): void + { + // A closure value vs a non-scalar leaf (a class / object) is gradually + // accepted — only a true-scalar target is a provable mismatch. + self::assertConforms( + self::sig([], self::cl(self::sig([], self::ref('int')))), + self::sig([], self::ref('App\\Closurish')), + ); + } + + public function testKnownClassAgainstUndeclaredTargetClassIsAccepted(): void + { + // Regression: isSubtype(knownChild, undeclaredParent) returns a hard false + // (BFS never reaches the unknown); it must NOT be read as a proven mismatch. + self::assertConforms( + self::sig([], self::ref('App\\Apple')), // known + self::sig([], self::ref('App\\External\\Thing')), // undeclared + ); + } + + public function testUndeclaredCandidateAgainstKnownTargetClassIsAccepted(): void + { + self::assertConforms( + self::sig([], self::ref('App\\External\\Thing')), // undeclared + self::sig([], self::ref('App\\Fruit')), // known + ); + } + + public function testViolationDetailNamesThePositionAndBothTypes(): void + { + $violation = self::engine()->check( + self::sig([self::p(self::ref('string'))], self::ref('int')), + self::sig([self::p(self::ref('int'))], self::ref('int')), + ); + self::assertNotNull($violation); + self::assertStringContainsString('parameter 1', $violation->detail); + self::assertStringContainsString('string', $violation->detail); + self::assertStringContainsString('int', $violation->detail); + } + + public function testReturnViolationDetailNamesBothTypes(): void + { + $violation = self::engine()->check( + self::sig([], self::ref('App\\Fruit')), + self::sig([], self::ref('App\\Apple')), + ); + self::assertNotNull($violation); + self::assertStringContainsString('return type', $violation->detail); + self::assertStringContainsString('Fruit', $violation->detail); + self::assertStringContainsString('Apple', $violation->detail); + } + + public function testByRefViolationDetailNamesTheDirections(): void + { + $violation = self::engine()->check( + self::sig([self::p(self::ref('int'), byRef: true)], self::ref('int')), + self::sig([self::p(self::ref('int'))], self::ref('int')), + ); + self::assertNotNull($violation); + self::assertStringContainsString('parameter 1', $violation->detail); + // Exact phrases (not bare 'by-ref', which is a substring of the fixed + // "by-reference-ness" text and would match vacuously). + self::assertStringContainsString('target by-value', $violation->detail); + self::assertStringContainsString('candidate by-ref', $violation->detail); + } + + // ---- Helpers --------------------------------------------------------- + + private static function engine(): ClosureSignatureConformance + { + // App\Apple <: App\Fruit, App\Orange <: App\Fruit; App\Closurish is a bare class. + $hierarchy = new TypeHierarchy([ + 'App\\Apple' => ['App\\Fruit'], + 'App\\Orange' => ['App\\Fruit'], + 'App\\Fruit' => [], + 'App\\Closurish' => [], + ]); + return new ClosureSignatureConformance($hierarchy); + } + + private static function assertConforms(ClosureSignature $candidate, ClosureSignature $target): void + { + $violation = self::engine()->check($candidate, $target); + self::assertNull( + $violation, + 'expected conformance but got: ' . ($violation?->detail ?? ''), + ); + } + + private static function assertViolation(string $kind, ClosureSignature $candidate, ClosureSignature $target): void + { + $violation = self::engine()->check($candidate, $target); + self::assertNotNull($violation, 'expected a violation but the pair conformed'); + self::assertSame($kind, $violation->kind, 'wrong violation kind (detail: ' . $violation->detail . ')'); + } + + /** + * @param list $params + */ + private static function sig(array $params, ?SigType $return, bool $nullable = false): ClosureSignature + { + return new ClosureSignature($params, $return, $nullable); + } + + private static function p(SigType $type, bool $byRef = false, bool $variadic = false, bool $optional = false): ClosureSignatureParam + { + // Exercise the constructor's `$optional = false` default in the common case + // (only pass it when true) so that default stays behaviorally pinned. + return $optional + ? new ClosureSignatureParam($type, $byRef, $variadic, true) + : new ClosureSignatureParam($type, $byRef, $variadic); + } + + private static function ref(string $name): SigTypeRef + { + $scalars = ['int', 'string', 'float', 'bool', 'true', 'false', 'mixed', 'object', + 'void', 'never', 'null', 'array', 'iterable', 'callable', 'self', 'static', 'parent']; + return new SigTypeRef(new TypeRef($name, [], isScalar: in_array($name, $scalars, true))); + } + + private static function refParam(string $name): SigTypeRef + { + return new SigTypeRef(new TypeRef($name, [], isTypeParam: true)); + } + + private static function cl(ClosureSignature $sig): SigClosure + { + return new SigClosure($sig); + } +} From d0d3a1cdf09cfca88f9e3678c58581781fa285cb Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 07:22:04 +0000 Subject: [PATCH 05/80] feat(closure-sig): extract a closure literal's declared signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn a `fn`/`function` literal into a candidate ClosureSignature the conformance engine can check against a `Closure(...)` target. Candidate types resolve against the enclosing file's namespace context, into the same FQN/scalar space the target's types were resolved into. Gradual by construction: an untyped parameter becomes `mixed` (the top type — always conforms), an absent return stays absent, and a nullable/union/intersection leaf is carried raw — so extraction never manufactures a false mismatch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/ClosureLiteralSignature.php | 117 ++++++++++++++ .../ClosureLiteralSignatureTest.php | 146 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 src/Transpiler/Monomorphize/ClosureLiteralSignature.php create mode 100644 test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php diff --git a/src/Transpiler/Monomorphize/ClosureLiteralSignature.php b/src/Transpiler/Monomorphize/ClosureLiteralSignature.php new file mode 100644 index 0000000..849d858 --- /dev/null +++ b/src/Transpiler/Monomorphize/ClosureLiteralSignature.php @@ -0,0 +1,117 @@ + new ClosureSignatureParam( + self::resolveParamType($p->type, $ctx), + $p->byRef, + $p->variadic, + $p->default !== null, + ), + $literal->params, + ); + + // Candidate closures are never nullable (a `?Closure` is a target concept); + // the flag defaults false and is not read for a candidate. + return new ClosureSignature($params, self::resolveType($literal->returnType, $ctx)); + } + + /** + * A parameter always has a type slot: an untyped parameter is `mixed` (the + * top type — always conforms under contravariance). + */ + private static function resolveParamType(?Node $type, NamespaceContext $ctx): SigType + { + // No isScalar flag: the engine classifies `mixed` by name (as gradual), + // so the flag would be dead here. + return self::resolveType($type, $ctx) ?? new SigTypeRef(new TypeRef('mixed')); + } + + /** + * Resolve a nikic type node to a {@see SigType}, or null when the slot is + * absent (an untyped return ⇒ gradual). A compound type (`?X`, union, + * intersection) is carried raw. + */ + private static function resolveType(?Node $type, NamespaceContext $ctx): ?SigType + { + // @infection-ignore-all — removing this early return is equivalent: a null + // node matches none of the branches below and falls through to the same + // bottom `return null`. + if ($type === null) { + return null; + } + if ($type instanceof NullableType || $type instanceof UnionType || $type instanceof IntersectionType) { + return new SigRaw(self::rawText($type)); + } + // @infection-ignore-all — a param/return type node is only ever null, a + // compound (handled above), or a simple Identifier/Name, so the negated + // predicate is unreachable-different: nothing else reaches this line. + if ($type instanceof Identifier || $type instanceof Name) { + $name = $type->toString(); + // @infection-ignore-all UnwrapStrToLower is killed by a capital-cased + // scalar test; the case-fold is load-bearing (`Int` ⇒ `int`). + $lower = strtolower($name); + if (in_array($lower, XphpSourceParser::SCALAR_TYPES, true)) { + return new SigTypeRef(new TypeRef($lower, [], isScalar: true)); + } + return new SigTypeRef(new TypeRef($ctx->resolveAgainstContext($name))); + } + // An unrecognized type-node shape (should not occur for a well-formed + // literal) is treated as absent ⇒ gradual, never a false mismatch. + return null; + } + + /** + * A readable rendering of a compound type for the raw leaf. + * + * @infection-ignore-all — display-only: a SigRaw leaf is always accepted + * gradually, so this text never surfaces in a diagnostic; and each + * union/intersection member is a plain Name/Identifier that renders as itself, + * so the array_map is equivalent to a direct implode. + */ + private static function rawText(Node $type): string + { + if ($type instanceof NullableType) { + return '?' . self::rawText($type->type); + } + if ($type instanceof UnionType) { + return implode('|', array_map(self::rawText(...), $type->types)); + } + if ($type instanceof IntersectionType) { + return implode('&', array_map(self::rawText(...), $type->types)); + } + if ($type instanceof Identifier || $type instanceof Name) { + return $type->toString(); + } + return '?'; + } +} diff --git a/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php b/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php new file mode 100644 index 0000000..c4f84f0 --- /dev/null +++ b/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php @@ -0,0 +1,146 @@ + 'App\\Models\\Fruit']), + ); + + self::assertCount(2, $sig->params); + self::assertSame('int', self::typeName($sig->params[0]->type)); + self::assertTrue(self::refIsScalar($sig->params[0]->type)); + self::assertSame('App\\Models\\Fruit', self::typeName($sig->params[1]->type), 'a use-imported class resolves via the alias'); + self::assertSame('App\\Apple', self::typeName($sig->return), 'a bare class resolves against the current namespace'); + } + + public function testUntypedParameterBecomesMixed(): void + { + $sig = self::extract(' $x;', self::ctx()); + + self::assertCount(1, $sig->params); + self::assertSame('mixed', self::typeName($sig->params[0]->type)); + } + + public function testAbsentReturnStaysAbsent(): void + { + $sig = self::extract(' $x;', self::ctx()); + + self::assertNull($sig->return, 'no return type ⇒ absent (gradual), distinct from mixed'); + } + + public function testByRefAndVariadicAndOptionalAreCaptured(): void + { + $sig = self::extract('params); + self::assertTrue($sig->params[0]->byRef); + self::assertFalse($sig->params[0]->optional); + self::assertTrue($sig->params[1]->optional); + self::assertTrue($sig->params[2]->variadic); + } + + public function testNullableAndUnionTypesAreCarriedRaw(): void + { + $sig = self::extract('params[0]->type); + self::assertSame('?int', $sig->params[0]->type->raw); + self::assertInstanceOf(SigRaw::class, $sig->params[1]->type); + self::assertSame('int|string', $sig->params[1]->type->raw); + self::assertInstanceOf(SigRaw::class, $sig->return); + self::assertSame('int|null', $sig->return->raw); + } + + public function testIntersectionTypeIsCarriedRaw(): void + { + $sig = self::extract('return); + self::assertSame('Countable&Traversable', $sig->return->raw); + } + + public function testArrowFunctionReturnIsExtracted(): void + { + $sig = self::extract(' (string) $x;', self::ctx()); + + self::assertCount(1, $sig->params); + self::assertSame('string', self::typeName($sig->return)); + } + + public function testBuiltinArrayAndCallableResolveAsBuiltins(): void + { + $sig = self::extract('params[0]->type)); + self::assertSame('callable', self::typeName($sig->params[1]->type)); + self::assertSame('void', self::typeName($sig->return)); + } + + public function testCapitalCasedScalarKeywordsAreRecognized(): void + { + // PHP type keywords are case-insensitive; `Int`/`Bool` must fold to the + // canonical lowercase scalar, not be mistaken for class names. + $sig = self::extract('params[0]->type)); + self::assertTrue(self::refIsScalar($sig->params[0]->type)); + self::assertSame('bool', self::typeName($sig->return)); + } + + // ---- Helpers --------------------------------------------------------- + + private static function extract(string $source, NamespaceContext $ctx): ClosureSignature + { + $parser = (new ParserFactory())->createForHostVersion(); + $ast = $parser->parse($source) ?? []; + $literal = (new NodeFinder())->findFirst($ast, static fn (Node $n): bool => $n instanceof Closure || $n instanceof ArrowFunction); + self::assertInstanceOf(Node::class, $literal); + \assert($literal instanceof Closure || $literal instanceof ArrowFunction); + + return ClosureLiteralSignature::extract($literal, $ctx); + } + + /** + * @param array $uses alias => FQN + */ + private static function ctx(string $namespace = '', array $uses = []): NamespaceContext + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace($namespace === '' ? null : $namespace); + foreach ($uses as $alias => $fqn) { + $ctx->indexUse(new \PhpParser\Node\Stmt\Use_([ + new \PhpParser\Node\UseItem(new \PhpParser\Node\Name($fqn), new \PhpParser\Node\Identifier($alias)), + ])); + } + return $ctx; + } + + private static function typeName(?SigType $type): ?string + { + return $type instanceof SigTypeRef ? $type->type->name : null; + } + + private static function refIsScalar(SigType $type): bool + { + return $type instanceof SigTypeRef && $type->type->isScalar; + } +} From ea4840da0c5742c9bbf59171d77e9c2bb5f2ba73 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 07:43:34 +0000 Subject: [PATCH 06/80] feat(monomorphize): gate closure-literal factories against their Closure(...) return type Wire the closure-signature conformance engine to the one statically decidable literal site: a function/method/closure/arrow that declares a `Closure(...)` return type and hands back a closure literal. The returned literal's signature is extracted and checked against the target (parameters contravariant, return covariant, by-reference exact, arity compatible); a provable mismatch fails `compile` fast and is collected by `check`. A parameter/property default is not a site: a default must be a constant expression and a closure literal is not one, so such a slot can never hold a literal candidate. Every other value flow (assignment, call argument) leaves the candidate non-literal at the target and stays gradual. The `Closure(...)` type is still erased to a bare `\Closure`; this adds only a compile-time gate, verified end to end by a fixture whose erased output runs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ClosureConformanceValidator.php | 203 ++++++++++++ src/Transpiler/Monomorphize/Compiler.php | 18 ++ .../Monomorphize/CheckPassIntegrationTest.php | 14 + .../ClosureConformanceIntegrationTest.php | 44 +++ .../ClosureConformanceValidatorTest.php | 300 ++++++++++++++++++ .../check/closure_conformance/source/Use.xphp | 13 + .../source/Use.xphp | 13 + .../source/Use.xphp | 48 +++ .../verify/runtime.php | 28 ++ 9 files changed, 681 insertions(+) create mode 100644 src/Transpiler/Monomorphize/ClosureConformanceValidator.php create mode 100644 test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php create mode 100644 test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php create mode 100644 test/fixture/check/closure_conformance/source/Use.xphp create mode 100644 test/fixture/compile/closure_conformance_reject/source/Use.xphp create mode 100644 test/fixture/compile/closure_conformance_runtime/source/Use.xphp create mode 100644 test/fixture/compile/closure_conformance_runtime/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/ClosureConformanceValidator.php b/src/Transpiler/Monomorphize/ClosureConformanceValidator.php new file mode 100644 index 0000000..f83de35 --- /dev/null +++ b/src/Transpiler/Monomorphize/ClosureConformanceValidator.php @@ -0,0 +1,203 @@ +;` inside a function / method / closure whose return type + * is `Closure(...)` — paired with the innermost enclosing function-like. + * - an arrow function `fn(...): Closure(...) => ` whose body IS the + * literal (arrows have no `Return_` node). + * + * A parameter/property *default* is deliberately NOT a site: PHP requires a + * default to be a constant expression, and a closure literal is not one, so a + * `Closure(...)`-typed default can never hold a literal candidate. Other value + * flows (a variable assigned then passed, a call argument, a property assignment) + * leave the candidate non-literal at the target, so the model stays gradual + * (accept). A target that references an enclosing type parameter is checked with + * those leaves still abstract here (⇒ gradual); the grounded per-specialization + * re-check happens later in the pipeline. + */ +final class ClosureConformanceValidator +{ + /** Diagnostic code for a closure literal that provably violates its `Closure(...)` target. */ + public const CODE = 'xphp.closure_conformance'; + + private readonly ClosureSignatureConformance $engine; + + public function __construct(TypeHierarchy $hierarchy) + { + $this->engine = new ClosureSignatureConformance($hierarchy); + } + + /** + * Validate one source file's AST. With a {@see DiagnosticCollector} every + * violation is collected (the `xphp check` path); without one the first + * violation throws (the fail-fast `compile` path). + * + * @param list $ast + */ + public function validateFile(array $ast, string $file, ?DiagnosticCollector $diagnostics): void + { + // A fresh context is already global-scope (no namespace, no uses); a + // `namespace` node encountered during the walk re-scopes it. + $ctx = new NamespaceContext(); + $traverser = new NodeTraverser(); + $traverser->addVisitor($this->buildVisitor($ctx, $file, $diagnostics)); + $traverser->traverse($ast); + } + + /** + * The resolved {@see ClosureSignature} a type node carries as its target, or + * null when the node is not a `Closure(...)` type. A `?Closure(...)` tags the + * inner {@see Name}, so a nullable wrapper is unwrapped first. + */ + public static function closureSigOf(?Node $type): ?ClosureSignature + { + if ($type instanceof NullableType) { + $type = $type->type; + } + if ($type instanceof Name) { + $sig = $type->getAttribute(XphpSourceParser::ATTR_CLOSURE_SIG); + return $sig instanceof ClosureSignature ? $sig : null; + } + return null; + } + + /** + * Whether an expression is a closure literal — the only candidate shape this + * validator can extract a signature from. A first-class callable + * (`foo(...)`) is a `*Call` node, not a {@see Closure}, so it is not a literal. + * + * @phpstan-assert-if-true Closure|ArrowFunction $node + */ + private static function isLiteral(?Node $node): bool + { + return $node instanceof Closure || $node instanceof ArrowFunction; + } + + /** + * Check the returned expression against its `Closure(...)` return target and + * report the first violation (throw or collect). A non-literal expression is + * not a candidate (a variable, a call, a property fetch) — it stays gradual + * and is silently skipped here. + */ + public function checkLiteral( + ClosureSignature $target, + Node\Expr $literal, + NamespaceContext $ctx, + string $file, + ?DiagnosticCollector $diagnostics, + ): void { + if (!self::isLiteral($literal)) { + return; + } + $candidate = ClosureLiteralSignature::extract($literal, $ctx); + $violation = $this->engine->check($candidate, $target); + if ($violation === null) { + return; + } + + $message = self::message($violation); + if ($diagnostics === null) { + throw new RuntimeException($message); + } + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE, + $message, + new SourceLocation($file, $literal->getStartLine()), + )); + } + + private static function message(ClosureConformanceViolation $violation): string + { + return 'Closure literal does not conform to the declared `Closure(...)` type: ' . $violation->detail; + } + + private function buildVisitor(NamespaceContext $ctx, string $file, ?DiagnosticCollector $diagnostics): NodeVisitorAbstract + { + return new class($this, $ctx, $file, $diagnostics) extends NodeVisitorAbstract { + /** + * The `Closure(...)` return target of each enclosing function-like that + * can hold `return` statements, innermost last. An arrow function has no + * `return` body, so it never pushes a frame. + * + * @var list + */ + private array $returnTargets = []; + + public function __construct( + private readonly ClosureConformanceValidator $validator, + private readonly NamespaceContext $ctx, + private readonly string $file, + private readonly ?DiagnosticCollector $diagnostics, + ) { + } + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->ctx->enterNamespace($node->name?->toString()); + } elseif ($node instanceof Use_) { + $this->ctx->indexUse($node); + } elseif ($node instanceof Function_ || $node instanceof ClassMethod || $node instanceof Closure) { + $this->returnTargets[] = ClosureConformanceValidator::closureSigOf($node->returnType); + } elseif ($node instanceof ArrowFunction) { + // Arrow body: the body expression IS the returned value. + $target = ClosureConformanceValidator::closureSigOf($node->returnType); + if ($target !== null) { + $this->validator->checkLiteral($target, $node->expr, $this->ctx, $this->file, $this->diagnostics); + } + } elseif ($node instanceof Return_) { + // Return position: the literal is the returned value of the + // innermost enclosing function-like. + $target = $this->returnTargets === [] ? null : $this->returnTargets[count($this->returnTargets) - 1]; + if ($target !== null && $node->expr !== null) { + $this->validator->checkLiteral($target, $node->expr, $this->ctx, $this->file, $this->diagnostics); + } + } + + return null; + } + + public function leaveNode(Node $node): null + { + if ($node instanceof Function_ || $node instanceof ClassMethod || $node instanceof Closure) { + array_pop($this->returnTargets); + } + + return null; + } + }; + } +} diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 8f33b64..902154c 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -119,6 +119,16 @@ public function compile( // Container's slot is invariant) fail here BEFORE instantiations // amplify the error. $registry->validateInnerVariance(); + // Closure-signature conformance at the statically-visible literal site (a + // `return`/arrow body whose declared return type is `Closure(...)`). + // Fail-fast in compile mode (null collector ⇒ throw on the first provable + // mismatch), matching the other source-level gates. A target that + // references an enclosing type parameter is checked with those leaves + // still abstract here (⇒ gradually accepted). + $closureValidator = new ClosureConformanceValidator($hierarchy); + foreach ($astPerFile as $filepath => $ast) { + $closureValidator->validateFile($ast, $filepath, null); + } foreach ($astPerFile as $filepath => $ast) { $collector->collectInstantiations($ast, $filepath); } @@ -329,6 +339,14 @@ public function check(FilepathArray $sources): DiagnosticCollector UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy, $diagnostics); $registry->validateDefaultsAgainstBounds(); $registry->validateInnerVariance(); + // Closure-signature conformance at the statically-visible literal site + // (a `Closure(...)` return handing back a closure literal). In + // validate-only mode every violation is collected (parse-failed files were + // already skipped from $astPerFile above). + $closureValidator = new ClosureConformanceValidator($hierarchy); + foreach ($astPerFile as $filepath => $ast) { + $closureValidator->validateFile($ast, $filepath, $diagnostics); + } foreach ($astPerFile as $filepath => $ast) { $collector->collectInstantiations($ast, $filepath); } diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 6e8131e..faf857a 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -486,6 +486,20 @@ private function compileFixture(string $fixture): void } } + public function testClosureConformanceViolationIsCollectedByCheck(): void + { + // Exercises the ClosureConformanceValidator step of check(): a return-site + // closure literal whose parameter is narrower than the target guarantees. + $diagnostics = $this->check('closure_conformance'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(ClosureConformanceValidator::CODE, $diagnostics->all()[0]->code); + self::assertStringContainsString( + 'parameter 1: string is not wider than int', + $diagnostics->all()[0]->message, + ); + } + private function check(string $fixture): DiagnosticCollector { return $this->buildCompiler()->check($this->sources($fixture)); diff --git a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php new file mode 100644 index 0000000..6d1f1e2 --- /dev/null +++ b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php @@ -0,0 +1,44 @@ +cleanup(); + } + } + + public function testNonConformingClosureFailsCompilation(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('parameter 1: string is not wider than int'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/closure_conformance_reject/source', + 'closure-conformance-reject', + ); + } +} diff --git a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php new file mode 100644 index 0000000..03c4f37 --- /dev/null +++ b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php @@ -0,0 +1,300 @@ +throwMode($source), 'compile mode must not throw'); + } + + /** + * @return iterable + */ + public static function conformingSites(): iterable + { + yield 'S-A return: same signature' => [ + ' $x; }', + ]; + yield 'S-A return: contravariant param widening' => [ + ' new Apple(); }', + ]; + yield 'S-A arrow body' => [ + ' fn(int $x): int => $x;', + ]; + yield 'S-A method return' => [ + ' $x; } }', + ]; + yield 'target references a type parameter ⇒ gradual here' => [ + ' { public function m(): Closure(T $x): T { return fn(string $x): int => 0; } }', + ]; + // No `Closure(...)` return target ⇒ no site, even though a literal is + // returned. A non-closure return type must not pair with the literal. + yield 'return of a literal from a non-closure return type' => [ + ' 0; }', + ]; + yield 'arrow body literal under a non-closure return type' => [ + ' fn(string $x): int => 0;', + ]; + } + + /** + * A provable mismatch surfaces in both modes, and the diagnostic carries the + * closure-conformance code, the literal's line, and the expected detail. + * + * @param non-empty-string $source + */ + #[DataProvider('violatingSites')] + public function testViolatingLiteralIsRejected(string $source, int $line, string $detailNeedle): void + { + $diagnostics = self::collect($source); + self::assertCount(1, $diagnostics); + $diagnostic = $diagnostics[0]; + self::assertSame(ClosureConformanceValidator::CODE, $diagnostic->code); + self::assertNotNull($diagnostic->location); + self::assertSame($line, $diagnostic->location->line, 'diagnostic points at the literal'); + self::assertStringStartsWith( + 'Closure literal does not conform to the declared `Closure(...)` type: ', + $diagnostic->message, + 'the human-readable prefix precedes the violation detail', + ); + self::assertStringContainsString($detailNeedle, $diagnostic->message); + + $thrown = $this->throwMode($source); + self::assertInstanceOf(RuntimeException::class, $thrown); + self::assertStringContainsString($detailNeedle, $thrown->getMessage()); + } + + /** + * @return iterable + */ + public static function violatingSites(): iterable + { + yield 'S-A return: param not wider (contravariance)' => [ + " 0;\n}", + 2, + 'parameter 1: string is not wider than int', + ]; + yield 'S-A return: return not a subtype (covariance)' => [ + " new Fruit();\n}", + 2, + 'return type: App\\Fruit is not a subtype of App\\Apple', + ]; + yield 'S-A arrow body: arity too few' => [ + "\n fn(int \$a): void => null;", + 2, + 'expects at least 2 parameter(s), candidate accepts at most 1', + ]; + yield 'S-A return: requires more' => [ + " null;\n}", + 2, + 'requires 2 parameter(s) but the target guarantees only 1', + ]; + yield 'S-A method return: by-ref mismatch' => [ + " null; }\n}", + 2, + 'by-reference-ness must match exactly', + ]; + } + + /** + * A returned expression that is not a closure literal (a variable, a call, a + * property fetch) is not a candidate: the validator must skip it, never fatal + * by extracting a signature from a non-closure node. + * + * @param non-empty-string $source + */ + #[DataProvider('nonLiteralValues')] + public function testNonLiteralValueIsSkipped(string $source): void + { + self::assertCount(0, self::collect($source)); + self::assertNull($this->throwMode($source)); + } + + /** + * @return iterable + */ + public static function nonLiteralValues(): iterable + { + yield 'return of a superglobal fetch' => [ + ' [ + ' 0; return $h; }', + ]; + yield 'return of a call result' => [ + ' 1; } function m(): Closure(int $x): int { return h(); }', + ]; + } + + public function testUseImportedTypeNamesResolveForConformance(): void + { + // Both the target's `Apple` and the candidate's `Fruit` reach the engine + // only if the `use` imports are tracked: without them each resolves to the + // undeclared `Client\...`, the relation is unprovable, and the real + // Fruit-is-not-a-subtype-of-Apple violation is silently lost. + $source = <<<'X' + new Fruit(); } + } + X; + $ast = self::parseRaw($source); + + $diagnostics = new DiagnosticCollector(); + self::validator($ast)->validateFile($ast, 'test.xphp', $diagnostics); + + $collected = $diagnostics->all(); + self::assertCount(1, $collected); + self::assertStringContainsString( + 'App\\Fruit is not a subtype of App\\Apple', + $collected[0]->message, + ); + } + + public function testGlobalBracedNamespaceIsHandledWithoutFatal(): void + { + // A bare `namespace { ... }` block has a null name; the walk must enter it + // (global scope) without dereferencing the absent name, and still reach the + // conformance site inside. + $source = " 0; } }"; + $ast = self::parseRaw($source); + + $diagnostics = new DiagnosticCollector(); + self::validator($ast)->validateFile($ast, 'test.xphp', $diagnostics); + + self::assertCount(1, $diagnostics->all()); + self::assertStringContainsString('is not wider than int', $diagnostics->all()[0]->message); + } + + public function testReturnLiteralIsPairedWithItsInnermostEnclosingFunction(): void + { + // The inner closure's OWN return type (Closure(int): int) is the target for + // its `return`; the outer function's `Closure(string): int` target must not + // leak in. A string param would be REJECTED against the outer target but is + // ACCEPTED against the inner one — so silence proves correct pairing. + $source = <<<'X' + $x; + }; + return fn(string $s): int => 0; + } + X; + + self::assertCount(0, self::collect($source)); + } + + // ---- outer-class decision helpers (directly mutation-covered) -------- + + public function testClosureSigOfUnwrapsNullableAndIgnoresPlainTypes(): void + { + $ast = self::parseRaw('type)); + self::assertNull(ClosureConformanceValidator::closureSigOf($params[1]->type), 'a plain scalar carries no target'); + self::assertNull(ClosureConformanceValidator::closureSigOf(null)); + } + + // ---- Helpers --------------------------------------------------------- + + /** + * @return list<\XPHP\Diagnostics\Diagnostic> + */ + private static function collect(string $source): array + { + $ast = self::parse($source); + $diagnostics = new DiagnosticCollector(); + self::validator($ast)->validateFile($ast, 'test.xphp', $diagnostics); + return $diagnostics->all(); + } + + private function throwMode(string $source): ?RuntimeException + { + $ast = self::parse($source); + try { + self::validator($ast)->validateFile($ast, 'test.xphp', null); + return null; + } catch (RuntimeException $e) { + return $e; + } + } + + /** + * @param list<\PhpParser\Node\Stmt> $ast + */ + private static function validator(array $ast): ClosureConformanceValidator + { + return new ClosureConformanceValidator(TypeHierarchy::fromAstPerFile(['test.xphp' => $ast])); + } + + /** + * @return list<\PhpParser\Node\Stmt> + */ + private static function parse(string $source): array + { + // The hierarchy needs App\Apple <: App\Fruit for the class-variance cases; + // inject the declarations INLINE (no added newline) so every fixture shares + // one `App` namespace + classes AND each literal keeps its original line. + $prelude = 'namespace App; class Fruit {} class Apple extends Fruit {} '; + $withPrelude = preg_replace('/^<\?php\s*/', " + */ + private static function parseRaw(string $source): array + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + return $parser->parse($source); + } + + /** + * @param list<\PhpParser\Node\Stmt> $ast + * @return list<\PhpParser\Node\Param> + */ + private static function firstFunctionParams(array $ast): array + { + $fn = (new \PhpParser\NodeFinder())->findFirstInstanceOf($ast, \PhpParser\Node\Stmt\Function_::class); + \assert($fn instanceof \PhpParser\Node\Stmt\Function_); + return array_values($fn->params); + } +} diff --git a/test/fixture/check/closure_conformance/source/Use.xphp b/test/fixture/check/closure_conformance/source/Use.xphp new file mode 100644 index 0000000..36007ab --- /dev/null +++ b/test/fixture/check/closure_conformance/source/Use.xphp @@ -0,0 +1,13 @@ + 0; +} diff --git a/test/fixture/compile/closure_conformance_reject/source/Use.xphp b/test/fixture/compile/closure_conformance_reject/source/Use.xphp new file mode 100644 index 0000000..ff8ecd1 --- /dev/null +++ b/test/fixture/compile/closure_conformance_reject/source/Use.xphp @@ -0,0 +1,13 @@ + 0; +} diff --git a/test/fixture/compile/closure_conformance_runtime/source/Use.xphp b/test/fixture/compile/closure_conformance_runtime/source/Use.xphp new file mode 100644 index 0000000..d9e9e8e --- /dev/null +++ b/test/fixture/compile/closure_conformance_runtime/source/Use.xphp @@ -0,0 +1,48 @@ +scale = fn(int $x): int => $x * 2; + } + + // S-A, return position (method): the returned arrow conforms to the target. + public function adder(): Closure(int $x): int + { + return fn(int $x): int => $x + 40; + } + + public function scaled(int $n): int + { + return ($this->scale)($n); + } +} + +// S-A, return position (free function). +function makeAdder(): Closure(int $x): int +{ + return fn(int $x): int => $x + 1; +} + +// S-A, arrow body: the arrow's own body IS the returned literal. +$mulFactory = fn(): Closure(int $x): int => fn(int $x): int => $x * 3; + +$f = new Factory(); +$scaled = $f->scaled(21); // 21 * 2 = 42 +$added = ($f->adder())(2); // 2 + 40 = 42 +$incremented = (makeAdder())(41); // 41 + 1 = 42 +$tripled = ($mulFactory())(14); // 14 * 3 = 42 diff --git a/test/fixture/compile/closure_conformance_runtime/verify/runtime.php b/test/fixture/compile/closure_conformance_runtime/verify/runtime.php new file mode 100644 index 0000000..d117343 --- /dev/null +++ b/test/fixture/compile/closure_conformance_runtime/verify/runtime.php @@ -0,0 +1,28 @@ +targetDir . '/Use.php'; + +Assert::assertSame(42, $scaled, 'stored property closure ran'); +Assert::assertSame(42, $added, 'method-return factory closure ran'); +Assert::assertSame(42, $incremented, 'free-function factory closure ran'); +Assert::assertSame(42, $tripled, 'arrow-body factory closure ran'); + +// The erased output must carry a bare \Closure, never a residual `Closure(int`. +$emitted = file_get_contents($fixture->targetDir . '/Use.php'); +Assert::assertIsString($emitted); +Assert::assertStringContainsString('\\Closure', $emitted); +Assert::assertStringNotContainsString('Closure(int', $emitted); From dc7e71f63c6fe4caec1e64ba8c8901d9e98c6ac0 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 08:02:05 +0000 Subject: [PATCH 07/80] feat(monomorphize): re-check closure factories after grounding their generic return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `Closure(...)` return target that references an enclosing type parameter is gradual at the abstract template (the parameter could ground to anything), so the pre-specialization pass accepts it. Once specialized, substitute the target's type-parameter leaves alongside the returned literal's own types and re-run conformance over each specialized class: a mismatch that only becomes provable after grounding — e.g. a `string`-parameter literal against a `Closure(T $x)` target resolved to `Closure(int $x)` — now fails the build. Structural mismatches (arity, by-reference) do not depend on grounding and are caught at the template, which fails fast before specialization; the grounded pass therefore only ever adds type-relation violations, never duplicates. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 12 ++ src/Transpiler/Monomorphize/Specializer.php | 58 ++++++++++ .../ClosureConformanceIntegrationTest.php | 31 +++++ .../SpecializerClosureSignatureTest.php | 107 ++++++++++++++++++ .../source/Use.xphp | 19 ++++ .../source/Use.xphp | 20 ++++ .../verify/runtime.php | 18 +++ 7 files changed, 265 insertions(+) create mode 100644 test/Transpiler/Monomorphize/SpecializerClosureSignatureTest.php create mode 100644 test/fixture/compile/closure_conformance_grounded_reject/source/Use.xphp create mode 100644 test/fixture/compile/closure_conformance_grounded_runtime/source/Use.xphp create mode 100644 test/fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 902154c..04b2c7f 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -188,6 +188,18 @@ public function compile( } } + // Phase 2.4: grounded closure-signature conformance. Each specialization's + // `Closure(...)` target now has its type parameters substituted, and the + // returned literal's types were substituted alongside it, so a mismatch + // that was gradual while the type parameter was abstract (e.g. a `string` + // literal parameter against a `Closure(T $x)` target grounded to `int`) + // becomes provable here. Fail-fast, like the pre-loop gate. Structural + // mismatches (arity / by-ref) don't depend on grounding and were already + // caught at the template pre-loop, which threw before reaching this point. + foreach ($specializedAsts as $generatedFqn => $classAst) { + $closureValidator->validateFile([$classAst], "", null); + } + // Phase 2.5: emit subtype edges between specializations whose template // declares variance markers. Runs once after the fixed-point loop // (Phase 2) finishes -- pairwise variance comparisons can't run until diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index d6a5e6e..bf35c6b 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -287,6 +287,19 @@ public function __construct(private array $substitution) public function leaveNode(Node $node): ?Node { + // Ground a closure-signature target in place. The erased `\Closure` + // head that carries it is fully-qualified, so it never reaches the + // type-param swap below; substitute its type-parameter leaves here. + if ($node instanceof Name) { + $sig = $node->getAttribute(XphpSourceParser::ATTR_CLOSURE_SIG); + if ($sig instanceof ClosureSignature) { + $node->setAttribute( + XphpSourceParser::ATTR_CLOSURE_SIG, + Specializer::substituteClosureSignature($sig, $this->substitution), + ); + } + } + if ($node instanceof Name && !$node->isFullyQualified()) { $parts = $node->getParts(); if (count($parts) === 1 && isset($this->substitution[$parts[0]])) { @@ -372,6 +385,51 @@ public static function substituteTypeRef(TypeRef $ref, array $subst): TypeRef return new TypeRef($ref->name, $newArgs, $ref->isScalar, $ref->isTypeParam); } + /** + * Ground a closure-signature target ({@see XphpSourceParser::ATTR_CLOSURE_SIG}) + * by substituting its type-parameter leaves with their concrete types, so the + * conformance validator's post-specialization pass checks `Closure(int): int` + * (not `Closure(T): T`) against the likewise-substituted returned literal. + * Mirrors the parser's own signature-resolution walk. + * + * Public for the same reason as {@see substituteTypeRef} — the shared + * anonymous-class visitor calls back into Specializer. + * + * @param array $subst + */ + public static function substituteClosureSignature(ClosureSignature $sig, array $subst): ClosureSignature + { + $params = array_map( + static fn (ClosureSignatureParam $p): ClosureSignatureParam => new ClosureSignatureParam( + self::substituteSigType($p->type, $subst), + $p->byRef, + $p->variadic, + $p->optional, + ), + $sig->params, + ); + $return = $sig->return === null ? null : self::substituteSigType($sig->return, $subst); + + return new ClosureSignature($params, $return, $sig->nullable); + } + + /** + * @param array $subst + */ + private static function substituteSigType(SigType $type, array $subst): SigType + { + if ($type instanceof SigTypeRef) { + return new SigTypeRef(self::substituteTypeRef($type->type, $subst)); + } + if ($type instanceof SigClosure) { + return new SigClosure(self::substituteClosureSignature($type->signature, $subst)); + } + + // SigRaw (union / intersection / nullable) is gradual and unstructured; + // there is nothing to ground. + return $type; + } + /** * Deep-clone an AST subtree. nikic/php-parser's __clone is shallow; we need * a recursive clone so mutations to the specialized copy don't bleed back diff --git a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php index 6d1f1e2..5c6af19 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php @@ -41,4 +41,35 @@ public function testNonConformingClosureFailsCompilation(): void 'closure-conformance-reject', ); } + + #[RunInSeparateProcess] + public function testGroundedGenericClosureConformsWhenTypeParameterResolves(): void + { + // `Closure(T): T` grounds to `Closure(int): int` under `Box`; the + // conforming factory compiles, erases, and runs. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/closure_conformance_grounded_runtime/source', + 'closure-conformance-grounded-run', + ); + $fixture->registerAutoload('App\\ClosureGroundRuntime\\'); + try { + require __DIR__ . '/../../fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + public function testGroundedGenericClosureFailsWhenTypeParameterResolvesToAConflict(): void + { + // Gradually accepted at the abstract template, but once `Box` grounds + // `Closure(T $x): T` to `Closure(int $x): int` the `string`-parameter + // literal is a provable contravariance violation. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('parameter 1: string is not wider than int'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/closure_conformance_grounded_reject/source', + 'closure-conformance-grounded-reject', + ); + } } diff --git a/test/Transpiler/Monomorphize/SpecializerClosureSignatureTest.php b/test/Transpiler/Monomorphize/SpecializerClosureSignatureTest.php new file mode 100644 index 0000000..bbeadc8 --- /dev/null +++ b/test/Transpiler/Monomorphize/SpecializerClosureSignatureTest.php @@ -0,0 +1,107 @@ + self::scalar('int')]); + + self::assertSame('int', self::leafName($grounded->params[0]->type)); + self::assertSame('int', self::leafName($grounded->return)); + } + + public function testNonTypeParameterLeavesAreUnchanged(): void + { + $sig = new ClosureSignature( + [new ClosureSignatureParam(new SigTypeRef(self::scalar('string')))], + new SigTypeRef(new TypeRef('App\\Fruit')), + ); + + $grounded = Specializer::substituteClosureSignature($sig, ['T' => self::scalar('int')]); + + self::assertSame('string', self::leafName($grounded->params[0]->type)); + self::assertSame('App\\Fruit', self::leafName($grounded->return)); + } + + public function testNestedClosureLeafIsSubstitutedRecursively(): void + { + // A parameter whose own type is `Closure(T): T` — the inner type parameter + // must ground too. + $inner = new ClosureSignature( + [new ClosureSignatureParam(new SigTypeRef(self::typeParam('T')))], + new SigTypeRef(self::typeParam('T')), + ); + $sig = new ClosureSignature([new ClosureSignatureParam(new SigClosure($inner))], null); + + $grounded = Specializer::substituteClosureSignature($sig, ['T' => self::scalar('int')]); + + $outerParam = $grounded->params[0]->type; + self::assertInstanceOf(SigClosure::class, $outerParam); + self::assertSame('int', self::leafName($outerParam->signature->params[0]->type)); + self::assertSame('int', self::leafName($outerParam->signature->return)); + } + + public function testRawLeafIsCarriedThroughUnchanged(): void + { + $raw = new SigRaw('int|string'); + $sig = new ClosureSignature([new ClosureSignatureParam($raw)], null); + + $grounded = Specializer::substituteClosureSignature($sig, ['T' => self::scalar('int')]); + + self::assertSame($raw, $grounded->params[0]->type, 'a gradual raw leaf is untouched'); + } + + public function testStructuralFlagsAndNullabilityArePreserved(): void + { + $sig = new ClosureSignature( + [ + new ClosureSignatureParam(new SigTypeRef(self::typeParam('T')), byRef: true), + new ClosureSignatureParam(new SigTypeRef(self::scalar('int')), variadic: true), + ], + null, + nullable: true, + ); + + $grounded = Specializer::substituteClosureSignature($sig, ['T' => self::scalar('int')]); + + self::assertTrue($grounded->params[0]->byRef); + self::assertTrue($grounded->params[1]->variadic); + self::assertTrue($grounded->nullable); + self::assertNull($grounded->return, 'an absent return stays absent'); + } + + // ---- Helpers --------------------------------------------------------- + + private static function typeParam(string $name): TypeRef + { + return new TypeRef($name, [], isScalar: false, isTypeParam: true); + } + + private static function scalar(string $name): TypeRef + { + return new TypeRef($name, [], isScalar: true); + } + + private static function leafName(?SigType $type): ?string + { + return $type instanceof SigTypeRef ? $type->type->name : null; + } +} diff --git a/test/fixture/compile/closure_conformance_grounded_reject/source/Use.xphp b/test/fixture/compile/closure_conformance_grounded_reject/source/Use.xphp new file mode 100644 index 0000000..796a366 --- /dev/null +++ b/test/fixture/compile/closure_conformance_grounded_reject/source/Use.xphp @@ -0,0 +1,19 @@ +` the target grounds to +// `Closure(int $x): int`; now the literal's `string` parameter is provably not +// wider than `int`, so compilation must fail. +class Box +{ + public function make(): Closure(T $x): T + { + return fn(string $x): int => 0; + } +} + +$box = new Box::(); diff --git a/test/fixture/compile/closure_conformance_grounded_runtime/source/Use.xphp b/test/fixture/compile/closure_conformance_grounded_runtime/source/Use.xphp new file mode 100644 index 0000000..0d5a676 --- /dev/null +++ b/test/fixture/compile/closure_conformance_grounded_runtime/source/Use.xphp @@ -0,0 +1,20 @@ +` it grounds to +// `Closure(int $x): int`, and the returned literal `fn(int $x): int` conforms. +class Box +{ + public function make(): Closure(T $x): T + { + return fn(int $x): int => $x + 40; + } +} + +$box = new Box::(); +$factory = $box->make(); +$result = $factory(2); // 2 + 40 = 42 diff --git a/test/fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php b/test/fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php new file mode 100644 index 0000000..cd10a58 --- /dev/null +++ b/test/fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php @@ -0,0 +1,18 @@ +targetDir . '/Use.php'; + +Assert::assertSame(42, $result, 'the grounded factory closure ran'); From 8f9e695c5e84cea003af37a507defc85e0cd3c41 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 08:20:57 +0000 Subject: [PATCH 08/80] fix(monomorphize): accept a closure factory whose return targets a built-in supertype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conformance engine read `TypeHierarchy::isSubtype(...) === false` as a proof of non-subtyping for any two declared classes. But the hierarchy seeds ancestor edges only from scanned source, not PHP's built-in class graph, so a real relation like `Exception <: Throwable` (or any user class whose ancestry passes through a built-in) returns a hard `false` — turning valid, idiomatic factories (exception, iterator, enum) into hard compile failures by default. A `false` verdict is trustworthy only when the target is a user-declared class: reaching it is possible solely over user edges, all of which are modeled. Reaching a built-in target may traverse unmodeled built-in ancestry, so treat a built-in target as unprovable and accept it gradually — consistent with the one-directional "never a false reject" rule the rest of the engine follows. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ClosureSignatureConformance.php | 20 +++++++---- src/Transpiler/Monomorphize/TypeHierarchy.php | 12 +++++++ .../ClosureConformanceIntegrationTest.php | 18 ++++++++++ .../ClosureSignatureConformanceTest.php | 36 +++++++++++++++++++ .../Monomorphize/TypeHierarchyTest.php | 9 +++++ .../source/Use.xphp | 21 +++++++++++ .../verify/runtime.php | 19 ++++++++++ 7 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 test/fixture/compile/closure_conformance_builtin_ok/source/Use.xphp create mode 100644 test/fixture/compile/closure_conformance_builtin_ok/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/ClosureSignatureConformance.php b/src/Transpiler/Monomorphize/ClosureSignatureConformance.php index 9049080..0d36eb3 100644 --- a/src/Transpiler/Monomorphize/ClosureSignatureConformance.php +++ b/src/Transpiler/Monomorphize/ClosureSignatureConformance.php @@ -218,14 +218,22 @@ private function provablyNotSubtype(SigType $sub, SigType $super): bool return self::normalizeScalar($sub->type->name) !== self::normalizeScalar($super->type->name); } if ($subKind === 'class' && $superKind === 'class') { - // Only provable when BOTH classes are known to the hierarchy: an - // undeclared class on either side leaves the relation unprovable, and - // `isSubtype(knownChild, undeclaredParent)` returns a hard `false` - // (the BFS simply never reaches the unknown) — which must NOT be read - // as a proven non-subtype, or valid code against an out-of-source class - // would be false-rejected. + // Provable only when BOTH classes are known to the hierarchy AND the + // target (super) is a user-declared class: + // - An undeclared class on either side leaves the relation unprovable; + // `isSubtype(knownChild, undeclaredParent)` returns a hard `false` + // (the BFS never reaches the unknown), which must NOT read as a + // proven non-subtype or out-of-source code would be false-rejected. + // - A BUILT-IN target is equally unprovable-as-`false`: the hierarchy + // models only ancestor edges scanned from source, not PHP's built-in + // class graph, so `isSubtype` returns `false` for a real relation + // like `Exception <: Throwable` (or any user class whose ancestry + // passes through a built-in). Reaching a USER target, by contrast, + // is possible only over user-declared edges — all modeled — so a + // `false` there is a genuine proof. Accept when the target is built-in. if (!$this->hierarchy->isDeclared($sub->type->name) || !$this->hierarchy->isDeclared($super->type->name) + || $this->hierarchy->isBuiltin($super->type->name) ) { return false; } diff --git a/src/Transpiler/Monomorphize/TypeHierarchy.php b/src/Transpiler/Monomorphize/TypeHierarchy.php index ad250bc..7e98d1f 100644 --- a/src/Transpiler/Monomorphize/TypeHierarchy.php +++ b/src/Transpiler/Monomorphize/TypeHierarchy.php @@ -153,6 +153,18 @@ public function isDeclared(string $fqn): bool return isset($this->ancestors[$fqn]) || in_array($fqn, self::BUILTIN_TYPES, true); } + /** + * Whether $fqn names a built-in PHP interface/class ({@see BUILTIN_TYPES}). + * These are `isDeclared`, but the hierarchy models none of their ancestor + * edges (it seeds edges only from scanned source), so an `isSubtype` verdict + * of `false` against a built-in target is unprovable — callers that treat a + * `false` as a proof must exclude a built-in target first. + */ + public function isBuiltin(string $fqn): bool + { + return in_array(ltrim($fqn, '\\'), self::BUILTIN_TYPES, true); + } + /** * Transitive ancestors of $fqn, nearest-first and de-duplicated, excluding * $fqn itself. Breadth-first over the direct-ancestor map, so the closest diff --git a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php index 5c6af19..876971c 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php @@ -31,6 +31,24 @@ public function testConformingClosuresCompileEraseAndRun(): void } } + #[RunInSeparateProcess] + public function testExceptionFactoryAgainstBuiltinThrowableTargetCompilesAndRuns(): void + { + // Regression: a user subclass of the built-in \Exception returned against a + // Closure(): \Throwable target must not be false-rejected (the hierarchy + // models no built-in ancestor edges). + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/closure_conformance_builtin_ok/source', + 'closure-conformance-builtin', + ); + $fixture->registerAutoload('App\\ClosureBuiltinOk\\'); + try { + require __DIR__ . '/../../fixture/compile/closure_conformance_builtin_ok/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + public function testNonConformingClosureFailsCompilation(): void { $this->expectException(RuntimeException::class); diff --git a/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php b/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php index ca0ad09..c54ee86 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php @@ -398,6 +398,39 @@ public function testUndeclaredCandidateAgainstKnownTargetClassIsAccepted(): void ); } + public function testBuiltinReturnTargetIsAcceptedGradually(): void + { + // Regression (false-reject): the hierarchy models no ancestor edges for + // PHP built-ins, so isSubtype('Exception','Throwable') returns a hard false. + // A built-in target must NOT be read as a proven mismatch — Exception really + // IS a Throwable. Covariant return: candidate Exception, target Throwable. + self::assertConforms( + self::sig([], self::ref('Exception')), + self::sig([], self::ref('Throwable')), + ); + } + + public function testUserSubclassOfBuiltinAgainstBuiltinReturnTargetIsAccepted(): void + { + // App\MyExc extends the built-in Exception; its ancestry escapes into the + // unmodeled built-in graph, so it is really a Throwable but BFS can't prove it. + self::assertConforms( + self::sig([], self::ref('App\\MyExc')), + self::sig([], self::ref('Throwable')), + ); + } + + public function testBuiltinParameterTargetIsAcceptedGradually(): void + { + // Contravariant parameter: target Exception, candidate Throwable. Throwable + // is genuinely wider than Exception, but the relation runs through the + // built-in graph — accept rather than false-reject. + self::assertConforms( + self::sig([self::p(self::ref('Throwable'))], self::ref('void')), + self::sig([self::p(self::ref('Exception'))], self::ref('void')), + ); + } + public function testViolationDetailNamesThePositionAndBothTypes(): void { $violation = self::engine()->check( @@ -441,11 +474,14 @@ public function testByRefViolationDetailNamesTheDirections(): void private static function engine(): ClosureSignatureConformance { // App\Apple <: App\Fruit, App\Orange <: App\Fruit; App\Closurish is a bare class. + // App\MyExc extends the built-in Exception (its ancestry escapes into PHP's + // unmodeled built-in graph). $hierarchy = new TypeHierarchy([ 'App\\Apple' => ['App\\Fruit'], 'App\\Orange' => ['App\\Fruit'], 'App\\Fruit' => [], 'App\\Closurish' => [], + 'App\\MyExc' => ['Exception'], ]); return new ClosureSignatureConformance($hierarchy); } diff --git a/test/Transpiler/Monomorphize/TypeHierarchyTest.php b/test/Transpiler/Monomorphize/TypeHierarchyTest.php index 37fa461..aaf841d 100644 --- a/test/Transpiler/Monomorphize/TypeHierarchyTest.php +++ b/test/Transpiler/Monomorphize/TypeHierarchyTest.php @@ -21,6 +21,15 @@ public function testBuiltinInterfaceIsItsOwnSubtype(): void self::assertTrue($hierarchy->isSubtype('Stringable', 'Stringable')); } + public function testIsBuiltinRecognisesBuiltinsAndStripsLeadingBackslash(): void + { + $hierarchy = new TypeHierarchy([]); + self::assertTrue($hierarchy->isBuiltin('Throwable')); + self::assertTrue($hierarchy->isBuiltin('\\Throwable'), 'a leading-backslash builtin still matches'); + self::assertFalse($hierarchy->isBuiltin('App\\Throwable'), 'a namespaced look-alike is not the builtin'); + self::assertFalse($hierarchy->isBuiltin('App\\Fruit')); + } + public function testUnknownConcreteTypeReturnsNull(): void { // Reject-by-uncertainty: caller can't prove SomeRandomClass satisfies anything because diff --git a/test/fixture/compile/closure_conformance_builtin_ok/source/Use.xphp b/test/fixture/compile/closure_conformance_builtin_ok/source/Use.xphp new file mode 100644 index 0000000..3d715e6 --- /dev/null +++ b/test/fixture/compile/closure_conformance_builtin_ok/source/Use.xphp @@ -0,0 +1,21 @@ + new MyException('boom'); +} + +$factory = makeThrower(); +$thrown = $factory(); diff --git a/test/fixture/compile/closure_conformance_builtin_ok/verify/runtime.php b/test/fixture/compile/closure_conformance_builtin_ok/verify/runtime.php new file mode 100644 index 0000000..bb7d61c --- /dev/null +++ b/test/fixture/compile/closure_conformance_builtin_ok/verify/runtime.php @@ -0,0 +1,19 @@ +targetDir . '/Use.php'; + +Assert::assertInstanceOf(\Throwable::class, $thrown, 'the factory returned a Throwable'); +Assert::assertSame('boom', $thrown->getMessage()); From 2febcea7db25481b1ec8cb4bec1db91034d65341 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 08:34:35 +0000 Subject: [PATCH 09/80] docs: document Closure(...) signature types and conformance checking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a syntax page for the `Closure(int $x): bool` signature type — its positions, erasure to a bare `\Closure`, return-position conformance rules (contravariant parameters, covariant return, exact by-ref, arity), the provable-only/gradual policy, and per-specialization grounding. Register the `xphp.closure_conformance` diagnostic (code table + verbatim error text) and add a CHANGELOG entry for the unreleased 0.3.0 line. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 ++++ docs/errors.md | 19 ++++++ docs/syntax/closure-types.md | 109 +++++++++++++++++++++++++++++++++++ docs/syntax/index.md | 1 + 4 files changed, 141 insertions(+) create mode 100644 docs/syntax/closure-types.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dca8bda..3b83b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ _In progress on this branch — content still accumulating; date set at tag time ### Added +- **`Closure(...)` signature types.** A type hint such as `Closure(int $x, string $y): + bool` may appear in any parameter, return, or property position; it documents the + callable a slot expects and **erases to a bare `\Closure`** in the emitted PHP. + Where a closure literal is returned against a `Closure(...)` return type (a + typed-closure factory), xphp checks conformance — parameters contravariant, return + covariant, by-reference exact, arity compatible — and fails the build on a + **provable** mismatch (`xphp.closure_conformance`), while accepting anything it can't + prove wrong (untyped ⇒ `mixed`, an unresolved or built-in supertype, a + still-abstract type parameter, a union/intersection). A signature that references an + enclosing type parameter is **grounded** per specialization, so `Registry` and + `Registry` check the same factory against different concrete targets. See + [closure types](docs/syntax/closure-types.md). - **Multi-root builds via an `xphp.json` manifest.** A project declares its source roots, output directory, and hash length in an `xphp.json` at the project root; `xphp compile` and `xphp check` auto-detect it (or take an explicit `--config`). diff --git a/docs/errors.md b/docs/errors.md index 1bcb174..4ba93b1 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -51,6 +51,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.undetermined_receiver` | a turbofish method call's receiver has no statically-known type (an untyped `foreach` variable, a local whose type is ambiguous after a branch), so the call can't be specialized — it would emit a call to a stripped method that fatals at runtime. Give the receiver a declared type | | `xphp.unspecializable_self_call` | a `$this`-rooted self-call forwards a type parameter to a **non-erasable** generic method (one whose parameter is used nested, in the return, or structurally). Forwarding to an *erasable* method — parameter used only as a direct input — compiles and runs; otherwise move the call to a typed-receiver context | | `xphp.unschedulable_covariant_upcast` | a value is upcast to a covariant *interface* whose element-consuming method (`contains`) needs a concrete implementation at the supertype argument that can neither be inherited through the covariant chain nor emitted directly onto the upcast source. Direct emission already covers the cases where inheritance can't carry it (the implementing class has another `extends` parent, implements only a parent of the interface, or reorders the clause); the upcast fails only when **no** emittable class body exists (a truly abstract or trait-only method), the method's **return type** names the element parameter (the widened argument would escape through a narrower return), or its parameters are bounded by **different** enclosing parameters (no single member can be derived). Provide a concrete implementation on a class — move a trait body onto the covariant base, or give the method a non-element return type | +| `xphp.closure_conformance` | a closure literal returned against a `Closure(...)` type doesn't conform to it — its parameters aren't wide enough, its return isn't narrow enough, its by-reference-ness differs, or its arity is incompatible | | `xphp.parse_error` | the file isn't valid PHP after the generic strip pass | | `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`; a finding that carries no identifier falls back to the literal `phpstan.error`) — present only when the PHPStan pass runs | | `phpstan.unavailable` | (Warning) no phpstan binary was found, so the PHPStan pass was skipped | @@ -377,6 +378,24 @@ Nested generic specialization exceeded depth 16. Latest registry: ``` +### Closure-signature conformance + +``` +Closure literal does not conform to the declared `Closure(...)` type: + +``` + +Emitted when a closure literal is returned against a `Closure(...)` return +type it doesn't satisfy. The `` names the exact mismatch, e.g. +`parameter 1: string is not wider than int` (a parameter must be the same +as or **wider** than the target's — contravariance), `return type: A is not +a subtype of B` (the return must be the same as or **narrower** — covariance), +`by-reference-ness must match exactly`, or an arity message. See +[closure types](syntax/closure-types.md). The check only ever reports a +*provable* mismatch: an unresolved class, a still-abstract type parameter, an +untyped (⇒ `mixed`) slot, a union/intersection, or a built-in supertype is +accepted rather than falsely rejected. + ### Parse / AST ``` diff --git a/docs/syntax/closure-types.md b/docs/syntax/closure-types.md new file mode 100644 index 0000000..4c5b740 --- /dev/null +++ b/docs/syntax/closure-types.md @@ -0,0 +1,109 @@ +# `Closure(...)` signature types + +xphp accepts a **closure signature type** — `Closure(int $x, string $y): +bool` — anywhere a type hint is allowed (a parameter, a return, a +property). It documents the shape of the callable a slot expects, then +**erases to a bare `\Closure`** at compile time, so the emitted PHP is +ordinary code that any PHP runtime accepts. + +```php + $x + $by; +} +``` + +compiles to: + +```php + $x + $by; +} +``` + +The parameter names inside the signature are documentation only (exactly +like a real closure's parameter names); only the types, order, by-reference +markers, and arity carry meaning. + +A signature may be nullable (`?Closure(int): int`), may omit the return +(`Closure(int $x)` — any return accepted), and may nest +(`Closure(Closure(int): int $f): int`). + +## Conformance checking + +Where a closure **literal** is returned against a `Closure(...)` return +type, xphp checks that the literal actually conforms, using the same +variance PHP enforces when an inherited method overrides its prototype: + +- **Parameters are contravariant** — each parameter of the literal must be + the same as or **wider** than the target's. +- **The return is covariant** — the literal's return must be the same as or + **narrower** than the target's. +- **By-reference-ness is exact**, and **arity must be compatible** (the + literal must accept every argument the target guarantees, and require no + more than the target guarantees). + +```php +function makeAdder(): Closure(int $x): int { + return fn(int $x): int => $x + 1; // ✓ conforms +} + +function makeBroken(): Closure(int $x): int { + return fn(string $x): int => 0; // ✗ compile error: + // parameter 1 is not wider than int +} +``` + +A mismatch is a compile error (`xphp compile` fails; `xphp check` reports +`xphp.closure_conformance`). See [errors](../errors.md). + +### It only rejects a *provable* mismatch + +The check is deliberately one-directional: it never rejects code it cannot +prove wrong. A parameter or return that is untyped (⇒ `mixed`), a class the +source set doesn't declare, a still-abstract generic type parameter, a +`self`/`static`/`parent`/`object`/`iterable`/`callable` leaf, a nullable / +union / intersection type, or a built-in supertype (returning a `\Exception` +where a `\Throwable` is expected) is **accepted**. This mirrors the RFC's +runtime leniency — lenient while unresolved, decide only when provable. + +Only the return-position "factory" pattern above is checked, because that is +the one place a closure literal statically meets a `Closure(...)` target: a +default value cannot be a closure (PHP requires a constant expression), and a +closure passed through a variable or a call argument is checked gradually +(accepted). + +## Generic signatures + +A closure signature may reference the enclosing type parameters, and each is +**grounded** against the concrete type argument when the class specializes: + +```php +class Registry { + public function factory(): Closure(T $value): bool { + return fn(int $value): bool => $value > 0; + } +} + +new Registry::(); // target grounds to Closure(int): bool — the literal conforms +new Registry::(); // target grounds to Closure(string): bool — the same literal + // is now rejected: int is not wider than string +``` + +## Not yet checked + +A union or intersection **member** inside a signature (`Closure(int|string +$x): void`) is currently carried through and accepted gradually rather than +variance-checked. Prefer a single type per slot where you want the check to +apply. +``` diff --git a/docs/syntax/index.md b/docs/syntax/index.md index 9eec30c..b16e61e 100644 --- a/docs/syntax/index.md +++ b/docs/syntax/index.md @@ -15,6 +15,7 @@ first. | [Classes and interfaces](classes-and-interfaces.md) | `class Box {}`, generic interfaces and traits, marker-interface runtime behavior | | [Methods and functions](methods-and-functions.md) | Generic methods (static + instance), generic free functions, bare top-level | | [Closures and arrows](closures-and-arrows.md) | `function(...)`, `fn(...) => ...`, captures incl. by-ref | +| [Closure types](closure-types.md) | `Closure(int $x): bool` signature types, erasure to `\Closure`, return-position conformance | | [Type bounds](type-bounds.md) | `T : Stringable`, `T : A & B`, `T : (A & B) \| C`, F-bounded `T : Box` | | [Variance](variance.md) | `out T`, `in T`, position rules, subtype edges between specializations | | [Defaults](defaults.md) | `T = int`, forward refs `Pair`, empty turbofish `$f::<>()` | From 1b5739741dbd48c8fcb67d50cca136a969849432 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 08:55:56 +0000 Subject: [PATCH 10/80] feat(monomorphize): variance-check union and intersection members in closure signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add structured SigUnion / SigIntersection leaves and teach the conformance engine their subtyping. The four decomposition arms fold into the existing binary predicate, decomposing the SUB side first (super-first would false-reject `int|string` against `string|int`, since the resulting AND-of-ORs strictly over-approximates the sound OR-of-ANDs): union sub A|B <: Y provable iff SOME member is (OR) union super X <: A|B provable iff vs EVERY member (AND) intersection super X <: A&B provable iff vs SOME member (OR) intersection sub A&B <: Y GRADUAL The intersection-sub case stays gradual on purpose: an intersection of incompatible members is uninhabited (`never`, a subtype of everything), and with no inhabitation check decomposing it would false-reject a vacuously-wide `never` parameter. Diagnostics render `A|B` / `A&B`. This is the engine only; the extractor and parser still emit SigRaw, so nothing reaches these arms yet — pinned by direct engine unit tests including the uninhabited-intersection accept. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ClosureSignatureConformance.php | 68 ++++++++-- .../Monomorphize/SigIntersection.php | 32 +++++ src/Transpiler/Monomorphize/SigRaw.php | 12 +- src/Transpiler/Monomorphize/SigType.php | 17 +-- src/Transpiler/Monomorphize/SigUnion.php | 30 +++++ .../ClosureSignatureConformanceTest.php | 125 ++++++++++++++++++ 6 files changed, 263 insertions(+), 21 deletions(-) create mode 100644 src/Transpiler/Monomorphize/SigIntersection.php create mode 100644 src/Transpiler/Monomorphize/SigUnion.php diff --git a/src/Transpiler/Monomorphize/ClosureSignatureConformance.php b/src/Transpiler/Monomorphize/ClosureSignatureConformance.php index 0d36eb3..105a89a 100644 --- a/src/Transpiler/Monomorphize/ClosureSignatureConformance.php +++ b/src/Transpiler/Monomorphize/ClosureSignatureConformance.php @@ -177,15 +177,61 @@ private function structural(ClosureSignature $candidate, ClosureSignature $targe */ private function provablyNotSubtype(SigType $sub, SigType $super): bool { - // A raw (union/intersection) leaf on either side is not yet structured; - // treat as gradual until WI-03 gives it variance. (This is the intentional - // statement of that rule; a SigRaw would also fall through to the defensive - // non-SigTypeRef guard below, so mutating this line is caught there — - // @infection-ignore-all.) + // A raw (unstructured) leaf on either side stays gradual — a target-side DNF + // `(A&B)|C`, an unresolved member, or an intersection carrying a scalar all + // fall back to SigRaw and are accepted here. if ($sub instanceof SigRaw || $super instanceof SigRaw) { return false; } + // ---- Compound leaves. Decompose the SUB side FIRST: doing the super side + // first would false-reject `int|string <: string|int` (the resulting + // `∧ᵥ∨ᵤ` over-approximates the sound `∨ᵤ∧ᵥ`). ---- + + // sub = union: `A|B <: Y` ⟺ EVERY member <: Y ⇒ provably-not iff SOME member is. + if ($sub instanceof SigUnion) { + foreach ($sub->members as $member) { + if ($this->provablyNotSubtype($member, $super)) { + return true; + } + } + return false; + } + + // sub = intersection: soundly `A&B <: Y` ⟺ `A<:Y ∨ B<:Y`, but an intersection + // of incompatible members is uninhabited (`never`, a subtype of everything) + // and there is no inhabitation check here — decomposing would false-reject. + // Kept gradual; the inhabited-intersection reject is a tracked follow-up. + // @infection-ignore-all — the explicit gradual return states the rule; a + // SigIntersection sub also falls through to the defensive non-SigTypeRef + // guard below, which returns the same false, so removing it is equivalent. + if ($sub instanceof SigIntersection) { + return false; + } + + // super = union: `X <: A|B` ⟺ X <: SOME member ⇒ provably-not iff vs EVERY member. + if ($super instanceof SigUnion) { + foreach ($super->members as $member) { + if (!$this->provablyNotSubtype($sub, $member)) { + return false; + } + } + return true; + } + + // super = intersection: `X <: A&B` ⟺ X <: EVERY member ⇒ provably-not iff vs SOME. + if ($super instanceof SigIntersection) { + foreach ($super->members as $member) { + if ($this->provablyNotSubtype($sub, $member)) { + return true; + } + } + // @infection-ignore-all — no member proved a violation; the defensive + // non-SigTypeRef guard below returns the same false for this SigIntersection + // super, so removing this explicit gradual return is equivalent. + return false; + } + if ($sub instanceof SigClosure && $super instanceof SigClosure) { return $this->check($sub->signature, $super->signature) !== null; } @@ -198,9 +244,9 @@ private function provablyNotSubtype(SigType $sub, SigType $super): bool return $leaf !== null && $this->classify($leaf->type) === 'scalar'; } - // @infection-ignore-all — defensive: SigRaw is handled above and both - // SigClosure cases too, so by here both operands are always SigTypeRef; - // this guard only exists for a future SigType arm and never fires. + // @infection-ignore-all — defensive: SigRaw, every compound (union/ + // intersection), and both SigClosure cases are handled above, so by here + // both operands are always SigTypeRef; this guard never fires. if (!$sub instanceof SigTypeRef || !$super instanceof SigTypeRef) { return false; } @@ -326,6 +372,12 @@ private static function display(SigType $type): string if ($type instanceof SigClosure) { return 'Closure(...)'; } + if ($type instanceof SigUnion) { + return implode('|', array_map(self::display(...), $type->members)); + } + if ($type instanceof SigIntersection) { + return implode('&', array_map(self::display(...), $type->members)); + } return $type instanceof SigRaw ? $type->raw : '?'; } } diff --git a/src/Transpiler/Monomorphize/SigIntersection.php b/src/Transpiler/Monomorphize/SigIntersection.php new file mode 100644 index 0000000..8c39ffd --- /dev/null +++ b/src/Transpiler/Monomorphize/SigIntersection.php @@ -0,0 +1,32 @@ + $members Two or more, in source order (order is not + * significant to conformance). + */ + public function __construct( + public array $members, + ) { + } +} diff --git a/src/Transpiler/Monomorphize/SigRaw.php b/src/Transpiler/Monomorphize/SigRaw.php index 6a01c05..a3913d7 100644 --- a/src/Transpiler/Monomorphize/SigRaw.php +++ b/src/Transpiler/Monomorphize/SigRaw.php @@ -5,11 +5,13 @@ namespace XPHP\Transpiler\Monomorphize; /** - * A signature leaf that was scanned and erased but not yet structurally parsed — - * currently a union (`A|B`) or intersection (`A&B`) member type. The raw source - * text is retained so a later work item can build the proper `SigUnion` / - * `SigIntersection` node; until then no conformance rule reads it, so the - * placeholder only has to survive erasure without losing the bytes. + * A signature leaf that was scanned and erased but could NOT be structured into a + * {@see SigUnion} / {@see SigIntersection} / {@see SigTypeRef} — the defensive + * fallback. Flat unions/intersections/nullables now structure, so this remains + * only for the residual shapes: a target-side DNF `(A&B)|C` (the token scanner + * doesn't split nested parens), a member that fails to resolve, or an + * intersection carrying a scalar member. The engine treats it as gradual (accept), + * so it only has to survive erasure without losing the bytes for the diagnostic. */ final readonly class SigRaw extends SigType { diff --git a/src/Transpiler/Monomorphize/SigType.php b/src/Transpiler/Monomorphize/SigType.php index 866a37a..7c7eb20 100644 --- a/src/Transpiler/Monomorphize/SigType.php +++ b/src/Transpiler/Monomorphize/SigType.php @@ -16,14 +16,15 @@ * and substitution machinery depend on, and the compound/nested shapes live here. * * Concrete arms: - * - `SigTypeRef` — a scalar / class / type-parameter / pseudo-type leaf, - * wrapping a `TypeRef` so the existing substitution + - * `TypeHierarchy` machinery is reused unchanged. - * - `SigClosure` — a nested `ClosureSignature`. - * - * A later work item adds `SigUnion` / `SigIntersection` for `A|B` / `A&B` leaves; - * introducing the abstract base now keeps that a purely additive extension rather - * than a schema break across the substitution visitor. + * - `SigTypeRef` — a scalar / class / type-parameter / pseudo-type leaf, + * wrapping a `TypeRef` so the existing substitution + + * `TypeHierarchy` machinery is reused unchanged. + * - `SigClosure` — a nested `ClosureSignature`. + * - `SigUnion` — an `A|B` (or nullable `?A` ≡ `A|null`) leaf. + * - `SigIntersection` — an `A&B` leaf. + * - `SigRaw` — a defensive fallback for a leaf that couldn't be + * structured (a target-side DNF, an unresolved member, an + * intersection carrying a scalar); accepted gradually. */ abstract readonly class SigType { diff --git a/src/Transpiler/Monomorphize/SigUnion.php b/src/Transpiler/Monomorphize/SigUnion.php new file mode 100644 index 0000000..72c5eea --- /dev/null +++ b/src/Transpiler/Monomorphize/SigUnion.php @@ -0,0 +1,30 @@ + $members Two or more, in source order. Order is not + * significant to conformance (the rules quantify over the set). + */ + public function __construct( + public array $members, + ) { + } +} diff --git a/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php b/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php index c54ee86..1c4091c 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php @@ -431,6 +431,121 @@ public function testBuiltinParameterTargetIsAcceptedGradually(): void ); } + // ---- Union / intersection member variance (WI-03) -------------------- + + public function testUnionTargetParameterRejectsTooNarrowCandidate(): void + { + // target Closure(int|string $x); candidate fn(int $x) can't accept a string + // the target may pass — sub-union OR finds the string arm provably-not. + self::assertViolation( + ClosureConformanceViolation::KIND_PARAM_TYPE, + self::sig([self::p(self::ref('int'))], null), // candidate + self::sig([self::p(self::union(self::ref('int'), self::ref('string')))], null), // target + ); + } + + public function testUnionParameterAcceptsEqualUnionRegardlessOfMemberOrder(): void + { + // The order-independence case that a super-first decomposition would false- + // reject: candidate string|int vs target int|string must conform. + self::assertConforms( + self::sig([self::p(self::union(self::ref('string'), self::ref('int')))], null), + self::sig([self::p(self::union(self::ref('int'), self::ref('string')))], null), + ); + } + + public function testNarrowerReturnConformsToUnionReturn(): void + { + // candidate return int; target return int|string — int fits the union. + self::assertConforms( + self::sig([], self::ref('int')), + self::sig([], self::union(self::ref('int'), self::ref('string'))), + ); + } + + public function testReturnOutsideUnionIsRejected(): void + { + // candidate return float; target return int|string — provably neither. + self::assertViolation( + ClosureConformanceViolation::KIND_RETURN_TYPE, + self::sig([], self::ref('float')), + self::sig([], self::union(self::ref('int'), self::ref('string'))), + ); + } + + public function testUnionReturnWithGradualMemberStaysGradual(): void + { + // candidate return App\Apple; target return int|App\External\Thing. Apple is + // provably not an int, but the undeclared Thing arm can't be disproven — so + // the union stays gradual and accepts (no false reject). A scalar candidate + // would instead reject, since a scalar is provably no class at all. + self::assertConforms( + self::sig([], self::ref('App\\Apple')), + self::sig([], self::union(self::ref('int'), self::ref('App\\External\\Thing'))), + ); + } + + public function testNarrowerReturnConformsToNullableUnionReturn(): void + { + // ?int modelled as int|null; candidate return int is a subtype — accept. + self::assertConforms( + self::sig([], self::ref('int')), + self::sig([], self::union(self::ref('int'), self::ref('null'))), + ); + } + + public function testSuperIntersectionReturnRejectsNonMember(): void + { + // candidate return App\Fruit; target return App\Apple & App\Closurish — + // Fruit is provably not an Apple, so the intersection is not satisfied. + self::assertViolation( + ClosureConformanceViolation::KIND_RETURN_TYPE, + self::sig([], self::ref('App\\Fruit')), + self::sig([], self::intersection(self::ref('App\\Apple'), self::ref('App\\Closurish'))), + ); + } + + public function testSuperIntersectionWithUndeclaredMemberStaysGradual(): void + { + // One member undeclared ⇒ the whole intersection is unprovable ⇒ accept. + self::assertConforms( + self::sig([], self::ref('App\\Apple')), + self::sig([], self::intersection(self::ref('App\\Apple'), self::ref('App\\External\\Thing'))), + ); + } + + public function testSubIntersectionParameterIsAcceptedEvenWhenUninhabited(): void + { + // CRITICAL cardinal-rule guard: target Closure(Apple&Orange $x) is + // uninhabited (two concrete siblings ⇒ `never`), and `never` is a subtype of + // everything, so ANY candidate parameter is vacuously wide. The sub-side + // intersection must stay gradual, never decompose into a reject. + self::assertConforms( + self::sig([self::p(self::ref('App\\Closurish'))], null), + self::sig([self::p(self::intersection(self::ref('App\\Apple'), self::ref('App\\Orange')))], null), + ); + } + + public function testViolationDetailRendersUnionMembers(): void + { + $violation = self::engine()->check( + self::sig([], self::ref('float')), + self::sig([], self::union(self::ref('int'), self::ref('string'))), + ); + self::assertNotNull($violation); + self::assertStringContainsString('int|string', $violation->detail); + } + + public function testViolationDetailRendersIntersectionMembers(): void + { + $violation = self::engine()->check( + self::sig([], self::ref('App\\Fruit')), + self::sig([], self::intersection(self::ref('App\\Apple'), self::ref('App\\Closurish'))), + ); + self::assertNotNull($violation); + self::assertStringContainsString('App\\Apple&App\\Closurish', $violation->detail); + } + public function testViolationDetailNamesThePositionAndBothTypes(): void { $violation = self::engine()->check( @@ -535,4 +650,14 @@ private static function cl(ClosureSignature $sig): SigClosure { return new SigClosure($sig); } + + private static function union(SigType ...$members): SigUnion + { + return new SigUnion(array_values($members)); + } + + private static function intersection(SigType ...$members): SigIntersection + { + return new SigIntersection(array_values($members)); + } } From 4618b7534b6b0959c06d69b44b9a5210713ad5df Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 09:03:00 +0000 Subject: [PATCH 11/80] feat(monomorphize): structure a closure literal's union/intersection/nullable types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The candidate extractor now builds SigUnion / SigIntersection from a closure literal's nikic type nodes instead of an opaque SigRaw: `?A` becomes `A|null`, a union maps its members (including a DNF `(A&B)` member, which nikic nests as an intersection inside the union — structured recursively for free), an intersection maps its members. A member that fails to resolve, or a scalar member in an intersection (only reachable from already-invalid PHP), falls the whole leaf back to a gradual SigRaw so no partially-structured leaf can false-decide. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/ClosureLiteralSignature.php | 53 ++++++++++++++++- .../ClosureLiteralSignatureTest.php | 57 +++++++++++++++---- 2 files changed, 98 insertions(+), 12 deletions(-) diff --git a/src/Transpiler/Monomorphize/ClosureLiteralSignature.php b/src/Transpiler/Monomorphize/ClosureLiteralSignature.php index 849d858..973fe30 100644 --- a/src/Transpiler/Monomorphize/ClosureLiteralSignature.php +++ b/src/Transpiler/Monomorphize/ClosureLiteralSignature.php @@ -69,8 +69,19 @@ private static function resolveType(?Node $type, NamespaceContext $ctx): ?SigTyp if ($type === null) { return null; } - if ($type instanceof NullableType || $type instanceof UnionType || $type instanceof IntersectionType) { - return new SigRaw(self::rawText($type)); + // `?A` ≡ `A|null`: a union of the inner type and the `null` leaf. + if ($type instanceof NullableType) { + $inner = self::resolveType($type->type, $ctx); + if ($inner === null) { + return new SigRaw(self::rawText($type)); + } + return new SigUnion([$inner, new SigTypeRef(new TypeRef('null', [], isScalar: true))]); + } + if ($type instanceof UnionType) { + return self::resolveUnion($type, $ctx); + } + if ($type instanceof IntersectionType) { + return self::resolveIntersection($type, $ctx); } // @infection-ignore-all — a param/return type node is only ever null, a // compound (handled above), or a simple Identifier/Name, so the negated @@ -90,6 +101,44 @@ private static function resolveType(?Node $type, NamespaceContext $ctx): ?SigTyp return null; } + /** + * Structure a `A|B` (possibly with a DNF `(A&B)` member, which nikic nests as + * an `IntersectionType` inside the union) into a {@see SigUnion}. If any member + * fails to resolve, the whole leaf falls back to a gradual {@see SigRaw} — never + * a partially-structured union that could false-decide. + */ + private static function resolveUnion(UnionType $type, NamespaceContext $ctx): SigType + { + $members = []; + foreach ($type->types as $member) { + $resolved = self::resolveType($member, $ctx); + if ($resolved === null) { + return new SigRaw(self::rawText($type)); + } + $members[] = $resolved; + } + return new SigUnion($members); + } + + /** + * Structure an `A&B` into a {@see SigIntersection}. A member that fails to + * resolve, or a **scalar** member (PHP forbids scalars in an intersection, so it + * can only come from already-invalid source), falls the whole leaf back to a + * gradual {@see SigRaw}. + */ + private static function resolveIntersection(IntersectionType $type, NamespaceContext $ctx): SigType + { + $members = []; + foreach ($type->types as $member) { + $resolved = self::resolveType($member, $ctx); + if ($resolved === null || ($resolved instanceof SigTypeRef && $resolved->type->isScalar)) { + return new SigRaw(self::rawText($type)); + } + $members[] = $resolved; + } + return new SigIntersection($members); + } + /** * A readable rendering of a compound type for the raw leaf. * diff --git a/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php b/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php index c4f84f0..3468371 100644 --- a/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php +++ b/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php @@ -58,24 +58,51 @@ public function testByRefAndVariadicAndOptionalAreCaptured(): void self::assertTrue($sig->params[2]->variadic); } - public function testNullableAndUnionTypesAreCarriedRaw(): void + public function testNullableAndUnionTypesAreStructured(): void { $sig = self::extract('params[0]->type); - self::assertSame('?int', $sig->params[0]->type->raw); - self::assertInstanceOf(SigRaw::class, $sig->params[1]->type); - self::assertSame('int|string', $sig->params[1]->type->raw); - self::assertInstanceOf(SigRaw::class, $sig->return); - self::assertSame('int|null', $sig->return->raw); + // ?int ≡ int|null + $a = $sig->params[0]->type; + self::assertInstanceOf(SigUnion::class, $a); + self::assertSame(['int', 'null'], self::memberNames($a)); + // The synthesized `null` leaf is scalar-flagged, matching the SCALAR_TYPES + // leaf path (`null` ∈ SCALAR_TYPES). + self::assertTrue(self::refIsScalar($a->members[1])); + + $b = $sig->params[1]->type; + self::assertInstanceOf(SigUnion::class, $b); + self::assertSame(['int', 'string'], self::memberNames($b)); + + self::assertInstanceOf(SigUnion::class, $sig->return); + self::assertSame(['int', 'null'], self::memberNames($sig->return)); } - public function testIntersectionTypeIsCarriedRaw(): void + public function testIntersectionTypeIsStructured(): void { $sig = self::extract('return); - self::assertSame('Countable&Traversable', $sig->return->raw); + self::assertInstanceOf(SigIntersection::class, $sig->return); + self::assertSame(['App\\Countable', 'App\\Traversable'], self::memberNames($sig->return)); + } + + public function testIntersectionWithAScalarMemberFallsBackToRaw(): void + { + // Not valid PHP (a scalar can't be an intersection member); nikic still + // builds it, and the extractor keeps it gradual rather than structuring. + $sig = self::extract('params[0]->type); + } + + public function testDnfUnionMemberIsStructuredRecursively(): void + { + $sig = self::extract('return; + self::assertInstanceOf(SigUnion::class, $ret); + self::assertInstanceOf(SigIntersection::class, $ret->members[0]); + self::assertInstanceOf(SigTypeRef::class, $ret->members[1]); } public function testArrowFunctionReturnIsExtracted(): void @@ -139,6 +166,16 @@ private static function typeName(?SigType $type): ?string return $type instanceof SigTypeRef ? $type->type->name : null; } + /** + * The `TypeRef` names of a compound leaf's members, in order. + * + * @return list + */ + private static function memberNames(SigUnion|SigIntersection $type): array + { + return array_map(self::typeName(...), $type->members); + } + private static function refIsScalar(SigType $type): bool { return $type instanceof SigTypeRef && $type->type->isScalar; From cb773f13bb471d10c4e827a8274e0b76fc1f7941 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 09:15:57 +0000 Subject: [PATCH 12/80] feat(monomorphize): structure flat union/intersection/nullable in target signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The target-side token scanner now splits a flat `A|B` / `A&B` / `?A` in a `Closure(...)` type into a SigUnion / SigIntersection (parseFlatCompound walks members after the first leaf), and the signature resolver recurses into their members so each resolves to the same FQN / scalar / type-parameter space as any other target leaf. A shape the flat splitter doesn't own — a DNF group `(A&B)|C`, a mixed `|`/`&` at top level, a `?`-prefixed member, or a scalar in an intersection — falls back to a gradual SigRaw with a correct span. With both the target scanner and the candidate extractor structuring compounds, a union/intersection member mismatch is now caught end to end, e.g. `Closure(): int|string` handed `fn(): float` fails the build. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 157 ++++++++++++++++-- .../ClosureConformanceValidatorTest.php | 65 ++++++++ .../ClosureSignatureParseTest.php | 139 +++++++++++++--- 3 files changed, 322 insertions(+), 39 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index b1b82a9..57fd566 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -1024,15 +1024,18 @@ private static function parseSigParam(array $tokens, int $i, string $source): ar /** * Parse one signature leaf type beginning at `$i` → `[SigType, nextIdx]`. * A nested `Closure(…)` becomes a `SigClosure`; a plain scalar / class / - * type-parameter / `Name<…>` leaf becomes a `SigTypeRef` (unresolved — the - * resolver walks the tree later); a `?` / union / intersection leaf is kept - * as raw text in a `SigRaw` (structured form deferred). + * type-parameter / `Name<…>` leaf becomes a `SigTypeRef`; a flat `?A` / `A|B` / + * `A&B` is structured into a `SigUnion` / `SigIntersection` (the member-splitting + * lives in {@see parseFlatCompound}); a shape the flat splitter does not own (a + * DNF group, a mixed `|`/`&`, a scalar in an intersection) falls back to a + * gradual `SigRaw`. Members are left unresolved — the resolver walks the tree. * - * @infection-ignore-all — leaf-kind selection (nested `SigClosure`, compound - * `SigRaw`, plain `SigTypeRef`) is pinned by the nested / union / intersection / - * nullable-leaf tests; the residual mutants are `$i < $n` lookahead guards and - * whitespace-skip offsets, equivalent because every path is entered only for the - * balanced, type-shaped spans the caller passes. + * @infection-ignore-all — leaf-kind DISPATCH (nested `SigClosure`, nullable, + * union/intersection, plain `SigTypeRef`) is pinned behaviorally by the parser's + * accept/reject fixtures; the member-splitting logic it delegates to lives in + * {@see parseFlatCompound} / {@see parseMemberLeaf} (fully mutation-covered), and + * the residual mutants here are `$i < $n` lookahead guards and whitespace-skip + * offsets, equivalent for the balanced, type-shaped spans the caller passes. * * @param list $tokens * @return array{0: SigType, 1: int} @@ -1071,20 +1074,132 @@ private static function parseSigType(array $tokens, int $i, string $source): arr } [$typeRef, $afterLeaf] = $parsed; - // Union / intersection continuation → raw (structured form is a later WI). $peek = self::skipWs($tokens, $afterLeaf); - $isCompound = $nullableLeaf - || ($peek < $n && ($tokens[$peek]->text === '|' - || ($tokens[$peek]->text === '&' && self::sigAmpersandIsIntersection($tokens, $peek)))); - if ($isCompound) { - $end = self::scanTypeExprEnd($tokens, $start); - $end = $end ?? ($afterLeaf - 1); - return [new SigRaw(self::sliceTokens($tokens, $source, $start, $end)), $end + 1]; + $hasUnion = $peek < $n && $tokens[$peek]->text === '|'; + $hasIntersection = $peek < $n && $tokens[$peek]->text === '&' + && self::sigAmpersandIsIntersection($tokens, $peek); + + // A leading `?` is `A|null`. `?A|B` / `?A&B` are invalid PHP; keep raw. + if ($nullableLeaf) { + if ($hasUnion || $hasIntersection) { + return self::rawSigLeaf($tokens, $source, $start, $afterLeaf); + } + return [new SigUnion([new SigTypeRef($typeRef), new SigTypeRef(new TypeRef('null'))]), $afterLeaf]; + } + + // A flat union / intersection continuation → structure it. A DNF paren, a + // mixed `|`/`&` at top level, or a scalar in an intersection falls back to a + // gradual SigRaw (structured DNF on the token side is a tracked follow-up). + if ($hasUnion || $hasIntersection) { + $compound = self::parseFlatCompound($tokens, $typeRef, $afterLeaf, $hasUnion ? '|' : '&'); + return $compound ?? self::rawSigLeaf($tokens, $source, $start, $afterLeaf); } return [new SigTypeRef($typeRef), $afterLeaf]; } + /** + * Collect the remaining members of a FLAT union / intersection (the first is + * already parsed) into a {@see SigUnion} / {@see SigIntersection}. Returns null + * to fall back to a gradual {@see SigRaw} for a case the flat splitter does not + * own: a DNF group `(…)`, a mixed `|`/`&` at top level, an intersection member + * that is a scalar (invalid PHP), or a member that isn't a type leaf. + * + * @param list $tokens + * @param string $sep '|' or '&' + * @return array{0: SigType, 1: int}|null + */ + private static function parseFlatCompound(array $tokens, TypeRef $first, int $afterFirst, string $sep): ?array + { + $n = count($tokens); + $members = [$first]; + $lastEnd = $afterFirst; + $i = self::skipWs($tokens, $afterFirst); + // @infection-ignore-all LessThan — `$i < $n` is a defensive bound; a well-formed + // signature always has a following `)` / `{`, so `$i` never reaches `$n` here. + while ($i < $n && $tokens[$i]->text === $sep) { + $j = self::skipWs($tokens, $i + 1); + // A member that isn't a type leaf — a DNF group `(…)`, a `?`-prefixed + // member, or end-of-input — bails the whole leaf to a gradual SigRaw. + $member = self::parseMemberLeaf($tokens, $j); + if ($member === null) { + return null; + } + [$ref, $lastEnd] = $member; + $members[] = $ref; + $i = self::skipWs($tokens, $lastEnd); + } + + // A different separator still ahead ⇒ a mix that needs DNF parens ⇒ bail. + // @infection-ignore-all LessThan — `$i < $n` is a defensive bound (a following + // `)` / `{` always exists); the separator conditions are pinned behaviorally. + if ($i < $n && ($tokens[$i]->text === '|' + || ($tokens[$i]->text === '&' && self::sigAmpersandIsIntersection($tokens, $i))) + ) { + return null; + } + + $leaves = array_map(static fn (TypeRef $r): SigTypeRef => new SigTypeRef($r), $members); + if ($sep === '|') { + return [new SigUnion($leaves), $lastEnd]; + } + // A scalar member makes the intersection invalid PHP ⇒ stay gradual (raw). + foreach ($members as $member) { + // @infection-ignore-all UnwrapLtrim/UnwrapStrToLower — a scalar keyword is + // never fully-qualified and arrives lowercased from the grammar, so both + // normalizations are defensive (same rationale as resolveTypeRef's scalar guard). + if (in_array(strtolower(ltrim($member->name, '\\')), self::SCALAR_TYPES, true)) { + return null; + } + } + return [new SigIntersection($leaves), $lastEnd]; + } + + /** + * Parse one union / intersection MEMBER leaf → `[TypeRef, nextIdx]`, or null if + * the position isn't a type leaf. Mirrors the primary-leaf parse in + * {@see parseSigType}: a `Name` / `Name<…>` via {@see parseTypeArg}, or a + * reserved-word type keyword (`array` / `callable` / `static`). + * + * @param list $tokens + * @return array{0: TypeRef, 1: int}|null + */ + private static function parseMemberLeaf(array $tokens, int $i): ?array + { + $parsed = self::parseTypeArg($tokens, $i); + if ($parsed !== null) { + return $parsed; + } + // @infection-ignore-all LessThan UnwrapStrToLower — the `$i < count` bound is + // defensive (the caller only reaches here mid-span, never at end-of-input); + // `strtolower` matches the first-leaf keyword branch but the resolver + // re-lowercases, so it is redundant. (The `$i + 1` end index is NOT ignored — + // it is pinned by a keyword-member-followed-by-parameter test.) + if ($i < count($tokens) && self::isSigTypeToken($tokens[$i])) { + return [new TypeRef(strtolower($tokens[$i]->text)), $i + 1]; + } + return null; + } + + /** + * The gradual {@see SigRaw} fallback for a compound leaf the flat splitter does + * not own — spans from `$start` to the end of the type expression. + * + * @infection-ignore-all — pure index plumbing: the `?? ($afterLeaf - 1)` arm is + * unreachable (scanTypeExprEnd returns non-null for the balanced spans that reach a + * bail), and the `$end + 1` next-index is the parser's shared convention, absorbed + * by every caller's `skipWs`, so a ±1 shift is unobservable. The raw text is + * display-only for a gradual leaf. Bail *detection* is pinned behaviorally. + * + * @param list $tokens + * @return array{0: SigRaw, 1: int} + */ + private static function rawSigLeaf(array $tokens, string $source, int $start, int $afterLeaf): array + { + $end = self::scanTypeExprEnd($tokens, $start) ?? ($afterLeaf - 1); + return [new SigRaw(self::sliceTokens($tokens, $source, $start, $end)), $end + 1]; + } + /** * True iff the `&` at `$idx` continues an intersection type (a Name follows) * rather than marking a by-reference parameter (a `$var` / `...` follows). @@ -2312,9 +2427,15 @@ private function resolveSigType(SigType $type): SigType if ($type instanceof SigClosure) { return new SigClosure($this->resolveClosureSignature($type->signature)); } + if ($type instanceof SigUnion) { + return new SigUnion(array_map($this->resolveSigType(...), $type->members)); + } + if ($type instanceof SigIntersection) { + return new SigIntersection(array_map($this->resolveSigType(...), $type->members)); + } - // SigRaw (union / intersection / nullable leaf) — structured - // resolution is a later work item; carry the raw text through. + // SigRaw — a leaf the flat splitter could not structure (a DNF group, + // an intersection with a scalar); carry the raw text through gradual. return $type; } diff --git a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php index 03c4f37..2383c7e 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php @@ -56,6 +56,12 @@ public static function conformingSites(): iterable yield 'S-A method return' => [ ' $x; } }', ]; + yield 'S-A union return: a narrower member conforms' => [ + ' 0; }', + ]; + yield 'S-A union parameter: candidate accepts the whole union' => [ + ' null; }', + ]; yield 'target references a type parameter ⇒ gradual here' => [ ' { public function m(): Closure(T $x): T { return fn(string $x): int => 0; } }', ]; @@ -126,6 +132,16 @@ public static function violatingSites(): iterable 2, 'by-reference-ness must match exactly', ]; + yield 'S-A union return: outside the union' => [ + " 0.0;\n}", + 2, + 'float is not a subtype of int|string', + ]; + yield 'S-A union parameter: candidate too narrow' => [ + " null;\n}", + 2, + 'parameter 1: int is not wider than int|string', + ]; } /** @@ -186,6 +202,55 @@ function make(): Closure(): Apple { return fn(): Fruit => new Fruit(); } ); } + public function testTargetUnionMembersAreResolvedForConformance(): void + { + // Both union members must resolve to `App\*` for the engine to prove `Fruit` + // is neither; if the resolver didn't recurse into union members they'd stay + // unqualified/undeclared and the violation would be silently lost. + $source = <<<'X' + new Fruit(); } + X; + $ast = self::parseRaw($source); + + $diagnostics = new DiagnosticCollector(); + self::validator($ast)->validateFile($ast, 'test.xphp', $diagnostics); + + self::assertCount(1, $diagnostics->all()); + self::assertStringContainsString( + 'is not a subtype of App\\Apple|App\\Orange', + $diagnostics->all()[0]->message, + ); + } + + public function testTargetIntersectionMembersAreResolvedForConformance(): void + { + // The intersection members must resolve too — `Fruit` is provably not an + // `App\Apple`, so the `App\Apple&App\Orange` return target is unsatisfied. + $source = <<<'X' + new Fruit(); } + X; + $ast = self::parseRaw($source); + + $diagnostics = new DiagnosticCollector(); + self::validator($ast)->validateFile($ast, 'test.xphp', $diagnostics); + + self::assertCount(1, $diagnostics->all()); + self::assertStringContainsString( + 'is not a subtype of App\\Apple&App\\Orange', + $diagnostics->all()[0]->message, + ); + } + public function testGlobalBracedNamespaceIsHandledWithoutFatal(): void { // A bare `namespace { ... }` block has a null name; the walk must enter it diff --git a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php index a5a374f..f7ddefe 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php @@ -15,9 +15,10 @@ * WI-01 scope: the scanner recognizes a `Closure(...)[: ret]` production in a * type slot, erases it to a bare `\Closure` (newline-preserving so line-keyed * markers stay aligned), and attaches the structured {@see ClosureSignature} as - * an AST attribute for the later conformance validator. Union / intersection / - * nullable leaf types are carried as raw text ({@see SigRaw}); their structured - * form is a later work item. No conformance checking happens here. + * an AST attribute for the later conformance validator. A flat union / intersection + * / nullable leaf is structured into a {@see SigUnion} / {@see SigIntersection}; a + * shape the flat splitter doesn't own (a DNF group, an intersection with a scalar) + * stays raw ({@see SigRaw}). No conformance checking happens here. */ final class ClosureSignatureParseTest extends TestCase { @@ -340,20 +341,21 @@ function apply(Closure(int): int $fn, int $n): int { } // =================================================================== - // Union / intersection leaves are erased but carried raw (WI-03) + // Flat union / intersection / nullable leaves are structured (WI-03) // =================================================================== - public function testUnionAndIntersectionLeavesAreErasedAndCarriedRaw(): void + public function testUnionAndIntersectionLeavesAreStructuredAndErased(): void { $source = 'params); - self::assertInstanceOf(SigRaw::class, $sig->params[0]->type); - self::assertSame('A&B', $sig->params[0]->type->raw); - self::assertInstanceOf(SigRaw::class, $sig->return); - self::assertSame('C|null', $sig->return->raw); + $param = $sig->params[0]->type; + self::assertInstanceOf(SigIntersection::class, $param); + self::assertSame(['A', 'B'], self::sigMemberNames($param)); + self::assertInstanceOf(SigUnion::class, $sig->return); + self::assertSame(['C', 'null'], self::sigMemberNames($sig->return)); // Still fully erased to a bare \Closure. $stripped = self::strip($source); @@ -362,15 +364,100 @@ public function testUnionAndIntersectionLeavesAreErasedAndCarriedRaw(): void self::assertStringNotContainsString('C|null', $stripped); } - public function testNullableLeafInsideSignatureIsCarriedRaw(): void + public function testUnionParameterSpanEndsSoNextParameterParses(): void + { + // The union's end index must be exact, or the following `bool $b` mis-parses. + $sig = self::firstSig('params); + self::assertInstanceOf(SigUnion::class, $sig->params[0]->type); + self::assertSame(['int', 'string'], self::sigMemberNames($sig->params[0]->type)); + self::assertSame('bool', self::refName($sig->params[1]->type)); + } + + public function testKeywordUnionMemberSpanEndsSoNextParameterParses(): void + { + // `array` is a reserved-word keyword member (parsed by the keyword branch, not + // parseTypeArg); its end index must land the following `bool $b` correctly. + $sig = self::firstSig('params); + self::assertInstanceOf(SigUnion::class, $sig->params[0]->type); + self::assertSame(['int', 'array'], self::sigMemberNames($sig->params[0]->type)); + self::assertSame('bool', self::refName($sig->params[1]->type)); + } + + public function testIntersectionParameterSpanEndsSoNextParameterParses(): void + { + $sig = self::firstSig('params); + self::assertInstanceOf(SigIntersection::class, $sig->params[0]->type); + self::assertSame(['Countable', 'Traversable'], self::sigMemberNames($sig->params[0]->type)); + self::assertSame('int', self::refName($sig->params[1]->type)); + } + + public function testScalarIntersectionBailSpanEndsSoNextParameterParses(): void + { + // The bailed SigRaw's end index must also be exact so `bool $b` parses. + $sig = self::firstSig('params); + self::assertInstanceOf(SigRaw::class, $sig->params[0]->type); + self::assertSame('bool', self::refName($sig->params[1]->type)); + } + + public function testMixedTopLevelSeparatorsFallBackToRaw(): void + { + // `A|B&C` mixes union and intersection at top level (needs DNF parens) — the + // flat splitter must not structure it; keep it a gradual SigRaw. + $sig = self::firstSig('return); + self::assertSame('A|B&C', $sig->return->raw); + } + + public function testUnionByReferenceParameterKeepsUnionAndMarker(): void + { + // `A|B &$r` — the trailing `&` is a by-ref marker (a `$var` follows), NOT an + // intersection continuation; the union stays structured and the param is by-ref. + $sig = self::firstSig('params); + self::assertInstanceOf(SigUnion::class, $sig->params[0]->type); + self::assertSame(['A', 'B'], self::sigMemberNames($sig->params[0]->type)); + self::assertTrue($sig->params[0]->byRef); + } + + public function testIntersectionWithScalarMemberStaysRawAndErases(): void + { + // `int&Countable` is invalid PHP (scalars can't intersect); the splitter keeps + // it a gradual SigRaw rather than structuring, and it still fully erases. + $source = 'params[0]->type); + self::assertSame('int&Countable', $sig->params[0]->type->raw); + self::assertStringNotContainsString('int&Countable', self::strip($source)); + } + + public function testNullableLeafInsideSignatureIsStructuredAsUnionWithNull(): void { // A `?Type` leaf INSIDE the signature (distinct from an outer `?Closure`) - // is kept raw until the structured-union work item. + // structures to `Type|null`; the outer nullable flag stays false. $sig = self::firstSig('params[0]->type); - self::assertSame('?int', $sig->params[0]->type->raw); + $param = $sig->params[0]->type; + self::assertInstanceOf(SigUnion::class, $param); + self::assertSame(['int', 'null'], self::sigMemberNames($param)); self::assertFalse($sig->nullable, 'the inner `?int` must not set the outer nullable flag'); } @@ -500,7 +587,7 @@ public function testNestedGenericAnglesInSignatureEraseFully(): void self::assertStringNotContainsString('>', $stripped); } - public function testThreeMemberUnionReturnCarriesFullRawAndErases(): void + public function testThreeMemberUnionReturnIsStructuredAndErases(): void { // `A|B|C` drives scanTypeExprEnd's `|`-continuation loop twice. A span that // stops after the first `|` yields raw `A|B` and leaves `|C` residue. @@ -508,8 +595,8 @@ public function testThreeMemberUnionReturnCarriesFullRawAndErases(): void $sig = self::firstSig($source); self::assertNotNull($sig); - self::assertInstanceOf(SigRaw::class, $sig->return); - self::assertSame('A|B|C', $sig->return->raw); + self::assertInstanceOf(SigUnion::class, $sig->return); + self::assertSame(['A', 'B', 'C'], self::sigMemberNames($sig->return)); self::assertStringNotContainsString('A|B|C', self::strip($source)); } @@ -677,21 +764,21 @@ public function testUnionWithArrayMemberErasesFullyWithoutResidue(): void $sig = self::firstSig($source); self::assertNotNull($sig); - self::assertInstanceOf(SigRaw::class, $sig->return); - self::assertSame('int|array', $sig->return->raw); + self::assertInstanceOf(SigUnion::class, $sig->return); + self::assertSame(['int', 'array'], self::sigMemberNames($sig->return)); $stripped = self::strip($source); self::assertStringNotContainsString('|array', $stripped); self::assertStringNotContainsString('|', $stripped); } - public function testNullableArrayLeafErasesAndIsCarriedRaw(): void + public function testNullableArrayLeafIsStructuredAsUnionWithNull(): void { $sig = self::firstSig('return); - self::assertSame('?array', $sig->return->raw); + self::assertInstanceOf(SigUnion::class, $sig->return); + self::assertSame(['array', 'null'], self::sigMemberNames($sig->return)); } // =================================================================== @@ -896,6 +983,16 @@ private static function refName(?SigType $type): ?string return $type->type->name; } + /** + * The (unresolved) member names of a compound leaf, in order. + * + * @return list + */ + private static function sigMemberNames(SigUnion|SigIntersection $type): array + { + return array_map(self::refName(...), $type->members); + } + private static function isScalarRef(SigType $type): bool { return $type instanceof SigTypeRef && $type->type->isScalar; From f16f4e36bba86445047a1664c4e6607c4212235d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 09:27:57 +0000 Subject: [PATCH 13/80] feat(monomorphize): ground type parameters inside union/intersection signature members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The specialization walk now recurses into SigUnion / SigIntersection members, so a `Closure(): T|int` return target grounds its `T` member alongside the concrete `int` when the class specializes. A member mismatch that was gradual at the abstract template (the `T` member accepts anything) becomes provable once grounded — `Box` turns `T|int` into `string|int`, and a class-returning factory literal now fails the build. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Specializer.php | 14 +++++++- .../ClosureConformanceIntegrationTest.php | 14 ++++++++ .../SpecializerClosureSignatureTest.php | 34 +++++++++++++++++++ .../source/Use.xphp | 22 ++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 test/fixture/compile/closure_conformance_grounded_union_reject/source/Use.xphp diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index bf35c6b..df231af 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -424,8 +424,20 @@ private static function substituteSigType(SigType $type, array $subst): SigType if ($type instanceof SigClosure) { return new SigClosure(self::substituteClosureSignature($type->signature, $subst)); } + if ($type instanceof SigUnion) { + return new SigUnion(array_map( + static fn (SigType $m): SigType => self::substituteSigType($m, $subst), + $type->members, + )); + } + if ($type instanceof SigIntersection) { + return new SigIntersection(array_map( + static fn (SigType $m): SigType => self::substituteSigType($m, $subst), + $type->members, + )); + } - // SigRaw (union / intersection / nullable) is gradual and unstructured; + // SigRaw (an unstructured DNF / scalar-bearing intersection) is gradual; // there is nothing to ground. return $type; } diff --git a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php index 876971c..899f97e 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php @@ -77,6 +77,20 @@ public function testGroundedGenericClosureConformsWhenTypeParameterResolves(): v } } + public function testGroundedUnionMemberFailsWhenTypeParameterResolvesToAConflict(): void + { + // `Closure(): T|int` grounds to `string|int` under `Box`; the class + // return literal is provably neither member, so the union member grounding + // turns a gradual accept at the template into a build failure. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is not a subtype of string|int'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/closure_conformance_grounded_union_reject/source', + 'closure-conformance-grounded-union', + ); + } + public function testGroundedGenericClosureFailsWhenTypeParameterResolvesToAConflict(): void { // Gradually accepted at the abstract template, but once `Box` grounds diff --git a/test/Transpiler/Monomorphize/SpecializerClosureSignatureTest.php b/test/Transpiler/Monomorphize/SpecializerClosureSignatureTest.php index bbeadc8..d5a8e25 100644 --- a/test/Transpiler/Monomorphize/SpecializerClosureSignatureTest.php +++ b/test/Transpiler/Monomorphize/SpecializerClosureSignatureTest.php @@ -59,6 +59,40 @@ public function testNestedClosureLeafIsSubstitutedRecursively(): void self::assertSame('int', self::leafName($outerParam->signature->return)); } + public function testTypeParameterInsideUnionMemberIsSubstituted(): void + { + // Closure(T|int $x) grounds the T member alongside the concrete int member. + $sig = new ClosureSignature( + [new ClosureSignatureParam(new SigUnion([ + new SigTypeRef(self::typeParam('T')), + new SigTypeRef(self::scalar('int')), + ]))], + null, + ); + + $grounded = Specializer::substituteClosureSignature($sig, ['T' => new TypeRef('App\\Apple')]); + + $param = $grounded->params[0]->type; + self::assertInstanceOf(SigUnion::class, $param); + self::assertSame('App\\Apple', self::leafName($param->members[0])); + self::assertSame('int', self::leafName($param->members[1])); + } + + public function testTypeParameterInsideIntersectionMemberIsSubstituted(): void + { + $sig = new ClosureSignature([], new SigIntersection([ + new SigTypeRef(self::typeParam('T')), + new SigTypeRef(new TypeRef('App\\Countable')), + ])); + + $grounded = Specializer::substituteClosureSignature($sig, ['T' => new TypeRef('App\\Apple')]); + + $ret = $grounded->return; + self::assertInstanceOf(SigIntersection::class, $ret); + self::assertSame('App\\Apple', self::leafName($ret->members[0])); + self::assertSame('App\\Countable', self::leafName($ret->members[1])); + } + public function testRawLeafIsCarriedThroughUnchanged(): void { $raw = new SigRaw('int|string'); diff --git a/test/fixture/compile/closure_conformance_grounded_union_reject/source/Use.xphp b/test/fixture/compile/closure_conformance_grounded_union_reject/source/Use.xphp new file mode 100644 index 0000000..a2ac0ae --- /dev/null +++ b/test/fixture/compile/closure_conformance_grounded_union_reject/source/Use.xphp @@ -0,0 +1,22 @@ +`, the union grounds to +// `string|int` — the `Fruit` literal is provably neither, so compilation fails. +class Box +{ + public function make(): Closure(): T|int + { + return fn(): Fruit => new Fruit(); + } +} + +$box = new Box::(); From 0158e4040568b3af41e6eadb0c2dab59ebb6c6f5 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 09:50:45 +0000 Subject: [PATCH 14/80] test(monomorphize): pin the sub-intersection gradual-accept paths against compound supers An intersection on the sub side of a signature subtyping check stays gradual (uninhabited intersections are `never`, a subtype of everything). Add behavioral accept tests covering that sub-intersection against a union super, an intersection super, and the covariant return analog, and sharpen the comment explaining why the early gradual return is equivalent to letting the compound-super arms recurse. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ClosureSignatureConformance.php | 10 ++++-- .../ClosureSignatureConformanceTest.php | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/Transpiler/Monomorphize/ClosureSignatureConformance.php b/src/Transpiler/Monomorphize/ClosureSignatureConformance.php index 105a89a..8515849 100644 --- a/src/Transpiler/Monomorphize/ClosureSignatureConformance.php +++ b/src/Transpiler/Monomorphize/ClosureSignatureConformance.php @@ -202,9 +202,13 @@ private function provablyNotSubtype(SigType $sub, SigType $super): bool // of incompatible members is uninhabited (`never`, a subtype of everything) // and there is no inhabitation check here — decomposing would false-reject. // Kept gradual; the inhabited-intersection reject is a tracked follow-up. - // @infection-ignore-all — the explicit gradual return states the rule; a - // SigIntersection sub also falls through to the defensive non-SigTypeRef - // guard below, which returns the same false, so removing it is equivalent. + // @infection-ignore-all — equivalent: a SigIntersection sub yields false down + // EVERY downstream path anyway. A leaf/closure super reaches the defensive + // non-SigTypeRef guard below (false); a compound super's arms recurse on this + // same SigIntersection sub against each leaf member, and each of those recursions + // bottoms out at that same guard (false), so the union AND / intersection OR both + // resolve to false too. The explicit early return only states the rule directly. + // The accept BEHAVIOUR is pinned by the sub-intersection accept tests. if ($sub instanceof SigIntersection) { return false; } diff --git a/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php b/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php index 1c4091c..1cee0b6 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php @@ -526,6 +526,38 @@ public function testSubIntersectionParameterIsAcceptedEvenWhenUninhabited(): voi ); } + public function testSubIntersectionParameterAcceptedAgainstUnionCandidate(): void + { + // Pins the sub-intersection-vs-union-super accept path: target parameter + // Apple&Orange (uninhabited ⇒ gradual sub side) checked contravariantly against + // a candidate parameter Apple|Orange. Decomposing the sub side would false-reject; + // it must stay gradual through the super-union arm. + self::assertConforms( + self::sig([self::p(self::union(self::ref('App\\Apple'), self::ref('App\\Orange')))], null), + self::sig([self::p(self::intersection(self::ref('App\\Apple'), self::ref('App\\Orange')))], null), + ); + } + + public function testSubIntersectionParameterAcceptedAgainstIntersectionCandidate(): void + { + // Sub-intersection-vs-intersection-super accept path: the sub-side intersection + // must stay gradual as the super-intersection arm recurses over each member. + self::assertConforms( + self::sig([self::p(self::intersection(self::ref('App\\Apple'), self::ref('App\\Closurish')))], null), + self::sig([self::p(self::intersection(self::ref('App\\Apple'), self::ref('App\\Orange')))], null), + ); + } + + public function testSubIntersectionReturnAcceptedAgainstUnionTarget(): void + { + // The return analog: candidate return Apple&Orange (gradual sub side) checked + // covariantly against a union target return Apple|Orange. Stays gradual. + self::assertConforms( + self::sig([], self::intersection(self::ref('App\\Apple'), self::ref('App\\Orange'))), + self::sig([], self::union(self::ref('App\\Apple'), self::ref('App\\Orange'))), + ); + } + public function testViolationDetailRendersUnionMembers(): void { $violation = self::engine()->check( From bde729f5892fbfa3b0f9e27ea1cb873449b91d4c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 09:53:31 +0000 Subject: [PATCH 15/80] docs: document union, intersection, and nullable members in Closure(...) signatures Describe member-by-member variance checking for flat unions, intersections, and nullable types, and note the two shapes that stay gradual (a DNF, and an intersection in a parameter position). Flag the current limitation that a parenthesised DNF in a signature's return position is not yet erased. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 +++++- docs/syntax/closure-types.md | 34 ++++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b83b97..00133c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,11 @@ _In progress on this branch — content still accumulating; date set at tag time prove wrong (untyped ⇒ `mixed`, an unresolved or built-in supertype, a still-abstract type parameter, a union/intersection). A signature that references an enclosing type parameter is **grounded** per specialization, so `Registry` and - `Registry` check the same factory against different concrete targets. See + `Registry` check the same factory against different concrete targets. A flat + union / intersection / nullable inside a signature (`Closure(int|string $x): void`, + `Closure(): A&B`, `?int`) is variance-checked member by member, following PHP's own + union/intersection subtyping; an intersection in a parameter position stays gradual + (an intersection of unrelated types is uninhabited). See [closure types](docs/syntax/closure-types.md). - **Multi-root builds via an `xphp.json` manifest.** A project declares its source roots, output directory, and hash length in an `xphp.json` at the project root; diff --git a/docs/syntax/closure-types.md b/docs/syntax/closure-types.md index 4c5b740..6372d91 100644 --- a/docs/syntax/closure-types.md +++ b/docs/syntax/closure-types.md @@ -100,10 +100,32 @@ new Registry::(); // target grounds to Closure(string): bool — the sa // is now rejected: int is not wider than string ``` -## Not yet checked - -A union or intersection **member** inside a signature (`Closure(int|string -$x): void`) is currently carried through and accepted gradually rather than -variance-checked. Prefer a single type per slot where you want the check to -apply. +## Union, intersection, and nullable members + +A flat union `A|B`, intersection `A&B`, or nullable `?A` inside a signature is +variance-checked member by member, following PHP's own subtyping: + +- a union in a **return** conforms when the literal's return fits **some** member + (`Closure(): int|string` accepts a `fn(): int`); +- a union in a **parameter** requires the literal to accept **every** member + (`Closure(int|string $x)` handed `fn(int $x)` fails — the literal can't take a + string); +- an intersection is checked where it is the expected (super) type — a value must + satisfy **every** member. + +As everywhere, an unprovable member keeps the whole leaf gradual: a union or +intersection that mentions an unresolved class, a type parameter, or a +pseudo-type is accepted rather than falsely rejected. + +Two shapes stay gradual (accepted) for now: a **DNF** type — a parenthesised +mix such as `Closure((A&B)|C $x): int` — is carried through without being +variance-checked, and an intersection used as an incoming (parameter) type is +too, because an intersection of unrelated types is uninhabited, so rejecting it +would be unsound. + +> **Known limitation.** A parenthesised DNF in a signature's **return** position +> (`Closure(): (A&B)|C`) is not yet erased and currently fails to compile — the +> leading `(` stops the type scanner. Write the return without parentheses, or +> annotate the slot as a bare `Closure` until this is supported. A DNF in a +> *parameter* position erases and runs normally. ``` From 5e8a10e61e0b42b450fbd4e88734a11582566b32 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 10:07:05 +0000 Subject: [PATCH 16/80] docs: sharpen closure-signature DNF limitation and a code comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct the sub-intersection subtyping comment to describe the closure-super path accurately, and rewrite the DNF caveat in the closure-types guide: a parenthesised DNF is unsupported in both positions — it fails to compile in a return and can throw off arity checking in a parameter — so steer users away from it entirely rather than implying a parameter DNF is always safe. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/syntax/closure-types.md | 24 +++++++++++-------- .../ClosureSignatureConformance.php | 7 +++--- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/syntax/closure-types.md b/docs/syntax/closure-types.md index 6372d91..e4cfe22 100644 --- a/docs/syntax/closure-types.md +++ b/docs/syntax/closure-types.md @@ -117,15 +117,19 @@ As everywhere, an unprovable member keeps the whole leaf gradual: a union or intersection that mentions an unresolved class, a type parameter, or a pseudo-type is accepted rather than falsely rejected. -Two shapes stay gradual (accepted) for now: a **DNF** type — a parenthesised -mix such as `Closure((A&B)|C $x): int` — is carried through without being -variance-checked, and an intersection used as an incoming (parameter) type is -too, because an intersection of unrelated types is uninhabited, so rejecting it +An intersection used as an incoming (parameter) type also stays gradual +(accepted): an intersection of unrelated types is uninhabited, so rejecting it would be unsound. -> **Known limitation.** A parenthesised DNF in a signature's **return** position -> (`Closure(): (A&B)|C`) is not yet erased and currently fails to compile — the -> leading `(` stops the type scanner. Write the return without parentheses, or -> annotate the slot as a bare `Closure` until this is supported. A DNF in a -> *parameter* position erases and runs normally. -``` +> **Known limitation — parenthesised DNF types are not fully supported.** A +> **DNF** signature type (a parenthesised mix such as `(A&B)|C`) is not yet +> parsed as a variance-checked type; avoid it inside a `Closure(...)` for now: +> +> - in a **return** position (`Closure(): (A&B)|C`) it is not erased and fails +> to compile — the leading `(` stops the type scanner; +> - in a **parameter** position at a checked factory site the leading `(` is +> mis-read, throwing the signature's arity off, which can wrongly reject an +> otherwise conforming literal. +> +> Write the type without the parentheses, or annotate the slot as a bare +> `Closure`, until DNF signature types are supported. diff --git a/src/Transpiler/Monomorphize/ClosureSignatureConformance.php b/src/Transpiler/Monomorphize/ClosureSignatureConformance.php index 8515849..6e49f68 100644 --- a/src/Transpiler/Monomorphize/ClosureSignatureConformance.php +++ b/src/Transpiler/Monomorphize/ClosureSignatureConformance.php @@ -203,10 +203,11 @@ private function provablyNotSubtype(SigType $sub, SigType $super): bool // and there is no inhabitation check here — decomposing would false-reject. // Kept gradual; the inhabited-intersection reject is a tracked follow-up. // @infection-ignore-all — equivalent: a SigIntersection sub yields false down - // EVERY downstream path anyway. A leaf/closure super reaches the defensive - // non-SigTypeRef guard below (false); a compound super's arms recurse on this + // EVERY downstream path anyway. A leaf super reaches the defensive non-SigTypeRef + // guard below (false); a closure super resolves false at the closure-vs-leaf branch + // (the intersection sub is no SigClosure); a compound super's arms recurse on this // same SigIntersection sub against each leaf member, and each of those recursions - // bottoms out at that same guard (false), so the union AND / intersection OR both + // bottoms out at one of those false results, so the union AND / intersection OR both // resolve to false too. The explicit early return only states the rule directly. // The accept BEHAVIOUR is pinned by the sub-intersection accept tests. if ($sub instanceof SigIntersection) { From af058e6c7d185c1963de12e93bfb733dca3dd368 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 12:43:55 +0000 Subject: [PATCH 17/80] fix(monomorphize): map anonymous template markers through the byte-offset map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The anonymous closure/arrow marker match compared the marker's ORIGINAL-source byte position against getStartFilePos() on the STRIPPED source. Any length-changing rewrite earlier in the file — the Name[] -> array sugar rewrite shortens by |name|-3 bytes — shifted the two apart, so a generic closure following such a rewrite silently lost its markers: type parameters never attached, the closure compiled unspecialized, and the emitted code fataled at runtime (TypeError on the un-erased T hint) despite a clean validation pass. Translate the node position back through the byte-offset map before comparing, the same mapping attachClosureSig already applies. Pinned by a fixture that places long-name array sugar ahead of a generic closure and executes the compiled output (fails pre-fix with the runtime TypeError). Named class/name markers match by line, not byte, and are NOT covered by this fix: any strip that replaces a newline with a space (a multi-line type-param list, turbofish arg list, or sugar span) still shifts every later line-keyed marker — the same silent-miscompile family, tracked separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 13 ++++++---- .../ArraySugarIntegrationTest.php | 20 +++++++++++++++ .../source/Use.xphp | 25 +++++++++++++++++++ .../verify/runtime.php | 19 ++++++++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 test/fixture/compile/array_sugar_before_generic_closure/source/Use.xphp create mode 100644 test/fixture/compile/array_sugar_before_generic_closure/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 57fd566..47db82f 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -2135,14 +2135,17 @@ public function enterNode(Node $node): null || $node instanceof Node\Expr\ArrowFunction ) { // Named templates match by (line, name); anonymous templates - // (closures + arrows) match by (line, bytePosition) -- the - // bytePosition recorded at the `function` / `static` / `fn` - // keyword aligns with nikic's `getStartFilePos()` for the - // same AST node. + // (closures + arrows) match by (kind, bytePosition). The marker + // records the ORIGINAL-source byte of the `function` / `static` / + // `fn` keyword, while getStartFilePos() reports the STRIPPED-source + // byte -- a length-changing rewrite earlier in the file (e.g. + // `LongName[]` -> `array`) shifts the two apart, so the stripped + // position maps back through the byte-offset map before comparing + // (same translation as attachClosureSig below). $isAnonymous = $node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction; $declName = $isAnonymous ? '' : $node->name->toString(); - $nodeStartByte = $node->getStartFilePos(); + $nodeStartByte = $this->byteOffsetMap->toOriginal($node->getStartFilePos()); $matchedParamNames = []; foreach ($this->methodMarkers as $i => $marker) { // @infection-ignore-all -- markers are populated jointly with diff --git a/test/Transpiler/Monomorphize/ArraySugarIntegrationTest.php b/test/Transpiler/Monomorphize/ArraySugarIntegrationTest.php index 2bffd3f..2891da6 100644 --- a/test/Transpiler/Monomorphize/ArraySugarIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ArraySugarIntegrationTest.php @@ -90,6 +90,26 @@ public function testNullableReturnEnforcesConcreteType(): void } } + /** + * A length-changing `Name[]` -> `array` rewrite BEFORE a generic closure + * shifts every following byte of the stripped source; the closure's + * marker (recorded at the ORIGINAL `function` byte) must map back through + * the byte-offset map, or the closure silently loses its type params and + * the emitted output fatals at runtime. + */ + #[\PHPUnit\Framework\Attributes\RunInSeparateProcess] + public function testSugarBeforeGenericClosureStillSpecializes(): void + { + $source = realpath(__DIR__ . '/../../fixture/compile/array_sugar_before_generic_closure/source') + ?: throw new RuntimeException('Fixture missing'); + $fixture = CompiledFixture::compile($source, 'array-sugar-marker-offset'); + try { + require __DIR__ . '/../../fixture/compile/array_sugar_before_generic_closure/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + private function compile(): void { $compiler = $this->buildCompiler(); diff --git a/test/fixture/compile/array_sugar_before_generic_closure/source/Use.xphp b/test/fixture/compile/array_sugar_before_generic_closure/source/Use.xphp new file mode 100644 index 0000000..2836be0 --- /dev/null +++ b/test/fixture/compile/array_sugar_before_generic_closure/source/Use.xphp @@ -0,0 +1,25 @@ +(T $x): T { + return $x; +}; + +$n = countRecords([new VeryLongRecordName()]); +$a = $id::(42); +$b = $id::('hello'); diff --git a/test/fixture/compile/array_sugar_before_generic_closure/verify/runtime.php b/test/fixture/compile/array_sugar_before_generic_closure/verify/runtime.php new file mode 100644 index 0000000..64bf482 --- /dev/null +++ b/test/fixture/compile/array_sugar_before_generic_closure/verify/runtime.php @@ -0,0 +1,19 @@ +targetDir . '/Use.php'; + +Assert::assertSame(1, $n); +Assert::assertSame(42, $a); +Assert::assertSame('hello', $b); From 157c99272758c92d6071db5c915d60daac54e13d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 12:58:23 +0000 Subject: [PATCH 18/80] docs(changelog): record the generic-closure marker offset fix Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00133c7..be4207b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,13 @@ _In progress on this branch — content still accumulating; date set at tag time ### Fixed +- **Generic closures after array-sugar rewrites specialize correctly.** The + `Name[]` → `array` rewrite shortens the source, and the byte-keyed marker that + attaches type parameters to an anonymous `function` / `fn` was compared + against the shifted position — so a generic closure appearing after such a + rewrite silently lost its type parameters, compiled unspecialized, and the + emitted code fataled at runtime despite a clean validation pass. Marker + positions are now translated through the byte-offset map before matching. - **Undeclared type parameters are now rejected** instead of silently compiling to a reference to a non-existent class. A bare, single-segment, non-imported type name used in a generic member, bound, or default that is neither a declared type From e67cf4b01584edec7d6c92cf0e84c4bfe1cb668b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 13:29:37 +0000 Subject: [PATCH 19/80] fix(monomorphize): scan parenthesised DNF groups as single signature type leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signature type scanner could not consume a `(`-group, neither leading a leaf nor continuing one after `|`/`&`. Four failure modes, one root cause: - `Closure(): (A&B)|C` in a return: the scan bailed on the `(`, the signature was never recognised, and the raw superset syntax reached PHP as a parse error. - `Closure(): A|(B&C)` in a function's return hint: the scan stopped MID-TYPE after `A`, erasing only through it — emitting the valid but wrong hint `\Closure|(B&C)` and recording a truncated `return = A` that could provably (and wrongly) reject a conforming literal. - `Closure((A&B)|C $x): int` at a checked site: the leaf fell back to a single-token raw `(`, the param loop read `A&B` as a second parameter (one-param target seen as two — false-rejecting a correct literal and false-accepting a wrong-arity one), and the declared return was dropped entirely. - `Closure(A|(B&C) $x)`: same family, mis-read as four parameters. scanTypeExprEnd now consumes a balanced group where a LEAF may start (scan start or right after a separator), validating the group interior with the same leaf machinery (names, generics, nested signatures, nested groups) and requiring it to end exactly at the matching `)` — so an expression-context paren (`... : ($x)`) still terminates the scan. The separator lookahead accepts `(` so trailing groups continue a compound. The existing SigRaw fallbacks already delegate span-finding here, so a DNF leaf now covers its full span and stays gradual (accepted, rendered verbatim in diagnostics); structuring DNF members for variance checking remains future work. The blanket infection ignore on scanTypeExprEnd is removed — the walk is now mutation-tested, with narrowly-scoped equivalence annotations where a mutant is provably unobservable. Pinned by parse-shape tests for all four modes, accept/reject conformance pairs in both modes, and a runtime fixture whose DNF-signature factories compile, erase, and execute. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 80 +++++- .../ClosureConformanceIntegrationTest.php | 18 ++ .../ClosureConformanceValidatorTest.php | 22 ++ .../ClosureSignatureParseTest.php | 256 ++++++++++++++++++ .../source/Use.xphp | 54 ++++ .../verify/runtime.php | 26 ++ 6 files changed, 444 insertions(+), 12 deletions(-) create mode 100644 test/fixture/compile/closure_conformance_dnf_runtime/source/Use.xphp create mode 100644 test/fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 47db82f..1d68349 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -763,6 +763,10 @@ private static function findClosureSigEnd(array $tokens, int $openIdx): ?int } } if ($closeIdx === null) { + // @infection-ignore-all ReturnRemoval — removing this early return + // sends `null + 1` into the skipWs below; the token near the file + // start it lands on is never a `:`, so the function still returns + // the null $closeIdx — equivalent, just accidental. return null; } } else { @@ -778,16 +782,16 @@ private static function findClosureSigEnd(array $tokens, int $openIdx): ?int /** * Index of the last token of a type expression starting at `$idx` — a leaf - * (scalar / class / `Name<…>` generic / nested `Closure(…)` / nullable) plus - * any `|` / `&` union-or-intersection continuation. Stops before a top-level - * `$var` / `{` / `;` / `,` / `)` / `=`. `null` if nothing type-shaped is there. + * (scalar / class / `Name<…>` generic / nested `Closure(…)` / nullable / + * parenthesised DNF group) plus any `|` / `&` union-or-intersection + * continuation. Stops before a top-level `$var` / `{` / `;` / `,` / `)` / + * `=`. `null` if nothing type-shaped is there. * - * @infection-ignore-all — flat token walk. The span it returns is pinned - * behaviorally (a wrong end leaves invalid residue that fails to re-parse, and the - * generic / union / intersection / nested-closure tests assert the exact shape); - * the residual mutants are walk-boundary guards and whitespace-skip offsets plus a - * sub-expression negation of the continuation predicate that is equivalent for the - * only members the caller can pass here (a name, a `?`-name, or `static`). + * A `(` is a DNF group only where a LEAF may start (scan start, or right + * after a `|` / `&` continuation), and only when its interior is itself a + * valid type expression ending exactly at the matching `)` — an + * expression-context paren (`Closure(int) : ($flag ? …)`) fails that + * interior check and terminates the scan as before. * * @param list $tokens */ @@ -796,6 +800,7 @@ private static function scanTypeExprEnd(array $tokens, int $idx): ?int $n = count($tokens); $last = null; $i = $idx; + $expectLeaf = true; while ($i < $n) { $t = $tokens[$i]; if ($t->id === T_WHITESPACE || $t->id === T_COMMENT || $t->id === T_DOC_COMMENT) { @@ -807,8 +812,19 @@ private static function scanTypeExprEnd(array $tokens, int $idx): ?int $i++; continue; } + if ($t->text === '(' && $expectLeaf) { + $close = self::scanGroupEnd($tokens, $i); + if ($close === null) { + break; + } + $last = $close; + $i = $close + 1; + $expectLeaf = false; + continue; + } if (self::isSigTypeToken($t)) { $last = $i; + $expectLeaf = false; $la = self::skipWs($tokens, $i + 1); if ($la < $n && ($tokens[$la]->text === '(' || self::isCastToken($tokens[$la])) && ltrim($t->text, '\\') === 'Closure') { $inner = self::findClosureSigEnd($tokens, $la); @@ -816,6 +832,12 @@ private static function scanTypeExprEnd(array $tokens, int $idx): ?int return null; } $last = $inner; + // @infection-ignore-all DecrementInteger Plus — re-entering the + // walk AT (or one before) the consumed signature's last token only + // revisits tokens that re-converge on the same `$last` (the walk is + // confluent after a consumed leaf). Skipping a token forward (+2) + // is NOT equivalent and is pinned by the group-with-nested- + // signature test. $i = $inner + 1; } elseif ($la < $n && $tokens[$la]->text === '<') { $inner = self::findAngleEnd($tokens, $la); @@ -831,15 +853,22 @@ private static function scanTypeExprEnd(array $tokens, int $idx): ?int } if ($t->text === '|' || $t->text === '&') { // `&` before a `$var` / `...` / `)` is a by-ref marker (belongs to - // the parameter, not the type) — stop. `&`/`|` before a Name is an - // intersection / union continuation. + // the parameter, not the type) — stop. `&`/`|` before a Name, `?`, + // or a `(` group is an intersection / union continuation. $nx = self::skipWs($tokens, $i + 1); + // @infection-ignore-all LogicalOrAllSubExprNegation — negating the + // member predicates only moves WHERE the walk breaks (at the + // separator vs one non-type token later); `$last` is set only by + // leaf branches, so the returned span is identical for every input. $continues = $nx < $n - && (self::isSigTypeToken($tokens[$nx]) || $tokens[$nx]->text === '?'); + && (self::isSigTypeToken($tokens[$nx]) + || $tokens[$nx]->text === '?' + || $tokens[$nx]->text === '('); if (!$continues) { break; } $i++; + $expectLeaf = true; continue; } break; @@ -847,6 +876,29 @@ private static function scanTypeExprEnd(array $tokens, int $idx): ?int return $last; } + /** + * Index of the matching `)` for a parenthesised DNF group whose `(` sits at + * `$openIdx`, or `null` when the interior is not a complete type expression + * ending exactly at that `)`. The interior reuses the full leaf machinery + * (names, generics via `findAngleEnd`, nested `Closure(…)` recursion, nested + * groups), so an expression paren (`($flag ? a() : b())`, `($x)`) is rejected + * and never absorbed into a type span. + * + * @param list $tokens + */ + private static function scanGroupEnd(array $tokens, int $openIdx): ?int + { + $inner = self::scanTypeExprEnd($tokens, self::skipWs($tokens, $openIdx + 1)); + if ($inner === null) { + // @infection-ignore-all ReturnRemoval — removing the early return sends + // `null + 1` into skipWs, whose result (a token near the file start) is + // never the group's `)`, so the ternary below still yields null. + return null; + } + $close = self::skipWs($tokens, $inner + 1); + return $close < count($tokens) && $tokens[$close]->text === ')' ? $close : null; + } + /** * Index of the matching `>` for a `<` at `$openIdx` (generic arg clause). * Assumes `splitMergedAngleTokens` has already split `>>` etc. `null` if @@ -923,6 +975,10 @@ private static function isNullablePrefixed(array $tokens, int $i): bool * Skip whitespace/comments walking backwards from `$i`; returns the index of * the first significant token at or before `$i`, or -1. * + * @infection-ignore-all GreaterThanOrEqualTo — `>= 0` vs `> 0` differs only in + * whether token 0 is tested for whitespace; token 0 is always the open tag + * (never T_WHITESPACE), so both stop at 0 with the same result. + * * @param list $tokens */ private static function skipWsBack(array $tokens, int $i): int diff --git a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php index 899f97e..825c855 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php @@ -31,6 +31,24 @@ public function testConformingClosuresCompileEraseAndRun(): void } } + #[RunInSeparateProcess] + public function testDnfGroupedSignaturesCompileEraseAndRun(): void + { + // DNF groups (`(A&B)|C`) in a signature's parameter and return: the + // group scans as one gradual leaf, the arity stays correct (the factory + // literal is accepted, not arity-false-rejected), the signature fully + // erases, and the compiled output executes. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/closure_conformance_dnf_runtime/source', + 'closure-conformance-dnf', + ); + try { + require __DIR__ . '/../../fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + #[RunInSeparateProcess] public function testExceptionFactoryAgainstBuiltinThrowableTargetCompilesAndRuns(): void { diff --git a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php index 2383c7e..3f05118 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php @@ -65,6 +65,20 @@ public static function conformingSites(): iterable yield 'target references a type parameter ⇒ gradual here' => [ ' { public function m(): Closure(T $x): T { return fn(string $x): int => 0; } }', ]; + yield 'S-A DNF group parameter: one param, gradual — matching arity accepted' => [ + // `(A&B)|C $x` is ONE parameter; a mis-scan that split the group into + // extra params false-rejected this correct literal on arity. + ' 0; }', + ]; + yield 'S-A DNF group return: gradual leaf accepts any return' => [ + ' 0; }', + ]; + yield 'S-A trailing DNF group return: not truncated to its first member' => [ + // A scan stopping mid-type recorded `return = A` and provably + // rejected a declared class that is no subtype of A; the full + // `A|(B&C)` leaf is gradual and must accept. + ' new Beta(); }', + ]; // No `Closure(...)` return target ⇒ no site, even though a literal is // returned. A non-closure return type must not pair with the literal. yield 'return of a literal from a non-closure return type' => [ @@ -142,6 +156,14 @@ public static function violatingSites(): iterable 2, 'parameter 1: int is not wider than int|string', ]; + yield 'S-A DNF group parameter: wrong arity still rejected' => [ + // The DNF leaf is gradual but the ARITY is not: the one-param target + // must reject a two-param literal (the group mis-scan used to record + // a phantom second parameter, false-accepting exactly this shape). + " 0;\n}", + 2, + 'requires 2 parameter(s) but the target guarantees only 1', + ]; } /** diff --git a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php index f7ddefe..d8fe124 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php @@ -461,6 +461,262 @@ public function testNullableLeafInsideSignatureIsStructuredAsUnionWithNull(): vo self::assertFalse($sig->nullable, 'the inner `?int` must not set the outer nullable flag'); } + // =================================================================== + // DNF groups — consumed as ONE gradual leaf, never mis-scanned + // =================================================================== + + public function testLeadingDnfGroupReturnErasesAndStaysRaw(): void + { + // `(A&B)|C` in the signature's return: the group is consumed as one + // gradual leaf and the whole signature erases (this shape used to stop + // the type scanner cold — the signature was never recognized and the + // raw superset syntax reached PHP as a parse error). + $source = 'params); + self::assertInstanceOf(SigRaw::class, $sig->return); + self::assertSame('(A&B)|C', $sig->return->raw); + self::assertStringNotContainsString('(A&B)', self::strip($source)); + } + + public function testTrailingDnfGroupReturnErasesFullyNotMidType(): void + { + // `A|(B&C)` in the return: the scan must consume PAST the `|(` — a scan + // that stops mid-type erases only through `A` and emits the valid-but- + // wrong hint `\Closure |(B&C)` with a truncated recorded return. + $source = "return); + self::assertSame('A|(B&C)', $sig->return->raw); + self::assertStringNotContainsString('|(B&C)', self::strip($source)); + } + + public function testLeadingDnfGroupParameterIsOneParamAndKeepsReturn(): void + { + // `(A&B)|C $x` is ONE parameter (a single-token `(` leaf used to split + // it into two, throwing the arity off) and the `: int` return — which + // sat past the mis-scanned span — must survive. + $sig = self::firstSig('params); + self::assertInstanceOf(SigRaw::class, $sig->params[0]->type); + self::assertSame('(A&B)|C', $sig->params[0]->type->raw); + self::assertSame('int', self::refName($sig->return)); + } + + public function testTrailingDnfGroupParameterIsOneParamAndKeepsReturn(): void + { + // `A|(B&C) $x` — the trailing-group order mis-parsed as FOUR params. + $sig = self::firstSig('params); + self::assertInstanceOf(SigRaw::class, $sig->params[0]->type); + self::assertSame('A|(B&C)', $sig->params[0]->type->raw); + self::assertSame('int', self::refName($sig->return)); + } + + public function testDnfGroupWithGenericAndNestedSignatureMember(): void + { + // A group interior reuses the full leaf machinery: a generic member + // whose argument is itself a nested signature (with a cast-token param + // list) must all land inside the one raw leaf. + $sig = self::firstSig(')|D $x): int $cb) {}'); + + self::assertNotNull($sig); + self::assertCount(1, $sig->params); + self::assertInstanceOf(SigRaw::class, $sig->params[0]->type); + self::assertSame('(A&B)|D', $sig->params[0]->type->raw); + } + + public function testNullableDnfGroupReturnStaysRawGradual(): void + { + // `?(A&B)` is not valid PHP, but signature types are erased before PHP + // sees them — the scanner keeps it one gradual raw leaf. + $sig = self::firstSig('return); + self::assertSame('?(A&B)', $sig->return->raw); + } + + public function testDnfGroupBesideFlatUnionParameter(): void + { + // A DNF leaf must not poison its neighbours: the flat union before it + // stays structured and the return stays typed. + $sig = self::firstSig('params); + self::assertInstanceOf(SigUnion::class, $sig->params[0]->type); + self::assertSame(['A', 'B'], self::sigMemberNames($sig->params[0]->type)); + self::assertInstanceOf(SigRaw::class, $sig->params[1]->type); + self::assertSame('(C&D)|E', $sig->params[1]->type->raw); + self::assertSame('F', self::refName($sig->return)); + } + + public function testPlainPhpDnfHintOutsideSignatureIsUntouched(): void + { + // Ordinary PHP DNF hints don't involve the signature scanner at all. + $source = 'params); + self::assertInstanceOf(SigRaw::class, $sig->params[0]->type); + self::assertSame('(A&B)|(C&D)', $sig->params[0]->type->raw); + self::assertSame('int', self::refName($sig->return)); + } + + public function testGroupWithNestedSignatureThenUnionMember(): void + { + // The walk must resume EXACTLY one token after the nested signature's + // end so the following `|A` continuation and the group's `)` are seen. + $sig = self::firstSig('params); + self::assertInstanceOf(SigRaw::class, $sig->params[0]->type); + self::assertSame('(Closure(int): int)|A', $sig->params[0]->type->raw); + } + + public function testGroupWithGenericMemberThenIntersectionMember(): void + { + // Same resume-offset pin for the ANGLE branch: after `A` the `&B` + // continuation and the `)` must still be reached. + $sig = self::firstSig('&B)|C $x): int $cb) {}'); + + self::assertNotNull($sig); + self::assertCount(1, $sig->params); + self::assertInstanceOf(SigRaw::class, $sig->params[0]->type); + self::assertSame('(A&B)|C', $sig->params[0]->type->raw); + } + + public function testSingleNameGroupIsConsumedFromItsFirstToken(): void + { + // `(A)|B` — a one-token interior; an interior scan that starts one + // token late sees only the `)` and wrongly rejects the group. + $sig = self::firstSig('params); + self::assertInstanceOf(SigRaw::class, $sig->params[0]->type); + self::assertSame('(A)|B', $sig->params[0]->type->raw); + } + + public function testNestedFullyQualifiedClosureSignatureInReturn(): void + { + // The nested-signature branch must recognize `\Closure` (leading `\`) + // — matching on the bare text would leave the nested tail unconsumed. + $sig = self::firstSig('return); + self::assertSame('int', self::refName($sig->return->signature->return)); + } + + public function testAdjacentGroupsAreNotSilentlySwallowed(): void + { + // `(A&B)(C&D)` is not a type — the scan ends after the first group and + // the junk tail must survive as loud residue, never be absorbed. + $stripped = self::strip('params); + self::assertInstanceOf(SigRaw::class, $sig->params[0]->type); + self::assertSame('(', $sig->params[0]->type->raw); + } + + public function testBareClosureMemberInSignatureUnionReturn(): void + { + // A bare `Closure` (no paren) as a union member inside a signature's + // return: the nested-signature branch must require the name AND the + // following `(` together — keying on either alone sends the bare name + // into the nested scan, which fails and kills the whole recognition. + $sig = self::firstSig('return); + self::assertSame(['Closure', 'null'], self::sigMemberNames($sig->return)); + } + + public function testTruncatedNestedSignatureFailsRecognitionCleanly(): void + { + // An unbalanced NESTED signature aborts recognition of the outer one — + // the whole file stays untouched. A scan that survives the nested + // failure restarts from the file start (where the leading bare name + // `A` would satisfy it) and corrupts the span. + $source = ' $x instanceof Counted ? $x->count() * 2 : 0; +} + +// S-A, trailing DNF group in the RETURN: the whole grouped union erases — +// no residue may survive into the emitted return hint. +function makeBoth(): Closure(): Plain|(Tagged&Counted) +{ + return fn(): Both => new Both(); +} + +$count = (makeCounter())(new Both()); +$made = (makeBoth())(); +$tag = $made->tag(); diff --git a/test/fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php b/test/fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php new file mode 100644 index 0000000..46d9618 --- /dev/null +++ b/test/fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php @@ -0,0 +1,26 @@ +targetDir . '/Use.php'; + +Assert::assertSame(42, $count, 'DNF-parameter factory closure ran'); +Assert::assertSame('both', $tag, 'DNF-return factory closure ran'); + +// Full erasure: no DNF residue may survive into the emitted output. +$emitted = file_get_contents($fixture->targetDir . '/Use.php'); +Assert::assertIsString($emitted); +Assert::assertStringContainsString('\\Closure', $emitted); +Assert::assertStringNotContainsString('(Tagged&Counted)', $emitted); +Assert::assertStringNotContainsString('|(', $emitted); From 9d5a7052440469bafad0edb43818a90a013d4876 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 13:30:06 +0000 Subject: [PATCH 20/80] docs: DNF groups in closure signatures are now gradual, not unsupported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the closure-types guide's DNF limitation callout — both former failure modes are fixed — with the actual semantics: a DNF group is one gradual leaf, checked for arity around it but never member-variance. Record the fix in the changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 +++++++++ docs/syntax/closure-types.md | 19 +++++++------------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be4207b..daa3efa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,15 @@ _In progress on this branch — content still accumulating; date set at tag time ### Fixed +- **Parenthesised DNF types inside `Closure(...)` signatures are supported.** A + DNF group (`(A&B)|C`, `A|(B&C)`) anywhere in a signature previously broke the + type scanner: a leading group in a return position failed to compile, a + trailing group in a return hint silently mis-erased (emitting a wrong type and + a truncated recorded return), and a group in a parameter threw the signature's + arity off — wrongly rejecting a conforming factory literal. A DNF group now + scans as one **gradual** leaf: the signature erases correctly, arity and the + slots around the group are checked as usual, and the group itself is accepted + rather than variance-checked member by member. - **Generic closures after array-sugar rewrites specialize correctly.** The `Name[]` → `array` rewrite shortens the source, and the byte-keyed marker that attaches type parameters to an anonymous `function` / `fn` was compared diff --git a/docs/syntax/closure-types.md b/docs/syntax/closure-types.md index e4cfe22..af36ed8 100644 --- a/docs/syntax/closure-types.md +++ b/docs/syntax/closure-types.md @@ -121,15 +121,10 @@ An intersection used as an incoming (parameter) type also stays gradual (accepted): an intersection of unrelated types is uninhabited, so rejecting it would be unsound. -> **Known limitation — parenthesised DNF types are not fully supported.** A -> **DNF** signature type (a parenthesised mix such as `(A&B)|C`) is not yet -> parsed as a variance-checked type; avoid it inside a `Closure(...)` for now: -> -> - in a **return** position (`Closure(): (A&B)|C`) it is not erased and fails -> to compile — the leading `(` stops the type scanner; -> - in a **parameter** position at a checked factory site the leading `(` is -> mis-read, throwing the signature's arity off, which can wrongly reject an -> otherwise conforming literal. -> -> Write the type without the parentheses, or annotate the slot as a bare -> `Closure`, until DNF signature types are supported. +A **DNF** signature type (a parenthesised mix such as `(A&B)|C`) is accepted in +every position — parameter, return, nested — and behaves like any other +unresolvable leaf: it erases with the rest of the signature and stays +**gradual** (never variance-checked member by member, never the cause of a +rejection). Arity, by-ref-ness, and the other structured slots around a DNF +leaf are still checked as usual. Structuring a DNF into variance-checked +members is a possible future refinement. From 4a43de58005bb60a5a1bd1c04dd2847269b752a2 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 13:34:48 +0000 Subject: [PATCH 21/80] fix(monomorphize): resolve fully-qualified literal types absolutely in conformance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The candidate extractor read a closure literal's param/return type via Name::toString(), which drops the leading backslash of a fully-qualified name. `fn(): \App\Fruit` inside `namespace App` therefore resolved as the relative `App\App\Fruit` — an undeclared class — and the leaf went gradual, silently missing provable violations that spell their types fully qualified. (Never a false reject: the flattening only ever over-accepted.) Branch on Name::isFullyQualified() and use toCodeString() there, which keeps the `\` for the resolver's absolute path. A relative `namespace\Foo` stays on toString(), which already resolves it correctly, and Identifier nodes (scalars) have no toCodeString() at all — so the branch is the precise seam, not a blanket swap. Pinned by extractor-level resolution tests and an accept/reject conformance pair in both modes (the reject fails pre-fix: the violation was invisible). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/ClosureLiteralSignature.php | 10 +++++- .../ClosureConformanceValidatorTest.php | 12 +++++++ .../ClosureLiteralSignatureTest.php | 36 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/Transpiler/Monomorphize/ClosureLiteralSignature.php b/src/Transpiler/Monomorphize/ClosureLiteralSignature.php index 973fe30..4ed13de 100644 --- a/src/Transpiler/Monomorphize/ClosureLiteralSignature.php +++ b/src/Transpiler/Monomorphize/ClosureLiteralSignature.php @@ -87,7 +87,15 @@ private static function resolveType(?Node $type, NamespaceContext $ctx): ?SigTyp // compound (handled above), or a simple Identifier/Name, so the negated // predicate is unreachable-different: nothing else reaches this line. if ($type instanceof Identifier || $type instanceof Name) { - $name = $type->toString(); + // A fully-qualified `\App\Foo` must keep its leading `\` so the + // resolver treats it as absolute — toString() strips it, and the + // name would mis-resolve relative to the current namespace (a + // silent over-accept via the undeclared-class gradual path). A + // relative `namespace\Foo` stays on toString(), which resolves it + // correctly. + $name = $type instanceof Name && $type->isFullyQualified() + ? $type->toCodeString() + : $type->toString(); // @infection-ignore-all UnwrapStrToLower is killed by a capital-cased // scalar test; the case-fold is load-bearing (`Int` ⇒ `int`). $lower = strtolower($name); diff --git a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php index 3f05118..cfd1737 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php @@ -65,6 +65,11 @@ public static function conformingSites(): iterable yield 'target references a type parameter ⇒ gradual here' => [ ' { public function m(): Closure(T $x): T { return fn(string $x): int => 0; } }', ]; + yield 'S-A return: fully-qualified literal type resolves and conforms' => [ + // `\App\Apple` must resolve absolutely (App\Apple <: App\Fruit), not + // relative to the namespace (App\App\Apple, undeclared ⇒ gradual). + ' new Apple(); }', + ]; yield 'S-A DNF group parameter: one param, gradual — matching arity accepted' => [ // `(A&B)|C $x` is ONE parameter; a mis-scan that split the group into // extra params false-rejected this correct literal on arity. @@ -156,6 +161,13 @@ public static function violatingSites(): iterable 2, 'parameter 1: int is not wider than int|string', ]; + yield 'S-A return: fully-qualified literal violation is caught' => [ + // Pre-fix, `\App\Fruit` flattened to the relative `App\App\Fruit` + // (undeclared ⇒ gradual) and this provable violation was missed. + " new Fruit();\n}", + 2, + 'App\\Fruit is not a subtype of App\\Apple', + ]; yield 'S-A DNF group parameter: wrong arity still rejected' => [ // The DNF leaf is gradual but the ARITY is not: the one-param target // must reject a two-param literal (the group mis-scan used to record diff --git a/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php b/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php index 3468371..a69e496 100644 --- a/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php +++ b/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php @@ -32,6 +32,42 @@ public function testExtractsTypedParametersAndReturn(): void self::assertSame('App\\Apple', self::typeName($sig->return), 'a bare class resolves against the current namespace'); } + public function testFullyQualifiedTypesKeepTheirAbsoluteResolution(): void + { + // `\App\Foo` inside `namespace App` must resolve to `App\Foo` — not the + // namespace-relative `App\App\Foo` (toString() drops the leading `\`, + // and the mis-resolved name is undeclared ⇒ silently gradual). + $sig = self::extract( + 'params[0]->type)); + self::assertSame('App\\Bar', self::typeName($sig->return)); + } + + public function testRelativeNamespaceTypeStillResolvesAgainstCurrent(): void + { + // `namespace\Foo` (Name\Relative) resolves via toString() — the + // fully-qualified branch must not capture it (toCodeString() would + // yield `namespace\Foo` and mis-resolve to `App\namespace\Foo`). + $sig = self::extract( + 'return)); + } + + public function testFullyQualifiedClosureTypeResolvesToClosure(): void + { + // `\Closure` is a class name, not a scalar — the FQ branch must hand it + // to the resolver, which strips the `\` to the plain `Closure` FQCN. + $sig = self::extract(' 1; };', self::ctx('App')); + + self::assertSame('Closure', self::typeName($sig->return)); + } + public function testUntypedParameterBecomesMixed(): void { $sig = self::extract(' $x;', self::ctx()); From 6f90639715d25d3d10f8e8d532d2cd3f15f5d473 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 13:35:20 +0000 Subject: [PATCH 22/80] docs(changelog): record the fully-qualified literal-type conformance fix Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index daa3efa..51438f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,12 @@ _In progress on this branch — content still accumulating; date set at tag time scans as one **gradual** leaf: the signature erases correctly, arity and the slots around the group are checked as usual, and the group itself is accepted rather than variance-checked member by member. +- **Fully-qualified types in closure literals participate in conformance.** A + factory literal spelling its type fully qualified (`fn(): \App\Fruit`) had the + leading `\` dropped during extraction, mis-resolving the name relative to the + current namespace — an undeclared class, so the check silently went gradual and + provable violations were missed. FQ names now resolve absolutely (relative and + imported names are unchanged). - **Generic closures after array-sugar rewrites specialize correctly.** The `Name[]` → `array` rewrite shortens the source, and the byte-keyed marker that attaches type parameters to an anonymous `function` / `fn` was compared From 027f64a894e19dbf366e1a92081391d920aa7b06 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 13:51:06 +0000 Subject: [PATCH 23/80] fix(monomorphize): bind relative namespace\Foo literal types to the namespace, not a use alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP resolves `namespace\Foo` against the current namespace uncondition- ally — a `use Other\Foo` import never captures it. The candidate extractor flattened the relative name to bare `Foo` (Relative::toString erases the prefix) and sent it through the alias-aware resolver, so a colliding import resolved it to `Other\Foo`; when that class was declared and provably unrelated to the target, a CONFORMING factory literal was falsely rejected. Resolve Name\Relative against the current namespace directly, bypassing the alias map. Pinned at the extractor level (alias-collision and global-namespace forms, both failing pre-fix) and by a conformance- level accept for the multi-namespace repro. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/ClosureLiteralSignature.php | 15 ++++++++-- .../ClosureConformanceValidatorTest.php | 23 ++++++++++++++ .../ClosureLiteralSignatureTest.php | 30 +++++++++++++++++-- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/Transpiler/Monomorphize/ClosureLiteralSignature.php b/src/Transpiler/Monomorphize/ClosureLiteralSignature.php index 4ed13de..a6df043 100644 --- a/src/Transpiler/Monomorphize/ClosureLiteralSignature.php +++ b/src/Transpiler/Monomorphize/ClosureLiteralSignature.php @@ -87,12 +87,21 @@ private static function resolveType(?Node $type, NamespaceContext $ctx): ?SigTyp // compound (handled above), or a simple Identifier/Name, so the negated // predicate is unreachable-different: nothing else reaches this line. if ($type instanceof Identifier || $type instanceof Name) { + // A relative `namespace\Foo` binds to the CURRENT namespace by PHP's + // rules — never to a `use` alias. toString() erases the `namespace\` + // prefix, so routing it through the alias-aware resolver would let a + // colliding `use Other\Foo` capture it and FALSE-REJECT a conforming + // literal. Resolve it against the namespace directly. + if ($type instanceof Name && $type->isRelative()) { + $ns = $ctx->currentNamespace(); + return new SigTypeRef(new TypeRef( + $ns === '' ? $type->toString() : $ns . '\\' . $type->toString(), + )); + } // A fully-qualified `\App\Foo` must keep its leading `\` so the // resolver treats it as absolute — toString() strips it, and the // name would mis-resolve relative to the current namespace (a - // silent over-accept via the undeclared-class gradual path). A - // relative `namespace\Foo` stays on toString(), which resolves it - // correctly. + // silent over-accept via the undeclared-class gradual path). $name = $type instanceof Name && $type->isFullyQualified() ? $type->toCodeString() : $type->toString(); diff --git a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php index cfd1737..9519906 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php @@ -236,6 +236,29 @@ function make(): Closure(): Apple { return fn(): Fruit => new Fruit(); } ); } + public function testRelativeNamespaceLiteralTypeIsNotCapturedByAUseAlias(): void + { + // PHP binds `namespace\Apple` to the CURRENT namespace regardless of the + // colliding `use Other\Apple`. Routing it through the alias-aware + // resolver captured `Other\Apple` (declared, unrelated to App\Fruit) and + // FALSE-REJECTED this conforming factory. + $source = <<<'X' + new \App\Apple(); } + } + X; + $ast = self::parseRaw($source); + + $diagnostics = new DiagnosticCollector(); + self::validator($ast)->validateFile($ast, 'test.xphp', $diagnostics); + + self::assertCount(0, $diagnostics->all(), 'namespace\\RealApple must bind to App, not the use alias'); + } + public function testTargetUnionMembersAreResolvedForConformance(): void { // Both union members must resolve to `App\*` for the engine to prove `Fruit` diff --git a/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php b/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php index a69e496..88481d9 100644 --- a/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php +++ b/test/Transpiler/Monomorphize/ClosureLiteralSignatureTest.php @@ -48,9 +48,9 @@ public function testFullyQualifiedTypesKeepTheirAbsoluteResolution(): void public function testRelativeNamespaceTypeStillResolvesAgainstCurrent(): void { - // `namespace\Foo` (Name\Relative) resolves via toString() — the - // fully-qualified branch must not capture it (toCodeString() would - // yield `namespace\Foo` and mis-resolve to `App\namespace\Foo`). + // `namespace\Foo` (Name\Relative) resolves against the CURRENT + // namespace directly — never via the fully-qualified branch + // (toCodeString() would mis-resolve to `App\namespace\Foo`). $sig = self::extract( 'return)); } + public function testRelativeNamespaceTypeIsNeverCapturedByAUseAlias(): void + { + // PHP binds `namespace\Foo` to the current namespace regardless of any + // `use Other\Foo` in scope; routing it through the alias-aware resolver + // would resolve `Other\Foo` and could FALSE-REJECT a conforming literal. + $sig = self::extract( + ' 'Other\\Foo']), + ); + + self::assertSame('App\\Foo', self::typeName($sig->return)); + } + + public function testRelativeNamespaceTypeInGlobalNamespace(): void + { + // `namespace\Foo` in the unnamed namespace is just `Foo`. + $sig = self::extract( + 'return)); + } + public function testFullyQualifiedClosureTypeResolvesToClosure(): void { // `\Closure` is a class name, not a scalar — the FQ branch must hand it From 233facc1f29d57e2e36557d0976e995b3b790509 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 14:07:29 +0000 Subject: [PATCH 24/80] test(monomorphize): pin the scanner shapes two equivalence claims got wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the DNF-group scanning found two mutation-ignore justifica- tions refuted by construction and one escaped mutant: - Resuming the walk one token BEFORE a consumed nested signature is not confluent: `Closure(): Closure(A)` re-consumes the nested signature (or corrupts the span with a stray `)`). Pin the nested-no-return shape and narrow the ignore to the genuinely equivalent `+ 0` resume. - Negating the separator-continuation predicate is not span-neutral: `A&|B` would be silently swallowed instead of left as loud residue. Drop the annotation and pin the residue. - A group leaf opening right after a completed name leaf (`A (B)`) must keep hitting the only-Closure structural throw, not be absorbed as a fresh group — pin the throw. Infection on the file: 100% covered MSI, zero escaped, zero timeouts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 16 ++++----- .../ClosureSignatureParseTest.php | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 1d68349..748e571 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -832,11 +832,13 @@ private static function scanTypeExprEnd(array $tokens, int $idx): ?int return null; } $last = $inner; - // @infection-ignore-all DecrementInteger Plus — re-entering the - // walk AT (or one before) the consumed signature's last token only - // revisits tokens that re-converge on the same `$last` (the walk is - // confluent after a consumed leaf). Skipping a token forward (+2) - // is NOT equivalent and is pinned by the group-with-nested- + // @infection-ignore-all DecrementInteger — re-entering the walk AT + // the consumed signature's last token (`+ 0`) only revisits a token + // that cannot extend or shrink the span (it is never a name with a + // consumable tail), so `$last` is unchanged. Re-entering BEFORE it + // (`- 1`) is NOT equivalent (it can re-consume the nested signature + // or overwrite `$last`) and is pinned by the nested-no-return test; + // skipping forward (`+ 2`) is pinned by the group-with-nested- // signature test. $i = $inner + 1; } elseif ($la < $n && $tokens[$la]->text === '<') { @@ -856,10 +858,6 @@ private static function scanTypeExprEnd(array $tokens, int $idx): ?int // the parameter, not the type) — stop. `&`/`|` before a Name, `?`, // or a `(` group is an intersection / union continuation. $nx = self::skipWs($tokens, $i + 1); - // @infection-ignore-all LogicalOrAllSubExprNegation — negating the - // member predicates only moves WHERE the walk breaks (at the - // separator vs one non-type token later); `$last` is set only by - // leaf branches, so the returned span is identical for every input. $continues = $nx < $n && (self::isSigTypeToken($tokens[$nx]) || $tokens[$nx]->text === '?' diff --git a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php index d8fe124..0ce30a8 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php @@ -682,6 +682,41 @@ public function testGroupInteriorMustReachTheClosingParen(): void self::assertSame('(', $sig->params[0]->type->raw); } + public function testNestedSignatureWithoutReturnInScannedReturnPosition(): void + { + // `Closure(A)` — a nested signature whose last token is its own `)` + // (no return type). The walk must resume exactly ONE token after it; + // resuming a token early re-consumes the nested signature (or corrupts + // the span with a stray `)` residue). + $source = 'return); + self::assertCount(1, $sig->return->signature->params); + self::assertNull($sig->return->signature->return); + self::assertSame('expectException(\RuntimeException::class); + $this->expectExceptionMessage('Only "Closure" may carry a call signature, "A" may not'); + self::strip(' Date: Mon, 6 Jul 2026 14:38:15 +0000 Subject: [PATCH 25/80] fix(monomorphize): lower array-sugar leaves inside Closure signatures to array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `Name[]` leaf inside a `Closure(...)` signature broke the scanner in three ways: in a return position the bracket pair survived erasure as raw superset syntax (PHP parse error); in a parameter position at a checked site it mis-parsed as THREE parameters (`U`, `[`, `]`) — falsely rejecting a correct one-param literal on arity and false-accepting a three-param one; and where the pair sat between the signature and its recognition gate the signature went unrecognised entirely, letting the global T[] rewrite fire mid-signature (parse error). Consume the empty bracket pair into the leaf (whitespace tolerated, the same spelling the global sugar accepts) and lower it to `array` — the lowering T[] gets everywhere else — at the first leaf, at union / intersection members, and under a nullable prefix. As a gradual leaf it can only widen acceptance; the arity around it is checked as before. The `Name[]` combination stays the pre-existing loud gap, in signatures as elsewhere; a non-empty `[expr]` is never treated as sugar. Pinned by parse-shape tests for all three modes plus the resume-offset and junk-residue edges, accept/reject conformance pairs in both modes (the accept fails pre-fix on the arity mis-parse), and a runtime fixture whose sugared factories compile, erase, and execute. Infection on the file: 100% covered MSI, zero escaped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 75 +++++++-- .../ClosureConformanceIntegrationTest.php | 17 ++ .../ClosureConformanceValidatorTest.php | 15 ++ .../ClosureSignatureParseTest.php | 157 ++++++++++++++++++ .../source/Use.xphp | 29 ++++ .../verify/runtime.php | 27 +++ 6 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 test/fixture/compile/closure_conformance_array_sugar_runtime/source/Use.xphp create mode 100644 test/fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 748e571..4013cc5 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -849,7 +849,18 @@ private static function scanTypeExprEnd(array $tokens, int $idx): ?int $last = $inner; $i = $inner + 1; } else { - $i++; + // `Name[]` array sugar: consume the bracket pair into the + // leaf so it erases with the signature (the global `T[]` → + // `array` rewrite never sees it). `Name[]` is NOT + // consumed — the generic-sugar combination stays the same + // loud pre-existing gap it is outside signatures. + $suffix = self::arraySuffixEnd($tokens, $i + 1); + if ($suffix !== null) { + $last = $suffix; + $i = $suffix + 1; + } else { + $i++; + } } continue; } @@ -884,6 +895,26 @@ private static function scanTypeExprEnd(array $tokens, int $idx): ?int * * @param list $tokens */ + /** + * Index of the `]` of an EMPTY `[ ]` bracket pair whose `[` is the first + * significant token at or after `$idx` (whitespace/comments tolerated, + * matching the global sugar's parseArraySuffix), or `null` when the next + * tokens are not array sugar. A non-empty `[expr]` is expression syntax, + * never type sugar — the `]` check rejects it. + * + * @param list $tokens + */ + private static function arraySuffixEnd(array $tokens, int $idx): ?int + { + $n = count($tokens); + $open = self::skipWs($tokens, $idx); + if ($open >= $n || $tokens[$open]->text !== '[') { + return null; + } + $close = self::skipWs($tokens, $open + 1); + return $close < $n && $tokens[$close]->text === ']' ? $close : null; + } + private static function scanGroupEnd(array $tokens, int $openIdx): ?int { $inner = self::scanTypeExprEnd($tokens, self::skipWs($tokens, $openIdx + 1)); @@ -1128,6 +1159,15 @@ private static function parseSigType(array $tokens, int $i, string $source): arr } [$typeRef, $afterLeaf] = $parsed; + // `Name[]` array sugar lowers to `array` — the same lowering the global + // rewrite applies outside signatures; as a gradual leaf it can never + // false-reject. `Name[]` is excluded (the pre-existing loud gap). + $suffix = self::arraySuffixEnd($tokens, $afterLeaf); + if ($suffix !== null && !$typeRef->isGeneric()) { + $typeRef = new TypeRef('array'); + $afterLeaf = $suffix + 1; + } + $peek = self::skipWs($tokens, $afterLeaf); $hasUnion = $peek < $n && $tokens[$peek]->text === '|'; $hasIntersection = $peek < $n && $tokens[$peek]->text === '&' @@ -1221,18 +1261,27 @@ private static function parseFlatCompound(array $tokens, TypeRef $first, int $af private static function parseMemberLeaf(array $tokens, int $i): ?array { $parsed = self::parseTypeArg($tokens, $i); - if ($parsed !== null) { - return $parsed; + if ($parsed === null) { + // @infection-ignore-all LessThan UnwrapStrToLower — the `$i < count` bound + // is defensive (the caller only reaches here mid-span, never at + // end-of-input); `strtolower` matches the first-leaf keyword branch but + // the resolver re-lowercases, so it is redundant. (The `$i + 1` end index + // is NOT ignored — it is pinned by a keyword-member-followed-by-parameter + // test.) + if ($i < count($tokens) && self::isSigTypeToken($tokens[$i])) { + $parsed = [new TypeRef(strtolower($tokens[$i]->text)), $i + 1]; + } } - // @infection-ignore-all LessThan UnwrapStrToLower — the `$i < count` bound is - // defensive (the caller only reaches here mid-span, never at end-of-input); - // `strtolower` matches the first-leaf keyword branch but the resolver - // re-lowercases, so it is redundant. (The `$i + 1` end index is NOT ignored — - // it is pinned by a keyword-member-followed-by-parameter test.) - if ($i < count($tokens) && self::isSigTypeToken($tokens[$i])) { - return [new TypeRef(strtolower($tokens[$i]->text)), $i + 1]; + if ($parsed === null) { + return null; } - return null; + // Member-level `Name[]` sugar lowers to `array`, same as the first leaf. + [$ref, $next] = $parsed; + $suffix = self::arraySuffixEnd($tokens, $next); + if ($suffix !== null && !$ref->isGeneric()) { + return [new TypeRef('array'), $suffix + 1]; + } + return $parsed; } /** @@ -2435,6 +2484,10 @@ private function markType(?Node $type): void */ private function attachClosureSig(Name $node): void { + // @infection-ignore-all ReturnRemoval — fast-path guard: a + // non-Closure name can never match a marker anyway (each + // recorded bytePosition holds the erased `\Closure` name + // itself), so falling through only wastes loop work. if (ltrim($node->toString(), '\\') !== 'Closure') { return; } diff --git a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php index 825c855..6fc528d 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php @@ -49,6 +49,23 @@ public function testDnfGroupedSignaturesCompileEraseAndRun(): void } } + #[RunInSeparateProcess] + public function testArraySugarSignaturesCompileEraseAndRun(): void + { + // Array-sugar leaves in a signature's parameter and return lower to + // `array`: the arity stays correct (one sugared param is ONE param, + // not three), the signature fully erases, and the output executes. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/closure_conformance_array_sugar_runtime/source', + 'closure-conformance-sugar', + ); + try { + require __DIR__ . '/../../fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + #[RunInSeparateProcess] public function testExceptionFactoryAgainstBuiltinThrowableTargetCompilesAndRuns(): void { diff --git a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php index 9519906..e2900bc 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php @@ -70,6 +70,14 @@ public static function conformingSites(): iterable // relative to the namespace (App\App\Apple, undeclared ⇒ gradual). ' new Apple(); }', ]; + yield 'S-A array-sugar parameter: one param lowered to array — matching arity accepted' => [ + // `U[] $x` is ONE parameter; the `[`/`]` mis-parse recorded three + // and false-rejected exactly this correct literal on arity. + ' null; }', + ]; + yield 'S-A array-sugar return: lowers to array and accepts an array literal' => [ + ' []; }', + ]; yield 'S-A DNF group parameter: one param, gradual — matching arity accepted' => [ // `(A&B)|C $x` is ONE parameter; a mis-scan that split the group into // extra params false-rejected this correct literal on arity. @@ -168,6 +176,13 @@ public static function violatingSites(): iterable 2, 'App\\Fruit is not a subtype of App\\Apple', ]; + yield 'S-A array-sugar parameter: wrong arity still rejected' => [ + // The lowered `array` leaf is gradual but the ARITY is not: the + // one-param target must reject a two-param literal. + " null;\n}", + 2, + 'requires 2 parameter(s) but the target guarantees only 1', + ]; yield 'S-A DNF group parameter: wrong arity still rejected' => [ // The DNF leaf is gradual but the ARITY is not: the one-param target // must reject a two-param literal (the group mis-scan used to record diff --git a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php index 0ce30a8..16b16f0 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php @@ -748,10 +748,167 @@ public function testTruncatedSourceAtEofDoesNotFatal(): void // reaches the group scan with its interior running off the file end. self::assertIsString(self::strip('return)); + self::assertStringNotContainsString('[]', self::strip($source)); + } + + public function testArraySugarParameterIsOneParamLoweredToArray(): void + { + // `U[] $x` is ONE parameter lowered to `array` (it used to mis-parse + // as THREE — `U`, `[`, `]` — falsely rejecting a correct one-param + // literal on arity at a checked site). + $source = 'params); + self::assertSame('array', self::refName($sig->params[0]->type)); + self::assertSame('void', self::refName($sig->return)); + self::assertStringNotContainsString('[]', self::strip($source)); + } + + public function testArraySugarReturnBeforeParamVariableStillRecognizes(): void + { + // `Closure(): T[] $cb` — the recognition gate looks at the token after + // the span; with the `[]` inside the span the `$cb` is visible again + // (it used to see `[`, fail the gate, and let the global rewrite fire + // MID-signature → parse error). + $sig = self::firstSig('params); + self::assertSame('array', self::refName($sig->return)); + } + + public function testArraySugarUnionMemberLowersToArray(): void + { + // Member-level sugar: `A|B[]` structures as a union of `A` and `array`. + $sig = self::firstSig('params); + self::assertInstanceOf(SigUnion::class, $sig->params[0]->type); + self::assertSame(['A', 'array'], self::sigMemberNames($sig->params[0]->type)); + } + + public function testNullableArraySugarLowersToArrayOrNull(): void + { + // `?int[]` ≡ array|null after lowering. + $sig = self::firstSig('params[0]->type); + self::assertSame(['array', 'null'], self::sigMemberNames($sig->params[0]->type)); + } + + public function testWhitespaceSeparatedArraySugarIsConsumed(): void + { + // The global sugar tolerates whitespace around the bracket pair; the + // signature scanner must accept the same spelling. + $sig = self::firstSig('params); + self::assertSame('array', self::refName($sig->params[0]->type)); + } + + public function testArraySugarWithUnionContinuationErasesFully(): void + { + // `int[]|null` — the walk must resume exactly one token after the `]` + // so the `|null` continuation joins the span; resuming on (or before) + // the `]` truncates the span and leaves `|null` residue. + $source = 'return); + self::assertSame(['array', 'null'], self::sigMemberNames($sig->return)); + self::assertStringNotContainsString('|null', self::strip($source)); + } + + public function testArraySugarWithGroupContinuationErasesFully(): void + { + // `int[]|(A&B)` — the token right after the `]` is the separator that + // carries the group; skipping it truncates the span. + $source = 'return); + self::assertSame('int[]|(A&B)', $sig->return->raw); + self::assertStringNotContainsString('|(', self::strip($source)); + } + + public function testNonEmptyBracketAfterReturnTypeStaysLoudResidue(): void + { + // `A[0]` is expression syntax, not array sugar — the bracket pair must + // NOT be absorbed into the type span; the junk survives loudly. + $stripped = self::strip('params); + self::assertInstanceOf(SigUnion::class, $sig->params[0]->type); + self::assertSame(['A', 'array'], self::sigMemberNames($sig->params[0]->type)); + self::assertTrue($sig->params[0]->variadic); + } + + public function testGenericArraySugarComboStaysLoud(): void + { + // `Foo[]` — the generic + sugar combination is the pre-existing + // loud gap everywhere; inside a signature it must stay loud residue, + // not be silently absorbed or lowered. + $stripped = self::strip('[] {}'); + + self::assertStringContainsString('[]', $stripped); + } + + public function testArrayAccessOfClosureCallIsUntouched(): void + { + // `$arr[Closure(1)]` — brackets in expression context around a call to + // a user symbol named `Closure`; nothing here is a type slot. + $source = ' array_sum(array_map(fn(Item $i): int => $i->v, $items)); +} + +function makeLister(): Closure(): int[] +{ + return fn(): array => [40, 2]; +} + +$sum = (makeSummer())([new Item(40), new Item(2)]); +$list = (makeLister())(); diff --git a/test/fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php b/test/fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php new file mode 100644 index 0000000..554aa70 --- /dev/null +++ b/test/fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php @@ -0,0 +1,27 @@ +targetDir . '/Use.php'; + +Assert::assertSame(42, $sum, 'sugared-parameter factory closure ran'); +Assert::assertSame([40, 2], $list, 'sugared-return factory closure ran'); + +// Full erasure: no bracket-pair residue may survive into the emitted output +// (the trailing `[40, 2]` array literal is body code, not a type). +$emitted = file_get_contents($fixture->targetDir . '/Use.php'); +Assert::assertIsString($emitted); +Assert::assertStringContainsString('\\Closure', $emitted); +Assert::assertStringNotContainsString('Item[]', $emitted); +Assert::assertStringNotContainsString('int[]', $emitted); From f77704be40d2923442f2f8c20a864817f5679c7b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 14:38:15 +0000 Subject: [PATCH 26/80] docs: array-sugar leaves in closure signatures lower to array Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 +++++++ docs/syntax/closure-types.md | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51438f2..86ab874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,13 @@ _In progress on this branch — content still accumulating; date set at tag time scans as one **gradual** leaf: the signature erases correctly, arity and the slots around the group are checked as usual, and the group itself is accepted rather than variance-checked member by member. +- **Array-sugar types inside `Closure(...)` signatures are supported.** A + sugared leaf (`Closure(Item[] $items): int`, `Closure(): int[]`) previously + broke the signature scanner: in a return position the bracket pair survived + erasure as a raw parse error, and in a parameter position it mis-parsed as + three parameters — wrongly rejecting a conforming factory literal on arity. + The sugar now lowers to `array` inside signatures, the same lowering it gets + everywhere else, and is checked as a gradual `array` leaf. - **Fully-qualified types in closure literals participate in conformance.** A factory literal spelling its type fully qualified (`fn(): \App\Fruit`) had the leading `\` dropped during extraction, mis-resolving the name relative to the diff --git a/docs/syntax/closure-types.md b/docs/syntax/closure-types.md index af36ed8..e94d31f 100644 --- a/docs/syntax/closure-types.md +++ b/docs/syntax/closure-types.md @@ -128,3 +128,9 @@ unresolvable leaf: it erases with the rest of the signature and stays rejection). Arity, by-ref-ness, and the other structured slots around a DNF leaf are still checked as usual. Structuring a DNF into variance-checked members is a possible future refinement. + +An **array-sugar** leaf (`Closure(Item[] $items): int`, `Closure(): int[]`) +lowers to `array` inside a signature — the same lowering `T[]` gets everywhere +else in xphp — and participates in conformance as `array` (a gradual leaf, so +it can only ever widen acceptance; the arity around it is still checked). The +`Name[]` combination remains unsupported, in signatures as elsewhere. From 36626e42311287e8fea1b0622546992af8cacfcd Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 17:08:37 +0000 Subject: [PATCH 27/80] fix(monomorphize): consume chained array sugar whole inside Closure signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught the sugar consumption stopping after ONE bracket pair where the global sugar consumes chains: `Closure(U[][] $x)` left the second pair as phantom parameters — falsely rejecting a conforming one-param `fn(array $x)` literal on arity — and `Closure(): int[][]` left `[]` residue that failed to re-parse. The suffix scan now loops over consecutive empty pairs (the lowering already collapses any depth to one `array`), a partial chain (`A[][0]`) consumes the sugar and leaves the junk loud, and the misplaced docblock between the group and suffix helpers is restored to its function. Pinned by chained param+return shapes (parse and conformance, both modes) and the partial-chain residue. Infection on the file: 100% covered MSI, zero escaped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 46 +++++++++++-------- .../ClosureConformanceValidatorTest.php | 5 ++ .../ClosureSignatureParseTest.php | 26 +++++++++++ 3 files changed, 58 insertions(+), 19 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 4013cc5..fa2e777 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -886,20 +886,11 @@ private static function scanTypeExprEnd(array $tokens, int $idx): ?int } /** - * Index of the matching `)` for a parenthesised DNF group whose `(` sits at - * `$openIdx`, or `null` when the interior is not a complete type expression - * ending exactly at that `)`. The interior reuses the full leaf machinery - * (names, generics via `findAngleEnd`, nested `Closure(…)` recursion, nested - * groups), so an expression paren (`($flag ? a() : b())`, `($x)`) is rejected - * and never absorbed into a type span. - * - * @param list $tokens - */ - /** - * Index of the `]` of an EMPTY `[ ]` bracket pair whose `[` is the first - * significant token at or after `$idx` (whitespace/comments tolerated, - * matching the global sugar's parseArraySuffix), or `null` when the next - * tokens are not array sugar. A non-empty `[expr]` is expression syntax, + * Index of the LAST `]` of one-or-more chained EMPTY `[ ]` bracket pairs + * whose first `[` is the first significant token at or after `$idx` + * (whitespace/comments tolerated), or `null` when the next tokens are not + * array sugar. Chains (`T[][]`) are consumed whole, matching the global + * sugar's parseArraySuffix. A non-empty `[expr]` is expression syntax, * never type sugar — the `]` check rejects it. * * @param list $tokens @@ -907,14 +898,31 @@ private static function scanTypeExprEnd(array $tokens, int $idx): ?int private static function arraySuffixEnd(array $tokens, int $idx): ?int { $n = count($tokens); - $open = self::skipWs($tokens, $idx); - if ($open >= $n || $tokens[$open]->text !== '[') { - return null; + $end = null; + while (true) { + $open = self::skipWs($tokens, $idx); + if ($open >= $n || $tokens[$open]->text !== '[') { + return $end; + } + $close = self::skipWs($tokens, $open + 1); + if ($close >= $n || $tokens[$close]->text !== ']') { + return $end; + } + $end = $close; + $idx = $close + 1; } - $close = self::skipWs($tokens, $open + 1); - return $close < $n && $tokens[$close]->text === ']' ? $close : null; } + /** + * Index of the matching `)` for a parenthesised DNF group whose `(` sits at + * `$openIdx`, or `null` when the interior is not a complete type expression + * ending exactly at that `)`. The interior reuses the full leaf machinery + * (names, generics via `findAngleEnd`, nested `Closure(…)` recursion, nested + * groups), so an expression paren (`($flag ? a() : b())`, `($x)`) is rejected + * and never absorbed into a type span. + * + * @param list $tokens + */ private static function scanGroupEnd(array $tokens, int $openIdx): ?int { $inner = self::scanTypeExprEnd($tokens, self::skipWs($tokens, $openIdx + 1)); diff --git a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php index e2900bc..1f0123f 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php @@ -78,6 +78,11 @@ public static function conformingSites(): iterable yield 'S-A array-sugar return: lowers to array and accepts an array literal' => [ ' []; }', ]; + yield 'S-A chained array-sugar parameter: still ONE param lowered to array' => [ + // `U[][]` consumes the whole chain; a one-pair-only consume left + // `[`/`]` phantom params and false-rejected this correct literal. + ' null; }', + ]; yield 'S-A DNF group parameter: one param, gradual — matching arity accepted' => [ // `(A&B)|C $x` is ONE parameter; a mis-scan that split the group into // extra params false-rejected this correct literal on arity. diff --git a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php index 16b16f0..53ed9e5 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php @@ -819,6 +819,32 @@ public function testNullableArraySugarLowersToArrayOrNull(): void self::assertSame(['array', 'null'], self::sigMemberNames($sig->params[0]->type)); } + public function testChainedArraySugarIsConsumedWhole(): void + { + // `U[][]` — the global sugar consumes chains; the signature scanner + // must match it. One pair consumed out of two leaves `[`/`]` residue: + // phantom params at a checked site (a false reject) or a parse error + // in a return. + $source = 'params); + self::assertSame('array', self::refName($sig->params[0]->type)); + self::assertSame('array', self::refName($sig->return)); + self::assertStringNotContainsString('[]', self::strip($source)); + } + + public function testPartialChainAfterCompletePairStaysLoudResidue(): void + { + // `A[][0]` — the first pair is sugar, the `[0]` is expression junk; + // consume the sugar, leave the junk loud. + $stripped = self::strip(' Date: Mon, 6 Jul 2026 19:21:18 +0000 Subject: [PATCH 28/80] fix(monomorphize): bind relative namespace\Foo names to the namespace in string resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The string-side resolver had no branch for PHP's relative-name form: a `namespace\D` target signature type fell through to the bare-name path and resolved as `App\namespace\D` — an impossible class (`namespace` is reserved), so the leaf silently went gradual and provable violations spelled with a relative target type were missed. The AST-side extractor gained its relative branch earlier; this is the same rule on the token path, placed in NamespaceContext::resolveAgainstContext so every string consumer (signature targets, bounds, defaults, generic args) inherits the correct binding at once. Only the exact case-insensitive `namespace\` segment is treated as relative — a class path merely starting with the word (Namespaced\Utils) keeps the bare-name route, and the alias map is never consulted for a relative reference. Pinned at the resolver level (binding, alias collision, global namespace, case-insensitivity, prefix-lookalike) and by conformance accept/reject pairs with relative-named TARGET types (the reject fails pre-fix: the violation was invisible). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/NamespaceContext.php | 10 ++++ .../ClosureConformanceValidatorTest.php | 14 ++++++ .../Monomorphize/NamespaceContextTest.php | 48 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/src/Transpiler/Monomorphize/NamespaceContext.php b/src/Transpiler/Monomorphize/NamespaceContext.php index 3c09f3c..3cc7b6c 100644 --- a/src/Transpiler/Monomorphize/NamespaceContext.php +++ b/src/Transpiler/Monomorphize/NamespaceContext.php @@ -69,6 +69,16 @@ public function resolveAgainstContext(string $name): string if (str_starts_with($name, '\\')) { return ltrim($name, '\\'); } + // `namespace\Foo` binds to the CURRENT namespace by PHP's rules — never + // to a `use` alias, and never as a literal first segment (`namespace` + // is a reserved word, so no real class name can start with it). The + // keyword is case-insensitive. + if (strncasecmp($name, 'namespace\\', 10) === 0) { + $rest = substr($name, 10); + return $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $rest + : $rest; + } $first = self::firstSegment($name); if (isset($this->useMap[$first])) { $rest = substr($name, strlen($first)); diff --git a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php index 1f0123f..83d1013 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php @@ -65,6 +65,13 @@ public static function conformingSites(): iterable yield 'target references a type parameter ⇒ gradual here' => [ ' { public function m(): Closure(T $x): T { return fn(string $x): int => 0; } }', ]; + yield 'S-A return: relative-named TARGET type resolves and conforms' => [ + // `namespace\Fruit` in the target must bind to App\Fruit — a + // mis-resolution to `App\namespace\Fruit` would be gradual, hiding + // the type from the check entirely (the conforming case must hold + // for the right reason: Apple <: Fruit, not "unknown class"). + ' new Apple(); }', + ]; yield 'S-A return: fully-qualified literal type resolves and conforms' => [ // `\App\Apple` must resolve absolutely (App\Apple <: App\Fruit), not // relative to the namespace (App\App\Apple, undeclared ⇒ gradual). @@ -174,6 +181,13 @@ public static function violatingSites(): iterable 2, 'parameter 1: int is not wider than int|string', ]; + yield 'S-A return: relative-named TARGET violation is caught' => [ + // Pre-fix, `namespace\Apple` resolved to `App\namespace\Apple` + // (undeclared ⇒ gradual) and this provable violation was missed. + " new Fruit();\n}", + 2, + 'App\\Fruit is not a subtype of App\\Apple', + ]; yield 'S-A return: fully-qualified literal violation is caught' => [ // Pre-fix, `\App\Fruit` flattened to the relative `App\App\Fruit` // (undeclared ⇒ gradual) and this provable violation was missed. diff --git a/test/Transpiler/Monomorphize/NamespaceContextTest.php b/test/Transpiler/Monomorphize/NamespaceContextTest.php index 2890826..470c31b 100644 --- a/test/Transpiler/Monomorphize/NamespaceContextTest.php +++ b/test/Transpiler/Monomorphize/NamespaceContextTest.php @@ -46,6 +46,54 @@ public function testUseAliasOverridesShortName(): void self::assertSame('Other\\Vendor\\LongName', $ctx->resolveAgainstContext('B')); } + public function testRelativeNamespaceNameBindsToCurrentNamespace(): void + { + // `namespace\Box` is PHP's explicit current-namespace reference; it + // must NOT fall into the bare-name path (which would produce the + // impossible `App\Containers\namespace\Box`). + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App\\Containers'); + + self::assertSame('App\\Containers\\Box', $ctx->resolveAgainstContext('namespace\\Box')); + } + + public function testRelativeNamespaceNameIsNeverCapturedByAUseAlias(): void + { + // A colliding `use Other\Box` must not rewrite `namespace\Box` — PHP + // never applies aliases to relative references. + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexUse(self::makeUse('Other\\Box')); + + self::assertSame('App\\Box', $ctx->resolveAgainstContext('namespace\\Box')); + } + + public function testRelativeNamespaceNameInGlobalNamespace(): void + { + $ctx = new NamespaceContext(); + + self::assertSame('Box', $ctx->resolveAgainstContext('namespace\\Box')); + } + + public function testRelativeNamespaceKeywordIsCaseInsensitive(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + + self::assertSame('App\\Box', $ctx->resolveAgainstContext('NAMESPACE\\Box')); + } + + public function testNameMerelyStartingWithNamespaceIsNotRelative(): void + { + // Only the exact `namespace\` keyword segment is relative — + // `Namespaced\Utils` is an ordinary (legal) class path and must take + // the bare-name route, keyword-prefix match notwithstanding. + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + + self::assertSame('App\\Namespaced\\Utils', $ctx->resolveAgainstContext('Namespaced\\Utils')); + } + public function testUseMapResolutionPreservesTailSegments(): void { $ctx = new NamespaceContext(); From 9e951afb6247829190d9e152d5bd033d1ae25ecd Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 19:34:55 +0000 Subject: [PATCH 29/80] docs(changelog): record the relative-name resolution fix Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86ab874..4a223e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,6 +199,13 @@ _In progress on this branch — content still accumulating; date set at tag time three parameters — wrongly rejecting a conforming factory literal on arity. The sugar now lowers to `array` inside signatures, the same lowering it gets everywhere else, and is checked as a gradual `array` leaf. +- **Relative `namespace\Foo` types resolve correctly everywhere names are read + from tokens.** A relative reference bound to the wrong name (`App\namespace\Foo`, + or a colliding `use` alias) wherever the resolver worked on raw token text — + most visibly a `Closure(): namespace\D` target type, which silently skipped + conformance checking. Relative names now bind to the current namespace, exactly + as PHP does, for signature targets, bounds, defaults, and generic arguments + alike; only the exact `namespace\` keyword segment is affected. - **Fully-qualified types in closure literals participate in conformance.** A factory literal spelling its type fully qualified (`fn(): \App\Fruit`) had the leading `\` dropped during extraction, mis-resolving the name relative to the From 22f900bd6655134a561a1f90f2946dc94d108824 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 21:21:45 +0000 Subject: [PATCH 30/80] fix(monomorphize): require a declaration header before treating `) :` as a return slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The return-slot heuristic accepted ANY `)` followed by `:` — a pattern ternaries, `case expr():` labels, and every alt-syntax construct (`if/elseif/while/for/foreach/declare (…):`) also produce. Two failure classes, both on valid PHP: - a call to a user function named `Closure` with a type-ish argument in those positions was SILENTLY ERASED to a bare `\Closure` constant fetch (`$a ? b() : Closure(...)`, `if ($x): Closure(A); endif;`, `case f(): Closure(A);`); - a call to ANY function there hard-failed the compile with the only-Closure structural error (`$a ? b() : g(FOO);`) — ordinary, Closure-free code. The walk now matches the `)` back to its `(` (whole-token compare, so parens inside string/cast tokens never skew the depth), hops one optional closure `use (…)` layer, skips an optional function name and by-ref `&`, and requires a T_FUNCTION/T_FN head that is NOT preceded by `::`/`->`/`?->` — `fn` and `function` are semi-reserved member names, so `$c ? C::fn() : Closure(A)` must stay a call. All previously recognized return-slot spellings (by-ref, use clauses, tight colons, nullable, modifiers, abstract/interface bodies, comments before the colon, paren-ish defaults) are pinned as still recognizing. The blanket mutation ignore on the old heuristic is removed — the new branch logic is fully mutation-tested (100% covered MSI, zero escaped), with narrowly-scoped ignores only for token-0 boundary guards, each justified in place. Pinned by an expression-context provider (17 shapes, byte-identical output), a declaration-slot provider (13 spellings), and a runtime fixture in which the compiled calls to the user's own `Closure` function execute. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 121 ++++++++++++++++-- .../ClosureConformanceIntegrationTest.php | 18 +++ .../ClosureSignatureParseTest.php | 81 ++++++++++++ .../source/Use.xphp | 29 +++++ .../verify/runtime.php | 26 ++++ 5 files changed, 262 insertions(+), 13 deletions(-) create mode 100644 test/fixture/compile/closure_named_user_function_runtime/source/Use.xphp create mode 100644 test/fixture/compile/closure_named_user_function_runtime/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index fa2e777..8c5122f 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -651,8 +651,9 @@ private static function tryParseClosureSignature(array $tokens, int $i, int $j, } // Position gate: a type slot is a param/property (a `$var` follows the whole - // signature) or a return type (the name sits just after `) :`, past an - // optional `?`). Anything else is expression-context `Closure(` — leave it. + // signature) or a return type (the name follows the `:` of a `function`/`fn` + // declaration header — see isClosureReturnSlot). Anything else is + // expression-context `Closure(` — leave it. $afterSpan = self::skipWs($tokens, $spanEnd + 1); $isParamOrProp = $afterSpan < $n && $tokens[$afterSpan]->id === T_VARIABLE; $isReturn = self::isClosureReturnSlot($tokens, $i); @@ -742,6 +743,9 @@ private static function isSigTypeToken(PhpToken $tok): bool private static function findClosureSigEnd(array $tokens, int $openIdx): ?int { $n = count($tokens); + // @infection-ignore-all GreaterThanOrEqualTo — every caller derives $openIdx + // from an existing token's lookahead, so $openIdx === $n is unreachable; + // the guard is defensive. if ($openIdx >= $n) { return null; } @@ -967,30 +971,121 @@ private static function findAngleEnd(array $tokens, int $openIdx): ?int } /** - * True iff the `Closure` at `$i` sits in a return-type slot: preceded (past an - * optional nullable `?` and whitespace) by `:` which is itself preceded by `)`. - * Keyed on `)`-then-`:` so a `case FOO: Closure($x);` label does not read as one. - * - * @infection-ignore-all — flat backward token walk. The true/false behavior is - * pinned behaviorally (return-position signatures are recognized; an expression - * `Closure(Foo::class)` is left untouched); the residual mutants are the `$q >= 0` - * boundary guard (index 0 is always T_OPEN_TAG, so `>= 0` never differs from `> 0`) - * and the `)`-before-`:` requirement, which no valid PHP can falsify — a `:` that - * immediately precedes a `Closure(…)` type is only ever a return-type colon. + * True iff the `Closure` at `$i` sits in a genuine RETURN-TYPE slot: preceded + * (past an optional nullable `?` and whitespace) by `:` which closes a + * parameter list (or a closure's `use (…)` clause) headed by `function` / `fn`. + * + * `)`-then-`:` alone is NOT enough — a ternary's else (`$a ? b() : …`), a + * `case expr():` label, and every alt-syntax construct (`if/elseif/while/ + * for/foreach/declare (…):`) produce the same pair; keying on them silently + * erased expression-context `Closure(…)` calls and hard-threw the + * only-Closure error on ordinary calls (`$a ? b() : g(FOO)`). The walk now + * matches the `)` back to its `(` and requires a declaration head, rejecting + * `C::fn()` / `$o->fn()` member CALLS whose head token is the semi-reserved + * `fn` / `function` after `::` / `->` / `?->`. * * @param list $tokens */ private static function isClosureReturnSlot(array $tokens, int $i): bool { $p = self::skipWsBack($tokens, $i - 1); + // @infection-ignore-all GreaterThanOrEqualTo — token 0 (open tag) is never + // `?`, so testing index 0 cannot change the outcome. if ($p >= 0 && $tokens[$p]->text === '?') { $p = self::skipWsBack($tokens, $p - 1); } + // @infection-ignore-all LessThan — token 0 (open tag) is never `:`, so + // testing index 0 cannot change the outcome. if ($p < 0 || $tokens[$p]->text !== ':') { return false; } $q = self::skipWsBack($tokens, $p - 1); - return $q >= 0 && $tokens[$q]->text === ')'; + // @infection-ignore-all LessThan — token 0 is always the open tag, never + // `)`, so testing index 0 cannot change the outcome. + if ($q < 0 || $tokens[$q]->text !== ')') { + return false; + } + $open = self::matchParenBack($tokens, $q); + if ($open === null) { + return false; + } + $h = self::skipWsBack($tokens, $open - 1); + // One `use (…)` layer: `function () use ($a): R` — the `)` before the + // `:` closes the use clause; hop to the parameter list it follows. + // (Use clauses don't nest, so one layer suffices.) + // @infection-ignore-all GreaterThanOrEqualTo — token 0 (open tag) is never + // T_USE, so testing index 0 cannot change the outcome. + if ($h >= 0 && $tokens[$h]->id === T_USE) { + $h = self::skipWsBack($tokens, $h - 1); + // @infection-ignore-all LessThan LogicalOr — token 0 is never `)` (LessThan); + // a closure's `use` is the ONLY `use (…)` form PHP allows, so the token + // before a reached T_USE is always the param list's `)` and h >= 0 always + // holds (T_USE implies preceding tokens) — the OR arms cannot disagree. + if ($h < 0 || $tokens[$h]->text !== ')') { + return false; + } + $open = self::matchParenBack($tokens, $h); + if ($open === null) { + return false; + } + $h = self::skipWsBack($tokens, $open - 1); + } + // Optional declaration pieces between the head and the `(`: a function + // NAME, then a by-ref `&` (`function &f(…)`, `fn &(…)`). The `&` is + // matched by text — PHP 8.1 splits the ampersand token ids. + // @infection-ignore-all GreaterThanOrEqualTo IncrementInteger — token 0 (open + // tag) is never a name; and starting the back-skip one index earlier only + // matters when the skipped token is significant, which for the name slot is + // only the by-ref `&` — both routes then land on the same T_FUNCTION and + // accept identically. + if ($h >= 0 && self::isNameToken($tokens[$h])) { + $h = self::skipWsBack($tokens, $h - 1); + } + // @infection-ignore-all GreaterThanOrEqualTo — token 0 is never `&`. + if ($h >= 0 && $tokens[$h]->text === '&') { + $h = self::skipWsBack($tokens, $h - 1); + } + // @infection-ignore-all LessThan — token 0 is never T_FUNCTION/T_FN, so + // testing index 0 cannot flip the head check. + if ($h < 0 || ($tokens[$h]->id !== T_FUNCTION && $tokens[$h]->id !== T_FN)) { + return false; + } + // `C::fn(…)` / `$o->function(…)` are member CALLS — `fn`/`function` are + // semi-reserved and lex as T_FN/T_FUNCTION even after `::`. + $before = self::skipWsBack($tokens, $h - 1); + // @infection-ignore-all LessThan — token 0 (open tag) is never `::`/`->`/`?->`, + // so testing index 0 cannot change the verdict. + return $before < 0 || !in_array( + $tokens[$before]->id, + [T_DOUBLE_COLON, T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR], + true, + ); + } + + /** + * Index of the `(` matching the `)` at `$closeIdx`, scanning backwards, or + * `null` if unbalanced. Compares whole-token text, so parens INSIDE a + * string/comment/cast token never perturb the depth. + * + * @param list $tokens + */ + private static function matchParenBack(array $tokens, int $closeIdx): ?int + { + $depth = 0; + // @infection-ignore-all GreaterThanOrEqualTo — token 0 is the open tag, never + // `(` or `)`, so excluding it from the walk cannot change the result. + for ($i = $closeIdx; $i >= 0; $i--) { + $t = $tokens[$i]->text; + if ($t === ')') { + $depth++; + } elseif ($t === '(') { + $depth--; + if ($depth === 0) { + return $i; + } + } + } + return null; } /** diff --git a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php index 6fc528d..1012c98 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php @@ -49,6 +49,24 @@ public function testDnfGroupedSignaturesCompileEraseAndRun(): void } } + #[RunInSeparateProcess] + public function testUserFunctionNamedClosureInExpressionColonsExecutes(): void + { + // A user function named `Closure` called after a ternary `:`, inside an + // alt-syntax `if (...):` block, and after a `case expr():` label — the + // `) :` pairs those positions produce must not be read as return-type + // slots; the calls compile untouched and RUN. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/closure_named_user_function_runtime/source', + 'closure-named-user-fn', + ); + try { + require __DIR__ . '/../../fixture/compile/closure_named_user_function_runtime/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + #[RunInSeparateProcess] public function testArraySugarSignaturesCompileEraseAndRun(): void { diff --git a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php index 53ed9e5..c835714 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php @@ -749,6 +749,7 @@ public function testTruncatedSourceAtEofDoesNotFatal(): void self::assertIsString(self::strip(' + */ + public static function expressionColonShapes(): iterable + { + $closureFn = ' [$closureFn . '$r = $a ? b() : Closure(...);']; + yield 'ternary else: const-arg call' => [$closureFn . '$r = $a ? b() : Closure(A);']; + yield 'ternary else: grouping parens' => [$closureFn . '$r = ($a) ? ($b) : Closure(A);']; + yield 'ternary else: new before the colon' => [$closureFn . '$r = $a ? new Foo() : Closure(A);']; + yield 'ternary else: ANY function name must not throw' => [' [$closureFn . 'if ($x): Closure(A); endif;']; + yield 'alt-syntax if: any name' => [' [' [' [' [' [$closureFn . 'switch ($a) { case f(): Closure(A); }']; + yield 'statement right after a body brace' => [ + // `{ Closure(A); }` — the walk must REQUIRE the `:`; skipping that + // check makes the body's `)` (of the enclosing header) masquerade + // as a return slot and erases the statement. + $closureFn . 'function f() { Closure(A); }', + ]; + yield 'static call of semi-reserved fn' => [$closureFn . '$r = $c ? C::fn() : Closure(A);']; + yield 'static call of semi-reserved function' => [$closureFn . '$r = $c ? C::function() : Closure(A);']; + yield 'instance call of semi-reserved fn' => [$closureFn . '$r = $c ? $o->fn() : Closure(A);']; + yield 'short ternary' => [$closureFn . '$r = b() ?: Closure(A);']; + yield 'goto label' => [$closureFn . 'lbl: Closure(A); goto lbl;']; + } + + /** + * Genuine declaration-header return slots — every spelling of the + * `function`/`fn` head the discriminator must accept. + * + * @param non-empty-string $source + */ + #[\PHPUnit\Framework\Attributes\DataProvider('declarationReturnSlots')] + public function testDeclarationReturnSlotsKeepRecognizing(string $source): void + { + self::assertNotNull(self::firstSig($source), 'the return slot must recognize the signature'); + self::assertStringContainsString('\\Closure', self::strip($source)); + } + + /** + * @return iterable + */ + public static function declarationReturnSlots(): iterable + { + yield 'nullable return slot' => [' [' [' [' [' fn(int $x): int => $x;']; + yield 'by-ref named function' => [' [' [' [' fn(int $x): int => $x;']; + yield 'parenthesised default in params' => [' [' [' ['targetDir . '/Use.php'; + +Assert::assertSame(42, $ternary, 'ternary-else call to App\\...\\Closure() executed'); +Assert::assertSame(20, $alt, 'alt-syntax-if call executed'); +Assert::assertSame(12, $case, 'case-label call executed'); + +// The calls survive verbatim — no bare `\Closure` constant fetch anywhere. +$emitted = file_get_contents($fixture->targetDir . '/Use.php'); +Assert::assertIsString($emitted); +Assert::assertStringNotContainsString('\\Closure ', $emitted); +Assert::assertStringContainsString('Closure(HALF)', $emitted); From b62aba47e25804cc7a308023abc065dea7da8eed Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 21:22:09 +0000 Subject: [PATCH 31/80] docs(changelog): record the return-slot detector fix Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a223e3..d3047fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,6 +199,15 @@ _In progress on this branch — content still accumulating; date set at tag time three parameters — wrongly rejecting a conforming factory literal on arity. The sugar now lowers to `array` inside signatures, the same lowering it gets everywhere else, and is checked as a gradual `array` leaf. +- **Expression-position `Closure(...)` calls are never mistaken for return types.** + The return-slot detector keyed on a bare `) :` pair — which ternaries, + `case expr():` labels, and alt-syntax blocks (`if/elseif/while/for/foreach/ + declare (…):`) also produce. A call to a user function named `Closure` in + those positions was silently rewritten into a `\Closure` constant fetch, and a + call to ANY function there (`$a ? b() : g(FOO);`) failed the compile with the + only-Closure error. The detector now requires an actual `function`/`fn` + declaration header (including by-ref, `use (…)` clauses, and tight spellings) + and ignores member calls of the semi-reserved names (`C::fn()`). - **Relative `namespace\Foo` types resolve correctly everywhere names are read from tokens.** A relative reference bound to the wrong name (`App\namespace\Foo`, or a colliding `use` alias) wherever the resolver worked on raw token text — From 3a5dc7b73fa95b579e10c6c90f01456052ad0c24 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 22:06:20 +0000 Subject: [PATCH 32/80] fix(monomorphize): prove non-subtyping against built-in targets for closed-world candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built-in-supertype guard accepted EVERY class-vs-class pair whose target is a built-in, because the hierarchy models no built-in ancestor edges (Exception <: Throwable must not false-reject). But when the candidate's ENTIRE ancestry is closed-world user code — itself and every chain member declared in scanned source, no built-in anywhere — no unmodeled edge can exist, and isSubtype() === false IS a real proof: `Closure(): \Throwable` returning a plain user class is now a compile error instead of a silent over-accept. Two implicit-edge traps keep their gradual accept: - enums: the collector previously dropped Enum_ nodes' implements clauses entirely AND never modeled the implicit UnitEnum/BackedEnum edges, so an enum masqueraded as a parentless plain class — the closed-world proof would have falsely rejected `enum E implements Countable` against `Closure(): \Countable`. Enum clauses and implicit edges are now collected (which also fixes enum bounds: `T : UnitEnum` now accepts an enum argument), putting built-ins in every enum's chain; - Stringable is auto-implemented by any class with __toString(), and methods are unmodeled — a Stringable/UnitEnum/BackedEnum target always stays gradual regardless of the candidate's world. Pinned by hierarchy-level tests (enum clause + implicit edges, the closed-world query across open/closed/built-in/unknown chains) and conformance accept/reject pairs in both modes: the new reject fails pre-fix, and every trap (Exception-chain, enum-implements, __toString, unknown-interface) is pinned as still accepting. Infection on both files: 100% covered MSI, zero escaped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ClosureSignatureConformance.php | 41 +++++++++--- src/Transpiler/Monomorphize/TypeHierarchy.php | 41 ++++++++++++ .../ClosureConformanceValidatorTest.php | 31 +++++++++ .../Monomorphize/TypeHierarchyTest.php | 64 +++++++++++++++++++ 4 files changed, 167 insertions(+), 10 deletions(-) diff --git a/src/Transpiler/Monomorphize/ClosureSignatureConformance.php b/src/Transpiler/Monomorphize/ClosureSignatureConformance.php index 6e49f68..037a45c 100644 --- a/src/Transpiler/Monomorphize/ClosureSignatureConformance.php +++ b/src/Transpiler/Monomorphize/ClosureSignatureConformance.php @@ -180,6 +180,13 @@ private function provablyNotSubtype(SigType $sub, SigType $super): bool // A raw (unstructured) leaf on either side stays gradual — a target-side DNF // `(A&B)|C`, an unresolved member, or an intersection carrying a scalar all // fall back to SigRaw and are accepted here. + // @infection-ignore-all LogicalOr ReturnRemoval — equivalent: a SigRaw on + // either side yields false down EVERY downstream path anyway (the compound + // arms recurse to leaf pairings; a raw against a leaf bottoms out at the + // defensive non-SigTypeRef guard, against a closure super at the + // closure-vs-leaf mismatch — all false). The early return only states the + // gradual rule directly; the accept BEHAVIOUR is pinned by the DNF-group + // and raw-leaf accept tests. if ($sub instanceof SigRaw || $super instanceof SigRaw) { return false; } @@ -269,25 +276,39 @@ private function provablyNotSubtype(SigType $sub, SigType $super): bool return self::normalizeScalar($sub->type->name) !== self::normalizeScalar($super->type->name); } if ($subKind === 'class' && $superKind === 'class') { - // Provable only when BOTH classes are known to the hierarchy AND the - // target (super) is a user-declared class: + // Provable only when BOTH classes are known to the hierarchy: // - An undeclared class on either side leaves the relation unprovable; // `isSubtype(knownChild, undeclaredParent)` returns a hard `false` // (the BFS never reaches the unknown), which must NOT read as a // proven non-subtype or out-of-source code would be false-rejected. - // - A BUILT-IN target is equally unprovable-as-`false`: the hierarchy - // models only ancestor edges scanned from source, not PHP's built-in - // class graph, so `isSubtype` returns `false` for a real relation - // like `Exception <: Throwable` (or any user class whose ancestry - // passes through a built-in). Reaching a USER target, by contrast, - // is possible only over user-declared edges — all modeled — so a - // `false` there is a genuine proof. Accept when the target is built-in. if (!$this->hierarchy->isDeclared($sub->type->name) || !$this->hierarchy->isDeclared($super->type->name) - || $this->hierarchy->isBuiltin($super->type->name) ) { return false; } + // A BUILT-IN target is normally unprovable-as-`false`: the hierarchy + // models only ancestor edges scanned from source, so `isSubtype` + // returns `false` for a real relation like `Exception <: Throwable` + // (or any user class whose ancestry passes through a built-in). + // EXCEPTION — a candidate whose ENTIRE ancestry is closed-world user + // code cannot reach any built-in over unmodeled edges, so `false` IS + // a proof there... unless PHP adds the edge implicitly at runtime: + // - `Stringable` is auto-implemented by any class with __toString() + // (methods are not modeled — stay gradual); + // - `UnitEnum` / `BackedEnum` are enum-implicit (enums carry these + // edges explicitly since the collector models them, so their chain + // is never built-in-free — this arm is a safety net). + if ($this->hierarchy->isBuiltin($super->type->name)) { + // @infection-ignore-all UnwrapLtrim — resolved TypeRef names arrive + // backslash-free from resolveAgainstContext on both sides; the trim + // is defensive parity with isBuiltin's own normalization. + $superName = ltrim($super->type->name, '\\'); + if (in_array($superName, ['Stringable', 'UnitEnum', 'BackedEnum'], true) + || !$this->hierarchy->hasClosedUserAncestry($sub->type->name) + ) { + return false; + } + } return $this->hierarchy->isSubtype($sub->type->name, $super->type->name) === false; } diff --git a/src/Transpiler/Monomorphize/TypeHierarchy.php b/src/Transpiler/Monomorphize/TypeHierarchy.php index 7e98d1f..2a3f48b 100644 --- a/src/Transpiler/Monomorphize/TypeHierarchy.php +++ b/src/Transpiler/Monomorphize/TypeHierarchy.php @@ -8,6 +8,7 @@ use PhpParser\Node\Name; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassLike; +use PhpParser\Node\Stmt\Enum_; use PhpParser\Node\Stmt\Interface_; use PhpParser\Node\Stmt\Namespace_; use PhpParser\Node\Stmt\Trait_; @@ -165,6 +166,30 @@ public function isBuiltin(string $fqn): bool return in_array(ltrim($fqn, '\\'), self::BUILTIN_TYPES, true); } + /** + * True when $fqn's ENTIRE ancestry is modeled from scanned source and is + * built-in-free: $fqn itself and every member of its ancestor chain is a + * user type declared in the source set. Under such a CLOSED WORLD an + * {@see isSubtype} verdict of `false` is a real proof even against a + * built-in target — no unmodeled built-in edge can exist, because every + * edge of the chain was collected from source and none leads outside it. + * An unknown ancestor (chain member that is no map key) or ANY built-in in + * the chain opens the world and returns false. + */ + public function hasClosedUserAncestry(string $fqn): bool + { + $fqn = ltrim($fqn, '\\'); + if (!isset($this->ancestors[$fqn]) || $this->isBuiltin($fqn)) { + return false; + } + foreach ($this->ancestorChain($fqn) as $ancestor) { + if (!isset($this->ancestors[$ancestor]) || $this->isBuiltin($ancestor)) { + return false; + } + } + return true; + } + /** * Transitive ancestors of $fqn, nearest-first and de-duplicated, excluding * $fqn itself. Breadth-first over the direct-ancestor map, so the closest @@ -343,10 +368,26 @@ public function enterNode(Node $node): null foreach ($node->extends as $interface) { $clauses[] = $interface; } + } elseif ($node instanceof Enum_) { + // Enums carry their `implements` clauses like classes do, PLUS + // PHP's implicit built-in edges: every enum is a UnitEnum, and a + // backed enum is also a BackedEnum. Without these an enum looks + // like a parentless plain class — a "closed world" it is not — + // and closed-world reasoning would falsely prove it unrelated + // to built-in interfaces it genuinely implements at runtime. + foreach ($node->implements as $interface) { + $clauses[] = $interface; + } } // Trait_ has no formal ancestors — uses-of-traits are statements inside the body // and would only matter for shared-method bounds, which we don't model. $directAncestors = []; + if ($node instanceof Enum_) { + $directAncestors[] = 'UnitEnum'; + if ($node->scalarType !== null) { + $directAncestors[] = 'BackedEnum'; + } + } $parameterized = []; foreach ($clauses as $clause) { $fqn = $this->resolveName($clause); diff --git a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php index 83d1013..05eba11 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php @@ -65,6 +65,28 @@ public static function conformingSites(): iterable yield 'target references a type parameter ⇒ gradual here' => [ ' { public function m(): Closure(T $x): T { return fn(string $x): int => 0; } }', ]; + yield 'S-A builtin target: chain THROUGH a built-in stays gradual' => [ + // MyErr's chain contains \Exception (a built-in), whose own edge to + // \Throwable is unmodeled — the world is open, so the relation is + // truly satisfied at runtime and must stay accepted. + ' new MyErr(); }', + ]; + yield 'S-A builtin target: enum implementing the interface stays accepted' => [ + // The enum's implements clause + implicit UnitEnum edge are modeled, + // so its world is OPEN (built-ins in chain) — never falsely proven + // unrelated to \Countable. + ' Sized::One; }', + ]; + yield 'S-A builtin target: __toString class vs \Stringable stays accepted' => [ + // Stringable is auto-implemented at runtime; methods are unmodeled, + // so the carve-out must keep this gradual even though Str's chain + // is closed-world built-in-free. + ' new Str(); }', + ]; + yield 'S-A builtin target: unknown interface in the chain stays accepted' => [ + // The undeclared interface could extend \Throwable — open world. + ' new Maybe(); }', + ]; yield 'S-A return: relative-named TARGET type resolves and conforms' => [ // `namespace\Fruit` in the target must bind to App\Fruit — a // mis-resolution to `App\namespace\Fruit` would be gradual, hiding @@ -181,6 +203,15 @@ public static function violatingSites(): iterable 2, 'parameter 1: int is not wider than int|string', ]; + yield 'S-A builtin target: closed-world candidate is provably rejected' => [ + // Apple's whole ancestry (Fruit) is declared user code with no + // built-in anywhere — no unmodeled edge to \Throwable can exist, so + // the `false` is a real proof. (This was the guard's blind spot: + // ANY built-in target used to go gradual.) + " new Apple();\n}", + 2, + 'App\\Apple is not a subtype of Throwable', + ]; yield 'S-A return: relative-named TARGET violation is caught' => [ // Pre-fix, `namespace\Apple` resolved to `App\namespace\Apple` // (undeclared ⇒ gradual) and this provable violation was missed. diff --git a/test/Transpiler/Monomorphize/TypeHierarchyTest.php b/test/Transpiler/Monomorphize/TypeHierarchyTest.php index aaf841d..38cd193 100644 --- a/test/Transpiler/Monomorphize/TypeHierarchyTest.php +++ b/test/Transpiler/Monomorphize/TypeHierarchyTest.php @@ -116,6 +116,70 @@ public function testLeadingBackslashIsNormalised(): void self::assertTrue($hierarchy->isSubtype('\\App\\Bar', '\\Stringable')); } + public function testFromAstPerFileCollectsEnumClausesAndImplicitEdges(): void + { + // An enum's `implements` clauses AND PHP's implicit edges must be + // modeled: every enum is a UnitEnum, a backed enum also a BackedEnum. + // Without them an enum masquerades as a parentless plain class and + // closed-world reasoning would falsely prove it unrelated to built-in + // interfaces it genuinely implements at runtime. + $parser = (new ParserFactory())->createForHostVersion(); + $src = <<<'PHP' +parse($src); + self::assertNotNull($ast); + + $hierarchy = TypeHierarchy::fromAstPerFile(['/x.php' => $ast]); + + self::assertTrue($hierarchy->isSubtype('App\\Pure', 'Countable'), 'enum implements clause collected'); + self::assertTrue($hierarchy->isSubtype('App\\Pure', 'UnitEnum'), 'every enum is a UnitEnum'); + self::assertNotTrue($hierarchy->isSubtype('App\\Pure', 'BackedEnum'), 'a pure enum is NOT a BackedEnum'); + self::assertTrue($hierarchy->isSubtype('App\\Backed', 'UnitEnum')); + self::assertTrue($hierarchy->isSubtype('App\\Backed', 'BackedEnum'), 'a backed enum is a BackedEnum'); + } + + public function testHasClosedUserAncestryRequiresFullyModeledBuiltinFreeChain(): void + { + $parser = (new ParserFactory())->createForHostVersion(); + $src = <<<'PHP' +parse($src); + self::assertNotNull($ast); + + $hierarchy = TypeHierarchy::fromAstPerFile(['/x.php' => $ast]); + + self::assertTrue($hierarchy->hasClosedUserAncestry('App\\Plain'), 'fully-modeled user chain is closed'); + self::assertTrue($hierarchy->hasClosedUserAncestry('\\App\\Plain'), 'leading backslash tolerated'); + self::assertFalse($hierarchy->hasClosedUserAncestry('App\\OpenAncestor'), 'an unknown ancestor opens the world'); + self::assertFalse($hierarchy->hasClosedUserAncestry('App\\ViaBuiltin'), 'a built-in in the chain opens the world'); + self::assertFalse($hierarchy->hasClosedUserAncestry('App\\MysteryIface'), 'an undeclared interface opens the world'); + self::assertFalse($hierarchy->hasClosedUserAncestry('App\\E'), 'an enum carries built-in edges (UnitEnum)'); + self::assertFalse($hierarchy->hasClosedUserAncestry('App\\NotDeclared'), 'an unknown class is never closed'); + self::assertFalse($hierarchy->hasClosedUserAncestry('Throwable'), 'a built-in itself is never closed'); + } + public function testFromAstPerFileCollectsClassExtendsImplements(): void { $parser = (new ParserFactory())->createForHostVersion(); From 598f28bafcdf8ddbb9cc265b3d92a06446e8226e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 22:14:41 +0000 Subject: [PATCH 33/80] fix(monomorphize): accept keyword method names and generic clauses in return-slot headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the return-slot discriminator found a regression: PHP allows every semi-reserved keyword as a METHOD name, and those lex as their own keyword tokens (`function list()` carries T_LIST, not a name token) — so the name-token test in the header walk rejected genuine return slots like `public function list(): Closure(int): int`, failing previously- compiling code with a misleading downstream parse error. xphp's own generic headers (`function(…)`, `function m(…)`) hit the same wall: the `<…>` clause sat between the head and the `(`. The walk now hops a balanced generic clause backwards and lets the name slot skip ANY single token that is neither the by-ref `&` nor the head itself — the T_FUNCTION/T_FN head requirement plus the member-call guard remain the real discriminator, so every junk shape still bottoms out there. The use-layer ignore justification is rewritten: member calls of a method named `use` DO reach that branch, and the `)` test (not the annotated boundary guards) is what rejects them. Pinned by keyword-named, by-ref-keyword, generic-closure, and bounded- generic-method provider entries — all five fail against the previous walk. (`function list(…)` — keyword AND generic — stays out: the generic-marker scanner itself rejects keyword method names, a separate pre-existing loud gap.) Infection on the file: 100% covered MSI, zero escaped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 72 +++++++++++++++---- .../ClosureSignatureParseTest.php | 8 +++ 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 8c5122f..0a04582 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -1017,10 +1017,13 @@ private static function isClosureReturnSlot(array $tokens, int $i): bool // T_USE, so testing index 0 cannot change the outcome. if ($h >= 0 && $tokens[$h]->id === T_USE) { $h = self::skipWsBack($tokens, $h - 1); - // @infection-ignore-all LessThan LogicalOr — token 0 is never `)` (LessThan); - // a closure's `use` is the ONLY `use (…)` form PHP allows, so the token - // before a reached T_USE is always the param list's `)` and h >= 0 always - // holds (T_USE implies preceding tokens) — the OR arms cannot disagree. + // @infection-ignore-all LessThan LogicalOr — token 0 is never `)` + // (LessThan); and a reached T_USE always has preceding significant + // tokens (`use` is never the first token after the open tag), so + // h >= 0 always holds and the OR arms cannot disagree. The `)` test + // itself is load-bearing: a member CALL of a method named `use` + // (`$o->use($q) : …`) reaches this branch with a non-`)` token and + // must be rejected — pinned behaviorally. if ($h < 0 || $tokens[$h]->text !== ')') { return false; } @@ -1030,15 +1033,32 @@ private static function isClosureReturnSlot(array $tokens, int $i): bool } $h = self::skipWsBack($tokens, $open - 1); } - // Optional declaration pieces between the head and the `(`: a function - // NAME, then a by-ref `&` (`function &f(…)`, `fn &(…)`). The `&` is - // matched by text — PHP 8.1 splits the ampersand token ids. - // @infection-ignore-all GreaterThanOrEqualTo IncrementInteger — token 0 (open - // tag) is never a name; and starting the back-skip one index earlier only - // matters when the skipped token is significant, which for the name slot is - // only the by-ref `&` — both routes then land on the same T_FUNCTION and - // accept identically. - if ($h >= 0 && self::isNameToken($tokens[$h])) { + // Optional declaration pieces between the head and the `(`, walked + // backwards: a GENERIC clause (`function(…)`, `function m(…)`), + // then a NAME, then a by-ref `&` (`function &f(…)`, `fn &(…)`). + // @infection-ignore-all GreaterThanOrEqualTo — token 0 (open tag) is + // never `>`. + if ($h >= 0 && $tokens[$h]->text === '>') { + $angleOpen = self::matchAngleBack($tokens, $h); + if ($angleOpen === null) { + return false; + } + $h = self::skipWsBack($tokens, $angleOpen - 1); + } + // The name may be ANY single token: PHP allows every semi-reserved + // keyword as a METHOD name (`function list()`, `function default()`), + // and those lex as their own keyword tokens, not T_STRING — so no + // name-token test can enumerate them. The head check below is the real + // discriminator; the name slot just skips one token that is neither + // the by-ref marker nor the head itself. + // @infection-ignore-all GreaterThanOrEqualTo IncrementInteger — token 0 + // (open tag) is never a name; and starting the back-skip one index + // earlier only matters when the skipped token is significant, which for + // the name slot is only the by-ref `&` — both routes then land on the + // same T_FUNCTION and accept identically. + if ($h >= 0 && $tokens[$h]->text !== '&' + && $tokens[$h]->id !== T_FUNCTION && $tokens[$h]->id !== T_FN + ) { $h = self::skipWsBack($tokens, $h - 1); } // @infection-ignore-all GreaterThanOrEqualTo — token 0 is never `&`. @@ -1069,6 +1089,32 @@ private static function isClosureReturnSlot(array $tokens, int $i): bool * * @param list $tokens */ + /** + * Index of the `<` matching the `>` at `$closeIdx`, scanning backwards, or + * `null` if unbalanced. Merged `>>` tokens are already split before any + * signature scanning, so whole-token text compares suffice. + * + * @param list $tokens + */ + private static function matchAngleBack(array $tokens, int $closeIdx): ?int + { + $depth = 0; + // @infection-ignore-all GreaterThanOrEqualTo — token 0 is the open tag, never + // `<` or `>`, so excluding it from the walk cannot change the result. + for ($i = $closeIdx; $i >= 0; $i--) { + $t = $tokens[$i]->text; + if ($t === '>') { + $depth++; + } elseif ($t === '<') { + $depth--; + if ($depth === 0) { + return $i; + } + } + } + return null; + } + private static function matchParenBack(array $tokens, int $closeIdx): ?int { $depth = 0; diff --git a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php index c835714..315bce3 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php @@ -1073,6 +1073,14 @@ public static function declarationReturnSlots(): iterable yield 'tight use clause (no space before use)' => [' [' fn(int $x): int => $x;']; yield 'by-ref named function' => [' [' $x; } }']; + yield 'keyword-named method (default)' => [' $x; } }']; + yield 'by-ref keyword-named method' => [' $x; } }']; + yield 'generic closure' => ['(T $x): Closure(int): int { return fn(int $y): int => $y; };']; + yield 'generic method with bound' => ['(T $x): Closure(int): int { return fn(int $y): int => $y; } }']; + // NOTE: `function list(...)` (generic + keyword name) is NOT here — + // the generic-marker scanner itself rejects keyword method names (a + // separate pre-existing loud gap, unrelated to the return-slot walk). yield 'closure with use clause' => [' [' [' fn(int $x): int => $x;']; From 29a1b6f8f0a9cfcdc7cb74aa8f08f26fd7ba3d89 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 22:15:46 +0000 Subject: [PATCH 34/80] docs(changelog): record the built-in-target conformance and enum-edge fixes Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3047fc..6300516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,6 +199,18 @@ _In progress on this branch — content still accumulating; date set at tag time three parameters — wrongly rejecting a conforming factory literal on arity. The sugar now lowers to `array` inside signatures, the same lowering it gets everywhere else, and is checked as a gradual `array` leaf. +- **Provable violations against built-in target types are now caught.** A factory + whose literal returns a class with fully-known, built-in-free ancestry checked + against a built-in target (`Closure(): \Throwable` returning a plain user + class) was silently accepted — the guard treated EVERY built-in target as + unprovable. When the candidate's whole ancestry is declared user code, the + non-relation is provable and now fails the build. Everything genuinely + satisfiable at runtime keeps compiling: subclasses of built-ins + (`extends \Exception` vs `\Throwable`), enums against interfaces they + implement (enum `implements` clauses and the implicit `UnitEnum`/`BackedEnum` + edges are now modeled — which also lets `T : UnitEnum` bounds accept enum + arguments), `__toString` classes against `\Stringable`, and candidates with + any unknown ancestor. - **Expression-position `Closure(...)` calls are never mistaken for return types.** The return-slot detector keyed on a bare `) :` pair — which ternaries, `case expr():` labels, and alt-syntax blocks (`if/elseif/while/for/foreach/ From a001201ac3db59459726a90b22808857d6d9858e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 22:16:35 +0000 Subject: [PATCH 35/80] docs: precise wording for when a built-in conformance target can reject Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/syntax/closure-types.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/syntax/closure-types.md b/docs/syntax/closure-types.md index e94d31f..03314d5 100644 --- a/docs/syntax/closure-types.md +++ b/docs/syntax/closure-types.md @@ -72,10 +72,15 @@ A mismatch is a compile error (`xphp compile` fails; `xphp check` reports The check is deliberately one-directional: it never rejects code it cannot prove wrong. A parameter or return that is untyped (⇒ `mixed`), a class the source set doesn't declare, a still-abstract generic type parameter, a -`self`/`static`/`parent`/`object`/`iterable`/`callable` leaf, a nullable / -union / intersection type, or a built-in supertype (returning a `\Exception` -where a `\Throwable` is expected) is **accepted**. This mirrors the RFC's -runtime leniency — lenient while unresolved, decide only when provable. +`self`/`static`/`parent`/`object`/`iterable`/`callable` leaf, or a nullable / +union / intersection type is **accepted**. A built-in supertype (a +`Closure(): \Throwable` target) is accepted whenever the candidate could +genuinely satisfy it — a subclass of a built-in (`\Exception`), an enum against +an interface it implements, a `__toString` class against `\Stringable`, or any +class with an unknown ancestor. Only a candidate whose **entire declared +ancestry is user code with no built-in anywhere** is provably unrelated to a +built-in target, and only that is rejected. This mirrors the RFC's runtime +leniency — lenient while unresolved, decide only when provable. Only the return-position "factory" pattern above is checked, because that is the one place a closure literal statically meets a `Closure(...)` target: a From f2bbaa63de943b6ec50cf8fb55dc397a3e417e3e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 22:29:06 +0000 Subject: [PATCH 36/80] test(monomorphize): pin the built-in-candidate, aliased-Stringable, and trait-__toString accepts Review probed these gradual paths green but found them unpinned: a built-in candidate against a built-in target, the Stringable carve-out reached through a use alias (post-resolution comparison), and Stringable auto-implementation via a trait-provided __toString. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ClosureConformanceValidatorTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php index 05eba11..c9c4a5e 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php @@ -83,6 +83,21 @@ public static function conformingSites(): iterable // is closed-world built-in-free. ' new Str(); }', ]; + yield 'S-A builtin target: built-in CANDIDATE stays accepted' => [ + // \Exception is itself a built-in — never closed-world; its real + // edge to \Throwable is unmodeled but genuine. + ' new \Exception(); }', + ]; + yield 'S-A builtin target: aliased Stringable carve-out still applies' => [ + // The carve-out must compare POST-resolution names — an alias + // spelling of Stringable gets the same gradual treatment. + ' new Text(); }', + ]; + yield 'S-A builtin target: trait-provided __toString stays accepted' => [ + // Stringable auto-implementation can come from a trait method — + // methods (trait or direct) are unmodeled either way. + ' new Tagged2(); }', + ]; yield 'S-A builtin target: unknown interface in the chain stays accepted' => [ // The undeclared interface could extend \Throwable — open world. ' new Maybe(); }', From 59cf33b368d040518ec788278369073388456179 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 6 Jul 2026 22:41:03 +0000 Subject: [PATCH 37/80] test(monomorphize): pin the static use-call reject and fix two comment slips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification flagged the use-layer annotation's example: after `->` a method named `use` lexes as a contextual T_STRING and never reaches the T_USE branch — only the static spelling `C::use($q)` does, and no test pinned it. Add the provider entry and correct the comment. Also restore matchParenBack's docblock, which the previous commit orphaned above matchAngleBack. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 22 ++++++++++--------- .../ClosureSignatureParseTest.php | 5 +++++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 0a04582..a760e57 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -1021,9 +1021,11 @@ private static function isClosureReturnSlot(array $tokens, int $i): bool // (LessThan); and a reached T_USE always has preceding significant // tokens (`use` is never the first token after the open tag), so // h >= 0 always holds and the OR arms cannot disagree. The `)` test - // itself is load-bearing: a member CALL of a method named `use` - // (`$o->use($q) : …`) reaches this branch with a non-`)` token and - // must be rejected — pinned behaviorally. + // itself is load-bearing: a STATIC member call of a method named + // `use` (`C::use($q) : …` — after `->` the name lexes as a + // contextual T_STRING and never reaches here) arrives with `::` + // before the T_USE and must be rejected — pinned by the + // static-use-call provider entry. if ($h < 0 || $tokens[$h]->text !== ')') { return false; } @@ -1082,13 +1084,6 @@ private static function isClosureReturnSlot(array $tokens, int $i): bool ); } - /** - * Index of the `(` matching the `)` at `$closeIdx`, scanning backwards, or - * `null` if unbalanced. Compares whole-token text, so parens INSIDE a - * string/comment/cast token never perturb the depth. - * - * @param list $tokens - */ /** * Index of the `<` matching the `>` at `$closeIdx`, scanning backwards, or * `null` if unbalanced. Merged `>>` tokens are already split before any @@ -1115,6 +1110,13 @@ private static function matchAngleBack(array $tokens, int $closeIdx): ?int return null; } + /** + * Index of the `(` matching the `)` at `$closeIdx`, scanning backwards, or + * `null` if unbalanced. Compares whole-token text, so parens INSIDE a + * string/comment/cast token never perturb the depth. + * + * @param list $tokens + */ private static function matchParenBack(array $tokens, int $closeIdx): ?int { $depth = 0; diff --git a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php index 315bce3..c7cf965 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php @@ -1042,6 +1042,11 @@ public static function expressionColonShapes(): iterable // as a return slot and erases the statement. $closureFn . 'function f() { Closure(A); }', ]; + yield 'static call of a method named use' => [ + // `C::use($q)` puts a real T_USE before the matched `(` — the + // use-layer hop must reject it (no `)` precedes the T_USE). + $closureFn . '$r = $c ? C::use($q) : Closure(A);', + ]; yield 'static call of semi-reserved fn' => [$closureFn . '$r = $c ? C::fn() : Closure(A);']; yield 'static call of semi-reserved function' => [$closureFn . '$r = $c ? C::function() : Closure(A);']; yield 'instance call of semi-reserved fn' => [$closureFn . '$r = $c ? $o->fn() : Closure(A);']; From 083b5c1a68bc4e535b37b1adcade1f0866ed4f1a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 07:22:33 +0000 Subject: [PATCH 38/80] fix(monomorphize): preserve newlines when stripping generic clauses and sugar spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every generic-clause and turbofish strip blanked its span with plain spaces, collapsing newlines — so any multi-line clause shifted every later line-keyed marker and the declarations after it silently lost their type params: compile and check both clean, emitted code keeping raw type-parameter hints that fatal at runtime. The `T[]` sugar rewrite dropped its span's newlines the same way. Blank stripped spans byte-wise (multibyte chars blank per byte) keeping every CR/LF in place, and re-append the sugar span's newlines after the lowered `array` so it still starts where the original type started. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 50 ++++-- .../MarkerAlignmentIntegrationTest.php | 34 ++++ .../Monomorphize/XphpSourceParserTest.php | 168 ++++++++++++++++++ .../multiline_generic_markers/source/Use.xphp | 61 +++++++ .../verify/runtime.php | 22 +++ 5 files changed, 325 insertions(+), 10 deletions(-) create mode 100644 test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php create mode 100644 test/fixture/compile/multiline_generic_markers/source/Use.xphp create mode 100644 test/fixture/compile/multiline_generic_markers/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index a760e57..54448a7 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -255,7 +255,7 @@ private function scanAndStrip(string $source): array $startByte = $tokens[$j]->pos; $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); $length = $endByte - $startByte; - $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; + $replacements[] = [$startByte, $length, self::blank(substr($source, $startByte, $length))]; $i = $endIdx + 1; continue; } @@ -289,7 +289,7 @@ private function scanAndStrip(string $source): array $startByte = $tokens[$k]->pos; $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); $length = $endByte - $startByte; - $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; + $replacements[] = [$startByte, $length, self::blank(substr($source, $startByte, $length))]; $i = $endIdx + 1; continue; } @@ -324,7 +324,7 @@ private function scanAndStrip(string $source): array $startByte = $tokens[$k]->pos; $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); $length = $endByte - $startByte; - $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; + $replacements[] = [$startByte, $length, self::blank(substr($source, $startByte, $length))]; $i = $endIdx + 1; continue; } @@ -360,7 +360,7 @@ private function scanAndStrip(string $source): array $startByte = $tokens[$k]->pos; $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); $length = $endByte - $startByte; - $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; + $replacements[] = [$startByte, $length, self::blank(substr($source, $startByte, $length))]; $i = $endIdx + 1; continue; } @@ -406,7 +406,7 @@ private function scanAndStrip(string $source): array $startByte = $dcTok->pos; $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); $length = $endByte - $startByte; - $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; + $replacements[] = [$startByte, $length, self::blank(substr($source, $startByte, $length))]; $i = $endIdx + 1; continue; } @@ -497,7 +497,7 @@ private function scanAndStrip(string $source): array $startByte = $dcTok->pos; $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); $length = $endByte - $startByte; - $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; + $replacements[] = [$startByte, $length, self::blank(substr($source, $startByte, $length))]; $i = $endIdx + 1; continue; } @@ -544,7 +544,7 @@ private function scanAndStrip(string $source): array $startByte = $tokens[$j]->pos; $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); $length = $endByte - $startByte; - $replacements[] = [$startByte, $length, str_repeat(' ', $length)]; + $replacements[] = [$startByte, $length, self::blank(substr($source, $startByte, $length))]; $i = $endIdx + 1; continue; } @@ -586,7 +586,15 @@ private function scanAndStrip(string $source): array if ($arraySuffixEnd !== null) { $startByte = $tok->pos; $endByte = $tokens[$arraySuffixEnd]->pos + strlen($tokens[$arraySuffixEnd]->text); - $replacements[] = [$startByte, $endByte - $startByte, 'array']; + // `array` is shorter than most sugar spans, so byte length + // can't be preserved here — but the span's newlines must be + // (line-keyed markers after it depend on the line count). + // ByteOffsetMap::fromReplacements absorbs the length delta. + $replacements[] = [ + $startByte, + $endByte - $startByte, + 'array' . self::newlinesOf(substr($source, $startByte, $endByte - $startByte)), + ]; $i = $arraySuffixEnd + 1; continue; } @@ -683,8 +691,7 @@ private static function tryParseClosureSignature(array $tokens, int $i, int $j, $spanEndByte = $tokens[$spanEnd]->pos + strlen($tokens[$spanEnd]->text); $drop = strlen('\\Closure') - $nameLen; $tail = substr($source, $spanStart + $nameLen + $drop, $spanEndByte - ($spanStart + $nameLen + $drop)); - $blankedTail = preg_replace('/[^\r\n]/', ' ', $tail) ?? ''; - $replacement = '\\Closure' . $blankedTail; + $replacement = '\\Closure' . self::blank($tail); return [$signature, $spanEnd, $spanStart, $spanEndByte - $spanStart, $replacement]; } @@ -2253,6 +2260,29 @@ private static function isPseudoType(string $name): bool /** * @param list $replacements [byte offset, original length, replacement text] */ + /** + * Equal-length whitespace for a stripped span, keeping every newline byte + * in place: byte-keyed markers after the span rely on the length, and + * line-keyed (class / method / name) markers rely on the line count — a + * multi-line `<…>` clause, turbofish arg list, or sugar span collapsed to + * spaces would shift every later marker off its node. Deliberately + * byte-wise (no `/u`): a multibyte character inside the span must blank + * to one space PER BYTE or the length invariant breaks. + */ + private static function blank(string $span): string + { + return preg_replace('/[^\r\n]/', ' ', $span) ?? ''; + } + + /** + * Just the newline bytes of a span, for length-CHANGING rewrites (the + * `T[]` -> `array` sugar) that still must not swallow line breaks. + */ + private static function newlinesOf(string $span): string + { + return preg_replace('/[^\r\n]/', '', $span) ?? ''; + } + private static function applyReplacements(string $source, array $replacements): string { usort($replacements, static fn (array $a, array $b): int => $b[0] <=> $a[0]); diff --git a/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php b/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php new file mode 100644 index 0000000..76679e7 --- /dev/null +++ b/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php @@ -0,0 +1,34 @@ +` clause, turbofish arg list, or + * `T[]` sugar span) must still specialize, and the emitted program must run. + * Misalignment is silent — compile and check both pass while the output + * carries raw un-erased type-parameter hints that fatal at runtime. + */ +final class MarkerAlignmentIntegrationTest extends TestCase +{ + #[RunInSeparateProcess] + public function testDeclarationsAfterMultiLineSpansStillSpecializeAndRun(): void + { + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/multiline_generic_markers/source', + 'multiline-generic-markers', + ); + $fixture->registerAutoload('App\\MultilineMarkers'); + try { + require __DIR__ . '/../../fixture/compile/multiline_generic_markers/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } +} diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 3b06c90..b4dc14d 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -563,6 +563,161 @@ public function testAnonymousClassWithAngleBracketsIsNotRecognizedAsTemplate(): self::assertStringContainsString('class', $stripped, 'anon-class `` must be left un-stripped'); } + public function testMultiLineTypeParamClauseKeepsLaterLineKeyedMarkersAligned(): void + { + // The `<…>` strip used to collapse newlines to spaces, shifting every + // later LINE-KEYED marker: `class Wide<\n T\n>` before `class Box` + // left Box un-generic — raw `T` hints in the emitted code, a silent + // runtime fatal. Blanking must preserve BOTH byte length and line count. + $source = <<<'PHP' + { + public function __construct(public T $v) {} +} + +class Box { + public function __construct(public T $v) {} +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + self::assertSame(strlen($source), strlen($stripped), 'blanking must preserve byte length'); + self::assertSame( + substr_count($source, "\n"), + substr_count($stripped, "\n"), + 'blanking must preserve line count', + ); + + $ast = $parser->parse($source); + $byName = self::classesByName($ast); + self::assertSame(['T'], self::paramNames($byName['Wide'])); + self::assertSame( + ['T'], + self::paramNames($byName['Box']), + 'the class declared AFTER a multi-line clause must keep its type params', + ); + } + + public function testMultiLineTurbofishArgListKeepsLaterMarkersAligned(): void + { + $source = <<<'PHP' + { + public function __construct(public T $v) {} +} + +$b = new Box::< + int +>(1); + +class Pair { + public function __construct(public U $u) {} +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + self::assertSame(strlen($source), strlen($stripped)); + self::assertSame(substr_count($source, "\n"), substr_count($stripped, "\n")); + + $ast = $parser->parse($source); + self::assertNotNull( + self::firstNameAttr($ast, 'Box', XphpSourceParser::ATTR_GENERIC_ARGS), + 'the multi-line turbofish itself must still attach', + ); + self::assertSame( + ['U'], + self::paramNames(self::classesByName($ast)['Pair']), + 'the class declared AFTER a multi-line turbofish must keep its type params', + ); + } + + public function testMultibyteInsideStrippedClauseBlanksPerByte(): void + { + // A multibyte character inside the blanked span (here in a comment the + // clause tolerates) must blank to one space PER BYTE — a /u-flagged + // blanking would shrink the file and shift every later byte-keyed marker. + $source = <<<'PHP' + {} + +class Box { + public function __construct(public T $v) {} +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + self::assertSame(strlen($source), strlen($stripped), 'multibyte spans must blank per byte'); + + $ast = $parser->parse($source); + self::assertSame(['T'], self::paramNames(self::classesByName($ast)['Box'])); + } + + public function testCrlfInsideStrippedClauseIsPreserved(): void + { + $source = " {}\r\nclass Box {\r\n" + . " public function __construct(public T \$v) {}\r\n}\r\n"; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + self::assertSame(strlen($source), strlen($stripped)); + self::assertSame(substr_count($source, "\r\n"), substr_count($stripped, "\r\n")); + + $ast = $parser->parse($source); + self::assertSame(['T'], self::paramNames(self::classesByName($ast)['Box'])); + } + + public function testArraySugarSpanWithNewlineKeepsLineCount(): void + { + // The sugar rewrite is length-CHANGING (`Rec[\n]` -> `array`), so byte + // length cannot be preserved — but the span's newlines must be + // re-appended or every later line-keyed marker shifts. + $source = <<<'PHP' + { + public function __construct(public T $v) {} +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + self::assertStringContainsString('array', $stripped); + self::assertSame( + substr_count($source, "\n"), + substr_count($stripped, "\n"), + 'a length-changing sugar rewrite must still keep the line count', + ); + self::assertSame( + strpos($source, 'Rec['), + strpos($stripped, 'array'), + 'the lowered type must start where the original type started — the ' + . 'span\'s newlines belong AFTER it, or positions stop rounding-trip', + ); + + $ast = $parser->parse($source); + self::assertSame( + ['T'], + self::paramNames(self::classesByName($ast)['Box']), + 'the class declared AFTER a newline-bearing sugar span must keep its type params', + ); + } + public function testForwardReferenceToEarlierTypeParamAsBoundIsAllowed(): void { // `class C` is NOT a self-reference -- U's bound references @@ -2759,6 +2914,19 @@ private static function collectClasses(array $ast): array return $found; } + /** + * @param array $ast + * @return array + */ + private static function classesByName(array $ast): array + { + $byName = []; + foreach (self::collectClasses($ast) as $class) { + $byName[$class->name?->toString() ?? ''] = $class; + } + return $byName; + } + /** * Find the first Name node whose ->toString() matches and return the value of $attribute. * diff --git a/test/fixture/compile/multiline_generic_markers/source/Use.xphp b/test/fixture/compile/multiline_generic_markers/source/Use.xphp new file mode 100644 index 0000000..3342461 --- /dev/null +++ b/test/fixture/compile/multiline_generic_markers/source/Use.xphp @@ -0,0 +1,61 @@ + { + public function __construct(public T $v) + { + } +} + +// Must still specialize although the clause above spanned lines. +final class Box +{ + public function __construct(public T $v) + { + } +} + +final class Rec +{ +} + +// Newline inside the `[]` sugar brackets — a length-CHANGING rewrite +// that must still keep the line count. +function tally(Rec[ +] $rs): int +{ + return \count($rs); +} + +// Multi-line bare generic type hint + multi-line turbofish arg list. +function pick(): Box< + int +> { + return new Box::< + int + >(7); +} + +// A generic closure AFTER all of the spans above — line- and byte-keyed +// markers both have to survive the strips. +$id = function(T $x): T { + return $x; +}; + +$w = new Wide::('w'); +$b = pick(); +$n = $id::(41); +$s = $id::('s'); +$c = tally([new Rec(), new Rec()]); diff --git a/test/fixture/compile/multiline_generic_markers/verify/runtime.php b/test/fixture/compile/multiline_generic_markers/verify/runtime.php new file mode 100644 index 0000000..d6ccb81 --- /dev/null +++ b/test/fixture/compile/multiline_generic_markers/verify/runtime.php @@ -0,0 +1,22 @@ +targetDir . '/Use.php'; + +Assert::assertSame('w', $w->v); +Assert::assertSame(7, $b->v); +Assert::assertSame(41, $n); +Assert::assertSame('s', $s); +Assert::assertSame(2, $c); From f815bef44b635ebe39ad361f2abd4de1c769cca8 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 07:43:05 +0000 Subject: [PATCH 39/80] fix(monomorphize): anchor anonymous-closure markers at the node's true start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit php-parser starts a Closure/ArrowFunction node at its first token — the first attribute group when attributed, the `static` keyword when static — while the scanner anchored the generic marker at `function`/`fn` (or, for `static function`, missed a preceding attribute). The byte-keyed match then failed silently: `static fn`, `#[A] function`, `#[A] fn`, and `#[A] static function` all compiled clean and emitted raw type-parameter hints that fatal at runtime. Walk back from the keyword over `static` and any number of complete attribute groups (square-bracket balanced, so array arguments inside an attribute don't derail it) to anchor at the true node start. Static ARROWS now specialize through the ordinary arrow path — they cannot bind `$this`, which is the only thing the dispatcher rewrite cannot carry — while static closures keep their loud not-yet-supported rejection, which now also fires when an attribute precedes them. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 96 ++++++++++++++++--- .../Monomorphize/CheckPassIntegrationTest.php | 21 ++++ .../MarkerAlignmentIntegrationTest.php | 15 +++ .../Monomorphize/XphpSourceParserTest.php | 59 ++++++++++++ .../closure_static_attributed/source/Use.xphp | 19 ++++ .../source/Use.xphp | 47 +++++++++ .../verify/runtime.php | 21 ++++ 7 files changed, 265 insertions(+), 13 deletions(-) create mode 100644 test/fixture/check/closure_static_attributed/source/Use.xphp create mode 100644 test/fixture/compile/attributed_generic_closures/source/Use.xphp create mode 100644 test/fixture/compile/attributed_generic_closures/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 54448a7..dd87e61 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -223,14 +223,17 @@ private function scanAndStrip(string $source): array while ($i < $n) { $tok = $tokens[$i]; - // Anonymous closure: `function(...){}` or - // `static function(...){}`. Recognized by T_FUNCTION followed - // immediately by `<` (no T_STRING name). For `static function` - // the leading T_STATIC was consumed in the same arm. + // Anonymous closure: `function(...){}` / `fn(...)`. + // Recognized by T_FUNCTION/T_FN followed immediately by `<` (no + // T_STRING name). `static`-prefixed shapes are consumed by the + // T_STATIC arm below before the loop ever reaches the keyword. if ($tok->id === T_FUNCTION || $tok->id === T_FN) { $isArrow = $tok->id === T_FN; - $anchorByte = $tok->pos; - $anchorLine = $tok->line; + // An attributed closure's node starts at its first `#[`, not at + // the keyword — anchor the (byte-matched) marker there. + $anchorIdx = self::anchorPastAttributeGroups($tokens, $i); + $anchorByte = $tokens[$anchorIdx]->pos; + $anchorLine = $tokens[$anchorIdx]->line; $j = self::skipWs($tokens, $i + 1); if ($j < $n && $tokens[$j]->text === '<') { // P5.7: defaults allowed on anonymous closures + arrows @@ -264,26 +267,33 @@ private function scanAndStrip(string $source): array // named-function path (T_FUNCTION) or skip the token (T_FN). } - // `static function(...)` -- the leading T_STATIC must be - // recognized so we can include it in the anchor byte position. + // `static function(...)` / `static fn(...)` -- the leading + // T_STATIC must be recognized so the anchor covers it (the node + // starts at `static`, or at a preceding attribute group). A static + // ARROW takes the ordinary arrow specialization path — it cannot + // bind `$this` by construction, which is the only thing the + // dispatcher rewrite cannot carry; static CLOSURES stay gated + // (kind `staticClosure` hard-fails at the call-site rewrite). if ($tok->id === T_STATIC) { $j = self::skipWs($tokens, $i + 1); - if ($j < $n && $tokens[$j]->id === T_FUNCTION) { + if ($j < $n && ($tokens[$j]->id === T_FUNCTION || $tokens[$j]->id === T_FN)) { + $isArrow = $tokens[$j]->id === T_FN; $k = self::skipWs($tokens, $j + 1); if ($k < $n && $tokens[$k]->text === '<') { $parsed = self::parseTypeParamList( $tokens, $k, - allowDefaults: false, + allowDefaults: $isArrow, allowVariance: false, ); if ($parsed !== null) { [$paramEntries, $endIdx] = $parsed; + $anchorIdx = self::anchorPastAttributeGroups($tokens, $i); $methodMarkers[] = [ - 'line' => $tok->line, + 'line' => $tokens[$anchorIdx]->line, 'name' => '', - 'kind' => 'staticClosure', - 'bytePosition' => $tok->pos, + 'kind' => $isArrow ? 'arrow' : 'staticClosure', + 'bytePosition' => $tokens[$anchorIdx]->pos, 'params' => $paramEntries, ]; $startByte = $tokens[$k]->pos; @@ -1177,6 +1187,66 @@ private static function skipWsBack(array $tokens, int $i): int return $i; } + /** + * The true start of an anonymous closure/arrow node whose keyword sits at + * `$keywordIdx`: php-parser starts the node at its FIRST token — the first + * attribute group when the closure is attributed — while the scanner sits + * on the `function` / `fn` / `static` keyword. Anonymous markers are + * matched byte-exact against the node start, so walk back over any number + * of complete `#[ … ]` groups (and the whitespace/comments between them) + * to the token the node actually starts at. Anything that is not a + * complete attribute group ends the walk. + * + * @param list $tokens + */ + private static function anchorPastAttributeGroups(array $tokens, int $keywordIdx): int + { + $anchor = $keywordIdx; + $i = self::skipWsBack($tokens, $keywordIdx - 1); + while ($i >= 0 && $tokens[$i]->text === ']') { + $open = self::matchAttributeGroupBack($tokens, $i); + if ($open === null) { + break; + } + $anchor = $open; + $i = self::skipWsBack($tokens, $open - 1); + } + return $anchor; + } + + /** + * Match a `]` at `$closeIdx` back to the `#[` (T_ATTRIBUTE) that opens its + * attribute group, tracking square-bracket balance so attribute arguments + * containing array literals (`#[A([1, 2])]`) don't derail the walk. + * Returns null when the balance lands on a plain `[` instead — the `]` + * closed ordinary array syntax, not an attribute group. + * + * @param list $tokens + */ + private static function matchAttributeGroupBack(array $tokens, int $closeIdx): ?int + { + $depth = 0; + // @infection-ignore-all GreaterThanOrEqualTo — token 0 is the open tag, never + // `]`, `[`, or `#[`, so excluding it from the walk cannot change the result. + for ($i = $closeIdx; $i >= 0; $i--) { + $t = $tokens[$i]; + if ($t->text === ']') { + $depth++; + } elseif ($t->id === T_ATTRIBUTE) { + $depth--; + if ($depth === 0) { + return $i; + } + } elseif ($t->text === '[') { + $depth--; + if ($depth === 0) { + return null; + } + } + } + return null; + } + /** * Build the structured `ClosureSignature` for a `(` at `$openIdx`. Throws the * variadic-not-last structural error. Nested `Closure(…)` leaves recurse. diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index faf857a..924908b 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -220,6 +220,27 @@ public function testCompileStillThrowsOnStaticGenericClosure(): void $this->compileFixture('closure_static'); } + public function testAttributedStaticGenericClosureIsCollectedByCheck(): void + { + // The attribute moves the closure node's start to `#[`; the generic + // marker must still bind there, so the static-closure reject FIRES — + // before, the marker was lost and the closure silently compiled raw. + $diagnostics = $this->check('closure_static_attributed'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(GenericMethodCompiler::CODE_UNSUPPORTED_STATIC_CLOSURE, $d->code); + self::assertNotNull($d->location); + self::assertSame(19, $d->location->line); + } + + public function testCompileStillThrowsOnAttributedStaticGenericClosure(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('static closures cannot yet be specialized'); + $this->compileFixture('closure_static_attributed'); + } + public function testUnresolvedGenericMethodTurbofishIsCollectedByCheck(): void { // A turbofish call to a generic method that exists nowhere on the receiver diff --git a/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php b/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php index 76679e7..5dc9e95 100644 --- a/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php +++ b/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php @@ -31,4 +31,19 @@ public function testDeclarationsAfterMultiLineSpansStillSpecializeAndRun(): void $fixture->cleanup(); } } + + #[RunInSeparateProcess] + public function testAttributedAndStaticGenericClosuresSpecializeAndRun(): void + { + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/attributed_generic_closures/source', + 'attributed-generic-closures', + ); + $fixture->registerAutoload('App\\AttributedClosures'); + try { + require __DIR__ . '/../../fixture/compile/attributed_generic_closures/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } } diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index b4dc14d..af26053 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -2770,6 +2770,65 @@ public function testGenericArrowFunctionIsParsed(): void self::assertSame('T', $params[0]->name); } + public function testGenericStaticArrowAttachesItsMarker(): void + { + // The node starts at `static`, not at `fn` — the marker must anchor + // there or the arrow silently loses its params (raw `T` in the output). + $source = <<<'PHP' +(T $x): T => $x; +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $arrow = self::findFirstNodeOfType($ast, \PhpParser\Node\Expr\ArrowFunction::class); + self::assertNotNull($arrow); + self::assertTrue($arrow->static); + $params = $arrow->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + self::assertIsArray($params); + self::assertSame('T', $params[0]->name); + } + + public function testAttributedGenericClosureAttachesItsMarker(): void + { + // The node starts at the first `#[`, not at `function`. + $source = <<<'PHP' +(T $x): T { + return $x; +}; +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $closure = self::findFirstNodeOfType($ast, \PhpParser\Node\Expr\Closure::class); + self::assertNotNull($closure); + $params = $closure->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + self::assertIsArray($params); + self::assertSame('T', $params[0]->name); + } + + public function testAttributedStaticGenericArrowAttachesItsMarker(): void + { + // Two ADJACENT attribute groups (no whitespace between them) — one + // with an array argument, so the walk back to the node start must + // balance the inner brackets AND resume exactly one token before + // each consumed group — plus `static`. + $source = <<<'PHP' +(U $y): U => $y; +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + $arrow = self::findFirstNodeOfType($ast, \PhpParser\Node\Expr\ArrowFunction::class); + self::assertNotNull($arrow); + self::assertTrue($arrow->static); + $params = $arrow->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + self::assertIsArray($params); + self::assertSame('U', $params[0]->name); + } + public function testGenericClosureDefaultIsAccepted(): void { // P5.7: defaults now allowed on anonymous closures; GMC pads diff --git a/test/fixture/check/closure_static_attributed/source/Use.xphp b/test/fixture/check/closure_static_attributed/source/Use.xphp new file mode 100644 index 0000000..978580f --- /dev/null +++ b/test/fixture/check/closure_static_attributed/source/Use.xphp @@ -0,0 +1,19 @@ +(T $x): T { + return $x; +}; + +$r = $f::(1); diff --git a/test/fixture/compile/attributed_generic_closures/source/Use.xphp b/test/fixture/compile/attributed_generic_closures/source/Use.xphp new file mode 100644 index 0000000..16c1a08 --- /dev/null +++ b/test/fixture/compile/attributed_generic_closures/source/Use.xphp @@ -0,0 +1,47 @@ +(T $x): T { + return $x; +}; + +$arrow = #[Marked] fn(T $x): T => $x; + +$sfn = static fn(T $x): T => $x; + +// Two groups, one with an array argument (inner brackets must balance), +// plus `static` — the node starts at the first `#[`. +$both = #[Marked] #[Tagged([1, 2])] static fn(T $x): T => $x; + +// Attribute on its own line — the walk back to the node start crosses it. +$split = #[Marked] +function(T $x): T { + return $x; +}; + +$a = $plain::(7); +$b = $arrow::('b'); +$c = $sfn::(3); +$d = $both::('d'); +$e = $split::(11); diff --git a/test/fixture/compile/attributed_generic_closures/verify/runtime.php b/test/fixture/compile/attributed_generic_closures/verify/runtime.php new file mode 100644 index 0000000..31f7805 --- /dev/null +++ b/test/fixture/compile/attributed_generic_closures/verify/runtime.php @@ -0,0 +1,21 @@ +targetDir . '/Use.php'; + +Assert::assertSame(7, $a); +Assert::assertSame('b', $b); +Assert::assertSame(3, $c); +Assert::assertSame('d', $d); +Assert::assertSame(11, $e); From 1cd8b741a7b9eee8b479ff50739efe3cac42efed Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 07:53:10 +0000 Subject: [PATCH 40/80] fix(monomorphize): match named generic markers on the declaration name's line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Named class/method/function markers matched on the NODE's start line, but php-parser starts a node at its first attribute group or modifier — so any header split across lines lost its marker: `#[Attr]` or `final` on its own line before a generic class false-rejected with "instantiated but never defined" (or silently emitted raw when only type-hinted), and the same split before a generic method or function silently dropped its type params, emitting raw hints that fatal at runtime. `#[Override]`-style attribute-on-its-own-line is idiomatic PHP. Match on the name Identifier's line instead — the marker already records the name token's line, so the two agree for every split shape (attribute, modifier, and keyword/name line breaks). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 22 ++++- .../MarkerAlignmentIntegrationTest.php | 15 +++ .../Monomorphize/XphpSourceParserTest.php | 93 +++++++++++++++++++ .../split_declaration_headers/source/Use.xphp | 66 +++++++++++++ .../verify/runtime.php | 22 +++++ 5 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 test/fixture/compile/split_declaration_headers/source/Use.xphp create mode 100644 test/fixture/compile/split_declaration_headers/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index dd87e61..70468d9 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -2435,7 +2435,12 @@ public function enterNode(Node $node): null $shortName = $node->name->toString(); $paramEntries = null; foreach ($this->classMarkers as $i => $marker) { - if ($marker['line'] === $node->getStartLine() && $marker['name'] === $shortName) { + // Match on the NAME Identifier's line, not the node's: + // the node starts at its first attribute group or + // modifier, which can sit on an earlier line + // (`#[Attr]\nfinal class Box`), while the marker + // records the name token's line. + if ($marker['line'] === $node->name->getStartLine() && $marker['name'] === $shortName) { $paramEntries = $marker['params']; unset($this->classMarkers[$i]); // @infection-ignore-all — break vs continue is equivalent after unset (marker is gone). @@ -2488,10 +2493,11 @@ public function enterNode(Node $node): null || $node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction ) { - // Named templates match by (line, name); anonymous templates + // Named templates match by (name line, name); anonymous templates // (closures + arrows) match by (kind, bytePosition). The marker - // records the ORIGINAL-source byte of the `function` / `static` / - // `fn` keyword, while getStartFilePos() reports the STRIPPED-source + // records the ORIGINAL-source byte of the node's first token (the + // first attribute group, `static`, or the keyword itself), + // while getStartFilePos() reports the STRIPPED-source // byte -- a length-changing rewrite earlier in the file (e.g. // `LongName[]` -> `array`) shifts the two apart, so the stripped // position maps back through the byte-offset map before comparing @@ -2499,6 +2505,12 @@ public function enterNode(Node $node): null $isAnonymous = $node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction; $declName = $isAnonymous ? '' : $node->name->toString(); + // Named declarations match on the NAME Identifier's line — + // the node itself starts at its first attribute group or + // modifier, which can sit on an earlier line + // (`#[Attr]\npublic function wrap`), while the marker + // records the name token's line. + $declLine = $isAnonymous ? -1 : $node->name->getStartLine(); $nodeStartByte = $this->byteOffsetMap->toOriginal($node->getStartFilePos()); $matchedParamNames = []; foreach ($this->methodMarkers as $i => $marker) { @@ -2508,7 +2520,7 @@ public function enterNode(Node $node): null $isMatch = $isAnonymous ? ($marker['kind'] !== 'named' && $marker['bytePosition'] === $nodeStartByte) - : ($marker['line'] === $node->getStartLine() + : ($marker['line'] === $declLine && $marker['name'] === $declName); if ($isMatch) { // Same two-pass scope-push-before-bound-build pattern diff --git a/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php b/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php index 5dc9e95..aea8acb 100644 --- a/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php +++ b/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php @@ -46,4 +46,19 @@ public function testAttributedAndStaticGenericClosuresSpecializeAndRun(): void $fixture->cleanup(); } } + + #[RunInSeparateProcess] + public function testSplitDeclarationHeadersSpecializeAndRun(): void + { + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/split_declaration_headers/source', + 'split-declaration-headers', + ); + $fixture->registerAutoload('App\\SplitHeaders'); + try { + require __DIR__ . '/../../fixture/compile/split_declaration_headers/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } } diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index af26053..5d35283 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -2829,6 +2829,99 @@ public function testAttributedStaticGenericArrowAttachesItsMarker(): void self::assertSame('U', $params[0]->name); } + public function testSplitDeclarationHeadersKeepTheirMarkers(): void + { + // php-parser starts a node at its first attribute group or modifier — + // when that sits on a DIFFERENT line than the declaration's name, the + // marker (recorded at the name token's line) must still bind. Every + // shape here either silently lost its type params or false-rejected + // with "instantiated but never defined" when matched on the node line. + $source = <<<'PHP' + { + public function __construct(public T $v) {} +} + +final +class Pair { + public function __construct(public U $u) {} +} + +class Wrap { + #[Note] + public function lift(T $x): T { return $x; } + + public static + function pick(T $x): T { return $x; } +} + +#[Note] +function ident(T $x): T { return $x; } + +function +double(T $x): array { return [$x, $x]; } +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $byName = self::classesByName($ast); + self::assertSame(['T'], self::paramNames($byName['Box'])); + self::assertSame(['U'], self::paramNames($byName['Pair'])); + + foreach ($byName['Wrap']->getMethods() as $method) { + $params = $method->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + self::assertIsArray($params, "method {$method->name->toString()} lost its marker"); + self::assertCount(1, $params); + } + + $functions = []; + foreach ($ast as $stmt) { + if ($stmt instanceof Namespace_) { + foreach ($stmt->stmts as $s) { + if ($s instanceof \PhpParser\Node\Stmt\Function_) { + $functions[$s->name->toString()] = $s; + } + } + } + } + self::assertSame(['ident', 'double'], array_keys($functions)); + foreach ($functions as $name => $fn) { + $params = $fn->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + self::assertIsArray($params, "function {$name} lost its marker"); + self::assertCount(1, $params); + } + } + + public function testSplitInterfaceAndTraitHeadersKeepTheirMarkers(): void + { + $source = <<<'PHP' + { + public function get(): T; +} + +#[Note] +trait Mixin { +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $iface = self::findFirstClassLike($ast, \PhpParser\Node\Stmt\Interface_::class); + self::assertNotNull($iface); + self::assertSame(['T'], self::paramNames($iface)); + + $trait = self::findFirstClassLike($ast, \PhpParser\Node\Stmt\Trait_::class); + self::assertNotNull($trait); + self::assertSame(['T'], self::paramNames($trait)); + } + public function testGenericClosureDefaultIsAccepted(): void { // P5.7: defaults now allowed on anonymous closures; GMC pads diff --git a/test/fixture/compile/split_declaration_headers/source/Use.xphp b/test/fixture/compile/split_declaration_headers/source/Use.xphp new file mode 100644 index 0000000..fbd8e71 --- /dev/null +++ b/test/fixture/compile/split_declaration_headers/source/Use.xphp @@ -0,0 +1,66 @@ + +{ + public function __construct(public T $v) + { + } +} + +final +class Pair +{ + public function __construct(public U $u) + { + } +} + +class Wrap +{ + #[Note] + public function lift(T $x): T + { + return $x; + } + + public static + function pick(T $x): T + { + return $x; + } +} + +#[Note] +function ident(T $x): T +{ + return $x; +} + +function +double(T $x): array +{ + return [$x, $x]; +} + +$b = new Box::('bx'); +$p = new Pair::(4); +$w = new Wrap(); +$l = $w->lift::(5); +$s = Wrap::pick::('pk'); +$i = ident::(9); +$d = double::('z'); diff --git a/test/fixture/compile/split_declaration_headers/verify/runtime.php b/test/fixture/compile/split_declaration_headers/verify/runtime.php new file mode 100644 index 0000000..f3cd528 --- /dev/null +++ b/test/fixture/compile/split_declaration_headers/verify/runtime.php @@ -0,0 +1,22 @@ +targetDir . '/Use.php'; + +Assert::assertSame('bx', $b->v); +Assert::assertSame(4, $p->u); +Assert::assertSame(5, $l); +Assert::assertSame('pk', $s); +Assert::assertSame(9, $i); +Assert::assertSame(['z', 'z'], $d); From 00ffbeb8df4a6b3b920ed5d964e76b74bba12b52 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 08:10:35 +0000 Subject: [PATCH 41/80] feat(monomorphize): loud backstop for generic markers that never bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every marker-alignment defect in this family was silent for the same structural reason: a class/method generic marker that fails to bind to its AST node simply evaporated after the attach walk, and the compiler carried on — emitting the declaration raw, with un-erased type-parameter hints that fatal at runtime behind a clean compile and a clean check. The strict parse path now throws when a class or method marker survives the walk unbound. Valid input can never trigger it — once parsing succeeded, every recognized declaration clause hangs off a real node — so the error is by construction a transpiler bug report, turning any future alignment defect from a silent de-generification into a loud compile error. The tolerant (LSP) path is exempt: half-typed code legitimately strands markers there. Name markers stay lax by design (pseudo-type type-args are skipped, gradual call shapes pass through). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 82 ++++++++++++++++++- .../Monomorphize/XphpSourceParserTest.php | 47 +++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 70468d9..47f7e97 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -138,7 +138,15 @@ public function parseWithMap(string $source): array } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap); + $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap); + // @infection-ignore-all — defensive backstop, unreachable from valid input by + // construction (see unboundDeclarationMarkerMessage): no test can reach a + // mutant here. The message builder is pinned by direct unit tests; this + // wiring exists to turn any FUTURE marker-alignment bug into a loud compile + // error instead of a silent de-generification of the declaration. + if ($unbound !== null) { + throw new RuntimeException($unbound); + } return [$ast, $byteOffsetMap]; } @@ -2373,16 +2381,22 @@ private static function applyReplacements(string $source, array $replacements): * recognition (closures / arrows, Phase 4) can dispatch to a different * matcher without having to peek at the rest of the marker shape. * + * Returns the loud-backstop error for the first class/method marker left + * unbound after the walk (always a transpiler alignment bug), or null when + * every declaration marker attached. The strict parse path throws it; the + * tolerant (LSP) path ignores it — half-typed code legitimately strands + * markers there. + * * @param list $ast * @param list}> $classMarkers * @param list}> $nameMarkers * @param list}> $methodMarkers * @param list $closureMarkers */ - private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap): void + private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap): ?string { $traverser = new NodeTraverser(); - $traverser->addVisitor(new + $visitor = new /** * @phpstan-import-type BoundDict from XphpSourceParser */ @@ -3061,8 +3075,68 @@ private function hasEnclosingTypeParams(): bool } return false; } - }); + + /** + * Diagnostic for a class/method marker that survived the walk + * unbound, or null when every one attached. Valid input can never + * leave one behind — once parsing succeeded, every recognized + * declaration clause hangs off a real AST node — so a survivor is + * a marker/AST alignment bug that would otherwise silently drop + * the clause's type parameters from the emitted code. Name markers + * are deliberately excluded: their attachment is lax by design + * (pseudo-type type-args are skipped, gradual call shapes pass + * through unclaimed). + */ + public function firstUnboundDeclarationMarker(): ?string + { + return XphpSourceParser::unboundDeclarationMarkerMessage( + $this->classMarkers, + $this->methodMarkers, + ); + } + }; + $traverser->addVisitor($visitor); $traverser->traverse($ast); + + return $visitor->firstUnboundDeclarationMarker(); + } + + /** + * Build the loud error for the first unbound class/method generic marker, + * or null when none survived. See the visitor's + * `firstUnboundDeclarationMarker` for why a survivor is always a + * transpiler bug: the strict parse path throws it rather than compiling + * on and silently de-generifying the declaration. + * + * @internal exposed for direct testing; not part of the public API. + * + * @param array $classMarkers + * @param array $methodMarkers + */ + public static function unboundDeclarationMarkerMessage(array $classMarkers, array $methodMarkers): ?string + { + foreach ($classMarkers as $marker) { + return self::unboundMarkerMessage(sprintf('`%s`', $marker['name']), $marker['line']); + } + foreach ($methodMarkers as $marker) { + $subject = $marker['name'] !== '' + ? sprintf('`%s`', $marker['name']) + : 'an anonymous closure'; + return self::unboundMarkerMessage($subject, $marker['line']); + } + return null; + } + + private static function unboundMarkerMessage(string $subject, int $line): string + { + return sprintf( + 'The generic type-parameter clause for %s (line %d) was recognized but never bound to ' + . 'its declaration — compiling on would silently drop the type parameters from the ' + . 'emitted code. This is a transpiler bug; please report it. As a workaround, keep the ' + . 'declaration header (attributes, modifiers, and name) on a single line.', + $subject, + $line, + ); } } diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 5d35283..0e7099c 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -2922,6 +2922,53 @@ trait Mixin { self::assertSame(['T'], self::paramNames($trait)); } + public function testUnboundDeclarationMarkerMessageIsNullWhenEverythingBound(): void + { + self::assertNull(XphpSourceParser::unboundDeclarationMarkerMessage([], [])); + } + + public function testUnboundClassMarkerProducesTheLoudBackstopError(): void + { + $msg = XphpSourceParser::unboundDeclarationMarkerMessage( + [['line' => 7, 'name' => 'Box']], + [], + ); + self::assertSame( + 'The generic type-parameter clause for `Box` (line 7) was recognized but never bound to ' + . 'its declaration — compiling on would silently drop the type parameters from the ' + . 'emitted code. This is a transpiler bug; please report it. As a workaround, keep the ' + . 'declaration header (attributes, modifiers, and name) on a single line.', + $msg, + ); + } + + public function testUnboundMethodAndClosureMarkersProduceTheLoudBackstopError(): void + { + $named = XphpSourceParser::unboundDeclarationMarkerMessage( + [], + [['line' => 3, 'name' => 'wrap']], + ); + self::assertNotNull($named); + self::assertStringContainsString('`wrap`', $named); + self::assertStringContainsString('line 3', $named); + + $anonymous = XphpSourceParser::unboundDeclarationMarkerMessage( + [], + [['line' => 9, 'name' => '']], + ); + self::assertNotNull($anonymous); + self::assertStringContainsString('an anonymous closure', $anonymous); + self::assertStringContainsString('line 9', $anonymous); + + // Class markers are reported first when both kinds survive. + $both = XphpSourceParser::unboundDeclarationMarkerMessage( + [['line' => 1, 'name' => 'A']], + [['line' => 2, 'name' => 'b']], + ); + self::assertNotNull($both); + self::assertStringContainsString('`A`', $both); + } + public function testGenericClosureDefaultIsAccepted(): void { // P5.7: defaults now allowed on anonymous closures; GMC pads From 68995a2fb691ce78d4e493904b6a955115cb312c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 08:13:14 +0000 Subject: [PATCH 42/80] feat(monomorphize): resolve php-parser Names through a qualification-aware seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several resolution sites flatten a Name with `toString()` before handing it to `resolveAgainstContext`, which drops the leading `\` of a fully-qualified name (doubling the namespace) and the `namespace\` prefix of a relative one (letting a `use` alias capture a name it must never touch). `NamespaceContext::resolveName(Name|Identifier)` feeds the resolver `toCodeString()` instead — `\App\Box`, `namespace\Box`, or the plain spelling — which the string-side branches already handle correctly, so the node's own qualification survives. Call sites migrate in the follow-up commits. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/NamespaceContext.php | 19 +++++++++++ .../Monomorphize/NamespaceContextTest.php | 33 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/Transpiler/Monomorphize/NamespaceContext.php b/src/Transpiler/Monomorphize/NamespaceContext.php index 3cc7b6c..8666138 100644 --- a/src/Transpiler/Monomorphize/NamespaceContext.php +++ b/src/Transpiler/Monomorphize/NamespaceContext.php @@ -4,6 +4,8 @@ namespace XPHP\Transpiler\Monomorphize; +use PhpParser\Node\Identifier; +use PhpParser\Node\Name; use PhpParser\Node\Stmt\Use_; use PhpParser\Node\UseItem; @@ -89,6 +91,23 @@ public function resolveAgainstContext(string $name): string : $name; } + /** + * Resolve a php-parser Name (or Identifier) honoring the node's own + * qualification: `toCodeString()` yields `\App\Box` for a fully-qualified + * name (the leading-backslash branch), `namespace\Box` for a relative name + * (the relative-binding branch — a `use` alias NEVER applies to either, + * per PHP's rules), and the plain spelling otherwise. Flattening a Name + * with `toString()` before resolving is exactly the bug this seam removes: + * it drops both prefixes, doubling the namespace on fully-qualified names + * and letting the alias map capture relative ones. + */ + public function resolveName(Name|Identifier $name): string + { + return $this->resolveAgainstContext( + $name instanceof Name ? $name->toCodeString() : $name->toString(), + ); + } + public function currentNamespace(): string { return $this->currentNamespace; diff --git a/test/Transpiler/Monomorphize/NamespaceContextTest.php b/test/Transpiler/Monomorphize/NamespaceContextTest.php index 470c31b..41283a1 100644 --- a/test/Transpiler/Monomorphize/NamespaceContextTest.php +++ b/test/Transpiler/Monomorphize/NamespaceContextTest.php @@ -20,6 +20,39 @@ public function testBareNameResolvesToCurrentNamespacePrefix(): void self::assertSame('App\\Containers\\Box', $ctx->resolveAgainstContext('Box')); } + public function testResolveNameHonorsAFullyQualifiedNodeDespiteAnAlias(): void + { + // A `\App\Box` node must resolve to `App\Box` — never alias-captured, + // never doubled with the current namespace (both are what flattening + // it with `toString()` used to produce). + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexUse(self::makeUse('Other\\Box')); + + self::assertSame('App\\Box', $ctx->resolveName(new Name\FullyQualified('App\\Box'))); + } + + public function testResolveNameBindsARelativeNodeToTheCurrentNamespaceDespiteAnAlias(): void + { + // `namespace\Box` binds to the CURRENT namespace by PHP's rules; the + // `use Other\Box` alias never applies to a relative name. + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexUse(self::makeUse('Other\\Box')); + + self::assertSame('App\\Box', $ctx->resolveName(new Name\Relative('Box'))); + } + + public function testResolveNamePlainNodeStillUsesTheAliasMap(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexUse(self::makeUse('Other\\Box')); + + self::assertSame('Other\\Box', $ctx->resolveName(new Name('Box'))); + self::assertSame('Other\\Box', $ctx->resolveName(new Identifier('Box'))); + } + public function testLeadingBackslashStripsAndShortCircuits(): void { $ctx = new NamespaceContext(); From 06962d299298c42a1819ffd9cc5ec247951c071c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 08:40:01 +0000 Subject: [PATCH 43/80] fix(monomorphize): honor FQ and relative spellings at generic call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic-args markers were recorded with the leading `\` trimmed and the `namespace\` prefix kept raw, then matched against `Name::toString()` — which erases both prefixes. Four defects flowed from that one mismatch: a fully-qualified turbofish (`new \App\Box::`) resolved its template against the current namespace AGAIN and hard-failed as the undefined `App\App\Box`; a fully-qualified generic-function call silently lost its dispatcher (undefined-function fatal at runtime); a relative turbofish (`new namespace\Box::`) never matched its marker and silently emitted a raw `new` against the stripped marker interface; and on one line, a relative generic return type stole the body turbofish's marker — specializing the wrong site. Record markers in their raw source spelling (folding only the case-insensitive `namespace` keyword to lowercase) and compare against `toCodeString()`, which preserves both prefixes. Admit fully-qualified names in the call-site rewriter — skipping them would leave the now-correctly-resolved FQ site newing the marker interface — and strip the generic attributes off the replacement so the specialized-AST re-rewrite stays idempotent. Ground generic args on fully-qualified references inside template bodies too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/CallSiteRewriter.php | 17 ++- src/Transpiler/Monomorphize/Specializer.php | 22 ++- .../Monomorphize/XphpSourceParser.php | 40 ++++- .../QualifiedCallSiteIntegrationTest.php | 34 +++++ .../Monomorphize/VisitorGuardsTest.php | 19 ++- .../Monomorphize/XphpSourceParserTest.php | 139 +++++++++++++++++- .../source/Use.xphp | 57 +++++++ .../verify/runtime.php | 29 ++++ 8 files changed, 335 insertions(+), 22 deletions(-) create mode 100644 test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php create mode 100644 test/fixture/compile/qualified_generic_call_sites/source/Use.xphp create mode 100644 test/fixture/compile/qualified_generic_call_sites/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/CallSiteRewriter.php b/src/Transpiler/Monomorphize/CallSiteRewriter.php index 8528512..01d5a79 100644 --- a/src/Transpiler/Monomorphize/CallSiteRewriter.php +++ b/src/Transpiler/Monomorphize/CallSiteRewriter.php @@ -46,7 +46,11 @@ public function __construct(private Registry $registry) public function leaveNode(Node $node): Node|int|null { - if ($node instanceof Name && !$node instanceof FullyQualified) { + // Fully-qualified names are admitted too: `new \App\Box::` / + // `\App\Box` carry the same attributes and must rewrite to the + // specialization — skipping them would silently leave the call site + // pointing at the stripped marker interface. + if ($node instanceof Name) { $args = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); // Empty `$args` is the call-site shape that asks the registry to pad @@ -57,7 +61,16 @@ public function leaveNode(Node $node): Node|int|null /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach. */ if (is_string($fqn) && self::allConcrete($args)) { $instantiation = $this->registry->recordInstantiation($fqn, $args); - return new FullyQualified($instantiation->generatedFqn, $node->getAttributes()); + // Drop the generic attributes from the replacement: the + // FullyQualified result re-enters this branch on the + // Phase-3.5 re-rewrite of specialized ASTs, so the + // rewrite must be idempotent by construction. + $attributes = $node->getAttributes(); + unset( + $attributes[XphpSourceParser::ATTR_GENERIC_ARGS], + $attributes[XphpSourceParser::ATTR_TEMPLATE_FQN], + ); + return new FullyQualified($instantiation->generatedFqn, $attributes); } } } diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index df231af..72424cf 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -300,13 +300,19 @@ public function leaveNode(Node $node): ?Node } } - if ($node instanceof Name && !$node->isFullyQualified()) { - $parts = $node->getParts(); - if (count($parts) === 1 && isset($this->substitution[$parts[0]])) { - $concrete = $this->substitution[$parts[0]]; - return Specializer::typeRefToNode($concrete, $node->getAttributes()); + if ($node instanceof Name) { + if (!$node->isFullyQualified()) { + $parts = $node->getParts(); + if (count($parts) === 1 && isset($this->substitution[$parts[0]])) { + $concrete = $this->substitution[$parts[0]]; + return Specializer::typeRefToNode($concrete, $node->getAttributes()); + } } + // Generic-args substitution runs for FULLY-QUALIFIED names + // too: `\App\Box` inside a template body carries the same + // attributes as the bare form and its `T` must ground when + // the class specializes. $args = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); if (is_array($args) && $args !== []) { /** @var list $args — ATTR_GENERIC_ARGS is a TypeRef list (set by XphpSourceParser); the type-hint lets array_map infer the callback's parameter as TypeRef. */ @@ -316,9 +322,15 @@ public function leaveNode(Node $node): ?Node ); $node->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, $substituted); + // @infection-ignore-all ReturnRemoval — falling through can't change + // anything: ATTR_RESOLVED_FQN and ATTR_GENERIC_ARGS are mutually + // exclusive by construction (shouldQualify tags only arg-less names), + // so the branch below never fires for an args-bearing node. return null; } + } + if ($node instanceof Name && !$node->isFullyQualified()) { // Bare, non-generic class/interface name carried over from the // template (extends Countable, new ArrayIterator, ...). The parser // tagged it with the FQN resolved against the source file's diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 47f7e97..2de509a 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -503,7 +503,7 @@ private function scanAndStrip(string $source): array $nameMarkers[] = [ 'line' => $nameLine, 'anchorLine' => $anchorLine, - 'name' => ltrim($nameText, '\\'), + 'name' => self::markerNameSpelling($nameText), 'kind' => 'named', 'bytePosition' => $tok->pos, 'args' => $args, @@ -553,7 +553,7 @@ private function scanAndStrip(string $source): array $nameMarkers[] = [ 'line' => $nameLine, 'anchorLine' => $anchorLine, - 'name' => ltrim($nameText, '\\'), + 'name' => self::markerNameSpelling($nameText), 'kind' => 'named', 'bytePosition' => $tok->pos, 'args' => $args, @@ -1195,6 +1195,24 @@ private static function skipWsBack(array $tokens, int $i): int return $i; } + /** + * Canonical marker spelling for a name token: the raw source text with + * only the case-insensitive `namespace\` keyword prefix folded to + * lowercase, so it compares equal to `Name::toCodeString()` (which always + * emits the keyword lowercase). The leading `\` of a fully-qualified name + * and the `namespace\` prefix are deliberately KEPT: stripping them + * conflated `\App\Box` with a qualified `App\Box`, and let a bare aliased + * `Box` on the same line steal a `namespace\Box` marker (or vice versa) — + * specializing the wrong site. + */ + private static function markerNameSpelling(string $nameText): string + { + if (strncasecmp($nameText, 'namespace\\', 10) === 0) { + return 'namespace\\' . substr($nameText, 10); + } + return $nameText; + } + /** * The true start of an anonymous closure/arrow node whose keyword sits at * `$keywordIdx`: php-parser starts the node at its FIRST token — the first @@ -2616,7 +2634,10 @@ public function enterNode(Node $node): null if ($node instanceof Node\Expr\FuncCall && $node->name instanceof Name) { // Claim the marker on FuncCall enter (parent fires before children) so the // inner Name doesn't pick it up and trigger the class-rewrite path. - $funcName = $node->name->toString(); + // toCodeString(): markers record the raw source spelling, so a + // `\App\make` / `namespace\make` marker must compare against the + // node's own qualified spelling, not the prefix-erased toString(). + $funcName = $node->name->toCodeString(); $startLine = $node->getStartLine(); foreach ($this->nameMarkers as $i => $marker) { // @infection-ignore-all -- the three `&&` clauses are jointly @@ -2667,7 +2688,12 @@ public function enterNode(Node $node): null } if ($node instanceof Name) { - $nameStr = $node->toString(); + // toCodeString(): a Relative node's toString() erases the + // `namespace\` prefix, so a bare `Box` node on the same line + // could steal a `namespace\Box` marker (and the relative + // node's own marker never matched at all — silently dropping + // its type args). The marker records the source spelling. + $nameStr = $node->toCodeString(); foreach ($this->nameMarkers as $i => $marker) { if ($marker['line'] === $node->getStartLine() && $marker['name'] === $nameStr) { $resolved = $this->resolveTypeRefList($marker['args']); @@ -2887,9 +2913,9 @@ private function shouldQualify(Name $node): bool private function resolveNameOnly(string $name): string { - if (str_starts_with($name, '\\')) { - return ltrim($name, '\\'); - } + // A prefixed spelling (`\App\Box`, `namespace\Box`) can never + // name a type param and resolves in resolveAgainstContext's own + // leading-backslash / relative branches. if ($this->isEnclosingTypeParam($name)) { return $name; } diff --git a/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php b/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php new file mode 100644 index 0000000..0a37d13 --- /dev/null +++ b/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php @@ -0,0 +1,34 @@ +registerAutoload('App\\QualifiedCalls'); + try { + require __DIR__ . '/../../fixture/compile/qualified_generic_call_sites/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } +} diff --git a/test/Transpiler/Monomorphize/VisitorGuardsTest.php b/test/Transpiler/Monomorphize/VisitorGuardsTest.php index 7bca360..2b3d100 100644 --- a/test/Transpiler/Monomorphize/VisitorGuardsTest.php +++ b/test/Transpiler/Monomorphize/VisitorGuardsTest.php @@ -31,17 +31,28 @@ final class VisitorGuardsTest extends TestCase // CallSiteRewriter // ===================================================================== - public function testCallSiteRewriterIgnoresFullyQualifiedNames(): void + public function testCallSiteRewriterRewritesFullyQualifiedGenericNamesExactlyOnce(): void { + // A fully-qualified name carrying generic attributes is a real call + // site (`new \App\Box::` after stripping) and MUST rewrite — + // skipping it would leave the emitted code newing the stripped marker + // interface. Idempotence across the specialized-AST re-rewrite comes + // from stripping the generic attributes off the replacement node, not + // from skipping FullyQualified names. $registry = new Registry(); - $fq = new FullyQualified('App\\Models\\Plastic'); + $fq = new FullyQualified('App\\Containers\\Box'); $fq->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, [new TypeRef('App\\Plastic')]); $fq->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, 'App\\Containers\\Box'); $ast = self::wrapNameInStmt($fq); - (new CallSiteRewriter($registry))->rewrite($ast); + $result = (new CallSiteRewriter($registry))->rewrite($ast); + + self::assertCount(1, $registry->instantiations(), 'the FQ call site must record its instantiation'); - self::assertSame([], $registry->instantiations(), 'already-FullyQualified nodes must not be re-rewritten'); + // The replacement must carry no generic attributes, so a second pass + // (the Phase-3.5 re-rewrite of specialized ASTs) records nothing new. + (new CallSiteRewriter($registry))->rewrite($result); + self::assertCount(1, $registry->instantiations(), 'the rewrite must be idempotent'); } public function testCallSiteRewriterIgnoresNameWithoutGenericArgs(): void diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 0e7099c..c1f3788 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -1975,10 +1975,10 @@ public function testWhitespaceBetweenDoubleColonAndAngleDefeatsTurbofish(): void public function testTypeHintPositionAcceptsFullyQualifiedOuterName(): void { - // Locks the ltrim('\\') on the bare-`<…>` (type-hint) branch: when the - // outer Name is fully qualified (`\App\Containers\Box`), the marker - // must be keyed by the trimmed form so the resolver -- which sees the - // AST Name's `toString()` (no leading backslash) -- can match. + // A fully-qualified outer Name (`\App\Containers\Box`) in a + // type-hint slot: the marker records the raw source spelling (leading + // `\` kept) and the resolver compares the node's `toCodeString()`, so + // the two agree and the generic args attach. $source = <<<'PHP' name); } + public function testFullyQualifiedTurbofishResolvesWithoutDoublingTheNamespace(): void + { + // `new \App\Box::` used to record ATTR_TEMPLATE_FQN = `App\App\Box` + // (the leading `\` was trimmed before namespace resolution), which + // hard-failed as an undefined template. + $source = <<<'PHP' + { public function __construct(public T $v) {} } +$b = new \App\Box::(1); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $finder = new \PhpParser\NodeFinder(); + $fq = null; + foreach ($finder->findInstanceOf($ast, Name::class) as $n) { + if ($n->isFullyQualified()) { + $fq = $n; + } + } + self::assertNotNull($fq); + self::assertSame('App\\Box', $fq->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN)); + $args = $fq->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + self::assertIsArray($args); + self::assertSame('int', $args[0]->name); + } + + public function testSameLineRelativeReturnAndBareTurbofishKeepTheirOwnMarkers(): void + { + // On ONE line, the relative return type's marker and the body + // turbofish's marker both spell a bare `Box` after toString() — the + // return-type node used to steal the body's marker (specializing the + // wrong site) while its own `namespace\Box` marker never bound. + $source = <<<'PHP' + { public function __construct(public T $v) {} } +function f(): namespace\Box { return new Box::('s'); } +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $finder = new \PhpParser\NodeFinder(); + $relative = null; + $bare = null; + foreach ($finder->findInstanceOf($ast, Name::class) as $n) { + if ($n->isRelative()) { + $relative = $n; + } elseif (!$n->isFullyQualified() && $n->toString() === 'Box' + && $n->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS) !== null + ) { + $bare = $n; + } + } + + self::assertNotNull($relative, 'relative return-type Name missing'); + self::assertSame('App\\Box', $relative->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN)); + $relArgs = $relative->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + self::assertIsArray($relArgs); + self::assertSame('int', $relArgs[0]->name, 'the return type must keep its OWN args'); + + self::assertNotNull($bare, 'body turbofish Name missing its args'); + $bareArgs = $bare->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + self::assertIsArray($bareArgs); + self::assertSame('string', $bareArgs[0]->name, 'the body turbofish must keep its OWN args'); + } + + public function testUppercaseRelativePrefixStillMatchesItsMarker(): void + { + // The `namespace` keyword is case-insensitive in source, but + // `toCodeString()` always emits it lowercase — the marker spelling + // must fold the prefix or `NAMESPACE\Box::` silently loses + // its marker. + $source = <<<'PHP' + { public function __construct(public T $v) {} } +$b = new NAMESPACE\Box::(1); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $finder = new \PhpParser\NodeFinder(); + $relative = null; + foreach ($finder->findInstanceOf($ast, Name::class) as $n) { + if ($n->isRelative()) { + $relative = $n; + } + } + self::assertNotNull($relative); + self::assertSame('App\\Box', $relative->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN)); + $args = $relative->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + self::assertIsArray($args); + self::assertSame('int', $args[0]->name); + } + + public function testQualifiedFunctionTurbofishesResolveAndAttach(): void + { + // `\App\make::(…)` used to double the namespace in + // ATTR_TEMPLATE_FQN (stripping the generic function with no dispatcher + // emitted — a silent undefined-function fatal); `namespace\make::<…>` + // never matched its marker at all and false-rejected as a + // missing-type-argument call. + $source = <<<'PHP' +(T $x): T { return $x; } +$a = \App\make::(1); +$b = namespace\make::('s'); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $finder = new \PhpParser\NodeFinder(); + $calls = $finder->findInstanceOf($ast, Node\Expr\FuncCall::class); + self::assertCount(2, $calls); + + $fqCall = $calls[0]; + self::assertSame('App\\make', $fqCall->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN)); + $fqArgs = $fqCall->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + self::assertIsArray($fqArgs); + self::assertSame('int', $fqArgs[0]->name); + + $relCall = $calls[1]; + self::assertSame('App\\make', $relCall->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN)); + $relArgs = $relCall->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + self::assertIsArray($relArgs); + self::assertSame('string', $relArgs[0]->name); + } + public function testTypeHintPositionStillAcceptsBareGenericArgs(): void { // Regression: type-hint sites (property type, return type, params, diff --git a/test/fixture/compile/qualified_generic_call_sites/source/Use.xphp b/test/fixture/compile/qualified_generic_call_sites/source/Use.xphp new file mode 100644 index 0000000..ae51075 --- /dev/null +++ b/test/fixture/compile/qualified_generic_call_sites/source/Use.xphp @@ -0,0 +1,57 @@ + +{ + public function __construct(public T $v) + { + } +} + +class Cache +{ + public function __construct(public T $key) + { + } +} + +class Holder +{ + public function __construct(public T $inner) + { + } + + // FQ generic return hint + FQ turbofish with a type-param arg inside + // a template body: both must ground when Holder specializes. + public function wrap(): \App\QualifiedCalls\Box + { + return new \App\QualifiedCalls\Box::($this->inner); + } +} + +function make(T $x): T +{ + return $x; +} + +// Relative generic return type and a bare turbofish on ONE line: each +// keeps its own marker (the return type must not steal the body's). +function build(): namespace\Box { return new Box::(9); } + +$a = new \App\QualifiedCalls\Box::(1); +$b = new namespace\Box::('r'); +$c = new \App\QualifiedCalls\Cache::<>('k'); +$h = new Holder::(5); +$d = $h->wrap(); +$e = build(); +$f = \App\QualifiedCalls\make::(3); +$g = namespace\make::('g'); diff --git a/test/fixture/compile/qualified_generic_call_sites/verify/runtime.php b/test/fixture/compile/qualified_generic_call_sites/verify/runtime.php new file mode 100644 index 0000000..a6176ae --- /dev/null +++ b/test/fixture/compile/qualified_generic_call_sites/verify/runtime.php @@ -0,0 +1,29 @@ +targetDir . '/Use.php'; + +Assert::assertSame(1, $a->v); +Assert::assertSame('r', $b->v); +Assert::assertSame('k', $c->key); +Assert::assertSame(5, $d->v); +Assert::assertSame(9, $e->v); +Assert::assertSame(3, $f); +Assert::assertSame('g', $g); + +// FQ, in-template, and same-line-relative int instantiations must all be +// the one int specialization. +Assert::assertSame(get_class($a), get_class($d)); +Assert::assertSame(get_class($a), get_class($e)); From 8782127e08b1a95fc001555d32c1c32c42103bf0 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 08:46:55 +0000 Subject: [PATCH 44/80] fix(monomorphize): synthesize all-defaults instantiations for FQ and relative bare news MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new \App\Box("hi")` on an all-defaults generic was excluded from the bare-new synthesis, so compile emitted the stripped `interface Box {}` and kept the call site — a guaranteed "Cannot instantiate interface" fatal behind a clean compile and a clean check. A relative `new namespace\Box(...)` reached the synthesis but resolved through the flattened name, so a colliding `use` alias silently captured it — skipping synthesis, or targeting the WRONG template when the alias pointed at another all-defaults generic. Admit fully-qualified news and resolve the spelling the author wrote (via the qualification-aware seam); the aliased bare form keeps targeting the aliased template. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/RegistryCollector.php | 13 +++++++-- .../QualifiedCallSiteIntegrationTest.php | 15 ++++++++++ .../Monomorphize/VisitorGuardsTest.php | 19 +++++++++---- .../source/OtherBox.xphp | 12 ++++++++ .../source/RelativeUse.xphp | 16 +++++++++++ .../source/Use.xphp | 19 +++++++++++++ .../verify/runtime.php | 28 +++++++++++++++++++ 7 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 test/fixture/compile/qualified_bare_new_defaults/source/OtherBox.xphp create mode 100644 test/fixture/compile/qualified_bare_new_defaults/source/RelativeUse.xphp create mode 100644 test/fixture/compile/qualified_bare_new_defaults/source/Use.xphp create mode 100644 test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/RegistryCollector.php b/src/Transpiler/Monomorphize/RegistryCollector.php index 205730f..ebaffb0 100644 --- a/src/Transpiler/Monomorphize/RegistryCollector.php +++ b/src/Transpiler/Monomorphize/RegistryCollector.php @@ -7,7 +7,6 @@ use PhpParser\Node; use PhpParser\Node\Expr\New_; use PhpParser\Node\Name; -use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\Namespace_; use PhpParser\Node\Stmt\Use_; @@ -143,10 +142,13 @@ public function enterNode(Node $node): null } } + // Fully-qualified bare news are included: `new \App\Box("hi")` on an + // all-defaults generic used to be skipped here, so compile emitted the + // stripped `interface Box {}` and KEPT the call site — a guaranteed + // runtime fatal behind a clean gate. if ($this->mode !== self::MODE_DEFINITIONS && $node instanceof New_ && $node->class instanceof Name - && !$node->class instanceof FullyQualified && $node->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS) === null ) { $this->synthesizeBareNewIfAllDefaults($node->class); @@ -164,7 +166,12 @@ public function enterNode(Node $node): null */ private function synthesizeBareNewIfAllDefaults(Name $name): void { - $resolved = $this->ctx->resolveAgainstContext($name->toString()); + // resolveName (not a flattened toString()): the spelling decides the + // template — `\App\Box` must not double the namespace, and a relative + // `namespace\Box` must bind to the current namespace even when a + // colliding `use` alias is in scope (otherwise synthesis targets the + // WRONG template, or silently skips). + $resolved = $this->ctx->resolveName($name); $definition = $this->registry->definition($resolved); if ($definition === null || $definition->typeParams === []) { return; diff --git a/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php b/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php index 0a37d13..fe0ea06 100644 --- a/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php +++ b/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php @@ -31,4 +31,19 @@ public function testQualifiedSpellingsSpecializeAndRun(): void $fixture->cleanup(); } } + + #[RunInSeparateProcess] + public function testQualifiedBareNewsSynthesizeDefaultsAndRun(): void + { + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/qualified_bare_new_defaults/source', + 'qualified-bare-new-defaults', + ); + $fixture->registerAutoload('App\\QualifiedDefaults', 'Other'); + try { + require __DIR__ . '/../../fixture/compile/qualified_bare_new_defaults/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } } diff --git a/test/Transpiler/Monomorphize/VisitorGuardsTest.php b/test/Transpiler/Monomorphize/VisitorGuardsTest.php index 2b3d100..2deb182 100644 --- a/test/Transpiler/Monomorphize/VisitorGuardsTest.php +++ b/test/Transpiler/Monomorphize/VisitorGuardsTest.php @@ -270,11 +270,13 @@ public function testCollectorBareNewSynthesisSkipsNameWithExistingGenericArgs(): self::assertTrue($only->concreteTypes[0]->isScalar); } - public function testCollectorBareNewSynthesisSkipsFullyQualifiedName(): void + public function testCollectorBareNewSynthesisIncludesFullyQualifiedName(): void { - // FullyQualified Name nodes already point at an explicit class -- no - // namespace + use-map resolution needed, and they're not a synthesis - // target. Pin that the collector ignores them. + // A FullyQualified bare new of an all-defaults generic is a real + // instantiation: skipping it emitted the stripped marker interface + // and KEPT the `new \Cache(...)` call site — a guaranteed runtime + // fatal behind a clean gate. It must synthesize the defaults tuple + // exactly like the bare spelling (without doubling the namespace). $registry = new Registry(); $class = new Class_(new Identifier('Cache')); $class->setAttribute( @@ -290,7 +292,14 @@ public function testCollectorBareNewSynthesisSkipsFullyQualifiedName(): void (new RegistryCollector($registry))->collect($ast, '/x.xphp'); - self::assertSame([], $registry->instantiations(), 'FullyQualified bare new must not be synthesized'); + $instantiations = $registry->instantiations(); + self::assertCount(1, $instantiations, 'FQ bare new of an all-defaults generic must synthesize'); + self::assertSame('Cache', reset($instantiations)->templateFqn); + self::assertSame( + [], + $bareNew->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS), + 'the synthesized marker must attach so the call-site rewriter fires', + ); } // ===================================================================== diff --git a/test/fixture/compile/qualified_bare_new_defaults/source/OtherBox.xphp b/test/fixture/compile/qualified_bare_new_defaults/source/OtherBox.xphp new file mode 100644 index 0000000..7023767 --- /dev/null +++ b/test/fixture/compile/qualified_bare_new_defaults/source/OtherBox.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public T $n) + { + } +} diff --git a/test/fixture/compile/qualified_bare_new_defaults/source/RelativeUse.xphp b/test/fixture/compile/qualified_bare_new_defaults/source/RelativeUse.xphp new file mode 100644 index 0000000..a2ef3aa --- /dev/null +++ b/test/fixture/compile/qualified_bare_new_defaults/source/RelativeUse.xphp @@ -0,0 +1,16 @@ + +{ + public function __construct(public T $v) + { + } +} + +$a = new \App\QualifiedDefaults\Box("hi"); diff --git a/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php b/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php new file mode 100644 index 0000000..b1e8c92 --- /dev/null +++ b/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php @@ -0,0 +1,28 @@ +targetDir . '/OtherBox.php'; +require $fixture->targetDir . '/Use.php'; +require $fixture->targetDir . '/RelativeUse.php'; + +Assert::assertSame('hi', $a->v); +Assert::assertSame('ho', $b->v); +Assert::assertSame(7, $c->n); + +// $a and $b are the same App-side specialization; $c is Other's. +Assert::assertSame(get_class($a), get_class($b)); +Assert::assertNotSame(get_class($a), get_class($c)); +Assert::assertStringContainsString('Other', get_class($c)); From fa3f6788d11964e58916846a0f32c74d5ec5eb29 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 09:01:58 +0000 Subject: [PATCH 45/80] fix(monomorphize): relative names are explicit class references, never aliases or type params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every AST-side resolver flattened relative names with toString(), which erases the `namespace\` prefix — so `class Gen extends namespace\Base` with `use Other\Base` in scope emitted the generated class extending \Other\Base (PHP requires the CURRENT namespace's Base; aliases never apply to relative names), and the same capture hit every marked position: param, property, return, catch, instanceof, const-fetch. A relative name colliding with a type parameter (`namespace\Thing` inside Gen) was substituted like the type param — emitting `int $x` where the author declared an explicit class reference. The generic-method receiver lookup and the type-hierarchy edge builder alias-captured too, the latter turning a valid conformance hierarchy into a PROVABLE-looking mismatch — a false reject. Resolve marked names through the qualification-aware seam, exclude relative spellings from the type-param swap and from suspect-type-param tagging, and add the missing relative branch to the receiver and hierarchy resolvers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 9 +++ src/Transpiler/Monomorphize/Specializer.php | 6 +- src/Transpiler/Monomorphize/TypeHierarchy.php | 7 ++ .../Monomorphize/XphpSourceParser.php | 20 ++++-- .../QualifiedCallSiteIntegrationTest.php | 15 ++++ .../Monomorphize/XphpSourceParserTest.php | 69 +++++++++++++++++++ .../source/LocalDefs.xphp | 24 +++++++ .../source/OtherDefs.xphp | 24 +++++++ .../source/Use.xphp | 61 ++++++++++++++++ .../verify/runtime.php | 26 +++++++ 10 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 test/fixture/compile/relative_names_in_templates/source/LocalDefs.xphp create mode 100644 test/fixture/compile/relative_names_in_templates/source/OtherDefs.xphp create mode 100644 test/fixture/compile/relative_names_in_templates/source/Use.xphp create mode 100644 test/fixture/compile/relative_names_in_templates/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 7237d68..8924fd5 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -2308,6 +2308,15 @@ private function resolveClassName(Name $name): string if (str_starts_with($raw, '\\')) { return ltrim($raw, '\\'); } + // `namespace\Svc` binds to the current namespace — never to a + // `use` alias (toString() erased the prefix, so ask the node). + // Alias capture here made the receiver's method-template lookup + // land on the wrong class. + if ($name->isRelative()) { + return $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $raw + : $raw; + } $first = self::firstSegment($raw); if (isset($this->useMap[$first])) { $rest = substr($raw, strlen($first)); diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index 72424cf..13ad879 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -301,7 +301,11 @@ public function leaveNode(Node $node): ?Node } if ($node instanceof Name) { - if (!$node->isFullyQualified()) { + // The type-param swap applies only to a PLAIN single-segment + // name: `\T` and `namespace\T` are explicit class references + // by spelling — substituting a type param into them emitted + // wrong signatures (`namespace\Thing $x` becoming `int $x`). + if (!$node->isFullyQualified() && !$node->isRelative()) { $parts = $node->getParts(); if (count($parts) === 1 && isset($this->substitution[$parts[0]])) { $concrete = $this->substitution[$parts[0]]; diff --git a/src/Transpiler/Monomorphize/TypeHierarchy.php b/src/Transpiler/Monomorphize/TypeHierarchy.php index 2a3f48b..48146e9 100644 --- a/src/Transpiler/Monomorphize/TypeHierarchy.php +++ b/src/Transpiler/Monomorphize/TypeHierarchy.php @@ -432,6 +432,13 @@ private function resolveName(Name $name): string if ($name->isFullyQualified() || str_starts_with($raw, '\\')) { return ltrim($raw, '\\'); } + // `namespace\Base` binds to the current namespace — never to a + // `use` alias. An alias-captured hierarchy edge here recorded + // the wrong parent, which conformance then treated as a + // PROVABLE mismatch — a false reject of valid code. + if ($name->isRelative()) { + return $this->qualify($raw); + } $first = self::firstSegment($raw); if (isset($this->useMap[$first])) { $rest = substr($raw, strlen($first)); diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 2de509a..135d4c0 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -2857,8 +2857,12 @@ private function markName(Name $node): void if (!$this->shouldQualify($node)) { return; } - $name = $node->toString(); - $resolved = $this->ctx->resolveAgainstContext($name); + // resolveName (not a flattened toString()): a relative + // `namespace\Base` must bind to the current namespace — a + // `use Other\Base` alias never applies to it, and resolving + // the bare tail through the alias map emitted generated code + // extending/typing the WRONG class. + $resolved = $this->ctx->resolveName($node); $node->setAttribute(XphpSourceParser::ATTR_RESOLVED_FQN, $resolved); // Flag a bare, single-segment, non-imported class reference used inside a @@ -2867,9 +2871,12 @@ private function markName(Name $node): void // a real (in-project / built-in) type or a stray/undeclared type parameter // like the `T` in `interface Foo { add(T $x): void; }`. The validator // resolves which using the declared-set; here we only record the suspicion. - if (count($node->getParts()) === 1 + // Relative names are excluded: `namespace\Thing` is as explicit a class + // reference as an import or a FQ spelling. + if (!$node->isRelative() + && count($node->getParts()) === 1 && $this->hasEnclosingTypeParams() - && !$this->ctx->isImported($name) + && !$this->ctx->isImported($node->toString()) ) { $node->setAttribute(XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE, $resolved); } @@ -2895,11 +2902,14 @@ private function shouldQualify(Name $node): bool return false; } $name = $node->toString(); + // A RELATIVE spelling is never a type param (`namespace\T` is an + // explicit class reference), so only a plain name defers to the + // enclosing scope here. // @infection-ignore-all — defensive only: an enclosing type-param is a single // segment that the Specializer substitutes (returning early via typeRefToNode) // before the resolved-FQN swap can ever read the attribute, so tagging it is // likewise unobservable. - if ($this->isEnclosingTypeParam($name)) { + if (!$node->isRelative() && $this->isEnclosingTypeParam($name)) { return false; } $parts = $node->getParts(); diff --git a/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php b/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php index fe0ea06..85a2fe0 100644 --- a/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php +++ b/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php @@ -32,6 +32,21 @@ public function testQualifiedSpellingsSpecializeAndRun(): void } } + #[RunInSeparateProcess] + public function testRelativeNamesInTemplatesBindToTheCurrentNamespaceAndRun(): void + { + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/relative_names_in_templates/source', + 'relative-names-in-templates', + ); + $fixture->registerAutoload('App\\RelativeTemplates', 'Other'); + try { + require __DIR__ . '/../../fixture/compile/relative_names_in_templates/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + #[RunInSeparateProcess] public function testQualifiedBareNewsSynthesizeDefaultsAndRun(): void { diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index c1f3788..e2c0807 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -2091,6 +2091,75 @@ class Box { public function __construct(public T $v) {} } self::assertSame('int', $args[0]->name); } + public function testRelativeNamesResolveToTheCurrentNamespaceNotTheAliasMap(): void + { + // `namespace\Thing` binds to App\Thing even with `use Other\Thing` in + // scope, and — being an explicit class reference — is never flagged as + // a suspect undeclared type parameter. + $source = <<<'PHP' + { + public function m(namespace\Thing $x): void {} +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $finder = new \PhpParser\NodeFinder(); + $relative = null; + foreach ($finder->findInstanceOf($ast, Name::class) as $n) { + if ($n->isRelative()) { + $relative = $n; + } + } + self::assertNotNull($relative); + self::assertSame( + 'App\\Thing', + $relative->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN), + 'the alias must not capture a relative name', + ); + self::assertNull( + $relative->getAttribute(XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE), + 'a relative name is an explicit class reference, never a suspect type param', + ); + } + + public function testRelativeNameCollidingWithATypeParamIsStillAClassReference(): void + { + // Inside `Gen`, `namespace\T` spells the CLASS App\T — it must get + // a resolved FQN (so the specializer swaps it to `\App\T`), never be + // treated as the type parameter. + $source = <<<'PHP' + { + public function m(namespace\T $x): void {} +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $finder = new \PhpParser\NodeFinder(); + $relative = null; + foreach ($finder->findInstanceOf($ast, Name::class) as $n) { + if ($n->isRelative()) { + $relative = $n; + } + } + self::assertNotNull($relative); + self::assertSame('App\\T', $relative->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN)); + self::assertNull( + $relative->getAttribute(XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE), + 'an explicit relative reference must not be flagged as a suspect type param' + . ' — here there is no alias, so only the relative exclusion protects it', + ); + } + public function testQualifiedFunctionTurbofishesResolveAndAttach(): void { // `\App\make::(…)` used to double the namespace in diff --git a/test/fixture/compile/relative_names_in_templates/source/LocalDefs.xphp b/test/fixture/compile/relative_names_in_templates/source/LocalDefs.xphp new file mode 100644 index 0000000..3780c1b --- /dev/null +++ b/test/fixture/compile/relative_names_in_templates/source/LocalDefs.xphp @@ -0,0 +1,24 @@ +(T $x): T + { + return $x; + } +} diff --git a/test/fixture/compile/relative_names_in_templates/source/OtherDefs.xphp b/test/fixture/compile/relative_names_in_templates/source/OtherDefs.xphp new file mode 100644 index 0000000..657c50c --- /dev/null +++ b/test/fixture/compile/relative_names_in_templates/source/OtherDefs.xphp @@ -0,0 +1,24 @@ + extends namespace\Base +{ + public function __construct(public T $v) + { + } +} + +class Keep +{ + // Bare `Thing` IS the type param; `namespace\Thing` is an explicit + // reference to the App\RelativeTemplates\Thing class. + public function __construct(public Thing $generic) + { + } + + public function label(namespace\Thing $t): string + { + return $t->tag; + } +} + +// Generic-method receiver typed with a relative name: the method +// template lookup must resolve `namespace\Svc` to THIS namespace's Svc. +function relay(namespace\Svc $s): int +{ + return $s->id::(5); +} + +// Conformance target + candidate hierarchy through a relative parent: +// Cat extends THIS namespace's Base, so the literal conforms. +class Cat extends namespace\Base +{ +} + +function factory(): Closure(): namespace\Base +{ + return fn(): Cat => new Cat(); +} + +$g = new Gen::(4); +$k = new Keep::(1); +$lbl = $k->label(new namespace\Thing('ok')); +$r = relay(new namespace\Svc()); +$make = factory(); +$cat = $make(); diff --git a/test/fixture/compile/relative_names_in_templates/verify/runtime.php b/test/fixture/compile/relative_names_in_templates/verify/runtime.php new file mode 100644 index 0000000..3e86a50 --- /dev/null +++ b/test/fixture/compile/relative_names_in_templates/verify/runtime.php @@ -0,0 +1,26 @@ +targetDir . '/LocalDefs.php'; +require $fixture->targetDir . '/OtherDefs.php'; +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame(4, $g->v); +Assert::assertSame('App\\RelativeTemplates\\Base', get_parent_class($g)); +Assert::assertSame(1, $k->generic); +Assert::assertSame('ok', $lbl); +Assert::assertSame(5, $r); +Assert::assertInstanceOf('App\\RelativeTemplates\\Cat', $cat); +Assert::assertSame('App\\RelativeTemplates\\Base', get_parent_class($cat)); From e42abe7ed817dc91a2346f5d8e900ff2e02cfbc0 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 11:01:58 +0000 Subject: [PATCH 46/80] fix(monomorphize): a use-function import is never a generic declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `use function b;` — invalid code — had its clause recorded as a declaration marker and stripped: php-parser then saw a well-formed import, no function node ever bound the marker, and the new unbound- marker backstop blamed the transpiler for the user's syntax error (the old parser silently swallowed the clause instead, which was worse). A declaration always opens its parameter list right after the clause, so the named arm now requires the `(`; and a name sitting directly after the `function` keyword is a declaration name, never a bare generic type hint, so the type-hint arm declines it too. The clause survives into the cleaned source and PHP reports its own syntax error, in both modes. Also corrects two comments-only slips the review caught: the substituting visitor's equivalence note documented a false invariant (the resolved-FQN and generic-args attributes DO coexist — equivalence holds via the call-site rewriter, not exclusivity), and a fixture overstated which variant of the same-line marker collision it pins. Notes the known static-arrow drift (the dispatcher closure is non-static; observable only via bind/reflection). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Specializer.php | 13 +++-- .../Monomorphize/XphpSourceParser.php | 51 ++++++++++++++----- .../Monomorphize/XphpSourceParserTest.php | 31 +++++++++++ .../source/Use.xphp | 6 ++- 4 files changed, 81 insertions(+), 20 deletions(-) diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index 13ad879..29a29e5 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -326,10 +326,15 @@ public function leaveNode(Node $node): ?Node ); $node->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, $substituted); - // @infection-ignore-all ReturnRemoval — falling through can't change - // anything: ATTR_RESOLVED_FQN and ATTR_GENERIC_ARGS are mutually - // exclusive by construction (shouldQualify tags only arg-less names), - // so the branch below never fires for an args-bearing node. + // @infection-ignore-all ReturnRemoval — falling through is + // observationally equivalent, but NOT because the attributes are + // exclusive: markName runs from the PARENT node's enterNode before + // the child Name's own attach sets ATTR_GENERIC_ARGS, so a generic + // reference in a marked position carries BOTH. Equivalence holds + // because the fallen-through swap yields a FullyQualified that still + // carries the (substituted, concrete) generic attributes, which the + // call-site rewriter now admits and rewrites to the same + // specialization the normal path reaches. return null; } } diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 135d4c0..e01988e 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -282,6 +282,10 @@ private function scanAndStrip(string $source): array // bind `$this` by construction, which is the only thing the // dispatcher rewrite cannot carry; static CLOSURES stay gated // (kind `staticClosure` hard-fails at the call-site rewrite). + // Known drift: the emitted dispatcher closure is non-static, so + // `Closure::bind` succeeds and `ReflectionFunction::isStatic()` + // is false where PHP's own static arrow would refuse/report — + // invisible to normal calls, which never carry a `$this`. if ($tok->id === T_STATIC) { $j = self::skipWs($tokens, $i + 1); if ($j < $n && ($tokens[$j]->id === T_FUNCTION || $tokens[$j]->id === T_FN)) { @@ -332,19 +336,31 @@ private function scanAndStrip(string $source): array ); if ($parsed !== null) { [$paramEntries, $endIdx] = $parsed; - $methodMarkers[] = [ - 'line' => $methodLine, - 'name' => $methodName, - 'kind' => 'named', - 'bytePosition' => $methodAnchorByte, - 'params' => $paramEntries, - ]; - $startByte = $tokens[$k]->pos; - $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); - $length = $endByte - $startByte; - $replacements[] = [$startByte, $length, self::blank(substr($source, $startByte, $length))]; - $i = $endIdx + 1; - continue; + // A function/method DECLARATION always opens its + // param list right after the clause; a + // `use function b;` import does not. Without + // this check the import's clause was recorded and + // stripped, leaving a marker no AST node can ever + // bind — misreported as a transpiler bug by the + // unbound-marker backstop. Left unstripped, PHP + // reports its own syntax error on the `<`, blaming + // the right party. + $afterClause = self::skipWs($tokens, $endIdx + 1); + if ($afterClause < $n && $tokens[$afterClause]->text === '(') { + $methodMarkers[] = [ + 'line' => $methodLine, + 'name' => $methodName, + 'kind' => 'named', + 'bytePosition' => $methodAnchorByte, + 'params' => $paramEntries, + ]; + $startByte = $tokens[$k]->pos; + $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); + $length = $endByte - $startByte; + $replacements[] = [$startByte, $length, self::blank(substr($source, $startByte, $length))]; + $i = $endIdx + 1; + continue; + } } } } @@ -529,7 +545,14 @@ private function scanAndStrip(string $source): array // the Name is preceded by `new` (catches the parenless `new Foo;` // and `new Foo` shapes that PHP accepts but the RFC turbofish // requirement refuses). Type-hint sites match neither check. - if ($j < $n && $tokens[$j]->text === '<') { + // A name directly after the `function` keyword is a DECLARATION + // name, never a type hint — when the declaration arm declined it + // (a `use function b;` import has no param list), the clause + // must survive so PHP reports its own syntax error instead of the + // import being silently swallowed. + $prevSig = self::skipWsBack($tokens, $i - 1); + $isDeclarationName = $prevSig >= 0 && $tokens[$prevSig]->id === T_FUNCTION; + if (!$isDeclarationName && $j < $n && $tokens[$j]->text === '<') { $parsed = self::parseTypeArgList($tokens, $j); if ($parsed !== null) { [$args, $endIdx] = $parsed; diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index e2c0807..4857eab 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -3122,6 +3122,37 @@ trait Mixin { self::assertSame(['T'], self::paramNames($trait)); } + public function testUseFunctionImportNeverBecomesAGenericMarker(): void + { + // `use function b;` is invalid code, but it must fail as PHP's own + // syntax error on the un-stripped `<` — not be silently swallowed + // (the clause used to strip, emitting `use function b ;`), and not be + // misreported as a transpiler bug by the unbound-marker backstop + // (an import never produces the Function_ node the marker binds to). + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + foreach (['use function b;', 'use function b as c;', 'use Foo\{function a};'] as $import) { + $source = "strip($source); + self::assertMatchesRegularExpression( + '//', + $stripped, + "the clause in `{$import}` must survive into the cleaned source", + ); + try { + $parser->parse($source); + self::fail("expected PHP's own parse error for `{$import}`"); + } catch (\PhpParser\Error) { + // PHP's syntax error — the right blame, in both modes. + } + } + + // Plain function imports keep compiling, including next to a genuine + // generic declaration. + $ast = $parser->parse("(T \$x): T { return \$x; }\n"); + self::assertNotEmpty($ast); + } + public function testUnboundDeclarationMarkerMessageIsNullWhenEverythingBound(): void { self::assertNull(XphpSourceParser::unboundDeclarationMarkerMessage([], [])); diff --git a/test/fixture/compile/qualified_generic_call_sites/source/Use.xphp b/test/fixture/compile/qualified_generic_call_sites/source/Use.xphp index ae51075..c5c9387 100644 --- a/test/fixture/compile/qualified_generic_call_sites/source/Use.xphp +++ b/test/fixture/compile/qualified_generic_call_sites/source/Use.xphp @@ -43,8 +43,10 @@ function make(T $x): T return $x; } -// Relative generic return type and a bare turbofish on ONE line: each -// keeps its own marker (the return type must not steal the body's). +// Relative generic return type and a bare turbofish on ONE line: both +// markers must bind (the relative one used to be silently dropped). +// The wrong-site STEAL variant — distinct type args per site — is pinned +// at the parser level, where a mismatched program can still be observed. function build(): namespace\Box { return new Box::(9); } $a = new \App\QualifiedCalls\Box::(1); From 46724b4f2ce5fb99db36f38d1804f2efa2e8f0c8 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 11:26:51 +0000 Subject: [PATCH 47/80] docs: record the marker-alignment and name-resolution fixes; static arrows specialize Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 47 ++++++++++++++++++++++++++++++ docs/caveats.md | 10 ++++++- docs/syntax/closures-and-arrows.md | 5 ++-- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6300516..68a2b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -294,6 +294,53 @@ _In progress on this branch — content still accumulating; date set at tag time scalars (`int`, `string`, `bool`, `float`, and case variants like `Int`) are unchanged; an undeclared `Double` member is now reported as `xphp.undeclared_type` instead of being silently absorbed as a scalar. +- **Generic declarations keep their type parameters regardless of header layout.** + Four marker-alignment defects silently de-generified declarations — clean compile, + clean `check`, raw `T` hints in the emitted code, `TypeError` at runtime: any + **multi-line** generic clause, turbofish argument list, or `T[]` sugar span + collapsed its newlines when stripped, shifting every later declaration off its + marker (`class Wide<\n T\n>` before `class Box` left `Box` raw); `static + fn(...)` anchored its marker at `fn` while the node starts at `static`; an + attribute before a generic closure (`#[A] function`, `#[A] static fn`) + moved the node start to `#[`; and an attribute or modifier on its own line before + a **named** generic declaration (`#[Override]` above `public function wrap`, + `final` above `class Pair`) lost the marker too — or false-rejected the class + as "instantiated but never defined". Stripping now preserves newlines + byte-for-byte (multibyte- and CRLF-safe; the `T[]` lowering re-appends its span's + newlines), anonymous markers anchor at the node's true start (walking back over + attribute groups and `static`), and named markers match on the declaration + name's line. A `static fn` now simply **specializes** like any arrow — it can + never bind `$this`; the rewritten dispatcher closure is technically non-static, + observable only via `Closure::bind`/reflection — while `static function` + keeps its loud not-yet-supported error, which now also fires when an attribute + precedes it. +- **A generic clause that fails to bind to its declaration is now a loud compile + error.** Every defect in the family above was silent for the same structural + reason: an unbound marker simply evaporated and the compiler carried on. The + strict compile path now reports it (as a transpiler bug to report), instead of + emitting silently de-generified code; the tolerant LSP path is exempt — + half-typed editor buffers legitimately strand markers. A `use function b;` + typo gets PHP's own syntax error on the `<` rather than being swallowed. +- **Fully-qualified and `namespace\`-relative spellings work at every generic + site.** Names were resolved from prefix-erased strings, losing the author's + qualification: `new \App\Box::` hard-failed as the undefined, doubled + `App\App\Box` — and an FQ generic **function** call (`\App\make::(...)`) + silently lost its dispatcher, an undefined-function fatal at runtime; + `new \App\Box("hi")` on an all-defaults generic silently emitted the stripped + marker interface plus the kept `new` — "Cannot instantiate interface" at + runtime; `new namespace\Box::` never bound its marker and silently emitted + the same fatal shape; on one line, a relative generic return type could steal + the body turbofish's marker, specializing the wrong site; `class Gen extends + namespace\Base` with a colliding `use Other\Base` in scope emitted the generated + class extending `\Other\Base` — a `use` alias never applies to a relative name — + and the same capture hit every marked type position, generic-method receiver + typing, and the conformance hierarchy (where a wrong parent edge could + false-reject a valid factory); and `namespace\Thing` colliding with a type + parameter was substituted like one, emitting `int $x` where the author wrote an + explicit class reference. Markers now record the raw source spelling + (case-folding only the `namespace` keyword), every resolver honors the node's + own qualification, fully-qualified call sites rewrite to their specializations, + and relative names bind to the current namespace — exactly as PHP does. ## [0.2.1] - 2026-06-17 diff --git a/docs/caveats.md b/docs/caveats.md index 68cfd17..6f8d30e 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -73,6 +73,12 @@ class Holder { ## `static` closures not supported +`static` **arrows** work: `static fn(T $x): T => $x` specializes +exactly like a plain arrow (an arrow can never bind `$this`, so the +`static` is inert; note the rewritten dispatcher closure is technically +non-static — observable only through `Closure::bind` or reflection). +The gap below is specific to the `static function` (closure) syntax. + ### ❌ What doesn't work ```php @@ -112,9 +118,11 @@ file-scope generic function side-steps it. ### ✅ Workaround -Drop the `static` modifier, or lift the body to a named function: +Use an arrow, drop the `static` modifier, or lift the body to a named +function: ```php +$f = static fn(T $x): T => $x; // works $f = function(T $x): T { return $x; }; // works // or function id(T $x): T { return $x; } diff --git a/docs/syntax/closures-and-arrows.md b/docs/syntax/closures-and-arrows.md index c0ee6d0..b0cae3e 100644 --- a/docs/syntax/closures-and-arrows.md +++ b/docs/syntax/closures-and-arrows.md @@ -87,8 +87,9 @@ ref-ness is preserved end-to-end. - > ⚠️ **`static` closures rejected** — `static function(...)` is rejected because generic static closures can't yet be specialized at - the call site (a capability gap, not a binding one). Use a named - generic function at file scope instead. See + the call site (a capability gap, not a binding one). A `static` + **arrow** (`static fn(...)`) specializes like a plain arrow. Use + an arrow or a named generic function at file scope instead. See [caveats](../caveats.md#static-closures-not-supported). - > ⚠️ **Variance markers not allowed** — `out T` / `in T` are rejected on From 2ec32fa2a6ef2c81db10c6c71f35e89d96fc5604 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 13:10:03 +0000 Subject: [PATCH 48/80] fix(monomorphize): match generic markers byte-exact, ending same-line marker steals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Named class/method markers and every name-marker branch (bare hints, turbofish, member calls, function calls, variable turbofish) matched by line — or line range — plus spelling, first-traversed-wins. Two same-spelling sites sharing a line could therefore cross-claim: `f(Box $a, Box $b)` emitted the specialization on the WRONG parameter; a return hint stole a `new Box::` marker, leaving a raw `new` against the marker interface; one-line same-name conditional classes handed the generic clause to the PLAIN class, rewriting it into an uninstantiable marker interface; and — a live false reject — `Plain::pick(5) + Util::pick::(4)` on one line drew bogus unresolved-call and missing-type-argument errors because the plain call's line range claimed the generic call's marker. Every marker already records the byte of the token it anchors at; match that byte (translated through the byte-offset map, so length-changing sugar rewrites stay exact) against the matching node's own anchor — the name Identifier for named declarations, the method Identifier for member calls, the callee Name/Variable for function and variable turbofish, the Name node itself for hints. Line-range matching is gone; multi-line member chains keep working because the identifier byte is position-exact regardless of layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 110 ++++++++++------ .../MarkerAlignmentIntegrationTest.php | 15 +++ .../Monomorphize/XphpSourceParserTest.php | 117 ++++++++++++++++++ .../source/Aliased.xphp | 15 +++ .../same_line_marker_pairs/source/Use.xphp | 81 ++++++++++++ .../same_line_marker_pairs/verify/runtime.php | 29 +++++ 6 files changed, 328 insertions(+), 39 deletions(-) create mode 100644 test/fixture/compile/same_line_marker_pairs/source/Aliased.xphp create mode 100644 test/fixture/compile/same_line_marker_pairs/source/Use.xphp create mode 100644 test/fixture/compile/same_line_marker_pairs/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index e01988e..7b09ab0 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -459,11 +459,9 @@ private function scanAndStrip(string $source): array $nameLine = $tok->line; // For member-access call sites (`Foo::method::<…>`, `$x->method::<…>`, // `$x?->method::<…>`), walk back past the operator to the receiver and - // record its line as the marker's anchor. nikic sets a MethodCall / - // StaticCall's getStartLine() to the leftmost token in the chain — so - // matching against just the identifier's line breaks the moment the - // operator+name are split across lines, e.g. `Foo::\n method::`. - // The resolver matches if startLine ∈ [anchorLine, line]. + // record its line as the marker's anchor. Matching is byte-exact + // against the method-name Identifier, so anchorLine is + // informational (diagnostics/debugging) rather than a match key. $anchorLine = self::memberAccessReceiverLine($tokens, $i) ?? $nameLine; $j = self::skipWs($tokens, $i + 1); @@ -2488,14 +2486,17 @@ public function enterNode(Node $node): null if ($node instanceof ClassLike && $node->name !== null) { $shortName = $node->name->toString(); + // Match on the NAME Identifier's BYTE (mapped back to the + // original source): the node starts at its first attribute + // group or modifier, and line-keyed matching let two + // same-name declarations on ONE line steal each other's + // markers (`if(){class B}else{class B}` handed the + // clause to the plain class). The marker records the name + // token's byte, which is unique per declaration. + $classNameByte = $this->originalByteOf($node->name); $paramEntries = null; foreach ($this->classMarkers as $i => $marker) { - // Match on the NAME Identifier's line, not the node's: - // the node starts at its first attribute group or - // modifier, which can sit on an earlier line - // (`#[Attr]\nfinal class Box`), while the marker - // records the name token's line. - if ($marker['line'] === $node->name->getStartLine() && $marker['name'] === $shortName) { + if ($marker['bytePosition'] === $classNameByte && $marker['name'] === $shortName) { $paramEntries = $marker['params']; unset($this->classMarkers[$i]); // @infection-ignore-all — break vs continue is equivalent after unset (marker is gone). @@ -2560,22 +2561,23 @@ public function enterNode(Node $node): null $isAnonymous = $node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction; $declName = $isAnonymous ? '' : $node->name->toString(); - // Named declarations match on the NAME Identifier's line — - // the node itself starts at its first attribute group or - // modifier, which can sit on an earlier line - // (`#[Attr]\npublic function wrap`), while the marker - // records the name token's line. - $declLine = $isAnonymous ? -1 : $node->name->getStartLine(); + // Named declarations match on the NAME Identifier's BYTE + // (the node itself starts at its first attribute group or + // modifier; a shared line no longer conflates two + // declarations). Anonymous ones match the node's own start + // byte — the marker anchored at the first attribute group, + // `static`, or the keyword. + $declByte = $isAnonymous ? -1 : $this->originalByteOf($node->name); $nodeStartByte = $this->byteOffsetMap->toOriginal($node->getStartFilePos()); $matchedParamNames = []; foreach ($this->methodMarkers as $i => $marker) { // @infection-ignore-all -- markers are populated jointly with - // both halves of the (line, name) or (kind, bytePosition) pair; - // any single-clause-only input is unreachable from the scanner. + // both halves of the (name byte, name) or (kind, bytePosition) + // pair; any single-clause-only input is unreachable from the scanner. $isMatch = $isAnonymous ? ($marker['kind'] !== 'named' && $marker['bytePosition'] === $nodeStartByte) - : ($marker['line'] === $declLine + : ($marker['bytePosition'] === $declByte && $marker['name'] === $declName); if ($isMatch) { // Same two-pass scope-push-before-bound-build pattern @@ -2632,18 +2634,17 @@ public function enterNode(Node $node): null && $node->name instanceof Node\Identifier ) { $callMethodName = $node->name->toString(); - $startLine = $node->getStartLine(); + // Match by the method-name Identifier's BYTE — the marker + // anchored at the method token, so this is exact for + // multi-line chains (`Foo::\n method::`) AND for two + // same-name calls sharing a line (the old line-range match + // let `Plain::pick(5) + Util::pick::(4)` cross-claim, + // false-rejecting both). Covers static, instance, and + // nullsafe calls alike. + $methodNameByte = $this->originalByteOf($node->name); foreach ($this->nameMarkers as $i => $marker) { - // Match by name + line-range overlap. The Call node's - // getStartLine() is the receiver's line; the marker's anchorLine - // is the same, and its (later) line is the identifier's line. - // Both can differ on multi-line `Foo::\n method::` or - // `$obj->\n method::` constructs. The same logic now - // covers static, instance, and nullsafe method calls -- the - // GenericMethodCompiler distinguishes them later by AST type. if ($marker['name'] === $callMethodName - && $startLine >= $marker['anchorLine'] - && $startLine <= $marker['line'] + && $marker['bytePosition'] === $methodNameByte ) { $resolvedArgs = $this->resolveTypeRefList($marker['args']); $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, $resolvedArgs); @@ -2661,14 +2662,17 @@ public function enterNode(Node $node): null // `\App\make` / `namespace\make` marker must compare against the // node's own qualified spelling, not the prefix-erased toString(). $funcName = $node->name->toCodeString(); - $startLine = $node->getStartLine(); + // Byte of the callee Name node — exact even for two + // same-name turbofish calls sharing a line. The + // variableTurbofish kind-guard is belt-and-braces (a + // Variable marker's byte is a `$`, never a name byte). + $calleeByte = $this->originalByteOf($node->name); foreach ($this->nameMarkers as $i => $marker) { - // @infection-ignore-all -- the three `&&` clauses are jointly + // @infection-ignore-all -- the clauses are jointly // populated when a marker is created; any single-clause-only // input is unreachable from XphpSourceParser's own scanner. if ($marker['name'] === $funcName - && $startLine >= $marker['anchorLine'] - && $startLine <= $marker['line'] + && $marker['bytePosition'] === $calleeByte && $marker['kind'] !== 'variableTurbofish' ) { $resolvedArgs = $this->resolveTypeRefList($marker['args']); @@ -2690,16 +2694,18 @@ public function enterNode(Node $node): null && is_string($node->name->name) ) { $varName = $node->name->name; - $startLine = $node->getStartLine(); + // Byte of the Variable node's `$` — equals the recorded + // T_VARIABLE position; exact for repeated `$f::<…>` calls + // of the same variable on one line. + $varByte = $this->originalByteOf($node->name); foreach ($this->nameMarkers as $i => $marker) { // @infection-ignore-all -- markers are populated jointly: - // kind/name/anchorLine/line are all set together by the + // kind/name/bytePosition are all set together by the // scanner's variable-turbofish arm, so single-clause // dropouts are unreachable. if ($marker['kind'] === 'variableTurbofish' && $marker['name'] === $varName - && $startLine >= $marker['anchorLine'] - && $startLine <= $marker['line'] + && $marker['bytePosition'] === $varByte ) { $resolvedArgs = $this->resolveTypeRefList($marker['args']); $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, $resolvedArgs); @@ -2717,8 +2723,14 @@ public function enterNode(Node $node): null // node's own marker never matched at all — silently dropping // its type args). The marker records the source spelling. $nameStr = $node->toCodeString(); + // Byte of the Name node itself — the old line match let a + // plain same-spelling hint on the same line steal a generic + // hint's marker (`f(Box $a, Box $b)` specialized the + // wrong parameter), and a return hint could steal a `new` + // turbofish's marker. + $nameByte = $this->originalByteOf($node); foreach ($this->nameMarkers as $i => $marker) { - if ($marker['line'] === $node->getStartLine() && $marker['name'] === $nameStr) { + if ($marker['bytePosition'] === $nameByte && $marker['name'] === $nameStr) { $resolved = $this->resolveTypeRefList($marker['args']); $node->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, $resolved); $templateFqn = $this->resolveNameOnly($nameStr); @@ -2875,6 +2887,26 @@ private function resolveSigType(SigType $type): SigType return $type; } + /** + * A node's start byte in the ORIGINAL source: getStartFilePos() + * reports the STRIPPED-source byte, so a length-changing rewrite + * earlier in the file (the `T[]` -> `array` lowering) shifts the + * two apart; the byte-offset map translates back. Returns -1 + * (matching no marker) for a position-less node — never produced + * by parsing real source, defensive only. + */ + private function originalByteOf(Node $node): int + { + $pos = $node->getStartFilePos(); + // @infection-ignore-all — defensive: php-parser records positions on + // every node parsed from source; -1 only occurs for synthesized nodes, + // which never reach the marker matchers. + if ($pos < 0) { + return -1; + } + return $this->byteOffsetMap->toOriginal($pos); + } + private function markName(Name $node): void { if (!$this->shouldQualify($node)) { diff --git a/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php b/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php index aea8acb..6e1dd39 100644 --- a/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php +++ b/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php @@ -47,6 +47,21 @@ public function testAttributedAndStaticGenericClosuresSpecializeAndRun(): void } } + #[RunInSeparateProcess] + public function testSameLineSameSpellingPairsBindTheirOwnMarkersAndRun(): void + { + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/same_line_marker_pairs/source', + 'same-line-marker-pairs', + ); + $fixture->registerAutoload('App\\SameLinePairs'); + try { + require __DIR__ . '/../../fixture/compile/same_line_marker_pairs/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + #[RunInSeparateProcess] public function testSplitDeclarationHeadersSpecializeAndRun(): void { diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 4857eab..b3b0b0b 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -3122,6 +3122,123 @@ trait Mixin { self::assertSame(['T'], self::paramNames($trait)); } + public function testVariableTurbofishStripPreservesByteLength(): void + { + // The `$var::<…>` strip must blank exactly its own span — byte length + // and line count of the whole file preserved. + $source = <<<'PHP' +(T $x): T => $x; +$r = $id::(1) + $id::(2); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $stripped = $parser->strip($source); + self::assertSame(strlen($source), strlen($stripped)); + self::assertSame(substr_count($source, "\n"), substr_count($stripped, "\n")); + } + + public function testSameLinePlainAndGenericHintsKeepTheirOwnMarkers(): void + { + // Line-keyed matching let the first-traversed same-spelling Name steal + // the generic hint's marker — specializing the WRONG parameter. + $source = <<<'PHP' + { public function __construct(public T $v) {} } +function f(Box $a, Box $b): int { return $b->v; } +function g(Box $a, Box $b): int { return $a->v; } +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $finder = new \PhpParser\NodeFinder(); + $hints = []; + foreach ($finder->findInstanceOf($ast, Name::class) as $n) { + if ($n->toString() === 'Box') { + $hints[] = $n->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS) !== null; + } + } + self::assertSame( + [false, true, true, false], + $hints, + 'each generic hint must keep its own marker regardless of same-line order', + ); + } + + public function testSameLineSameNameStaticCallsDoNotCrossClaim(): void + { + // `Plain::pick(5) + Util::pick::(4)` on one line: the line-range + // match let the PLAIN call steal the generic call's marker — both then + // rejected (bogus unresolved-call + missing-type-argument errors). + $source = <<<'PHP' +(T $x): T { return $x; } } +class Plain { public static function pick(int $x): int { return $x; } } +$r = Plain::pick(5) + Util::pick::(4); +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $finder = new \PhpParser\NodeFinder(); + $byClass = []; + foreach ($finder->findInstanceOf($ast, Node\Expr\StaticCall::class) as $call) { + assert($call->class instanceof Name); + $byClass[$call->class->toString()] = + $call->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + } + self::assertNull($byClass['Plain'], 'the plain call must not claim the marker'); + self::assertIsArray($byClass['Util'], 'the generic call must keep its marker'); + self::assertSame('int', $byClass['Util'][0]->name); + } + + public function testReturnHintDoesNotStealANewTurbofishMarker(): void + { + $source = <<<'PHP' + { public function __construct(public T $v) {} } +function mk(): Box { return new Box::(1); } +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $finder = new \PhpParser\NodeFinder(); + $fn = $finder->findFirstInstanceOf($ast, \PhpParser\Node\Stmt\Function_::class); + self::assertNotNull($fn); + assert($fn->returnType instanceof Name); + self::assertNull( + $fn->returnType->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS), + 'the plain return hint must not claim the instantiation marker', + ); + $new = $finder->findFirstInstanceOf($ast, Node\Expr\New_::class); + self::assertNotNull($new); + assert($new->class instanceof Name); + $args = $new->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + self::assertIsArray($args); + self::assertSame('int', $args[0]->name); + } + + public function testOneLineConditionalSameNameClassesBindTheRightMarker(): void + { + $source = <<<'PHP' + { public T $v; } } +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $classes = self::collectClasses($ast); + self::assertCount(2, $classes); + self::assertNull( + $classes[0]->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS), + 'the plain class must not claim the generic clause', + ); + self::assertSame(['T'], self::paramNames($classes[1])); + } + public function testUseFunctionImportNeverBecomesAGenericMarker(): void { // `use function b;` is invalid code, but it must fail as PHP's own diff --git a/test/fixture/compile/same_line_marker_pairs/source/Aliased.xphp b/test/fixture/compile/same_line_marker_pairs/source/Aliased.xphp new file mode 100644 index 0000000..7f202da --- /dev/null +++ b/test/fixture/compile/same_line_marker_pairs/source/Aliased.xphp @@ -0,0 +1,15 @@ + $gen): string +{ + return $gen->v; +} + +$hv = h(new B::(3), new B::('al')); diff --git a/test/fixture/compile/same_line_marker_pairs/source/Use.xphp b/test/fixture/compile/same_line_marker_pairs/source/Use.xphp new file mode 100644 index 0000000..e469755 --- /dev/null +++ b/test/fixture/compile/same_line_marker_pairs/source/Use.xphp @@ -0,0 +1,81 @@ + +{ + public function __construct(public T $v) + { + } +} + +class Rec +{ +} + +class Util +{ + public static function pick(T $x): T + { + return $x; + } + + public function id(T $x): T + { + return $x; + } +} + +class Plain +{ + public static function pick(int $x): int + { + return $x; + } +} + +// Same-name static calls — one generic, one not — on ONE line. The old +// line-range match cross-claimed and false-rejected BOTH calls. +function mixCalls(): int { return Plain::pick(5) + Util::pick::(4); } + +// Plain hint + generic hint, same spelling, one line — both orders. +function f(Box $a, Box $b): int { return $b->v; } +function g(Box $a, Box $b): int { return $a->v; } + +// Return hint + `new` turbofish on one line — the hint must not steal +// the instantiation's marker. +function mk(): Box { return new Box::(1); } + +// Two same-name turbofish with DIFFERENT args on one line — each site +// keeps its own argument tuple. +function pair(): array { return [new Box::(2), new Box::('s')]; } + +// Length-changing sugar and a generic hint on the SAME line — byte +// positions must round-trip through the offset map. +function tally(Rec[] $rs, Box $c): int { return \count($rs) + $c->v; } + +// One-line same-name conditional classes — the plain class must stay a +// class (it used to become an uninstantiable marker interface). +if (true) { class B { } } else { class B { public T $v; } } + +$m = mixCalls(); +$a = f(new Box::('p'), new Box::(7)); +$b = g(new Box::(8), new Box::('q')); +$k = mk(); +[$p1, $p2] = pair(); +$t = tally([new Rec()], new Box::(10)); +$bb = new B(); + +// Same variable turbofish twice on one line, and a member turbofish +// inside string interpolation. +$id = fn(T $x): T => $x; +$sum = $id::(1) + $id::(2); +$u = new Util(); +$msg = "v={$u->id::(9)}"; diff --git a/test/fixture/compile/same_line_marker_pairs/verify/runtime.php b/test/fixture/compile/same_line_marker_pairs/verify/runtime.php new file mode 100644 index 0000000..46c765b --- /dev/null +++ b/test/fixture/compile/same_line_marker_pairs/verify/runtime.php @@ -0,0 +1,29 @@ +targetDir . '/Use.php'; +require $fixture->targetDir . '/Aliased.php'; + +Assert::assertSame(9, $m); +Assert::assertSame(7, $a); +Assert::assertSame(8, $b); +Assert::assertSame(1, $k->v); +Assert::assertSame(2, $p1->v); +Assert::assertSame('s', $p2->v); +Assert::assertNotSame(get_class($p1), get_class($p2)); +Assert::assertSame(11, $t); +Assert::assertSame('App\\SameLinePairs\\B', get_class($bb)); +Assert::assertSame(3, $sum); +Assert::assertSame('v=9', $msg); +Assert::assertSame('al', $hv); From 89ef9ac24dd00052eafc49178c46f4b9d9ad5f8a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 13:23:04 +0000 Subject: [PATCH 49/80] feat(monomorphize): reject generic closures that are never specialized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specialization is call-site-driven: a generic closure or arrow that no `$var::<...>(...)` call grounds kept its raw type-parameter hints in the emitted output — references to the non-existent class `App\T` that fatal the moment the value is invoked, behind a clean compile and a clean check. Handing the closure away as a plain callable (a return value, an array_map argument, a stored callback) cannot ground it either, and the direct untyped call was already loudly rejected — so the silent gap was exactly the never-called / handed-away template. Every generic closure/arrow template that no call site attempted to specialize is now rejected in both modes as `xphp.unspecialized_generic_closure` (compile fails, check collects one diagnostic per orphan) — across assigned, return-position, argument-position, use-capturing, defaulted, and conditional shapes, and uniformly when the type parameters are referenced nowhere (the clause is dead syntax; the remedy is deleting it). A template whose call sites drew their own rejects (static closure, `$this` capture, missing turbofish) is not double-reported. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/errors.md | 1 + docs/syntax/closures-and-arrows.md | 7 ++ .../Monomorphize/GenericMethodCompiler.php | 94 +++++++++++++++++-- .../Monomorphize/CheckPassIntegrationTest.php | 35 +++++++ .../ClosureDispatcherIntegrationTest.php | 28 +++--- .../Use.expected.php | 7 -- .../EnclosingParamBoundIntegrationTest.php | 2 +- .../closure_unspecialized/source/Use.xphp | 34 +++++++ .../source/Util.xphp | 6 +- 9 files changed, 179 insertions(+), 35 deletions(-) delete mode 100644 test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest/testEmptyArgSetsLeavesOriginalAssignUntouched/Use.expected.php create mode 100644 test/fixture/check/closure_unspecialized/source/Use.xphp diff --git a/docs/errors.md b/docs/errors.md index 4ba93b1..feb5c54 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -46,6 +46,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.duplicate_generic_function` | the same generic function is declared in two files | | `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) | | `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) | +| `xphp.unspecialized_generic_closure` | a generic closure/arrow is declared but never turbofish-called, so nothing grounds its type parameters — the emitted value would keep raw hints naming non-existent classes (`App\T`) and fatal on first invocation, even when only handed away as a callable. Call it with `$var::<...>(...)`, or remove the `<...>` clause (also flagged when the parameters are never referenced — the clause is dead syntax) | | `xphp.unresolved_generic_call` | a turbofish method call (`$obj->m::<…>()` / `Foo::m::<…>()`) names a generic method that can't be resolved on the receiver's type — a typo or wrong receiver type, caught at compile time instead of fataling at runtime | | `xphp.bound_unprovable` | a method-generic bound that references an enclosing class type parameter (`contains`) can't be proven because the receiver's type argument isn't determinable here — a raw `Box` with no argument, a branch whose arms disagree, a static call, or a `$this` self-call. Ground the receiver (bind it to a typed local) or the build fails | | `xphp.undetermined_receiver` | a turbofish method call's receiver has no statically-known type (an untyped `foreach` variable, a local whose type is ambiguous after a branch), so the call can't be specialized — it would emit a call to a stripped method that fatals at runtime. Give the receiver a declared type | diff --git a/docs/syntax/closures-and-arrows.md b/docs/syntax/closures-and-arrows.md index b0cae3e..9d7d76b 100644 --- a/docs/syntax/closures-and-arrows.md +++ b/docs/syntax/closures-and-arrows.md @@ -92,6 +92,13 @@ ref-ness is preserved end-to-end. an arrow or a named generic function at file scope instead. See [caveats](../caveats.md#static-closures-not-supported). +- > ⚠️ **Declared-but-never-called rejected** — a generic closure that no + `$var::<...>(...)` call grounds is a compile error + (`xphp.unspecialized_generic_closure`): specialization is call-site + driven, so the value would otherwise keep raw type-parameter hints and + fatal on first invocation — including when handed away as a plain + callable. Call it with a turbofish, or drop the `<...>` clause. + - > ⚠️ **Variance markers not allowed** — `out T` / `in T` are rejected on anonymous templates. They have no stable identity for an `extends` chain. See [caveats](../caveats.md#variance-markers-are-class-level-only). diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 8924fd5..80dc192 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -91,6 +91,7 @@ final class GenericMethodCompiler public const CODE_BOUND_UNPROVABLE = 'xphp.bound_unprovable'; public const CODE_UNDETERMINED_RECEIVER = 'xphp.undetermined_receiver'; public const CODE_UNSPECIALIZABLE_SELF_CALL = 'xphp.unspecializable_self_call'; + public const CODE_UNSPECIALIZED_GENERIC_CLOSURE = 'xphp.unspecialized_generic_closure'; /** * @param ?DiagnosticCollector $diagnostics When null (the default — `xphp compile`), every @@ -479,6 +480,17 @@ private function rewriteCallSites( * }> */ public array $closureDispatchPlan = []; + /** + * spl_object_id set of closure/arrow templates some call site + * ATTEMPTED to specialize (turbofish lookup or bare-call + * resolution) — even when that call then drew its own reject + * (static closure, `$this` capture, missing turbofish). Read + * after the walk: a generic template never attempted anywhere + * is an orphan that would emit raw type-param hints. + * + * @var array + */ + public array $attemptedClosureTemplates = []; /** * Snapshot stack for scope isolation across nested * Function_/ClassMethod/Closure/ArrowFunction boundaries. On enter we push the @@ -2160,6 +2172,10 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): null if ($template === null) { return null; } + // Attempted — even if this call draws one of the eager rejects + // below, the template is not an ORPHAN and must not draw a + // second (unspecialized-closure) error for the same root cause. + $this->attemptedClosureTemplates[spl_object_id($template)] = true; // Eager rejections -- preserved from pre-P5.4 behavior so the // throw fires at the first offending call site, before the @@ -2352,6 +2368,10 @@ private function resolveBareGenericCall(FuncCall $node): ?array if ($template === null) { return null; } + // A bare call to a generic closure draws the loud + // missing-turbofish reject — that already covers the + // template, so it is not an orphan too. + $this->attemptedClosureTemplates[spl_object_id($template)] = true; $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); /** @var list|null $params */ return is_array($params) ? [$params, '$' . $node->name->name] : null; @@ -2437,6 +2457,11 @@ private static function lastSegment(string $name): string $traverser->addVisitor($visitor); $traverser->traverse($ast); + // Runs BEFORE the emit gate: check mode must collect the orphan + // diagnostics too (this is a validation, not an emission side-effect). + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $this->rejectUnspecializedClosureTemplates($ast, $visitor->attemptedClosureTemplates, $currentFile); + // Validate-only (check) skips all emission: no dispatcher materialization, no buffered // appends. The traversal above already produced the diagnostics via the call-site checks. if (!$emit) { @@ -2456,12 +2481,61 @@ private static function lastSegment(string $name): string } } + /** + * Reject every generic closure/arrow template no call site attempted to + * specialize. Specialization is call-site-driven: with no in-scope + * `$var::<...>(...)` grounding its type parameters, the template's + * type-parameter hints survive into the emitted output as references to + * non-existent classes (`App\T`) — a guaranteed TypeError behind a clean + * gate the moment the value is invoked. Handing the closure away as a + * plain callable cannot ground it (the dispatcher rewrite only exists for + * turbofish sites), so declared-but-never-specialized is always an error + * — including when the type parameters are referenced nowhere: the + * clause is dead syntax, and the remedy is deleting it. + * + * Runs in BOTH modes (compile throws at the first orphan; check collects + * one diagnostic per orphan). + * + * @param list $ast + * @param array $attemptedTemplateIds spl_object_id set of + * templates some call site looked up (successfully or into one of + * the eager rejects — those already carry their own error). + */ + private function rejectUnspecializedClosureTemplates(array $ast, array $attemptedTemplateIds, string $currentFile): void + { + $finder = new \PhpParser\NodeFinder(); + foreach ($finder->find($ast, static fn (Node $n): bool => ($n instanceof Closure || $n instanceof ArrowFunction) + && is_array($n->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS))) as $template) { + if (isset($attemptedTemplateIds[spl_object_id($template)])) { + continue; + } + $message = sprintf( + 'Generic %s is declared with type parameters but never specialized: no ' + . '`$var::<...>(...)` call grounds them, so its type-parameter hints would reach ' + . 'the emitted code as references to non-existent classes. Call it with an ' + . 'explicit turbofish, or remove the `<...>` clause.', + $template instanceof ArrowFunction ? 'arrow function' : 'closure', + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_UNSPECIALIZED_GENERIC_CLOSURE, + $message, + new SourceLocation($currentFile, $template->getStartLine()), + )); + continue; + } + throw new RuntimeException($message); + } + } + /** * Pass 2: turn each collected dispatch-plan entry into a dispatcher * closure plus specialized top-level functions. Skips entries whose - * argSets are empty -- a generic closure template that was declared - * but never called via turbofish keeps its original Assign untouched, - * so reflection on unused templates stays faithful. + * argSets are empty -- every reachable declared-but-never-called template + * was already rejected by rejectUnspecializedClosureTemplates before this + * pass runs, so an empty argSet here means the template's only call sites + * drew their own (eager) rejects. * */ private function finalizeClosureDispatchers(object $visitor, int $hashLength): void @@ -2585,10 +2659,11 @@ public function __construct(private bool &$found) /** * @infection-ignore-all -- this pre-scan keeps the rewrite pass alive for files that have no * NAMED generic templates but do use anonymous generics. It fires on a variable turbofish - * call (`$f::<…>()`) AND on a generic closure/arrow TEMPLATE assignment — the latter so a - * BARE call to that closure (`$f('x')`, no turbofish) is still traversed and diagnosed rather - * than silently skipped. (Inner anonymous visitor: Infection's blind spot — the closure-call - * diagnosis is covered behaviorally by the bare-closure-call accept/reject tests.) + * call (`$f::<…>()`) AND on ANY generic closure/arrow TEMPLATE — not just assigned ones — + * so a bare call (`$f('x')`), a return-position or argument-position template, and the + * never-called orphan are all traversed and diagnosed rather than silently skipped. + * (Inner anonymous visitor: Infection's blind spot — covered behaviorally by the + * bare-closure-call and unspecialized-closure accept/reject tests.) */ public function enterNode(Node $node): null { @@ -2601,9 +2676,8 @@ public function enterNode(Node $node): null ) { $this->found = true; } - if ($node instanceof Assign - && ($node->expr instanceof Closure || $node->expr instanceof ArrowFunction) - && is_array($node->expr->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)) + if (($node instanceof Closure || $node instanceof ArrowFunction) + && is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)) ) { $this->found = true; } diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 924908b..072a6da 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -241,6 +241,41 @@ public function testCompileStillThrowsOnAttributedStaticGenericClosure(): void $this->compileFixture('closure_static_attributed'); } + public function testUnspecializedGenericClosuresAreCollectedByCheck(): void + { + // Declared-but-never-turbofish-called generic closures across every + // position (assigned, return, argument, use-capturing, defaulted, + // unused-param, conditional else-arm) each draw ONE diagnostic; the + // called twins draw none. Note the eager static/$this rejects also + // stay single diagnostics (their templates count as attempted) — + // pinned by the closure_static / closure_this_capture tests' counts. + $diagnostics = $this->check('closure_unspecialized'); + + $all = $diagnostics->all(); + self::assertCount(7, $all); + $byLine = []; + foreach ($all as $d) { + self::assertSame(GenericMethodCompiler::CODE_UNSPECIALIZED_GENERIC_CLOSURE, $d->code); + self::assertStringContainsString('never specialized', $d->message); + self::assertNotNull($d->location); + $byLine[$d->location->line] = $d->message; + } + ksort($byLine); + // The static arrow, return-position arrow, argument-position closure, + // use-capturing closure, defaulted closure, unused-param arrow, and + // the conditional else-arm arrow — NOT the called twins. + self::assertSame([12, 16, 19, 22, 24, 27, 30], array_keys($byLine)); + self::assertStringContainsString('Generic arrow function', $byLine[12]); + self::assertStringContainsString('Generic closure', $byLine[19]); + } + + public function testCompileThrowsOnUnspecializedGenericClosure(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('never specialized'); + $this->compileFixture('closure_unspecialized'); + } + public function testUnresolvedGenericMethodTurbofishIsCollectedByCheck(): void { // A turbofish call to a generic method that exists nowhere on the receiver diff --git a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php index be5e2c2..9d2a4c7 100644 --- a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php @@ -195,10 +195,12 @@ public function testUseClauseClosureSpecializesViaDispatcher(): void $this->rrmdir(dirname($dir)); } - public function testEmptyArgSetsLeavesOriginalAssignUntouched(): void + public function testTemplateNeverCalledViaTurbofishIsRejected(): void { - // Template declared but never called via turbofish. The Assign - // RHS stays as the original closure body; no dispatcher emitted. + // Template declared but never called via turbofish. It used to keep + // its original Assign untouched — emitting raw `T` hints that name + // the non-existent class App\T and fatal on first invocation. It is + // now rejected loudly instead. $dir = $this->mkdir('disp-empty'); file_put_contents($dir . '/Use.xphp', <<<'PHP' (T $x): T { return $x; }; PHP); - $this->compile($dir); - $out = file_get_contents($dir . '/dist/Use.php'); - self::assertIsString($out); - // Negative invariants kept: no dispatcher tag-parameter and no - // specialized function emitted when the template is never called. - self::assertStringNotContainsString('__xphp_tag', $out); - self::assertStringNotContainsString('closure_id_T_', $out); - SnapshotHash::assertMatches( - __DIR__ . '/ClosureDispatcherIntegrationTest/testEmptyArgSetsLeavesOriginalAssignUntouched/Use.expected.php', - $out, - ); - - $this->rrmdir(dirname($dir)); + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('never specialized'); + $this->compile($dir); + } finally { + $this->rrmdir(dirname($dir)); + } } public function testNestedScopeCallsShareDispatcher(): void diff --git a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest/testEmptyArgSetsLeavesOriginalAssignUntouched/Use.expected.php b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest/testEmptyArgSetsLeavesOriginalAssignUntouched/Use.expected.php deleted file mode 100644 index 5b71f69..0000000 --- a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest/testEmptyArgSetsLeavesOriginalAssignUntouched/Use.expected.php +++ /dev/null @@ -1,7 +0,0 @@ -(T $x): T { return $x; }; } + public function a(): void { $f = function(T $x): T { return $x; }; $f::(1); } public function b(callable $f): mixed { return $f('x'); } } PHP, diff --git a/test/fixture/check/closure_unspecialized/source/Use.xphp b/test/fixture/check/closure_unspecialized/source/Use.xphp new file mode 100644 index 0000000..fcc10fc --- /dev/null +++ b/test/fixture/check/closure_unspecialized/source/Use.xphp @@ -0,0 +1,34 @@ +(T $x): T => $x; + +function makes(): callable +{ + return fn(U $y): U => $y; +} + +$mapped = array_map(function(V $z): V { return $z; }, [1]); + +$w = 5; +$g = function(W $q) use ($w) { return $q; }; + +$h = function(X $r): X { return $r; }; + +// Unused type param — dead syntax, rejected uniformly. +$i = fn(int $s): int => $s + 1; + +// Conditional twins: the if-arm is grounded, the else-arm is an orphan. +if ($w > 0) { $c = fn(Q $a): Q => $a; $c::(1); } else { $c = fn(R $b): R => $b; } + +// Called — must NOT be flagged. +$ok = fn(Z $v): Z => $v; +$ok::(1); diff --git a/test/fixture/check/undeclared_type_param_method/source/Util.xphp b/test/fixture/check/undeclared_type_param_method/source/Util.xphp index 5a8dcaa..8197dbe 100644 --- a/test/fixture/check/undeclared_type_param_method/source/Util.xphp +++ b/test/fixture/check/undeclared_type_param_method/source/Util.xphp @@ -20,9 +20,13 @@ function wrap(C $x): A return $x; } -// Generic closure and arrow: `D` and `E` are stray parameters. +// Generic closure and arrow: `D` and `E` are stray parameters. Both are +// turbofish-called so the only diagnostics here stay the undeclared-type +// ones (an uncalled generic closure draws its own, different error). $closure = function(D $x): A { return $x; }; +$closure::(1); $arrow = fn(E $x): A => $x; +$arrow::(2); From fd430cbb0f671abc585b255c78941c0895427f9b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 13:40:12 +0000 Subject: [PATCH 50/80] test(monomorphize): make the same-line sugar pin genuinely length-changing `Rec[]` lowers to `array` at the same byte length, so the fixture ran on the identity offset map and never exercised the shift path it claims to pin; `R[]` grows by one byte and does. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/fixture/compile/same_line_marker_pairs/source/Use.xphp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/fixture/compile/same_line_marker_pairs/source/Use.xphp b/test/fixture/compile/same_line_marker_pairs/source/Use.xphp index e469755..f617858 100644 --- a/test/fixture/compile/same_line_marker_pairs/source/Use.xphp +++ b/test/fixture/compile/same_line_marker_pairs/source/Use.xphp @@ -16,7 +16,7 @@ class Box } } -class Rec +class R { } @@ -59,7 +59,7 @@ function pair(): array { return [new Box::(2), new Box::('s')]; } // Length-changing sugar and a generic hint on the SAME line — byte // positions must round-trip through the offset map. -function tally(Rec[] $rs, Box $c): int { return \count($rs) + $c->v; } +function tally(R[] $rs, Box $c): int { return \count($rs) + $c->v; } // One-line same-name conditional classes — the plain class must stay a // class (it used to become an uninstantiable marker interface). @@ -70,7 +70,7 @@ $a = f(new Box::('p'), new Box::(7)); $b = g(new Box::(8), new Box::('q')); $k = mk(); [$p1, $p2] = pair(); -$t = tally([new Rec()], new Box::(10)); +$t = tally([new R()], new Box::(10)); $bb = new B(); // Same variable turbofish twice on one line, and a member turbofish From 11e5b7f8b1e771f98e59f009fcc588af646406d2 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 14:26:45 +0000 Subject: [PATCH 51/80] fix(monomorphize): a non-concrete turbofish is an attempt, not an orphan `$f::($v)` inside a still-abstract enclosing template bails out of the rewrite before the template lookup, so the unspecialized-closure check misread the template as never-called and rejected it with advice the author had already followed. The bail now marks the template as attempted; how such a call grounds when the enclosing template specializes remains that pipeline's own (tracked) concern. Also restores the static-analysis gate to green: reattach the applyReplacements docblock that the blanking helpers were inserted above (its parameter type annotated the wrong function), prove the extracted signature params list-shaped where it is built, and drop a redundant ignore and assert. Aligns the error-table wording with the actual rule (no IN-SCOPE grounding call, not merely "never called"). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/errors.md | 2 +- infection.json5 | 6 +++++- .../Monomorphize/ClosureLiteralSignature.php | 10 +++++----- .../Monomorphize/GenericMethodCompiler.php | 12 +++++++++++- src/Transpiler/Monomorphize/XphpSourceParser.php | 6 +++--- .../check/closure_unspecialized/source/Use.xphp | 8 ++++++++ 6 files changed, 33 insertions(+), 11 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index feb5c54..781ff64 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -46,7 +46,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.duplicate_generic_function` | the same generic function is declared in two files | | `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) | | `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) | -| `xphp.unspecialized_generic_closure` | a generic closure/arrow is declared but never turbofish-called, so nothing grounds its type parameters — the emitted value would keep raw hints naming non-existent classes (`App\T`) and fatal on first invocation, even when only handed away as a callable. Call it with `$var::<...>(...)`, or remove the `<...>` clause (also flagged when the parameters are never referenced — the clause is dead syntax) | +| `xphp.unspecialized_generic_closure` | a generic closure/arrow is declared but no in-scope `$var::<...>(...)` call grounds its type parameters — the emitted value would keep raw hints naming non-existent classes (`App\T`) and fatal on first invocation, even when only handed away as a callable (which cannot ground it). Call it with a turbofish in the scope that declares it, or remove the `<...>` clause (also flagged when the parameters are never referenced — the clause is dead syntax) | | `xphp.unresolved_generic_call` | a turbofish method call (`$obj->m::<…>()` / `Foo::m::<…>()`) names a generic method that can't be resolved on the receiver's type — a typo or wrong receiver type, caught at compile time instead of fataling at runtime | | `xphp.bound_unprovable` | a method-generic bound that references an enclosing class type parameter (`contains`) can't be proven because the receiver's type argument isn't determinable here — a raw `Box` with no argument, a branch whose arms disagree, a static call, or a `$this` self-call. Ground the receiver (bind it to a typed local) or the build fails | | `xphp.undetermined_receiver` | a turbofish method call's receiver has no statically-known type (an untyped `foreach` variable, a local whose type is ambiguous after a branch), so the call can't be specialized — it would emit a call to a stripped method that fatals at runtime. Give the receiver a declared type | diff --git a/infection.json5 b/infection.json5 index 64ad07e..d693e6f 100644 --- a/infection.json5 +++ b/infection.json5 @@ -45,7 +45,11 @@ "XPHP\\FileSystem\\FilepathArray::__construct", "XPHP\\FileSystem\\FilepathArray::filter", "XPHP\\Transpiler\\Monomorphize\\BoundIntersection::__construct", - "XPHP\\Transpiler\\Monomorphize\\BoundUnion::__construct" + "XPHP\\Transpiler\\Monomorphize\\BoundUnion::__construct", + // ClosureLiteralSignature::extract: array_map over php-parser's + // already-0-indexed params list — the wrapper exists purely to + // prove list-ness to PHPStan; unwrapping it is a no-op. + "XPHP\\Transpiler\\Monomorphize\\ClosureLiteralSignature::extract" ] }, diff --git a/src/Transpiler/Monomorphize/ClosureLiteralSignature.php b/src/Transpiler/Monomorphize/ClosureLiteralSignature.php index a6df043..8c3fb85 100644 --- a/src/Transpiler/Monomorphize/ClosureLiteralSignature.php +++ b/src/Transpiler/Monomorphize/ClosureLiteralSignature.php @@ -22,15 +22,15 @@ * file's {@see NamespaceContext}. * * Gradual by construction: an untyped parameter becomes `mixed` (the top type, - * always conforms), an absent return stays absent (gradual), and a - * nullable / union / intersection leaf is carried as {@see SigRaw} (gradual until - * WI-03) — so extraction never manufactures a false mismatch. + * always conforms), an absent return stays absent (gradual), and a leaf the + * splitter can't structure is carried as {@see SigRaw} (gradual) — so + * extraction never manufactures a false mismatch. */ final class ClosureLiteralSignature { public static function extract(Closure|ArrowFunction $literal, NamespaceContext $ctx): ClosureSignature { - $params = array_map( + $params = array_values(array_map( static fn (Param $p): ClosureSignatureParam => new ClosureSignatureParam( self::resolveParamType($p->type, $ctx), $p->byRef, @@ -38,7 +38,7 @@ public static function extract(Closure|ArrowFunction $literal, NamespaceContext $p->default !== null, ), $literal->params, - ); + )); // Candidate closures are never nullable (a `?Closure` is a target concept); // the flag defaults false and is not read for a candidate. diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 80dc192..ae32adf 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -2070,6 +2070,17 @@ private function rewriteFuncCall(FuncCall $node): ?Node return null; } if ($isVarTurbofish && $args !== [] && !self::allConcrete($args)) { + // The author DID write a turbofish for this template — its + // args just aren't concrete at this point (`$f::($v)` + // inside a still-abstract enclosing template). Mark it + // attempted so the unspecialized-closure orphan check does + // not misdiagnose it as never-called; how such a call + // grounds when the enclosing template specializes is that + // pipeline's own (tracked, pre-existing) concern. + $template = $this->currentScopeClosureTemplates[$node->name->name] ?? null; + if ($template !== null) { + $this->attemptedClosureTemplates[spl_object_id($template)] = true; + } return null; } // Variable turbofish `$var::(...)` / `$var::<>(...)`: @@ -2459,7 +2470,6 @@ private static function lastSegment(string $name): string // Runs BEFORE the emit gate: check mode must collect the orphan // diagnostics too (this is a validation, not an emission side-effect). - // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. $this->rejectUnspecializedClosureTemplates($ast, $visitor->attemptedClosureTemplates, $currentFile); // Validate-only (check) skips all emission: no dispatcher materialization, no buffered diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 7b09ab0..7eec6a1 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -2374,9 +2374,6 @@ private static function isPseudoType(string $name): bool ); } - /** - * @param list $replacements [byte offset, original length, replacement text] - */ /** * Equal-length whitespace for a stripped span, keeping every newline byte * in place: byte-keyed markers after the span rely on the length, and @@ -2400,6 +2397,9 @@ private static function newlinesOf(string $span): string return preg_replace('/[^\r\n]/', '', $span) ?? ''; } + /** + * @param list $replacements [byte offset, original length, replacement text] + */ private static function applyReplacements(string $source, array $replacements): string { usort($replacements, static fn (array $a, array $b): int => $b[0] <=> $a[0]); diff --git a/test/fixture/check/closure_unspecialized/source/Use.xphp b/test/fixture/check/closure_unspecialized/source/Use.xphp index fcc10fc..21f34f8 100644 --- a/test/fixture/check/closure_unspecialized/source/Use.xphp +++ b/test/fixture/check/closure_unspecialized/source/Use.xphp @@ -32,3 +32,11 @@ if ($w > 0) { $c = fn(Q $a): Q => $a; $c::(1); } else { $c = fn(R $b) // Called — must NOT be flagged. $ok = fn(Z $v): Z => $v; $ok::(1); + +// Turbofish with a NON-CONCRETE argument (the enclosing template's own +// parameter): the author wrote a call, so this is not an orphan either. +function relay(S $v): S +{ + $inner = fn(I $x): I => $x; + return $inner::($v); +} From 00039c8935b77b8724e431384080bf8bc28654f3 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 16:09:33 +0000 Subject: [PATCH 52/80] docs: record byte-exact markers and unspecialized-closure rejection Two Fixed entries for the current section: generic markers now bind byte-exact so same-spelling sites on one line no longer steal each other's markers, and a generic closure that nothing specializes is rejected (xphp.unspecialized_generic_closure) instead of silently emitting raw type-parameter hints. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68a2b60..dd6193f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -341,6 +341,32 @@ _In progress on this branch — content still accumulating; date set at tag time (case-folding only the `namespace` keyword), every resolver honors the node's own qualification, fully-qualified call sites rewrite to their specializations, and relative names bind to the current namespace — exactly as PHP does. +- **Generic markers bind byte-exact — two same-spelling sites on one line can no + longer steal each other's markers.** Marker matching was line-keyed + (first-traversed-wins), so `f(Box $a, Box $b)` emitted the specialization + on the **wrong parameter**; a plain return hint could steal a + `new Box::(...)` marker, leaving a raw `new` against the marker interface; + one-line same-name conditional classes handed the generic clause to the plain + class — rewriting it into an uninstantiable marker interface; and + `Plain::pick(5) + Util::pick::(4)` on one line **false-rejected both + calls** (the plain call's line-range claimed the generic call's marker). Every + marker now matches on the byte of the token it anchors at, translated through + the byte-offset map — exact across multi-line member chains, length-changing + `T[]` rewrites, interpolated-string turbofish, and multibyte identifiers. +- **A generic closure that nothing specializes is now rejected** + (`xphp.unspecialized_generic_closure`) instead of silently emitting raw + type-parameter hints. Specialization is call-site-driven, so a generic + closure/arrow with no in-scope grounding `$var::<...>(...)` call kept hints + naming the non-existent class `App\T` in the emitted output — a `TypeError` on + first invocation behind a clean compile and a clean `check`, including when the + value was only handed away as a callable (`array_map($f, ...)`, a returned + factory) — which cannot ground it. Both modes reject every such shape: + assigned-but-uncalled, return-position, argument-position, `use (...)`- + capturing, defaulted, conditional arms, and — uniformly — a clause whose + parameters are never referenced (dead syntax; delete it). A template whose + call sites drew their own rejection (static closure, `$this` capture, missing + turbofish) is not double-reported, and a turbofish with still-abstract type + arguments counts as a real call. ## [0.2.1] - 2026-06-17 From 1014cb04c3aff3ef07257bb71184e8821ebe3312 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 17:25:59 +0000 Subject: [PATCH 53/80] fix(monomorphize): reject a bare new of a non-defaults generic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare `new Box(...)` where `Box` has a required (non-defaulted) type parameter and no turbofish was silently skipped by the instantiation collector: the call site was left pointing at the stripped marker `interface Box {}`, so the emitted code fatalled with "Cannot instantiate interface" behind a clean compile and a clean check. Route the non-all-defaults bare-new through the canonical padArgsWithDefaults reporter, so it fails with the same xphp.missing_type_argument code and message as the function/method call path — throwing in compile, collecting in check. All-defaults generics still synthesize and run. Guard against a false reject: when a plain `class B` coexists with a generic `class B` (conditional same-name declarations), the bare `new B` resolves to the instantiable plain class at runtime, so the collector now records non-generic class names and skips the reject for them. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Registry.php | 54 +++++++++++++++++++ .../Monomorphize/RegistryCollector.php | 22 +++++++- .../Monomorphize/CheckPassIntegrationTest.php | 52 ++++++++++++++++++ .../DefaultedGenericIntegrationTest.php | 21 ++++++++ .../Monomorphize/VisitorGuardsTest.php | 52 ++++++++++++++---- .../source/Use.xphp | 21 ++++++++ .../bare_new_missing_arg/source/Box.xphp | 12 +++++ .../bare_new_missing_arg/source/Use.xphp | 10 ++++ .../source/Box.xphp | 12 +++++ .../source/Use.xphp | 11 ++++ .../source/Use.xphp | 24 +++++++++ .../verify/runtime.php | 19 +++++++ 12 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 test/fixture/check/bare_new_conditional_same_name/source/Use.xphp create mode 100644 test/fixture/check/bare_new_missing_arg/source/Box.xphp create mode 100644 test/fixture/check/bare_new_missing_arg/source/Use.xphp create mode 100644 test/fixture/check/bare_new_missing_arg_qualified/source/Box.xphp create mode 100644 test/fixture/check/bare_new_missing_arg_qualified/source/Use.xphp create mode 100644 test/fixture/compile/bare_new_self_in_generic_body/source/Use.xphp create mode 100644 test/fixture/compile/bare_new_self_in_generic_body/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index b375807..967ac74 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -48,6 +48,16 @@ final class Registry /** @var array Keyed by template FQN. */ private array $definitions = []; + /** + * FQNs that also have a NON-generic class/interface/trait declaration (a plain `class B {}` + * alongside a generic `class B {}`, e.g. in mutually exclusive conditional branches). A + * bare `new B` on such a name resolves to the plain class at runtime, so the bare-new guard + * must NOT reject it. Keyed by the namespace-normalized FQN (no leading `\`); value always true. + * + * @var array + */ + private array $nonGenericClassNames = []; + /** @var array Keyed by full generated FQCN. */ private array $instantiations = []; @@ -183,6 +193,50 @@ private function padWithDefaults(string $templateFqn, array $args, ?SourceLocati ); } + /** + * Record that `$fqn` has a non-generic class/interface/trait declaration. See + * {@see $nonGenericClassNames}. `$fqn` is already namespace-normalized (no leading `\`) by + * every caller, and is looked up verbatim in {@see reportMissingTypeArgumentsForBareNew}. + */ + public function recordNonGenericClass(string $fqn): void + { + $this->nonGenericClassNames[$fqn] = true; + } + + /** + * Report a bare `new` of a generic template that supplies no type arguments and cannot pad + * entirely from defaults (some parameter is required). Routes through the canonical + * `padArgsWithDefaults`, so the code (`xphp.missing_type_argument`), message, and + * throw-vs-collect duality match the function/method call path exactly. The padded result is + * discarded — a bare `new` of a non-all-defaults generic is a hard error, not an instantiation, + * so nothing is recorded (recording the partial tuple would store a bogus specialization and + * draw spurious secondary diagnostics). A no-op when the template isn't recorded, or when a + * non-generic class of the same name also exists (the bare `new` resolves to that at runtime). + * + * `$templateFqn` is the resolveName-normalized name (no leading `\`), matching the keys of both + * `$definitions` and `$nonGenericClassNames`. + */ + public function reportMissingTypeArgumentsForBareNew(string $templateFqn, ?SourceLocation $callSite): void + { + $definition = $this->definitions[$templateFqn] ?? null; + if ($definition === null) { + return; + } + // A plain class of the same name also exists (conditional same-name declaration): the bare + // `new` resolves to the instantiable class at runtime, so rejecting it would be a false + // reject. The generic twin is emitted as a marker interface; the plain class is what runs. + if (($this->nonGenericClassNames[$templateFqn] ?? false) === true) { + return; + } + self::padArgsWithDefaults( + $definition->typeParams, + [], + $templateFqn, + $this->diagnostics, + $callSite, + ); + } + /** * Pad an arg list with defaults declared on `$params`, substituting * already-positional concretes into any type-param references in the diff --git a/src/Transpiler/Monomorphize/RegistryCollector.php b/src/Transpiler/Monomorphize/RegistryCollector.php index ebaffb0..d613c9a 100644 --- a/src/Transpiler/Monomorphize/RegistryCollector.php +++ b/src/Transpiler/Monomorphize/RegistryCollector.php @@ -113,9 +113,10 @@ public function enterNode(Node $node): null && $node instanceof ClassLike && $node->name !== null) { $params = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); - if (is_array($params)) { + $isGenericTemplate = is_array($params) && $params !== [] && is_string($fqn); + if ($isGenericTemplate) { /** @var list $params — set as a list by XphpSourceParser::resolveAndAttach. */ - if ($params !== [] && is_string($fqn) && !$this->isAlreadyRecorded($fqn)) { + if (!$this->isAlreadyRecorded($fqn)) { $this->registry->recordDefinition( $fqn, $node->name->toString(), @@ -124,6 +125,14 @@ public function enterNode(Node $node): null $this->currentFile, ); } + } else { + // A NON-generic class/interface/trait. Record its FQN so the bare-new guard does + // not reject a `new B` when a plain `class B` coexists with a generic `class B` + // (conditional same-name declarations). ATTR_TEMPLATE_FQN is only attached to + // generic templates, so compute the declaration FQN from the namespace context. + $ns = $this->ctx->currentNamespace(); + $plainFqn = $ns !== '' ? $ns . '\\' . $node->name->toString() : $node->name->toString(); + $this->registry->recordNonGenericClass($plainFqn); } } @@ -178,6 +187,15 @@ private function synthesizeBareNewIfAllDefaults(Name $name): void } foreach ($definition->typeParams as $param) { if ($param->default === null) { + // A generic template with a required (non-defaulted) parameter, used as a bare + // `new` with no turbofish. It cannot be padded from defaults, so it was silently + // skipped -- leaving the call site pointing at the stripped marker `interface`, + // a guaranteed runtime fatal behind a clean gate. Report it loudly instead, + // matching the function/method call path (`xphp.missing_type_argument`). + $this->registry->reportMissingTypeArgumentsForBareNew( + $resolved, + new SourceLocation($this->currentFile, $name->getStartLine()), + ); return; } } diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 072a6da..f9c579d 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -145,6 +145,49 @@ public function testMissingTypeArgumentIsCollectedByCheck(): void self::assertNotNull($diagnostics->all()[0]->location); } + public function testBareNewOfNonDefaultsGenericIsCollectedByCheck(): void + { + // `new Box(5)` where `Box` has a required (non-defaulted) param and no + // turbofish: cannot pad from defaults, so it is a missing-type-argument error + // — same code and message as the call path, routed through padArgsWithDefaults. + $diagnostics = $this->check('bare_new_missing_arg'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Registry::CODE_MISSING_TYPE_ARGUMENT, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('Use.xphp', $d->location->file); + self::assertSame(10, $d->location->line); + } + + public function testBareNewQualifiedAndRelativeSpellingsAreBothCollected(): void + { + // FQ `new \…\Box(5)` and relative `new namespace\Box(6)` reject with the same + // verdict as the bare spelling, and BOTH are collected in one run (check does + // not throw-on-first). + $diagnostics = $this->check('bare_new_missing_arg_qualified'); + + self::assertCount(2, $diagnostics->all()); + foreach ($diagnostics->all() as $d) { + self::assertSame(Registry::CODE_MISSING_TYPE_ARGUMENT, $d->code); + self::assertNotNull($d->location); + } + self::assertSame([10, 11], array_map( + static fn ($d): ?int => $d->location?->line, + $diagnostics->all(), + )); + } + + public function testBareNewOfConditionalSameNamePlainClassIsNotRejected(): void + { + // False-reject guard: a plain `class B` (live branch) coexists with a generic `class B` + // (dead branch). `new B` resolves to the instantiable plain class, so the bare-new guard + // must stay silent — rejecting the generic twin's name here would be a false reject. + $diagnostics = $this->check('bare_new_conditional_same_name'); + + self::assertSame([], $diagnostics->all()); + } + public function testUndefinedTemplateIsCollectedByCheck(): void { // Exercises the collectUndefinedTemplates() step of check(). @@ -501,6 +544,15 @@ public function testCompileStillThrowsOnGenericMethodMissingArgument(): void $this->compileFixture('generic_method_missing_arg'); } + public function testCompileThrowsOnBareNewOfNonDefaultsGeneric(): void + { + // Compile mode (no collector) throws the identical missing-type-argument message + // the call path throws — confirms the shared reporter keeps the throw path intact. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('has no default'); + $this->compileFixture('bare_new_missing_arg'); + } + public function testCompileStillThrowsOnDuplicateGenericFunction(): void { $this->expectException(RuntimeException::class); diff --git a/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php b/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php index 441bccd..49eec68 100644 --- a/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php +++ b/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php @@ -6,12 +6,14 @@ use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard as StandardPrinter; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use RuntimeException; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FilepathArray; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; +use XPHP\TestSupport\CompiledFixture; use XPHP\TestSupport\SnapshotHash; final class DefaultedGenericIntegrationTest extends TestCase @@ -87,6 +89,25 @@ public function testFullDefaultsFixtureGeneratesExpectedSpecializations(): void ); } + #[RunInSeparateProcess] + public function testBareNewSelfInNonDefaultsGenericBodyIsNotRejectedAndRuns(): void + { + // WI-06 false-reject guard: the bare-new reject fires only on a bare `new` of the + // *template name*. A bare `new self` inside a non-defaults generic body must NOT be + // rejected -- `self` resolves to no template and the Specializer rewrites it per + // instantiation. Compiled and executed end-to-end. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/bare_new_self_in_generic_body/source', + 'bare-new-self', + ); + try { + $fixture->registerAutoload('App\\BareNewSelfInGenericBody'); + require __DIR__ . '/../../fixture/compile/bare_new_self_in_generic_body/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + public function testEmptyTurbofishAndBareNewProduceSameSpecialization(): void { // After compile, both `new Cache;` and `new Cache::<>` in the same diff --git a/test/Transpiler/Monomorphize/VisitorGuardsTest.php b/test/Transpiler/Monomorphize/VisitorGuardsTest.php index 2deb182..c1ce773 100644 --- a/test/Transpiler/Monomorphize/VisitorGuardsTest.php +++ b/test/Transpiler/Monomorphize/VisitorGuardsTest.php @@ -15,6 +15,8 @@ use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Property; use PHPUnit\Framework\TestCase; +use RuntimeException; +use XPHP\Diagnostics\DiagnosticCollector; /** * Targets the LogicalAnd → LogicalOr guard-condition mutations in the three Monomorphize @@ -194,23 +196,53 @@ public function testCollectorIgnoresNameWithNonConcreteArgs(): void self::assertSame([], $registry->instantiations()); } - public function testCollectorBareNewSynthesisSkipsTemplatesWithRequiredParams(): void + public function testCollectorBareNewOnNonDefaultsTemplateThrowsInCompileMode(): void { - // `class Box` (no default) -- a bare `new Box;` must NOT synthesize - // a zero-arg instantiation. Only all-defaults templates are eligible. + // `class Box` (no default) -- a bare `new Box;` cannot pad from defaults, so it + // is a hard error, NOT a silent skip (the old behavior left the site pointing at + // the stripped marker interface -> a runtime fatal). Compile mode (no collector) + // throws, and nothing is recorded. $registry = new Registry(); + + try { + (new RegistryCollector($registry))->collect(self::classAndBareNew(), '/x.xphp'); + self::fail('expected a RuntimeException for a bare new of a non-defaults generic'); + } catch (RuntimeException $e) { + self::assertStringContainsString('has no default', $e->getMessage()); + } + + self::assertSame([], $registry->instantiations(), 'a rejected bare new records no instantiation'); + } + + public function testCollectorBareNewOnNonDefaultsTemplateCollectsInCheckMode(): void + { + // Check mode (with a collector) reports the missing-type-argument diagnostic and + // continues -- still recording no instantiation for the rejected site. + $diagnostics = new DiagnosticCollector(); + $registry = new Registry(Registry::DEFAULT_HASH_HEX_LENGTH, null, $diagnostics); + + (new RegistryCollector($registry))->collect(self::classAndBareNew(), '/x.xphp'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_MISSING_TYPE_ARGUMENT, $diagnostics->all()[0]->code); + self::assertSame([], $registry->instantiations()); + } + + /** + * `class Box` (required param, no default) followed by a bare `new Box;`. + * + * @return list + */ + private static function classAndBareNew(): array + { $class = new Class_(new Identifier('Box')); $class->setAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS, [new TypeParam('T')]); $class->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, 'Box'); - $bareNew = new \PhpParser\Node\Expr\New_(new Name('Box')); - $ast = [ + + return [ $class, - new \PhpParser\Node\Stmt\Expression($bareNew), + new \PhpParser\Node\Stmt\Expression(new \PhpParser\Node\Expr\New_(new Name('Box'))), ]; - - (new RegistryCollector($registry))->collect($ast, '/x.xphp'); - - self::assertSame([], $registry->instantiations(), 'bare new on a non-defaulted template must not synthesize an instantiation'); } public function testCollectorBareNewSynthesisRecordsAllDefaultsTemplate(): void diff --git a/test/fixture/check/bare_new_conditional_same_name/source/Use.xphp b/test/fixture/check/bare_new_conditional_same_name/source/Use.xphp new file mode 100644 index 0000000..157bab6 --- /dev/null +++ b/test/fixture/check/bare_new_conditional_same_name/source/Use.xphp @@ -0,0 +1,21 @@ +` (dead branch). +// A bare `new B` resolves to the instantiable plain class at runtime, so the bare-new +// guard must NOT reject it -- rejecting would be a false reject. Expect 0 diagnostics. +if (true) { + class B + { + } +} else { + class B + { + public T $v; + } +} + +$b = new B(); diff --git a/test/fixture/check/bare_new_missing_arg/source/Box.xphp b/test/fixture/check/bare_new_missing_arg/source/Box.xphp new file mode 100644 index 0000000..5b5942a --- /dev/null +++ b/test/fixture/check/bare_new_missing_arg/source/Box.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public T $v) + { + } +} diff --git a/test/fixture/check/bare_new_missing_arg/source/Use.xphp b/test/fixture/check/bare_new_missing_arg/source/Use.xphp new file mode 100644 index 0000000..220602f --- /dev/null +++ b/test/fixture/check/bare_new_missing_arg/source/Use.xphp @@ -0,0 +1,10 @@ + a runtime "cannot instantiate interface" fatal. Rejected. +$b = new Box(5); diff --git a/test/fixture/check/bare_new_missing_arg_qualified/source/Box.xphp b/test/fixture/check/bare_new_missing_arg_qualified/source/Box.xphp new file mode 100644 index 0000000..d9f5523 --- /dev/null +++ b/test/fixture/check/bare_new_missing_arg_qualified/source/Box.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public T $v) + { + } +} diff --git a/test/fixture/check/bare_new_missing_arg_qualified/source/Use.xphp b/test/fixture/check/bare_new_missing_arg_qualified/source/Use.xphp new file mode 100644 index 0000000..4f98c42 --- /dev/null +++ b/test/fixture/check/bare_new_missing_arg_qualified/source/Use.xphp @@ -0,0 +1,11 @@ + +{ + public function __construct(public T $v) + { + } + + public function copy(): self + { + return new self($this->v); + } +} + +$n = new Node::(5); +$c = $n->copy(); diff --git a/test/fixture/compile/bare_new_self_in_generic_body/verify/runtime.php b/test/fixture/compile/bare_new_self_in_generic_body/verify/runtime.php new file mode 100644 index 0000000..f4cf154 --- /dev/null +++ b/test/fixture/compile/bare_new_self_in_generic_body/verify/runtime.php @@ -0,0 +1,19 @@ +targetDir . '/Use.php'; + +Assert::assertSame(5, $n->v); +Assert::assertSame(5, $c->v); +Assert::assertNotSame($n, $c); From d26525df0db97d61d9192b52ed66f69363362f2a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 17:34:20 +0000 Subject: [PATCH 54/80] fix(monomorphize): emit a valid forwarding closure for a turbofish FCC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A first-class callable of a turbofish specialization (`$g = $f::(...)`) emitted `$f('T_…', ...)` — the specialization tag prepended beside the `...` placeholder, which is not valid PHP: the output failed to parse. The finalize pass blindly array_unshift-ed the tag onto every recorded call site's args, and the FCC's args are just the placeholder. Detect the first-class-callable form in the variable-turbofish rewriter and, instead of recording a direct call site, return a forwarding closure `fn(...$p) => $f('', ...$p)` that routes through the dispatcher. It preserves callable semantics — positional, variadic, and named arguments, plus the captures the dispatcher already carries — and the argSet is still registered so the dispatcher and specialization are generated. The variadic parameter name is kept distinct from the dispatcher variable so it cannot shadow it. Empty-turbofish all-defaults FCCs (`$f::<>(...)`) go through the same path; an FCC of a `$this`-capturing closure still draws the loud capture error. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 47 ++++++++++++++++++- .../ClosureDispatcherIntegrationTest.php | 45 ++++++++++++++++++ .../turbofish_fcc_closure/source/Use.xphp | 33 +++++++++++++ .../turbofish_fcc_closure/verify/runtime.php | 22 +++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 test/fixture/compile/turbofish_fcc_closure/source/Use.xphp create mode 100644 test/fixture/compile/turbofish_fcc_closure/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index ae32adf..bd1db8f 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -22,6 +22,7 @@ use PhpParser\Node\Name; use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\NullableType; +use PhpParser\Node\Param; use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\Case_; use PhpParser\Node\Stmt\Catch_; @@ -2175,7 +2176,7 @@ private function rewriteFuncCall(FuncCall $node): ?Node * * @param list $args */ - private function rewriteVariableTurbofishCall(FuncCall $node, array $args): null + private function rewriteVariableTurbofishCall(FuncCall $node, array $args): ?Node { assert($node->name instanceof Variable && is_string($node->name->name)); $varName = $node->name->name; @@ -2302,6 +2303,21 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): null $entry['seenTagSet'][$tag] = true; $entry['argSets'][] = $args; } + unset($entry); + + // First-class callable of a turbofish specialization (`$g = $f::(...)`). + // The call site's args are just the `...` placeholder, so prepending the tag arg + // (the direct-call path below) would emit `$f('T_…', ...)` — illegal PHP that does + // not parse. Instead return a forwarding closure that binds the tag and forwards + // through the dispatcher `$varName` (materialized by finalize from the argSet just + // recorded), preserving callable semantics (arity, variadics, named args, and the + // captures the dispatcher already carries). It is NOT added to `callSites`, so + // finalize leaves it alone. + if ($node->isFirstClassCallable()) { + return $this->buildForwardingFccClosure($varName, $tag, $node); + } + + $entry = &$this->closureDispatchPlan[$planKey]; // Pair each call site with its PADDED tag so finalize doesn't // recompute from the original `ATTR_METHOD_GENERIC_ARGS` (which // may be empty for the `$f::<>()` default-padding shape). @@ -2312,6 +2328,35 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): null return null; } + /** + * Build the forwarding closure that replaces a first-class-callable turbofish + * (`$f::(...)`): `fn(...$p) => $f('', ...$p)`. Calling it routes through the + * dispatcher `$varName` (which finalize installs in that variable) to the specialization + * named by `$tag`. The variadic param name is guaranteed distinct from `$varName` so it + * never shadows the captured dispatcher inside the body. + */ + private function buildForwardingFccClosure(string $varName, string $tag, FuncCall $node): ArrowFunction + { + $paramName = '__xphp_fcc_args'; + while ($paramName === $varName) { + $paramName .= '_'; + } + $forward = new FuncCall( + new Variable($varName), + [ + new Arg(new String_($tag)), + new Arg(new Variable($paramName), false, true), + ], + ); + return new ArrowFunction( + [ + 'params' => [new Param(new Variable($paramName), null, null, false, true)], + 'expr' => $forward, + ], + $node->getAttributes(), + ); + } + private function resolveClassName(Name $name): string { $raw = $name->toString(); diff --git a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php index 9d2a4c7..9b9c4c2 100644 --- a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php @@ -255,6 +255,51 @@ public function testNestedScopeCallsShareDispatcher(): void $this->rrmdir(dirname($dir)); } + #[RunInSeparateProcess] + public function testFirstClassCallableTurbofishClosureEmitsValidForwardingClosureAndRuns(): void + { + // `$g = $f::(...)` used to emit `$f('T_…', ...)` — a tag arg beside the FCC + // placeholder, which does not parse. It now emits a forwarding closure that routes + // through the dispatcher, preserving captures / variadics / named args. The require + // below both parses (else it fatals) and executes the emitted output. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/turbofish_fcc_closure/source', + 'fcc-closure', + ); + try { + require __DIR__ . '/../../fixture/compile/turbofish_fcc_closure/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + public function testFirstClassCallableOfThisCapturingClosureIsRejected(): void + { + // The eager `$this`-capture reject fires for an FCC too (it sits before the FCC + // handling): an FCC of a `$this`-capturing generic closure draws the loud + // capture error, not a broken forwarding closure. + $dir = $this->mkdir('fcc-this'); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (T $x): T => $x + $this->n; + return $f::(...); + } + } + PHP); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('captures `$this`'); + $this->compile($dir); + } finally { + $this->rrmdir(dirname($dir)); + } + } + // ----- helpers --------------------------------------------------------- private function mkdir(string $tag): string diff --git a/test/fixture/compile/turbofish_fcc_closure/source/Use.xphp b/test/fixture/compile/turbofish_fcc_closure/source/Use.xphp new file mode 100644 index 0000000..011c28b --- /dev/null +++ b/test/fixture/compile/turbofish_fcc_closure/source/Use.xphp @@ -0,0 +1,33 @@ +(...)`) must emit a +// valid forwarding closure that routes through the dispatcher -- not `$f('T_…', ...)`, +// which does not parse. Every shape below is compiled and executed. + +// Implicit-capture arrow: the FCC must forward THROUGH the dispatcher so the captured +// $mul stays bound. +$mul = 10; +$f = fn(T $x): T => $x * $mul; +$g = $f::(...); +$viaCall = $g(5); // 50 +$viaMap = array_map($g, [1, 2, 3]); // [10, 20, 30] + +// Named argument rides the `...` placeholder through to the specialized param `$x`. +$id = fn(T $x): T => $x; +$h = $id::(...); +$named = $h(x: 42); // 42 + +// Empty-turbofish all-defaults FCC. +$d = fn(T $y): T => $y; +$e = $d::<>(...); +$defaulted = $e(7); // 7 + +// A direct turbofish call AND an FCC of the same tag share one dispatcher. +$twice = fn(T $x): T => $x; +$direct = $twice::(4); // 4 +$fcc = $twice::(...); +$viaFcc = $fcc(8); // 8 diff --git a/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php b/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php new file mode 100644 index 0000000..42962bd --- /dev/null +++ b/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php @@ -0,0 +1,22 @@ +targetDir . '/Use.php'; + +Assert::assertSame(50, $viaCall); +Assert::assertSame([10, 20, 30], $viaMap); +Assert::assertSame(42, $named); +Assert::assertSame(7, $defaulted); +Assert::assertSame(4, $direct); +Assert::assertSame(8, $viaFcc); From 65360c959f9a5ccbc8f36cc9dbe4bbf11c2aa7a0 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 17:45:14 +0000 Subject: [PATCH 55/80] test(monomorphize): pin the turbofish-FCC forwarding-param collision guard Exercise the branch that suffixes the forwarding closure's variadic parameter when the dispatcher variable is itself named like the reserved param: a closure assigned to `$__xphp_fcc_args` and turbofish-FCC'd would otherwise shadow the captured dispatcher and fatal at runtime. Confirmed to fail when the suffixing loop is disabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/fixture/compile/turbofish_fcc_closure/source/Use.xphp | 7 +++++++ .../compile/turbofish_fcc_closure/verify/runtime.php | 1 + 2 files changed, 8 insertions(+) diff --git a/test/fixture/compile/turbofish_fcc_closure/source/Use.xphp b/test/fixture/compile/turbofish_fcc_closure/source/Use.xphp index 011c28b..b51aba1 100644 --- a/test/fixture/compile/turbofish_fcc_closure/source/Use.xphp +++ b/test/fixture/compile/turbofish_fcc_closure/source/Use.xphp @@ -31,3 +31,10 @@ $twice = fn(T $x): T => $x; $direct = $twice::(4); // 4 $fcc = $twice::(...); $viaFcc = $fcc(8); // 8 + +// Collision guard: the dispatcher variable is literally named like the reserved +// forwarding param. The forwarding param must be suffixed so it does not shadow the +// captured dispatcher inside the closure body. +$__xphp_fcc_args = fn(T $x): T => $x; +$collide = $__xphp_fcc_args::(...); +$collided = $collide(9); // 9 diff --git a/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php b/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php index 42962bd..96217b1 100644 --- a/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php +++ b/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php @@ -20,3 +20,4 @@ Assert::assertSame(7, $defaulted); Assert::assertSame(4, $direct); Assert::assertSame(8, $viaFcc); +Assert::assertSame(9, $collided); From 0044f8d79086fb61bc4efad8e89300b4e60fc89a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 17:55:19 +0000 Subject: [PATCH 56/80] docs: record the bare-new reject and the turbofish-FCC forwarding closure - errors.md: xphp.missing_type_argument now also covers a bare `new` of a generic without all-defaults. - closures-and-arrows.md: a first-class callable of a specialization emits a forwarding closure through the dispatcher. - CHANGELOG: two Fixed entries for the current section. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 18 ++++++++++++++++++ docs/errors.md | 2 +- docs/syntax/closures-and-arrows.md | 6 ++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6193f..8b59847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,24 @@ _In progress on this branch — content still accumulating; date set at tag time ### Fixed +- **A bare `new` of a generic without all-defaults is rejected instead of + silently emitting an uninstantiable marker.** `new Box(...)` where `Box` + has a required type parameter and no turbofish was skipped by the + instantiation collector, leaving the call site pointing at the stripped marker + `interface Box {}` — so the emitted code fatalled with "Cannot instantiate + interface" behind a clean compile and a clean `check`. It now fails with + `xphp.missing_type_argument` (throwing in `compile`, collected in `check`), + exactly as a turbofish-less generic call does. All-defaults generics still + instantiate from a bare `new`, and a bare `new B` still works when a plain + `class B` coexists with a generic `class B` (conditional same-name + declarations resolve to the plain class at runtime). +- **A first-class callable of a turbofish specialization emits valid PHP.** + `$g = $f::(...)` on a generic closure emitted `$f('T_…', ...)` — the + specialization tag prepended beside the `...` placeholder, which does not + parse, so the output failed to load. It now emits a forwarding closure that + routes through the dispatcher, preserving callable semantics (positional, + variadic, and named arguments and the closure's captures); empty-turbofish + all-defaults FCCs (`$f::<>(...)`) work the same way. - **Parenthesised DNF types inside `Closure(...)` signatures are supported.** A DNF group (`(A&B)|C`, `A|(B&C)`) anywhere in a signature previously broke the type scanner: a leading group in a return position failed to compile, a diff --git a/docs/errors.md b/docs/errors.md index 781ff64..0dcfd48 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -37,7 +37,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: |------|---------| | `xphp.bound_violation` | a concrete type argument doesn't satisfy its parameter's bound | | `xphp.default_bound_violation` | a parameter's default doesn't satisfy its own bound | -| `xphp.missing_type_argument` | a required type argument was omitted and has no default — including a **turbofish-less call** to a generic method, function, or closure (`$x->pick('a')` instead of `$x->pick::('a')`): a method generic takes no inference, so the type argument must be supplied explicitly | +| `xphp.missing_type_argument` | a required type argument was omitted and has no default — including a **turbofish-less call** to a generic method, function, or closure (`$x->pick('a')` instead of `$x->pick::('a')`), and a **bare `new` of a generic without all-defaults** (`new Box(...)` where `Box` has a required parameter, instead of `new Box::(...)`): the type argument takes no inference, so it must be supplied explicitly | | `xphp.too_many_type_arguments` | more type arguments were supplied than the template declares (e.g. `Box::` for a one-parameter `Box`) | | `xphp.variance_position` | an `out T` / `in T` parameter appears in a position its variance forbids | | `xphp.inner_variance` | variance is violated through another generic's slot (composition) | diff --git a/docs/syntax/closures-and-arrows.md b/docs/syntax/closures-and-arrows.md index 9d7d76b..f0eb251 100644 --- a/docs/syntax/closures-and-arrows.md +++ b/docs/syntax/closures-and-arrows.md @@ -76,6 +76,12 @@ ref-ness is preserved end-to-end. dispatcher, including `&` byref captures. - The variable receiver stays unchanged at call sites — `$f::(...)` becomes `$f('T_', ...)`, not a renamed call. +- A **first-class callable** of a specialization (`$g = $f::(...)`) + becomes a forwarding closure `fn(...$a) => $f('T_', ...$a)` that + routes through the dispatcher, so `$g` stays a callable with the + specialization bound (positional, variadic, and named arguments and the + closure's captures are all preserved). Empty-turbofish all-defaults FCCs + (`$f::<>(...)`) work the same way. ## Caveats From 5541076f9ffb42917f38ed67413a594337b14390 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 19:15:00 +0000 Subject: [PATCH 57/80] fix(monomorphize): ground inner turbofish type args on specialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a generic template specializes, an inner variable-turbofish call (`$inner::(...)`) in its body carries the enclosing type parameter in its recorded type arguments. The substituting visitor grounded closure signatures, bare type-param names, and generic-args trees, but never the turbofish's ATTR_METHOD_GENERIC_ARGS — so `$inner::` stayed abstract after `S -> int` and the inner closure could not be dispatched to a concrete specialization. Substitute those recorded type arguments too, so a later per-specialization closure-grounding pass sees `$inner::`. This is inert end-to-end on its own (nothing consumes the grounded args until that pass lands); it is a prerequisite. Pinned by a unit test confirmed to fail beforehand. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Specializer.php | 23 ++++++ .../SpecializerMethodGenericArgsTest.php | 82 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 test/Transpiler/Monomorphize/SpecializerMethodGenericArgsTest.php diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index 29a29e5..1ad1d60 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -5,6 +5,7 @@ namespace XPHP\Transpiler\Monomorphize; use PhpParser\Node; +use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\NullsafeMethodCall; use PhpParser\Node\Expr\Variable; @@ -287,6 +288,28 @@ public function __construct(private array $substitution) public function leaveNode(Node $node): ?Node { + // Ground a variable-turbofish call's type arguments in place. An inner + // `$inner::(...)` inside a generic template body parses as a FuncCall on + // a Variable whose type args live in ATTR_METHOD_GENERIC_ARGS; when the + // enclosing generic specializes (`S → int`) those args must ground too, so + // the per-specialization closure-grounding pass sees `$inner::` and can + // dispatch it. Left un-substituted the closure keeps a raw `I` hint and + // fatals at runtime. (This substitution is e2e-inert until that grounding + // pass runs — it only rewrites the recorded type args, never emits.) + if ($node instanceof FuncCall) { + $methodArgs = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + if (is_array($methodArgs) && $methodArgs !== []) { + /** @var list $methodArgs — ATTR_METHOD_GENERIC_ARGS is a TypeRef list (set by XphpSourceParser). */ + $node->setAttribute( + XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, + array_map( + fn (TypeRef $a): TypeRef => Specializer::substituteTypeRef($a, $this->substitution), + $methodArgs, + ), + ); + } + } + // Ground a closure-signature target in place. The erased `\Closure` // head that carries it is fully-qualified, so it never reaches the // type-param swap below; substitute its type-parameter leaves here. diff --git a/test/Transpiler/Monomorphize/SpecializerMethodGenericArgsTest.php b/test/Transpiler/Monomorphize/SpecializerMethodGenericArgsTest.php new file mode 100644 index 0000000..41b5ff9 --- /dev/null +++ b/test/Transpiler/Monomorphize/SpecializerMethodGenericArgsTest.php @@ -0,0 +1,82 @@ +(...)`) inside its body carries the enclosing type parameter in + * ATTR_METHOD_GENERIC_ARGS. {@see Specializer::specializeFunction} must ground those + * recorded type arguments (`S` → `int`) so the per-specialization closure-grounding pass + * sees `$inner::` rather than the abstract `$inner::` — otherwise the inner + * closure keeps a raw hint and fatals at runtime. + * + * This substitution is end-to-end inert on its own (nothing consumes the grounded args + * until the re-entry pass lands); the assertion is on the recorded attribute directly. + */ +final class SpecializerMethodGenericArgsTest extends TestCase +{ + public function testInnerTurbofishArgsAreGroundedOnFunctionSpecialization(): void + { + $call = new FuncCall(new Variable('inner'), [new Arg(new Variable('v'))]); + $call->setAttribute( + XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, + [new TypeRef('S', [], isScalar: false, isTypeParam: true)], + ); + $template = new Function_('relay', [ + 'params' => [new Param(new Variable('v'))], + 'stmts' => [new Return_($call)], + ]); + + $specialized = (new Specializer())->specializeFunction( + $template, + ['S' => new TypeRef('int', [], isScalar: true)], + 'relay_T_int', + ); + + $calls = (new NodeFinder())->findInstanceOf($specialized, FuncCall::class); + self::assertCount(1, $calls); + $grounded = $calls[0]->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + self::assertIsArray($grounded); + self::assertCount(1, $grounded); + self::assertInstanceOf(TypeRef::class, $grounded[0]); + self::assertSame('int', $grounded[0]->name); + self::assertFalse($grounded[0]->isTypeParam, 'the grounded arg is concrete, not a type parameter'); + } + + public function testConcreteInnerTurbofishArgsSurviveSpecializationUnchanged(): void + { + // An inner turbofish already concrete (`$inner::`) is not a type param of the + // enclosing template, so substitution leaves it exactly as-is. + $call = new FuncCall(new Variable('inner'), [new Arg(new Variable('v'))]); + $call->setAttribute( + XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, + [new TypeRef('int', [], isScalar: true)], + ); + $template = new Function_('keep', [ + 'params' => [new Param(new Variable('v'))], + 'stmts' => [new Return_($call)], + ]); + + $specialized = (new Specializer())->specializeFunction( + $template, + ['S' => new TypeRef('string', [], isScalar: true)], + 'keep_T_string', + ); + + $calls = (new NodeFinder())->findInstanceOf($specialized, FuncCall::class); + $grounded = $calls[0]->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + self::assertIsArray($grounded); + self::assertSame('int', $grounded[0]->name); + } +} From 6134ec2bb290f3d81ec4cc2900b24b20fba1888c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 19:56:22 +0000 Subject: [PATCH 58/80] fix(monomorphize): report the real source line for parse-stage rejections in check mode The scanner's xphp-specific rejections (variance markers on methods/closures, legacy `+T`/`-T` glyphs, malformed or misordered generic defaults, non-Closure call signatures, variadic-position errors) were thrown as bare RuntimeExceptions carrying no position. `check` caught them and reported every one at line 1, which is useless for an editor jumping to the offending syntax. Introduce XphpParseException (extends RuntimeException) carrying the offending token's original-source line, and throw it with `$tokens[$i]->line` at the nine token-aware scanner throw sites. `check` catches it specifically and reports the real line, with the line-1 fallback preserved for the remaining position-less structural rejections (self-bound, later-referencing default) raised over parsed entries. Compile mode is unchanged: XphpParseException is a RuntimeException, so the existing catch keeps the same message and throw behavior. Pinned by check-mode line assertions across three distinct throw sites, a fallback-path test for a position-less rejection, and an unchanged compile-mode throw pin. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 21 +++++- .../Monomorphize/XphpParseException.php | 33 +++++++++ .../Monomorphize/XphpSourceParser.php | 32 +++++---- test/Console/CheckCommandTest.php | 5 +- .../Monomorphize/CheckPassIntegrationTest.php | 68 +++++++++++++++++++ .../parse_error/source/ClosureVariance.xphp | 2 +- .../source/Pair.xphp | 11 +++ .../source/Box.xphp | 9 +++ .../source/Animal.xphp | 12 ++++ .../parse_self_bound/source/SelfBound.xphp | 9 +++ 10 files changed, 183 insertions(+), 19 deletions(-) create mode 100644 src/Transpiler/Monomorphize/XphpParseException.php create mode 100644 test/fixture/check/parse_line_default_ordering/source/Pair.xphp create mode 100644 test/fixture/check/parse_line_legacy_variance/source/Box.xphp create mode 100644 test/fixture/check/parse_line_variance_method/source/Animal.xphp create mode 100644 test/fixture/check/parse_self_bound/source/SelfBound.xphp diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 04b2c7f..c292e00 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -327,9 +327,26 @@ public function check(FilepathArray $sources): DiagnosticCollector // (Broken.xphp -> line 11). new SourceLocation($filepath, $line > 0 ? $line : 1), )); + } catch (XphpParseException $e) { + // xphp-specific parse-time rejections from the scanner (e.g. variance markers + // on methods, malformed generic defaults) — these carry the offending token's + // original-source line so the diagnostic points at the real site. + $line = $e->sourceLine(); + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_PARSE_ERROR, + $e->getMessage(), + // @infection-ignore-all GreaterThan/IncrementInteger/DecrementInteger -- the + // scanner supplies a real line (>= 1) or 0 when no token position was + // available; every `> 0` boundary variant routes 0 to the same `?: 1` + // fallback, so the mutants are equivalent. The real-line path is pinned by + // CheckPassIntegrationTest's testParseTime* cases. + new SourceLocation($filepath, $line > 0 ? $line : 1), + )); } catch (RuntimeException $e) { - // xphp-specific parse-time rejections from the parser (e.g. variance markers on - // methods) — these carry no line, so the diagnostic points at the file (line 1). + // Remaining xphp parse-time rejections that carry no token position (e.g. + // structural checks over parsed entries) — the diagnostic points at the file + // (line 1). $diagnostics->add(new Diagnostic( Severity::Error, self::CODE_PARSE_ERROR, diff --git a/src/Transpiler/Monomorphize/XphpParseException.php b/src/Transpiler/Monomorphize/XphpParseException.php new file mode 100644 index 0000000..e22152e --- /dev/null +++ b/src/Transpiler/Monomorphize/XphpParseException.php @@ -0,0 +1,33 @@ +sourceLine; + } +} diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 7eec6a1..1efcef1 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -710,10 +710,10 @@ private static function tryParseClosureSignature(array $tokens, int $i, int $j, $name = ltrim($tokens[$i]->text, '\\'); if ($name !== 'Closure') { - throw new RuntimeException(sprintf( + throw new XphpParseException(sprintf( 'Only "Closure" may carry a call signature, "%s" may not', $name, - )); + ), $tokens[$i]->line); } $nullable = self::isNullablePrefixed($tokens, $i); @@ -1330,7 +1330,7 @@ private static function buildClosureSignature(array $tokens, int $openIdx, strin $sawVariadic = false; while ($i < $n && $tokens[$i]->text !== ')') { if ($sawVariadic) { - throw new RuntimeException('Only the last parameter of a Closure signature can be variadic'); + throw new XphpParseException('Only the last parameter of a Closure signature can be variadic', $tokens[$i]->line); } [$param, $i] = self::parseSigParam($tokens, $i, $source); $params[] = $param; @@ -1655,10 +1655,11 @@ private static function parseTypeParamList( // hint rather than letting them fall through to a bare `null` (which // would surface downstream as an opaque PHP syntax error). if ($i < $n && ($tokens[$i]->text === '+' || $tokens[$i]->text === '-')) { - throw new RuntimeException( + throw new XphpParseException( 'The `+T` / `-T` variance syntax was replaced by `out T` / `in T`. ' . 'Write `out` for covariance and `in` for contravariance, e.g. ' . '`class Box` or `class Consumer`.', + $tokens[$i]->line, ); } @@ -1676,13 +1677,14 @@ private static function parseTypeParamList( $afterMarker = self::skipWs($tokens, $i + 1); if ($afterMarker < $n && self::isNameToken($tokens[$afterMarker])) { if (!$allowVariance) { - throw new RuntimeException( + throw new XphpParseException( 'Variance markers `out T` / `in T` are not supported on methods, ' . 'functions, closures, or arrow functions — variance is a ' . 'class-level-only feature by design: a function or closure ' . 'specialization has no stable class identity to anchor a ' . 'subtype `extends` edge to. Move the generic to a class-level ' . 'type parameter.', + $tokens[$i]->line, ); } $variance = $tokens[$i]->text === 'out' @@ -1696,6 +1698,7 @@ private static function parseTypeParamList( return null; } $paramName = ltrim($tokens[$i]->text, '\\'); + $paramNameLine = $tokens[$i]->line; $i++; // Reserve `out` / `in` as variance markers: they can never name a @@ -1705,10 +1708,11 @@ private static function parseTypeParamList( // second `out` read as the name), `class Pair`, // `class Box` — all reject with a single diagnostic. if (in_array($paramName, ['out', 'in'], true)) { - throw new RuntimeException( + throw new XphpParseException( '`out` and `in` are variance markers and cannot name a type parameter. ' . 'Use `out T` for covariance or `in T` for contravariance, and pick a ' . 'different name for the parameter itself.', + $paramNameLine, ); } @@ -1727,22 +1731,22 @@ private static function parseTypeParamList( $afterBound = self::skipWs($tokens, $i); if ($afterBound < $n && $tokens[$afterBound]->text === '=') { if (!$allowDefaults) { - throw new RuntimeException(sprintf( + throw new XphpParseException(sprintf( 'Generic parameter `%s` has a default value, which is not yet ' . 'supported on static closures. Drop the `static` modifier or ' . 'assign the closure to a named function.', $paramName, - )); + ), $tokens[$afterBound]->line); } $afterEq = self::skipWs($tokens, $afterBound + 1); $parsedDefault = self::parseTypeArg($tokens, $afterEq); if ($parsedDefault === null) { - throw new RuntimeException(sprintf( + throw new XphpParseException(sprintf( 'Generic parameter `%s` has an invalid default; only a single ' . 'concrete or generic type is allowed after `=` (no nullable ' . 'or union shapes).', $paramName, - )); + ), $tokens[$afterBound]->line); } [$default, $i] = $parsedDefault; // Reject `T = Box | Other` (union) and `T = Box & Other` (intersection) @@ -1753,20 +1757,20 @@ private static function parseTypeParamList( if ($afterDefault < $n && ($tokens[$afterDefault]->text === '|' || $tokens[$afterDefault]->text === '&') ) { - throw new RuntimeException(sprintf( + throw new XphpParseException(sprintf( 'Generic parameter `%s` has an invalid default; only a single ' . 'concrete or generic type is allowed after `=` (no nullable ' . 'or union shapes).', $paramName, - )); + ), $tokens[$afterDefault]->line); } $sawDefault = true; } elseif ($sawDefault) { - throw new RuntimeException(sprintf( + throw new XphpParseException(sprintf( 'Generic parameter `%s` has no default but follows a parameter with ' . 'a default. Required type parameters must precede defaulted ones.', $paramName, - )); + ), $paramNameLine); } $entries[] = [ diff --git a/test/Console/CheckCommandTest.php b/test/Console/CheckCommandTest.php index 65ac295..1f5658d 100644 --- a/test/Console/CheckCommandTest.php +++ b/test/Console/CheckCommandTest.php @@ -79,9 +79,10 @@ public function testParseErrorIsReportedButOtherFilesStillChecked(): void // A PHP syntax error: reported with its real line. self::assertSame(Compiler::CODE_PARSE_ERROR, $byFile['Broken.xphp']['code']); self::assertSame(11, $byFile['Broken.xphp']['line']); - // An xphp-specific parse rejection (no line): reported at line 1. + // An xphp-specific parse rejection: reported at the offending token's real line + // (the `` variance marker sits on line 9), not the line-1 fallback. self::assertSame(Compiler::CODE_PARSE_ERROR, $byFile['ClosureVariance.xphp']['code']); - self::assertSame(1, $byFile['ClosureVariance.xphp']['line']); + self::assertSame(9, $byFile['ClosureVariance.xphp']['line']); // A VALID file is still checked despite the two unparseable files. self::assertSame('xphp.bound_violation', $byFile['Use.xphp']['code']); } diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index f9c579d..63b88a4 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -583,6 +583,74 @@ public function testCompileStillThrowsOnUndefinedTemplate(): void } } + public function testParseTimeVarianceOnMethodReportsRealLine(): void + { + // A parser-stage rejection (variance marker on a method) is caught in check mode + // and must report the offending token's real source line, not the line-1 fallback + // used for position-less parse failures. `eat` sits on line 9. + $diagnostics = $this->check('parse_line_variance_method'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); + self::assertStringContainsString('Variance markers', $d->message); + self::assertNotNull($d->location); + self::assertSame(9, $d->location->line); + } + + public function testParseTimeLegacyVarianceGlyphReportsRealLine(): void + { + // The `+T` legacy-variance rejection fires from a different throw site; it too + // carries its token line. `class Box<+T>` sits on line 7. + $diagnostics = $this->check('parse_line_legacy_variance'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); + self::assertStringContainsString('`+T` / `-T` variance syntax', $d->message); + self::assertNotNull($d->location); + self::assertSame(7, $d->location->line); + } + + public function testParseTimeDefaultOrderingReportsRealLine(): void + { + // The required-after-defaulted rejection anchors to the offending parameter's + // name line (`class Pair` on line 9), proving the name-line path. + $diagnostics = $this->check('parse_line_default_ordering'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); + self::assertStringContainsString('Required type parameters must precede', $d->message); + self::assertNotNull($d->location); + self::assertSame(9, $d->location->line); + } + + public function testParseTimePositionlessRejectionFallsBackToLineOne(): void + { + // A structural rejection raised over parsed entries (a self-bound `T : T`) carries + // no token position, so check mode collects it via the fallback catch at line 1 — + // and it is collected, not propagated (the file is still reported, exit stays clean + // of a fatal). + $diagnostics = $this->check('parse_self_bound'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); + self::assertStringContainsString('cannot use itself as a bound', $d->message); + self::assertNotNull($d->location); + self::assertSame(1, $d->location->line); + } + + public function testCompileStillThrowsOnVarianceOnMethod(): void + { + // Compile mode catches the same rejection as a RuntimeException (XphpParseException + // extends it) — the message path is unchanged; only check mode reads the line. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Variance markers'); + $this->compileFixture('parse_line_variance_method'); + } + private function compileFixture(string $fixture): void { $work = sys_get_temp_dir() . '/xphp-check-compile-' . uniqid('', true); diff --git a/test/fixture/check/parse_error/source/ClosureVariance.xphp b/test/fixture/check/parse_error/source/ClosureVariance.xphp index 9b2efed..9f25955 100644 --- a/test/fixture/check/parse_error/source/ClosureVariance.xphp +++ b/test/fixture/check/parse_error/source/ClosureVariance.xphp @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Check\ParseError; // xphp-specific parse-time rejection (not a PHP syntax error): variance markers -// are not allowed on closures. Throws a RuntimeException during parse, with no line. +// are not allowed on closures. Throws during parse, carrying this token's line. $f = function (T $x): T { return $x; }; diff --git a/test/fixture/check/parse_line_default_ordering/source/Pair.xphp b/test/fixture/check/parse_line_default_ordering/source/Pair.xphp new file mode 100644 index 0000000..5fbd61d --- /dev/null +++ b/test/fixture/check/parse_line_default_ordering/source/Pair.xphp @@ -0,0 +1,11 @@ + +{ +} diff --git a/test/fixture/check/parse_line_legacy_variance/source/Box.xphp b/test/fixture/check/parse_line_legacy_variance/source/Box.xphp new file mode 100644 index 0000000..c786f97 --- /dev/null +++ b/test/fixture/check/parse_line_legacy_variance/source/Box.xphp @@ -0,0 +1,9 @@ + +{ +} diff --git a/test/fixture/check/parse_line_variance_method/source/Animal.xphp b/test/fixture/check/parse_line_variance_method/source/Animal.xphp new file mode 100644 index 0000000..836c942 --- /dev/null +++ b/test/fixture/check/parse_line_variance_method/source/Animal.xphp @@ -0,0 +1,12 @@ +(T $food): void + { + } +} diff --git a/test/fixture/check/parse_self_bound/source/SelfBound.xphp b/test/fixture/check/parse_self_bound/source/SelfBound.xphp new file mode 100644 index 0000000..fbbd942 --- /dev/null +++ b/test/fixture/check/parse_self_bound/source/SelfBound.xphp @@ -0,0 +1,9 @@ + +{ +} From 0b1d94d58cd6c9ce684db4717d340add5ae928dd Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 20:06:40 +0000 Subject: [PATCH 59/80] test(monomorphize): pin the two distinct default-reject line sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invalid-default (`T = ?int`) and union-default (`T = Foo | Bar`) rejections report their line from `$tokens[$afterBound]` and `$tokens[$afterDefault]` respectively — index expressions distinct from the variance sites' `$tokens[$i]` and the name sites' captured line. They were hand-verified but unpinned; a future edit to either index could silently regress the reported line with no failing test. Add a check-mode line assertion for each. Also tighten the line-fallback comment to describe actual usage: every current throw site supplies a real token line, so the `> 0` guard is a defensive floor for the exception's documented "no position" (0) contract rather than a branch any site currently exercises. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 11 ++++---- .../Monomorphize/CheckPassIntegrationTest.php | 28 +++++++++++++++++++ .../source/Holder.xphp | 9 ++++++ .../parse_line_union_default/source/Slot.xphp | 11 ++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 test/fixture/check/parse_line_invalid_default/source/Holder.xphp create mode 100644 test/fixture/check/parse_line_union_default/source/Slot.xphp diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index c292e00..90d359a 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -336,11 +336,12 @@ public function check(FilepathArray $sources): DiagnosticCollector Severity::Error, self::CODE_PARSE_ERROR, $e->getMessage(), - // @infection-ignore-all GreaterThan/IncrementInteger/DecrementInteger -- the - // scanner supplies a real line (>= 1) or 0 when no token position was - // available; every `> 0` boundary variant routes 0 to the same `?: 1` - // fallback, so the mutants are equivalent. The real-line path is pinned by - // CheckPassIntegrationTest's testParseTime* cases. + // @infection-ignore-all GreaterThan/IncrementInteger/DecrementInteger -- every + // current throw site supplies a real token line (>= 1), so this `> 0` guard is + // a defensive floor for the exception's documented 0 ("no position") contract. + // The fallback value equals the boundary (1), so shifting or flipping the `> 0` + // test routes to the same result — the mutants are equivalent. The real-line + // path is pinned by CheckPassIntegrationTest's testParseTime* cases. new SourceLocation($filepath, $line > 0 ? $line : 1), )); } catch (RuntimeException $e) { diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 63b88a4..a424d3e 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -626,6 +626,34 @@ public function testParseTimeDefaultOrderingReportsRealLine(): void self::assertSame(9, $d->location->line); } + public function testParseTimeInvalidDefaultReportsRealLine(): void + { + // An invalid default shape (`T = ?int`) rejects from the `$tokens[$afterBound]` + // (the `=`) throw site — a distinct index from the variance sites. Line 7. + $diagnostics = $this->check('parse_line_invalid_default'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); + self::assertStringContainsString('has an invalid default', $d->message); + self::assertNotNull($d->location); + self::assertSame(7, $d->location->line); + } + + public function testParseTimeUnionDefaultReportsRealLine(): void + { + // A union default (`T = Foo | Bar`) rejects from the `$tokens[$afterDefault]` + // (the `|`) throw site — again a distinct index. `class Slot<...>` on line 9. + $diagnostics = $this->check('parse_line_union_default'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); + self::assertStringContainsString('has an invalid default', $d->message); + self::assertNotNull($d->location); + self::assertSame(9, $d->location->line); + } + public function testParseTimePositionlessRejectionFallsBackToLineOne(): void { // A structural rejection raised over parsed entries (a self-bound `T : T`) carries diff --git a/test/fixture/check/parse_line_invalid_default/source/Holder.xphp b/test/fixture/check/parse_line_invalid_default/source/Holder.xphp new file mode 100644 index 0000000..1994224 --- /dev/null +++ b/test/fixture/check/parse_line_invalid_default/source/Holder.xphp @@ -0,0 +1,9 @@ + +{ +} diff --git a/test/fixture/check/parse_line_union_default/source/Slot.xphp b/test/fixture/check/parse_line_union_default/source/Slot.xphp new file mode 100644 index 0000000..94a11b2 --- /dev/null +++ b/test/fixture/check/parse_line_union_default/source/Slot.xphp @@ -0,0 +1,11 @@ + +{ +} From 507e7609c602baa91a73b79fc3d95f6d43a6dc9d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 20:07:51 +0000 Subject: [PATCH 60/80] docs: record real-line reporting for parse-stage rejections in check Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b59847..3036b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,17 @@ _In progress on this branch — content still accumulating; date set at tag time ### Fixed +- **`check` reports parse-stage rejections at their real source line.** Syntax + the scanner rejects before the AST exists — a variance marker on a method or + closure (`out T` / `in T`), the legacy `+T` / `-T` glyphs, a malformed or + misordered generic default (`T = ?int`, `T = Foo | Bar`, a required parameter + after a defaulted one), or a call signature on a non-`Closure` name — was + reported by `check` at line 1 regardless of where it occurred, so an editor + could not navigate to it (the real location survived only in the message text). + These now carry the offending token's line. A few structural rejections raised + after parsing (a self-referential bound, a default referencing a later + parameter) still fall back to line 1, where no token position is available. + `compile` behaviour is unchanged. - **A bare `new` of a generic without all-defaults is rejected instead of silently emitting an uninstantiable marker.** `new Box(...)` where `Box` has a required type parameter and no turbofish was skipped by the From 69f0116895ae4b2abcdd1ca98e92fcaa1b80abc9 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 7 Jul 2026 21:13:08 +0000 Subject: [PATCH 61/80] fix(monomorphize): reject a generic clause on a `use` import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generic clause on a namespace-import `use` was scanned as a type instantiation. Depending on the import form this either mis-blamed a double-qualified phantom template at a null line, or — for a grouped import whose (mis)resolved member collided with a real template — drove a specialization whose absolute name was emitted inside a `use N\{…}` prefix group. That last case is unparseable PHP produced silently: it passed both `check` and `compile` and only failed when the emitted file was loaded. Reject the clause at resolve time, before the blanket Name branch consumes its marker. The import forms are precisely typed at the AST (`Stmt\Use_` / `Stmt\GroupUse`, matching each member name and the group prefix), so the reject can never touch a generic trait-use (`Stmt\TraitUse`), which stays a supported, specialized construct. The rejection throws `XphpParseException`, so it carries the offending import's real line and is collected in `check` and thrown in `compile` alike, naming the source-spelled symbol rather than the requalified phantom. Matching is byte-exact, so two imports of the same qualified name (one clause-less) never cross-fire. `use function …<…>` is unaffected: its clause is left unstripped, so php-parser reports its own syntax error before this stage. Pinned by check-mode rejects across all five import forms (right symbol, right line), a same-spelling non-cross-fire case, an exact-message assertion, compile-mode throw, clean ordinary/grouped imports, and an execute test proving generic trait-use still specializes and runs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 58 ++++++ .../UseImportGenericClauseTest.php | 197 ++++++++++++++++++ .../use_generic_import_aliased/source/C.xphp | 9 + .../use_generic_import_const/source/C.xphp | 9 + .../use_generic_import_grouped/source/C.xphp | 9 + .../source/C.xphp | 9 + .../source/C.xphp | 11 + .../use_generic_import_single/source/C.xphp | 9 + .../use_grouped_import_clean/source/C.xphp | 9 + .../use_ordinary_import_clean/source/C.xphp | 9 + .../compile/use_trait_generic/source/Use.xphp | 24 +++ .../use_trait_generic/verify/runtime.php | 16 ++ 12 files changed, 369 insertions(+) create mode 100644 test/Transpiler/Monomorphize/UseImportGenericClauseTest.php create mode 100644 test/fixture/check/use_generic_import_aliased/source/C.xphp create mode 100644 test/fixture/check/use_generic_import_const/source/C.xphp create mode 100644 test/fixture/check/use_generic_import_grouped/source/C.xphp create mode 100644 test/fixture/check/use_generic_import_grouped_prefix/source/C.xphp create mode 100644 test/fixture/check/use_generic_import_shadowed_name/source/C.xphp create mode 100644 test/fixture/check/use_generic_import_single/source/C.xphp create mode 100644 test/fixture/check/use_grouped_import_clean/source/C.xphp create mode 100644 test/fixture/check/use_ordinary_import_clean/source/C.xphp create mode 100644 test/fixture/compile/use_trait_generic/source/Use.xphp create mode 100644 test/fixture/compile/use_trait_generic/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 1efcef1..e8e0d0c 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -7,6 +7,7 @@ use PhpParser\Node; use PhpParser\Node\Name; use PhpParser\Node\Stmt\ClassLike; +use PhpParser\Node\Stmt\GroupUse; use PhpParser\Node\Stmt\Namespace_; use PhpParser\Node\Stmt\Use_; use PhpParser\NodeTraverser; @@ -2471,6 +2472,16 @@ public function __construct( public function enterNode(Node $node): null { + if ($node instanceof Use_ || $node instanceof GroupUse) { + // Reject a generic clause on a namespace-import BEFORE the blanket + // Name branch below resolves its marker (which mis-qualifies the + // already-absolute import name) or CallSiteRewriter emits an + // absolute specialized name inside a `use N\{…}` prefix group + // (unparseable PHP). A generic TRAIT-use is a Stmt\TraitUse — a + // different node that never reaches here and keeps specializing. + $this->rejectGenericClauseOnImport($node); + } + if ($node instanceof Namespace_) { // @infection-ignore-all — bare `namespace { ... }` (no name) isn't used in any // fixture; the null-coalesce branch never observably differs from a missing name. @@ -2899,6 +2910,53 @@ private function resolveSigType(SigType $type): SigType * (matching no marker) for a position-less node — never produced * by parsing real source, defensive only. */ + /** + * A generic clause on a namespace-import `use` — single (`use App\Box;`), + * aliased (`… as B`), `use const`, or grouped (`use App\{Box, …}`, and the + * clause-on-prefix shape `use App\{…}`) — has no valid xphp meaning: imports + * name a symbol, they don't instantiate one. Left alone, the single/aliased/const + * forms mis-blame a double-qualified phantom template, and the grouped form emits + * an absolute specialized name inside a `use N\{…}` prefix group, which is + * unparseable PHP produced silently. Reject it here — before the blanket Name + * branch consumes the marker — so both `check` (collected) and `compile` (thrown) + * report the right symbol at the import's line. `use function …<…>` never reaches + * this stage (its clause is left unstripped, so php-parser rejects it first). + * + * @param Use_|GroupUse $node + */ + private function rejectGenericClauseOnImport(Node $node): void + { + $prefix = $node instanceof GroupUse ? $node->prefix->toCodeString() : null; + /** @var list $candidates [name node, display symbol] */ + $candidates = []; + if ($node instanceof GroupUse) { + $candidates[] = [$node->prefix, $prefix]; + } + foreach ($node->uses as $use) { + $spelling = $use->name->toCodeString(); + $candidates[] = [ + $use->name, + $prefix !== null ? $prefix . '\\' . $spelling : $spelling, + ]; + } + foreach ($candidates as [$name, $symbol]) { + $byte = $this->originalByteOf($name); + $spelling = $name->toCodeString(); + foreach ($this->nameMarkers as $marker) { + if ($marker['bytePosition'] === $byte && $marker['name'] === $spelling) { + throw new XphpParseException(sprintf( + 'A generic clause is not allowed on a `use` import. Import the ' + . 'template with a plain `use %s;` and apply the type arguments ' + . 'at the use site (the hint `%s<...>` or the call `%s::<...>()`).', + $symbol, + $symbol, + $symbol, + ), $name->getStartLine()); + } + } + } + } + private function originalByteOf(Node $node): int { $pos = $node->getStartFilePos(); diff --git a/test/Transpiler/Monomorphize/UseImportGenericClauseTest.php b/test/Transpiler/Monomorphize/UseImportGenericClauseTest.php new file mode 100644 index 0000000..b0c76c3 --- /dev/null +++ b/test/Transpiler/Monomorphize/UseImportGenericClauseTest.php @@ -0,0 +1,197 @@ +;`, aliased, `use const`, + * grouped, or the clause-on-prefix `use App\{…}`) has no valid xphp meaning. Left alone + * the single/aliased/const forms mis-blamed a double-qualified phantom template (`Main\App\Box`) + * at a null line, and the grouped form emitted an absolute specialized name inside a `use N\{…}` + * prefix group — unparseable PHP produced silently, escaping the check gate. Each form now + * rejects with ONE diagnostic naming the real symbol at the import's real line, in both + * `check` (collected) and `compile` (thrown). Ordinary imports and generic **trait-use** + * (a `Stmt\TraitUse`, a different node) are unaffected. + */ +final class UseImportGenericClauseTest extends TestCase +{ + /** + * @return iterable + */ + public static function rejectedForms(): iterable + { + // fixture => [expected-symbol-in-message, offending line] + yield 'single' => ['use_generic_import_single', 'App\\Box', 7]; + yield 'aliased' => ['use_generic_import_aliased', 'App\\Box', 7]; + yield 'const' => ['use_generic_import_const', 'App\\BOX', 7]; + yield 'grouped member' => ['use_generic_import_grouped', 'App\\Box', 7]; + yield 'grouped prefix' => ['use_generic_import_grouped_prefix', 'App', 7]; + } + + #[DataProvider('rejectedForms')] + public function testCheckRejectsGenericClauseOnImportWithRightSymbolAndLine( + string $fixture, + string $symbol, + int $line, + ): void { + $diagnostics = $this->check($fixture); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); + self::assertStringContainsString('not allowed on a `use` import', $d->message); + // Names the source spelling, never the requalified phantom (`Main\App\Box`). + self::assertStringContainsString('`use ' . $symbol . ';`', $d->message); + self::assertStringNotContainsString('Main\\App', $d->message); + self::assertNotNull($d->location); + self::assertSame($line, $d->location->line); + } + + public function testMessageNamesTheSymbolAndBothUseSiteForms(): void + { + // Pin the full message so a reordered or truncated build is caught (the parts are + // otherwise only substring-checked): it names the source symbol and shows both the + // hint and the turbofish-call use-site forms. + $d = $this->check('use_generic_import_single')->all()[0]; + + self::assertSame( + 'A generic clause is not allowed on a `use` import. Import the template with a ' + . 'plain `use App\\Box;` and apply the type arguments at the use site (the hint ' + . '`App\\Box<...>` or the call `App\\Box::<...>()`).', + $d->message, + ); + } + + public function testAClauselessImportSharingASpellingIsNotRejected(): void + { + // Two imports of the SAME qualified name via distinct aliases (`use App\Box as B1;` + // then `use App\Box as B2;`): the clause-less first import must NOT be rejected. + // Matching is byte-exact, not by spelling alone — a name-only match would cross-fire + // and blame the wrong (clause-less) import. Exactly one diagnostic, at the + // clause-bearing import (line 9), naming `App\Box`. + $diagnostics = $this->check('use_generic_import_shadowed_name'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); + self::assertNotNull($d->location); + self::assertSame(9, $d->location->line); + self::assertStringContainsString('`use App\\Box;`', $d->message); + } + + public function testCompileThrowsOnGenericClauseImport(): void + { + // Compile mode: XphpParseException (a RuntimeException) propagates out of parse() + // before any emit — the grouped silent fatal can no longer reach the writer. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('not allowed on a `use` import'); + $this->compileFixture('use_generic_import_grouped'); + } + + public function testOrdinaryImportIsUnaffected(): void + { + self::assertSame([], $this->check('use_ordinary_import_clean')->all()); + } + + public function testGroupedImportWithoutClauseIsUnaffected(): void + { + self::assertSame([], $this->check('use_grouped_import_clean')->all()); + } + + #[RunInSeparateProcess] + public function testGenericTraitUseStillSpecializesAndRuns(): void + { + // The must-keep guard: a generic trait-use is a Stmt\TraitUse, never a Stmt\Use_/ + // GroupUse, so the import reject never sees it. It still specializes, emits valid + // PHP, and executes end-to-end. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/use_trait_generic/source', + 'use-trait-generic', + ); + try { + $fixture->registerAutoload('App\\UseTraitGeneric'); + require __DIR__ . '/../../fixture/compile/use_trait_generic/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + private function check(string $fixture): DiagnosticCollector + { + return $this->buildCompiler()->check($this->sources($fixture)); + } + + private function compileFixture(string $fixture): void + { + $work = sys_get_temp_dir() . '/xphp-useimport-' . uniqid('', true); + mkdir($work, 0o755, true); + try { + $this->buildCompiler()->compile( + $this->sources($fixture), + $this->sourceDir($fixture), + $work . '/dist', + $work . '/cache', + ); + } finally { + self::rrmdir($work); + } + } + + private function sources(string $fixture): FilepathArray + { + return (new NativeFileFinder()) + ->find($this->sourceDir($fixture)) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + } + + private function sourceDir(string $fixture): string + { + return realpath(__DIR__ . '/../../fixture/check/' . $fixture . '/source') + ?: throw new RuntimeException("Fixture missing: {$fixture}"); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } + + private function buildCompiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } +} diff --git a/test/fixture/check/use_generic_import_aliased/source/C.xphp b/test/fixture/check/use_generic_import_aliased/source/C.xphp new file mode 100644 index 0000000..7df0966 --- /dev/null +++ b/test/fixture/check/use_generic_import_aliased/source/C.xphp @@ -0,0 +1,9 @@ + as B; + +class C {} diff --git a/test/fixture/check/use_generic_import_const/source/C.xphp b/test/fixture/check/use_generic_import_const/source/C.xphp new file mode 100644 index 0000000..8bd9155 --- /dev/null +++ b/test/fixture/check/use_generic_import_const/source/C.xphp @@ -0,0 +1,9 @@ +; + +class C {} diff --git a/test/fixture/check/use_generic_import_grouped/source/C.xphp b/test/fixture/check/use_generic_import_grouped/source/C.xphp new file mode 100644 index 0000000..1a368ac --- /dev/null +++ b/test/fixture/check/use_generic_import_grouped/source/C.xphp @@ -0,0 +1,9 @@ +, Bag}; + +class C {} diff --git a/test/fixture/check/use_generic_import_grouped_prefix/source/C.xphp b/test/fixture/check/use_generic_import_grouped_prefix/source/C.xphp new file mode 100644 index 0000000..3ce4576 --- /dev/null +++ b/test/fixture/check/use_generic_import_grouped_prefix/source/C.xphp @@ -0,0 +1,9 @@ +\{Box}; + +class C {} diff --git a/test/fixture/check/use_generic_import_shadowed_name/source/C.xphp b/test/fixture/check/use_generic_import_shadowed_name/source/C.xphp new file mode 100644 index 0000000..3cbfe44 --- /dev/null +++ b/test/fixture/check/use_generic_import_shadowed_name/source/C.xphp @@ -0,0 +1,11 @@ + as B2; + +class C {} diff --git a/test/fixture/check/use_generic_import_single/source/C.xphp b/test/fixture/check/use_generic_import_single/source/C.xphp new file mode 100644 index 0000000..e4ac40f --- /dev/null +++ b/test/fixture/check/use_generic_import_single/source/C.xphp @@ -0,0 +1,9 @@ +; + +class C {} diff --git a/test/fixture/check/use_grouped_import_clean/source/C.xphp b/test/fixture/check/use_grouped_import_clean/source/C.xphp new file mode 100644 index 0000000..937c3df --- /dev/null +++ b/test/fixture/check/use_grouped_import_clean/source/C.xphp @@ -0,0 +1,9 @@ +`) inside a class body is NOT a namespace +// import: it specializes the trait and inlines its members. The WI-08 use-import +// reject must leave it untouched -- compiled and executed end-to-end here. +trait Holder +{ + public function label(): string + { + return 'held'; + } +} + +class Box +{ + use Holder; +} + +$box = new Box(); +$label = $box->label(); diff --git a/test/fixture/compile/use_trait_generic/verify/runtime.php b/test/fixture/compile/use_trait_generic/verify/runtime.php new file mode 100644 index 0000000..3185ce2 --- /dev/null +++ b/test/fixture/compile/use_trait_generic/verify/runtime.php @@ -0,0 +1,16 @@ +targetDir . '/Use.php'; + +Assert::assertSame('held', $label); From 128f8b8c31c84255afa5402cb14df0315148d558 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 8 Jul 2026 05:15:02 +0000 Subject: [PATCH 62/80] docs: record the use-import generic-clause rejection - CHANGELOG: Fixed entry for rejecting a generic clause on a `use` import (single/aliased/const/grouped), eliminating the grouped silent unparseable emit. - errors.md: broaden the xphp.parse_error row to cover parse-time xphp rejections (variance markers, malformed defaults, generic clause on a use import) reported at the offending line, not only post-strip PHP syntax errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 +++++++++++++ docs/errors.md | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3036b05..4a5d322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,19 @@ _In progress on this branch — content still accumulating; date set at tag time ### Fixed +- **A generic clause on a `use` import is rejected instead of misfiring.** Writing + a type argument on an import — `use App\Box;`, `use App\Box as B;`, + `use const App\BOX;`, or the grouped `use App\{Box, Bag};` — has no + meaning (imports name a symbol; they don't instantiate one). It used to either + blame a phantom double-qualified template (`Main\App\Box`) with no line, or — for + a grouped import whose name collided with a real template — **silently emit + unparseable PHP** (an absolute specialized name inside a `use N\{…}` prefix group) + that passed both `check` and `compile` and only failed when the file was loaded. + Every form now draws one clear diagnostic naming the real symbol at the import's + line, collected by `check` and thrown by `compile`. Import the template plainly + (`use App\Box;`) and apply the type arguments at the use site. A generic + **trait-use** (`class C { use Holder; }`) is unaffected — it still specializes + the trait. - **`check` reports parse-stage rejections at their real source line.** Syntax the scanner rejects before the AST exists — a variance marker on a method or closure (`out T` / `in T`), the legacy `+T` / `-T` glyphs, a malformed or diff --git a/docs/errors.md b/docs/errors.md index 0dcfd48..9137681 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -53,7 +53,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.unspecializable_self_call` | a `$this`-rooted self-call forwards a type parameter to a **non-erasable** generic method (one whose parameter is used nested, in the return, or structurally). Forwarding to an *erasable* method — parameter used only as a direct input — compiles and runs; otherwise move the call to a typed-receiver context | | `xphp.unschedulable_covariant_upcast` | a value is upcast to a covariant *interface* whose element-consuming method (`contains`) needs a concrete implementation at the supertype argument that can neither be inherited through the covariant chain nor emitted directly onto the upcast source. Direct emission already covers the cases where inheritance can't carry it (the implementing class has another `extends` parent, implements only a parent of the interface, or reorders the clause); the upcast fails only when **no** emittable class body exists (a truly abstract or trait-only method), the method's **return type** names the element parameter (the widened argument would escape through a narrower return), or its parameters are bounded by **different** enclosing parameters (no single member can be derived). Provide a concrete implementation on a class — move a trait body onto the covariant base, or give the method a non-element return type | | `xphp.closure_conformance` | a closure literal returned against a `Closure(...)` type doesn't conform to it — its parameters aren't wide enough, its return isn't narrow enough, its by-reference-ness differs, or its arity is incompatible | -| `xphp.parse_error` | the file isn't valid PHP after the generic strip pass | +| `xphp.parse_error` | the source can't be parsed — either a PHP syntax error after the generic strip pass, or a parse-time xphp rejection (a variance marker on a method/closure, a malformed generic default, a generic clause on a `use` import), reported at the offending line | | `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`; a finding that carries no identifier falls back to the literal `phpstan.error`) — present only when the PHPStan pass runs | | `phpstan.unavailable` | (Warning) no phpstan binary was found, so the PHPStan pass was skipped | | `phpstan.run_failed` | (Warning) phpstan was found but couldn't complete (e.g. a config error) | From 1879de6d931f3a48a5af4d79abd6bd9775552d22 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 8 Jul 2026 05:52:46 +0000 Subject: [PATCH 63/80] feat(monomorphize): function/const-aware name resolution in NamespaceContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP resolves a free-function name against `use function` imports (and a const against `use const`), independently of class imports — a class `use App\Helper;` never binds a `helper()` call. NamespaceContext lumped all three import kinds into one alias map, so it could not resolve a function or const name correctly. Partition `use function` / `use const` imports into their own maps (the class map still keeps every import, so class resolution and isImported() are unchanged), and add resolveFunctionName / resolveConstName: an unqualified name binds its own import map then the current namespace (no leading backslash added — the caller's in-set guard decides whether to fully-qualify, preserving PHP's global fallback); a qualified name's leading segment resolves via the class/namespace map; FQ and relative names bind as PHP does. This is the resolution half of re-qualifying free-function and const references in relocated generic template bodies. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/NamespaceContext.php | 84 +++++++++++++- .../Monomorphize/NamespaceContextTest.php | 106 +++++++++++++++++- 2 files changed, 187 insertions(+), 3 deletions(-) diff --git a/src/Transpiler/Monomorphize/NamespaceContext.php b/src/Transpiler/Monomorphize/NamespaceContext.php index 8666138..ed0ea59 100644 --- a/src/Transpiler/Monomorphize/NamespaceContext.php +++ b/src/Transpiler/Monomorphize/NamespaceContext.php @@ -20,8 +20,12 @@ final class NamespaceContext { private string $currentNamespace = ''; - /** @var array alias → FQN */ + /** @var array alias → FQN (class/namespace `use` imports) */ private array $useMap = []; + /** @var array alias → FQN (`use function` imports) */ + private array $functionUseMap = []; + /** @var array alias → FQN (`use const` imports) */ + private array $constUseMap = []; /** * Push a new enclosing namespace. `$name` is the namespace string @@ -35,6 +39,8 @@ public function enterNamespace(?string $name): void // never observably differs from '' in the test suite. $this->currentNamespace = $name ?? ''; $this->useMap = []; + $this->functionUseMap = []; + $this->constUseMap = []; } /** @@ -50,8 +56,84 @@ public function indexUse(Use_ $use): void } $fqn = $u->name->toString(); $alias = $u->alias?->toString() ?? self::lastSegment($fqn); + // The class/namespace map keeps EVERY import (including function/const, + // as it always has) so class resolution and isImported() are unchanged. $this->useMap[$alias] = $fqn; + // Additionally route `use function` / `use const` into their own maps so + // function/const resolution honours PHP's separate symbol namespaces — a + // class `use App\Helper;` must never capture a `helper()` call. The item's + // own type wins when set (mixed groups), else the statement's type. + $type = $u->type !== Use_::TYPE_UNKNOWN ? $u->type : $use->type; + if ($type === Use_::TYPE_FUNCTION) { + $this->functionUseMap[$alias] = $fqn; + } elseif ($type === Use_::TYPE_CONSTANT) { + $this->constUseMap[$alias] = $fqn; + } + } + } + + /** + * Resolve a php-parser Name used as a FREE-FUNCTION callee to its FQN, + * honouring PHP's function-resolution rules: an unqualified name binds a + * `use function` import first, else the current namespace (with a global + * fallback the caller preserves by NOT rewriting when the FQN is unknown); + * a qualified name's leading segment is a NAMESPACE, resolved via the + * class/namespace `use` map — never the function-import map. + */ + public function resolveFunctionName(Name $name): string + { + return $this->resolveCallable($name->toCodeString(), $this->functionUseMap); + } + + /** + * Resolve a php-parser Name used as a CONST fetch to its FQN, the same way + * as {@see resolveFunctionName} but against the `use const` map. + */ + public function resolveConstName(Name $name): string + { + return $this->resolveCallable($name->toCodeString(), $this->constUseMap); + } + + /** + * Shared function/const resolution. `$symbolUseMap` is the `use function` + * or `use const` alias map consulted for UNqualified names only; qualified + * names resolve their leading namespace segment via the class/namespace map. + * + * @param array $symbolUseMap + */ + private function resolveCallable(string $code, array $symbolUseMap): string + { + if (str_starts_with($code, '\\')) { + return ltrim($code, '\\'); + } + if (strncasecmp($code, 'namespace\\', 10) === 0) { + $rest = substr($code, 10); + return $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $rest + : $rest; + } + if (!str_contains($code, '\\')) { + // Unqualified: a `use function` / `use const` import binds it; else the + // current namespace. (No leading backslash is added — the caller's + // in-set guard decides whether to fully-qualify, so PHP's global + // fallback survives for names the unit does not define.) + if (isset($symbolUseMap[$code])) { + return $symbolUseMap[$code]; + } + return $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $code + : $code; } + // Qualified `A\b`: the leading segment is a namespace, brought into scope + // by a class/namespace `use` (never a function/const import), else it is + // relative to the current namespace. + $first = self::firstSegment($code); + if (isset($this->useMap[$first])) { + return $this->useMap[$first] . substr($code, strlen($first)); + } + return $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $code + : $code; } /** diff --git a/test/Transpiler/Monomorphize/NamespaceContextTest.php b/test/Transpiler/Monomorphize/NamespaceContextTest.php index 41283a1..037701b 100644 --- a/test/Transpiler/Monomorphize/NamespaceContextTest.php +++ b/test/Transpiler/Monomorphize/NamespaceContextTest.php @@ -209,13 +209,115 @@ public function testIsImportedResetsWithNamespace(): void self::assertFalse($ctx->isImported('Box')); } - private static function makeUse(string $fqn, ?string $alias = null): Use_ + public function testFunctionNameUnqualifiedBindsCurrentNamespace(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + + self::assertSame('App\\helper', $ctx->resolveFunctionName(new Name('helper'))); + } + + public function testFunctionNameUnqualifiedBindsUseFunctionImport(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('Lib'); + $ctx->indexUse(self::makeUse('App\\helper', type: Use_::TYPE_FUNCTION)); + + self::assertSame('App\\helper', $ctx->resolveFunctionName(new Name('helper'))); + } + + public function testAClassImportNeverCapturesAFunctionCall(): void + { + // A class `use App\helper;` must NOT bind a `helper()` call — PHP keeps class + // and function symbol namespaces separate. Without a `use function`, the call + // binds the current namespace. + $ctx = new NamespaceContext(); + $ctx->enterNamespace('Lib'); + $ctx->indexUse(self::makeUse('App\\helper')); // class import + + self::assertSame('Lib\\helper', $ctx->resolveFunctionName(new Name('helper'))); + } + + public function testFunctionNameFullyQualifiedAndRelativeAndQualified(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexUse(self::makeUse('Vendor\\Pkg', alias: 'V')); // namespace import + + self::assertSame('Other\\f', $ctx->resolveFunctionName(new Name\FullyQualified('Other\\f'))); + self::assertSame('App\\f', $ctx->resolveFunctionName(new Name\Relative('f'))); + // Qualified: leading segment resolves via the class/namespace map, not function imports. + self::assertSame('Vendor\\Pkg\\f', $ctx->resolveFunctionName(new Name('V\\f'))); + // Qualified with no matching import falls to the current namespace. + self::assertSame('App\\Sub\\f', $ctx->resolveFunctionName(new Name('Sub\\f'))); + // A name merely STARTING with `namespace` is an ordinary qualified path, not the + // relative `namespace\` keyword — it must take the qualified route, not bind relative. + self::assertSame('App\\Namespaced\\f', $ctx->resolveFunctionName(new Name('Namespaced\\f'))); + } + + public function testConstNameUsesItsOwnImportMapSeparateFromFunctions(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('Lib'); + $ctx->indexUse(self::makeUse('App\\FACTOR', type: Use_::TYPE_CONSTANT)); + $ctx->indexUse(self::makeUse('App\\helper', type: Use_::TYPE_FUNCTION)); + + self::assertSame('App\\FACTOR', $ctx->resolveConstName(new Name('FACTOR'))); + // A `use const` does not bind a function call, and vice-versa. + self::assertSame('Lib\\FACTOR', $ctx->resolveFunctionName(new Name('FACTOR'))); + self::assertSame('Lib\\helper', $ctx->resolveConstName(new Name('helper'))); + } + + public function testMixedGroupItemTypesRouteToTheRightMaps(): void + { + // `use App\{function f, const C, D}` — item-level types partition the maps. + $ctx = new NamespaceContext(); + $ctx->enterNamespace('Lib'); + $ctx->indexUse(self::makeMultiTypedUse('App', [ + ['name' => 'App\\f', 'type' => Use_::TYPE_FUNCTION], + ['name' => 'App\\C', 'type' => Use_::TYPE_CONSTANT], + ['name' => 'App\\D', 'type' => Use_::TYPE_NORMAL], + ])); + + self::assertSame('App\\f', $ctx->resolveFunctionName(new Name('f'))); + self::assertSame('App\\C', $ctx->resolveConstName(new Name('C'))); + // The class item `D` is not a function/const import. + self::assertSame('Lib\\f2', $ctx->resolveFunctionName(new Name('f2'))); + } + + public function testFunctionAndConstMapsResetOnNamespaceChange(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('Lib'); + $ctx->indexUse(self::makeUse('App\\helper', type: Use_::TYPE_FUNCTION)); + self::assertSame('App\\helper', $ctx->resolveFunctionName(new Name('helper'))); + + $ctx->enterNamespace('Other'); + self::assertSame('Other\\helper', $ctx->resolveFunctionName(new Name('helper'))); + } + + private static function makeUse(string $fqn, ?string $alias = null, int $type = Use_::TYPE_NORMAL): Use_ { $useItem = new UseItem( new Name($fqn), $alias !== null ? new Identifier($alias) : null, ); - return new Use_([$useItem]); + return new Use_([$useItem], type: $type); + } + + /** + * A single group-style `Use_` whose items carry their own per-item type. + * + * @param list $entries + */ + private static function makeMultiTypedUse(string $prefix, array $entries): Use_ + { + $items = array_map( + static fn (array $e): UseItem => new UseItem(new Name($e['name']), null, $e['type']), + $entries, + ); + // Statement type UNKNOWN so the per-item types govern (as a mixed group parses). + return new Use_($items, type: Use_::TYPE_UNKNOWN); } /** From 83ad5294a047ba6c29280d022662a004deecf9e9 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 8 Jul 2026 06:00:24 +0000 Subject: [PATCH 64/80] feat(monomorphize): collect free function and const definitions Record the FQNs of every free function and free constant declared in the compilation unit while the definitions pass walks the ASTs. Function names are keyed case-insensitively (function and namespace names are), const names with a case-insensitive namespace but a case-sensitive short name. Class methods and class constants are different node types and are not collected. This is the membership half of re-qualifying unqualified free-function/const references in relocated generic template bodies: the Specializer will fully qualify such a reference only when its resolved target is known here, so builtins and any name the unit does not define keep PHP's global fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Registry.php | 69 +++++++++++++++++++ .../Monomorphize/RegistryCollector.php | 20 ++++++ .../RegistryCollectorFreeSymbolTest.php | 63 +++++++++++++++++ test/Transpiler/Monomorphize/RegistryTest.php | 43 ++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 test/Transpiler/Monomorphize/RegistryCollectorFreeSymbolTest.php diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 967ac74..6044658 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -58,6 +58,26 @@ final class Registry */ private array $nonGenericClassNames = []; + /** + * FQNs of free functions defined in the compilation unit, keyed by the FULLY lowercased + * FQN (PHP function names — and namespace segments — are case-insensitive). Drives the + * re-qualification of unqualified free-function calls in relocated generic template bodies: + * a call is fully-qualified only when its resolved target is known here, so builtins and any + * name the unit does not define keep PHP's global fallback. Value always true. + * + * @var array + */ + private array $functionNames = []; + + /** + * FQNs of free constants defined in the compilation unit, keyed with the namespace portion + * lowercased and the const short-name preserved (const names are case-sensitive; namespaces + * are not). Same role as {@see $functionNames} for `ConstFetch` names. Value always true. + * + * @var array + */ + private array $constNames = []; + /** @var array Keyed by full generated FQCN. */ private array $instantiations = []; @@ -203,6 +223,55 @@ public function recordNonGenericClass(string $fqn): void $this->nonGenericClassNames[$fqn] = true; } + /** + * Record a free function defined in the compilation unit. `$fqn` is namespace-normalized + * (no leading `\`). Keyed case-insensitively (function + namespace names). + */ + public function recordFunction(string $fqn): void + { + $this->functionNames[strtolower($fqn)] = true; + } + + /** Whether the unit defines a free function with this (namespace-normalized) FQN. */ + public function hasFunction(string $fqn): bool + { + // Value-check (not isset): so a mutated `recordFunction` storing a non-true value is caught. + return ($this->functionNames[strtolower($fqn)] ?? false) === true; + } + + /** + * Record a free constant defined in the compilation unit. `$fqn` is namespace-normalized + * (no leading `\`). Keyed with a case-insensitive namespace and a case-sensitive short name. + */ + public function recordConst(string $fqn): void + { + $this->constNames[self::constKey($fqn)] = true; + } + + /** Whether the unit defines a free constant with this (namespace-normalized) FQN. */ + public function hasConst(string $fqn): bool + { + // Value-check (not isset): so a mutated `recordConst` storing a non-true value is caught. + return ($this->constNames[self::constKey($fqn)] ?? false) === true; + } + + /** + * @infection-ignore-all — constKey is a symmetric key-derivation used by BOTH recordConst and + * hasConst, so any structural mutation (substr bounds, concat order/removal) transforms every + * key uniformly and preserves the exact membership + case relationships the behavioral tests + * assert (namespace-insensitive, short-name-sensitive) — the mutants are equivalent. The + * SEMANTIC contract is pinned by RegistryTest::testConstMembershipHasCaseInsensitiveNamespace… + * (the `strtolower` itself is a plain call, not mutated here). + */ + private static function constKey(string $fqn): string + { + $pos = strrpos($fqn, '\\'); + // A global const (no namespace) is keyed by its case-sensitive short name alone. + return $pos === false + ? $fqn + : strtolower(substr($fqn, 0, $pos)) . substr($fqn, $pos); + } + /** * Report a bare `new` of a generic template that supplies no type arguments and cannot pad * entirely from defaults (some parameter is required). Routes through the canonical diff --git a/src/Transpiler/Monomorphize/RegistryCollector.php b/src/Transpiler/Monomorphize/RegistryCollector.php index d613c9a..ad81069 100644 --- a/src/Transpiler/Monomorphize/RegistryCollector.php +++ b/src/Transpiler/Monomorphize/RegistryCollector.php @@ -8,6 +8,8 @@ use PhpParser\Node\Expr\New_; use PhpParser\Node\Name; use PhpParser\Node\Stmt\ClassLike; +use PhpParser\Node\Stmt\Const_; +use PhpParser\Node\Stmt\Function_; use PhpParser\Node\Stmt\Namespace_; use PhpParser\Node\Stmt\Use_; use PhpParser\NodeTraverser; @@ -109,6 +111,24 @@ public function enterNode(Node $node): null $this->ctx->indexUse($node); } + if ($this->mode !== self::MODE_INSTANTIATIONS) { + // Free functions and constants defined in the unit. Recorded so the Specializer can + // re-qualify an unqualified `helper()` / `FOO` in a relocated template body to the + // ORIGINAL namespace only when the target is known here — leaving builtins and any + // undefined name to PHP's global fallback. (Class methods are ClassMethod and class + // constants are ClassConst — different node types — so this never sees a member.) + $ns = $this->ctx->currentNamespace(); + if ($node instanceof Function_) { + $name = $node->name->toString(); + $this->registry->recordFunction($ns !== '' ? $ns . '\\' . $name : $name); + } elseif ($node instanceof Const_) { + foreach ($node->consts as $const) { + $name = $const->name->toString(); + $this->registry->recordConst($ns !== '' ? $ns . '\\' . $name : $name); + } + } + } + if ($this->mode !== self::MODE_INSTANTIATIONS && $node instanceof ClassLike && $node->name !== null) { $params = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); diff --git a/test/Transpiler/Monomorphize/RegistryCollectorFreeSymbolTest.php b/test/Transpiler/Monomorphize/RegistryCollectorFreeSymbolTest.php new file mode 100644 index 0000000..b32dee4 --- /dev/null +++ b/test/Transpiler/Monomorphize/RegistryCollectorFreeSymbolTest.php @@ -0,0 +1,63 @@ +createForHostVersion()->parse(<<<'PHP' + collectDefinitions($ast, 'test.xphp'); + + self::assertTrue($registry->hasFunction('App\\helper')); + self::assertTrue($registry->hasConst('App\\FACTOR')); + self::assertTrue($registry->hasConst('App\\SCALE'), 'every const in a multi-const declaration'); + + // A class method is not a free function; a class constant is not a free constant. + self::assertFalse($registry->hasFunction('App\\method')); + self::assertFalse($registry->hasConst('App\\NOT_FREE')); + self::assertFalse($registry->hasConst('App\\Box\\NOT_FREE')); + } + + public function testGlobalNamespaceFreeSymbolsAreCollectedWithoutPrefix(): void + { + $registry = new Registry(); + $collector = new RegistryCollector($registry); + + $ast = (new ParserFactory())->createForHostVersion()->parse(<<<'PHP' + collectDefinitions($ast, 'test.xphp'); + + self::assertTrue($registry->hasFunction('toplevel')); + self::assertTrue($registry->hasConst('TOP')); + self::assertFalse($registry->hasFunction('App\\toplevel')); + } +} diff --git a/test/Transpiler/Monomorphize/RegistryTest.php b/test/Transpiler/Monomorphize/RegistryTest.php index 28162f0..74efa0f 100644 --- a/test/Transpiler/Monomorphize/RegistryTest.php +++ b/test/Transpiler/Monomorphize/RegistryTest.php @@ -87,6 +87,49 @@ public function testDistinctTypesProduceDistinctEntries(): void } } + public function testFunctionMembershipIsCaseInsensitive(): void + { + $registry = new Registry(); + $registry->recordFunction('App\\helper'); + + // Function names AND namespace segments are case-insensitive in PHP. + self::assertTrue($registry->hasFunction('App\\helper')); + self::assertTrue($registry->hasFunction('app\\HELPER')); + self::assertFalse($registry->hasFunction('App\\other')); + self::assertFalse($registry->hasFunction('Other\\helper')); + } + + public function testConstMembershipHasCaseInsensitiveNamespaceButSensitiveShortName(): void + { + $registry = new Registry(); + $registry->recordConst('App\\FACTOR'); + + // Namespace case-insensitive, const short-name case-sensitive. + self::assertTrue($registry->hasConst('App\\FACTOR')); + self::assertTrue($registry->hasConst('app\\FACTOR')); + self::assertFalse($registry->hasConst('App\\factor')); + self::assertFalse($registry->hasConst('App\\Factor')); + } + + public function testGlobalFunctionAndConstMembership(): void + { + $registry = new Registry(); + $registry->recordFunction('helper'); + $registry->recordConst('FACTOR'); + + self::assertTrue($registry->hasFunction('HELPER')); + self::assertTrue($registry->hasConst('FACTOR')); + self::assertFalse($registry->hasConst('factor')); + } + + public function testUnrecordedFunctionAndConstAreAbsent(): void + { + $registry = new Registry(); + + self::assertFalse($registry->hasFunction('App\\strlen')); + self::assertFalse($registry->hasConst('App\\PHP_EOL')); + } + public function testRecordInstantiationRecursivelyRegistersNestedInstantiations(): void { $registry = new Registry(); From 519785ee2d0caf13e80b6e8b8709fec8e17b0ae8 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 8 Jul 2026 06:19:44 +0000 Subject: [PATCH 65/80] fix(monomorphize): re-qualify free functions and consts in specialized bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generic class body is relocated out of its origin namespace into XPHP\Generated\… when it specializes, so an unqualified free-function call (`helper()`) or const fetch (`FACTOR`) in it rebinds against the generated namespace — then PHP's global fallback — instead of the origin. When the origin defined that symbol, the specialized copy called a phantom that does not exist and fatalled at load/run (silently, under --no-check). The resolver now records each free-function callee's and const fetch's resolved FQN (honoring the file's `use function` / `use const` imports, relative and qualified spellings), and after the fixed-point loop every finalized specialization is swept: an unqualified reference is fully-qualified to that FQN only when the compilation unit defines the symbol. So an in-unit `helper()` becomes `\App\helper()`, a `use function Vendor\make` call becomes `\Vendor\make()`, and a relative or qualified spelling is pinned to the same target the template resolved — while builtins (`strlen`), magic constants (`true`/`false`/`null`), and any name the unit does not define keep the global fallback untouched. Generic functions/methods are unaffected: their specializations are appended into the origin namespace, not relocated. Pinned by execute fixtures that compile and RUN the output: a body mixing bare, qualified, const, builtin, and magic-constant references (asserting the computed result), and a cross-namespace `use function`/`use const` template. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 9 +++ src/Transpiler/Monomorphize/Specializer.php | 41 +++++++++++++ .../Monomorphize/XphpSourceParser.php | 34 +++++++++++ .../Monomorphize/FreeSymbolRequalifyTest.php | 57 +++++++++++++++++++ .../free_symbol_requalify/source/Use.xphp | 24 ++++++++ .../free_symbol_requalify/source/helpers.xphp | 12 ++++ .../free_symbol_requalify/source/sub.xphp | 10 ++++ .../free_symbol_requalify/verify/runtime.php | 22 +++++++ .../free_symbol_use_import/source/Use.xphp | 23 ++++++++ .../free_symbol_use_import/source/lib.xphp | 12 ++++ .../free_symbol_use_import/verify/runtime.php | 18 ++++++ 11 files changed, 262 insertions(+) create mode 100644 test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php create mode 100644 test/fixture/compile/free_symbol_requalify/source/Use.xphp create mode 100644 test/fixture/compile/free_symbol_requalify/source/helpers.xphp create mode 100644 test/fixture/compile/free_symbol_requalify/source/sub.xphp create mode 100644 test/fixture/compile/free_symbol_requalify/verify/runtime.php create mode 100644 test/fixture/compile/free_symbol_use_import/source/Use.xphp create mode 100644 test/fixture/compile/free_symbol_use_import/source/lib.xphp create mode 100644 test/fixture/compile/free_symbol_use_import/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 90d359a..d425d0d 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -188,6 +188,15 @@ public function compile( } } + // Phase 2.3: re-qualify free-function calls and const fetches in every finalized + // specialization. Each body was relocated out of its origin namespace into + // XPHP\Generated\…, where an unqualified `helper()` / `FOO` would otherwise rebind + // against the generated namespace and fatal. Runs after the loop so closer-supplied + // members are covered too; the guard only qualifies symbols the unit defines. + foreach ($specializedAsts as $classAst) { + Specializer::requalifyFreeSymbols($classAst, $registry); + } + // Phase 2.4: grounded closure-signature conformance. Each specialization's // `Closure(...)` target now has its type parameters substituted, and the // returned literal's types were substituted alongside it, so a mismatch diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index 1ad1d60..069962f 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -5,6 +5,7 @@ namespace XPHP\Transpiler\Monomorphize; use PhpParser\Node; +use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\NullsafeMethodCall; @@ -95,6 +96,46 @@ public function specialize(ClassLike $template, array $substitution, int $hashLe return $cloned; } + /** + * Fully-qualify unqualified free-function callees and const fetches in a relocated + * class body, keyed on the resolver-recorded ATTR_RESOLVED_FUNC_FQN / _CONST_FQN and + * gated by the unit's collected symbol sets. An already fully-qualified name is left + * alone (namespace-invariant); an unknown name is left bare (global fallback). + * + * A relocated body has moved out of its origin namespace into XPHP\Generated\…, so an + * unqualified `helper()` / `FOO` would rebind against the generated namespace (then the + * global fallback) instead of the origin. Run over EVERY finalized specialization — + * including members supplied by the covariant-upcast closer — once the fixed-point loop + * has settled. + */ + public static function requalifyFreeSymbols(ClassLike $specialized, Registry $registry): void + { + $traverser = new NodeTraverser(); + $traverser->addVisitor(new class ($registry) extends NodeVisitorAbstract { + public function __construct(private Registry $registry) + { + } + + public function leaveNode(Node $node): null + { + if ($node instanceof FuncCall && $node->name instanceof Name && !$node->name->isFullyQualified()) { + $fqn = $node->name->getAttribute(XphpSourceParser::ATTR_RESOLVED_FUNC_FQN); + if (is_string($fqn) && $this->registry->hasFunction($fqn)) { + $node->name = new FullyQualified($fqn, $node->name->getAttributes()); + } + } elseif ($node instanceof ConstFetch && !$node->name->isFullyQualified()) { + $fqn = $node->name->getAttribute(XphpSourceParser::ATTR_RESOLVED_CONST_FQN); + if (is_string($fqn) && $this->registry->hasConst($fqn)) { + $node->name = new FullyQualified($fqn, $node->name->getAttributes()); + } + } + + return null; + } + }); + $traverser->traverse([$specialized]); + } + /** * Replace each erasable generic method with its E-erased concrete form; non-erasable methods and * non-method statements pass through unchanged. diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index e8e0d0c..e3f7daf 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -73,6 +73,15 @@ final class XphpSourceParser // apply and a bare name would otherwise resolve into XPHP\Generated\...). public const ATTR_RESOLVED_FQN = 'xphp:resolvedFqn'; + // Resolved FQN for a FREE-FUNCTION callee Name / a CONST-fetch Name. Like + // ATTR_RESOLVED_FQN but for the function/const symbol namespaces (which have a + // global fallback classes lack): recorded at parse time honoring the file's + // `use function` / `use const` imports + namespace, and read by the Specializer + // to fully-qualify the reference on a relocated clone ONLY when the compilation + // unit defines that symbol — so builtins and unknown names keep the fallback. + public const ATTR_RESOLVED_FUNC_FQN = 'xphp:resolvedFuncFqn'; + public const ATTR_RESOLVED_CONST_FQN = 'xphp:resolvedConstFqn'; + // Method-scoped generics (one type-param set per method, distinct from any class-level set). public const ATTR_METHOD_GENERIC_PARAMS = 'xphp:methodGenericParams'; public const ATTR_METHOD_GENERIC_ARGS = 'xphp:methodGenericArgs'; @@ -2700,6 +2709,31 @@ public function enterNode(Node $node): null } } + // Free-function callee: record its resolved FQN (honoring `use function` + // + namespace) so the Specializer can fully-qualify it on a relocated + // clone when the unit defines it. Skip generic-turbofish calls (owned by + // GenericMethodCompiler, marked with ATTR_METHOD_GENERIC_ARGS above) and + // variable callees (`$fn()` — not a Name). + if ($node instanceof Node\Expr\FuncCall + && $node->name instanceof Name + && $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) === null + ) { + $node->name->setAttribute( + XphpSourceParser::ATTR_RESOLVED_FUNC_FQN, + $this->ctx->resolveFunctionName($node->name), + ); + } + + // Const fetch: record its resolved FQN the same way. `true`/`false`/`null` + // are ConstFetch nodes too, but their resolved FQN is never in the const + // set, so the Specializer's in-set guard leaves them untouched. + if ($node instanceof Node\Expr\ConstFetch) { + $node->name->setAttribute( + XphpSourceParser::ATTR_RESOLVED_CONST_FQN, + $this->ctx->resolveConstName($node->name), + ); + } + // Variable-turbofish call site: `$var::<...>(...)` -- nikic // parses this (after the scanner stripped `::<...>`) as // `FuncCall(name: Variable, args: [...])`. The marker's name diff --git a/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php b/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php new file mode 100644 index 0000000..5f68741 --- /dev/null +++ b/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php @@ -0,0 +1,57 @@ +registerAutoload('App\\FreeSym'); + require __DIR__ . '/../../fixture/compile/free_symbol_requalify/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testUseFunctionAndUseConstImportsBindTheImportedNamespaceAtRuntime(): void + { + // `use function Vendor\make; use const Vendor\RATE;` in an App-namespaced template: + // make(3)=6 + RATE=100 = 106. The imports do not travel with the relocated body, so the + // references are fully-qualified to Vendor's symbols — a naive `App\make` would fatal. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/free_symbol_use_import/source', + 'free-symbol-use-import', + ); + try { + $fixture->registerAutoload('App', 'Vendor'); + require __DIR__ . '/../../fixture/compile/free_symbol_use_import/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } +} diff --git a/test/fixture/compile/free_symbol_requalify/source/Use.xphp b/test/fixture/compile/free_symbol_requalify/source/Use.xphp new file mode 100644 index 0000000..fc062ad --- /dev/null +++ b/test/fixture/compile/free_symbol_requalify/source/Use.xphp @@ -0,0 +1,24 @@ + +{ + public function run(T $seed): int + { + return scale(1) // bare free function -> \App\FreeSym\scale + + BONUS // bare const -> \App\FreeSym\BONUS + + Sub\tweak(2) // qualified function -> \App\FreeSym\Sub\tweak + + strlen('ab') // builtin -> stays global + + (true ? 0 : 99); // magic const -> stays global (never rewritten) + } +} + +$box = new Box::(); +$result = $box->run(0); diff --git a/test/fixture/compile/free_symbol_requalify/source/helpers.xphp b/test/fixture/compile/free_symbol_requalify/source/helpers.xphp new file mode 100644 index 0000000..15180e8 --- /dev/null +++ b/test/fixture/compile/free_symbol_requalify/source/helpers.xphp @@ -0,0 +1,12 @@ + 10+5+3+2+0 = 20 + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + */ + +use PHPUnit\Framework\Assert; + +// Free functions/consts aren't autoloadable, so define them before Use.php runs its +// top-level instantiation (the specialized class itself autoloads via PSR-4). +require $fixture->targetDir . '/helpers.php'; +require $fixture->targetDir . '/sub.php'; +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame(20, $result); diff --git a/test/fixture/compile/free_symbol_use_import/source/Use.xphp b/test/fixture/compile/free_symbol_use_import/source/Use.xphp new file mode 100644 index 0000000..e30bf07 --- /dev/null +++ b/test/fixture/compile/free_symbol_use_import/source/Use.xphp @@ -0,0 +1,23 @@ + +{ + public function compute(T $seed): int + { + return make(3) + RATE; + } +} + +$wrap = new Wrap::(); +$computed = $wrap->compute(0); diff --git a/test/fixture/compile/free_symbol_use_import/source/lib.xphp b/test/fixture/compile/free_symbol_use_import/source/lib.xphp new file mode 100644 index 0000000..5f1f420 --- /dev/null +++ b/test/fixture/compile/free_symbol_use_import/source/lib.xphp @@ -0,0 +1,12 @@ + 106. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/lib.php'; +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame(106, $computed); From 01272b810cf63f015fe96e6de0092c5961867612 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 8 Jul 2026 10:03:24 +0000 Subject: [PATCH 66/80] fix(monomorphize): re-qualify free symbols in gap-filled and group-imported bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in the free-function/const re-qualification of relocated template bodies, both surfacing as `Call to undefined function XPHP\Generated\…()` at runtime behind a clean compile: - Covariant-upcast gap-fill appends erasable members onto concrete specs AFTER the main re-qualification sweep, so a gap-filled body that called an in-unit free function or read an in-unit const kept the bare name and fatalled when that upcast member was invoked. The sweep now also runs over each spec the gap-fill touches, before the call-site rewrite. Idempotent on members that were already fully qualified. - Group-form imports (`use function N\{a, b}`, `use N\{function a, const B}`) were never indexed into the function/const symbol maps — only single `use` statements were — so a body referencing a group-imported symbol resolved to the wrong namespace and fatalled. GroupUse is now indexed alongside Use_ in both collector visitors, routing only its function/const members and leaving class resolution untouched. Both are pinned by execute fixtures confirmed to fatal without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 15 ++-- .../Monomorphize/NamespaceContext.php | 45 +++++++++- .../Monomorphize/RegistryCollector.php | 8 +- src/Transpiler/Monomorphize/Specializer.php | 7 +- .../Monomorphize/XphpSourceParser.php | 7 +- .../Monomorphize/FreeSymbolRequalifyTest.php | 37 +++++++++ .../Monomorphize/NamespaceContextTest.php | 82 +++++++++++++++++++ .../source/AbstractColl.xphp | 21 +++++ .../source/Bag.xphp | 4 + .../source/Book.xphp | 4 + .../source/Collection.xphp | 7 ++ .../source/Couple.xphp | 9 ++ .../source/Lookup.xphp | 7 ++ .../source/Lst.xphp | 4 + .../source/Product.xphp | 4 + .../source/Tuple.xphp | 8 ++ .../source/Use.xphp | 26 ++++++ .../source/helpers.xphp | 5 ++ .../verify/runtime.php | 23 ++++++ .../source/Use.xphp | 24 ++++++ .../source/lib.xphp | 19 +++++ .../verify/runtime.php | 18 ++++ 22 files changed, 370 insertions(+), 14 deletions(-) create mode 100644 test/fixture/compile/covariant_gapfill_free_symbol/source/AbstractColl.xphp create mode 100644 test/fixture/compile/covariant_gapfill_free_symbol/source/Bag.xphp create mode 100644 test/fixture/compile/covariant_gapfill_free_symbol/source/Book.xphp create mode 100644 test/fixture/compile/covariant_gapfill_free_symbol/source/Collection.xphp create mode 100644 test/fixture/compile/covariant_gapfill_free_symbol/source/Couple.xphp create mode 100644 test/fixture/compile/covariant_gapfill_free_symbol/source/Lookup.xphp create mode 100644 test/fixture/compile/covariant_gapfill_free_symbol/source/Lst.xphp create mode 100644 test/fixture/compile/covariant_gapfill_free_symbol/source/Product.xphp create mode 100644 test/fixture/compile/covariant_gapfill_free_symbol/source/Tuple.xphp create mode 100644 test/fixture/compile/covariant_gapfill_free_symbol/source/Use.xphp create mode 100644 test/fixture/compile/covariant_gapfill_free_symbol/source/helpers.xphp create mode 100644 test/fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php create mode 100644 test/fixture/compile/free_symbol_group_use_import/source/Use.xphp create mode 100644 test/fixture/compile/free_symbol_group_use_import/source/lib.xphp create mode 100644 test/fixture/compile/free_symbol_group_use_import/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index d425d0d..15bc427 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -188,11 +188,12 @@ public function compile( } } - // Phase 2.3: re-qualify free-function calls and const fetches in every finalized - // specialization. Each body was relocated out of its origin namespace into - // XPHP\Generated\…, where an unqualified `helper()` / `FOO` would otherwise rebind - // against the generated namespace and fatal. Runs after the loop so closer-supplied - // members are covered too; the guard only qualifies symbols the unit defines. + // Phase 2.3: re-qualify free-function calls and const fetches in every specialization + // produced by the fixed-point loop. Each body was relocated out of its origin namespace + // into XPHP\Generated\…, where an unqualified `helper()` / `FOO` would otherwise rebind + // against the generated namespace and fatal. The guard only qualifies symbols the unit + // defines. (Members appended later by the covariant-upcast gap-fill are swept in Phase 3.5, + // since they don't exist yet here.) foreach ($specializedAsts as $classAst) { Specializer::requalifyFreeSymbols($classAst, $registry); } @@ -234,6 +235,10 @@ public function compile( // never emitted as a class-load fatal. Re-rewrite the specs it appended a member to so the new // member's type references are fully qualified like the rest. foreach ($closer->supplyUnmetMembers($registry, $specializedAsts) as $generatedFqn) { + // The gap-fill member's body is a relocated template body too, so re-qualify its + // free-function/const references (Phase 2.3 ran before this member existed). Idempotent + // on the spec's pre-existing members — their callees are already fully qualified. + Specializer::requalifyFreeSymbols($specializedAsts[$generatedFqn], $registry); $rewritten = $rewriter->rewrite([$specializedAsts[$generatedFqn]]); $first = $rewritten[0]; assert($first instanceof \PhpParser\Node\Stmt\ClassLike); diff --git a/src/Transpiler/Monomorphize/NamespaceContext.php b/src/Transpiler/Monomorphize/NamespaceContext.php index ed0ea59..02580a0 100644 --- a/src/Transpiler/Monomorphize/NamespaceContext.php +++ b/src/Transpiler/Monomorphize/NamespaceContext.php @@ -6,6 +6,7 @@ use PhpParser\Node\Identifier; use PhpParser\Node\Name; +use PhpParser\Node\Stmt\GroupUse; use PhpParser\Node\Stmt\Use_; use PhpParser\Node\UseItem; @@ -64,11 +65,47 @@ public function indexUse(Use_ $use): void // class `use App\Helper;` must never capture a `helper()` call. The item's // own type wins when set (mixed groups), else the statement's type. $type = $u->type !== Use_::TYPE_UNKNOWN ? $u->type : $use->type; - if ($type === Use_::TYPE_FUNCTION) { - $this->functionUseMap[$alias] = $fqn; - } elseif ($type === Use_::TYPE_CONSTANT) { - $this->constUseMap[$alias] = $fqn; + $this->routeSymbolImport($type, $alias, $fqn); + } + } + + /** + * Index a `GroupUse` (`use N\{a, function b, const C}`) into the function/const + * symbol maps. Only `use function` / `use const` members contribute a callable or + * const alias, so a class/namespace group-import routes nothing — and (unlike + * indexUse) the class/namespace useMap is left untouched, keeping class resolution + * exactly as it was before group-imported free symbols were recognised. Each member + * FQN is the group prefix joined with the member name; the alias is the member's + * `as` name, else the last segment of the member name. + */ + public function indexGroupUse(GroupUse $use): void + { + $prefix = $use->prefix->toString(); + foreach ($use->uses as $u) { + // @phpstan-ignore-next-line instanceof.alwaysTrue — mirrors indexUse's defensive guard against php-parser's PHPDoc-narrowed use-item type. + if (!$u instanceof UseItem) { + continue; } + $member = $u->name->toString(); + $fqn = $prefix . '\\' . $member; + $alias = $u->alias?->toString() ?? self::lastSegment($member); + // The item's own type wins when set (mixed `use N\{function a, const B}`), + // else the group statement's type (`use function N\{a, b}`). + $type = $u->type !== Use_::TYPE_UNKNOWN ? $u->type : $use->type; + $this->routeSymbolImport($type, $alias, $fqn); + } + } + + /** + * Route a single import into the callable/const symbol maps by its resolved type. + * A class/namespace import (any other type) contributes to neither. + */ + private function routeSymbolImport(int $type, string $alias, string $fqn): void + { + if ($type === Use_::TYPE_FUNCTION) { + $this->functionUseMap[$alias] = $fqn; + } elseif ($type === Use_::TYPE_CONSTANT) { + $this->constUseMap[$alias] = $fqn; } } diff --git a/src/Transpiler/Monomorphize/RegistryCollector.php b/src/Transpiler/Monomorphize/RegistryCollector.php index ad81069..a87b531 100644 --- a/src/Transpiler/Monomorphize/RegistryCollector.php +++ b/src/Transpiler/Monomorphize/RegistryCollector.php @@ -10,6 +10,7 @@ use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\Const_; use PhpParser\Node\Stmt\Function_; +use PhpParser\Node\Stmt\GroupUse; use PhpParser\Node\Stmt\Namespace_; use PhpParser\Node\Stmt\Use_; use PhpParser\NodeTraverser; @@ -98,10 +99,12 @@ public function enterNode(Node $node): null // never appears in any fixture, so the null-safe call is observationally // identical to the non-null version on every test input. $this->ctx->enterNamespace($node->name?->toString()); - // @infection-ignore-all — dual-handled by the standalone Use_ branch below; dead loop. + // @infection-ignore-all — dual-handled by the standalone Use_/GroupUse branches below; dead loop. foreach ($node->stmts as $inner) { if ($inner instanceof Use_) { $this->ctx->indexUse($inner); + } elseif ($inner instanceof GroupUse) { + $this->ctx->indexGroupUse($inner); } } } @@ -109,6 +112,9 @@ public function enterNode(Node $node): null if ($node instanceof Use_) { // @infection-ignore-all — dual-handled by the inner foreach above. $this->ctx->indexUse($node); + } elseif ($node instanceof GroupUse) { + // @infection-ignore-all — dual-handled by the inner foreach above. + $this->ctx->indexGroupUse($node); } if ($this->mode !== self::MODE_INSTANTIATIONS) { diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index 069962f..5b181f4 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -104,9 +104,10 @@ public function specialize(ClassLike $template, array $substitution, int $hashLe * * A relocated body has moved out of its origin namespace into XPHP\Generated\…, so an * unqualified `helper()` / `FOO` would rebind against the generated namespace (then the - * global fallback) instead of the origin. Run over EVERY finalized specialization — - * including members supplied by the covariant-upcast closer — once the fixed-point loop - * has settled. + * global fallback) instead of the origin. The Compiler runs this over every specialization + * from the fixed-point loop, and again over each spec the covariant-upcast gap-fill appends + * a member to (that member is created after the first sweep). Idempotent: an already + * fully-qualified name is skipped. */ public static function requalifyFreeSymbols(ClassLike $specialized, Registry $registry): void { diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index e3f7daf..eefa5d6 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -2495,10 +2495,12 @@ public function enterNode(Node $node): null // @infection-ignore-all — bare `namespace { ... }` (no name) isn't used in any // fixture; the null-coalesce branch never observably differs from a missing name. $this->ctx->enterNamespace($node->name?->toString()); - // @infection-ignore-all — redundant with the standalone Use_ branch below; dead loop. + // @infection-ignore-all — redundant with the standalone Use_/GroupUse branches below; dead loop. foreach ($node->stmts as $inner) { if ($inner instanceof Use_) { $this->ctx->indexUse($inner); + } elseif ($inner instanceof GroupUse) { + $this->ctx->indexGroupUse($inner); } } } @@ -2506,6 +2508,9 @@ public function enterNode(Node $node): null if ($node instanceof Use_) { // @infection-ignore-all — dual-handled by the inner foreach above. $this->ctx->indexUse($node); + } elseif ($node instanceof GroupUse) { + // @infection-ignore-all — dual-handled by the inner foreach above. + $this->ctx->indexGroupUse($node); } if ($node instanceof ClassLike && $node->name !== null) { diff --git a/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php b/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php index 5f68741..1953ce6 100644 --- a/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php +++ b/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php @@ -54,4 +54,41 @@ public function testUseFunctionAndUseConstImportsBindTheImportedNamespaceAtRunti $fixture->cleanup(); } } + + #[RunInSeparateProcess] + public function testGroupUseFunctionAndConstImportsBindTheImportedNamespaceAtRuntime(): void + { + // `use function Vendor\{make, scale}; use Vendor\{const RATE, const STEP};` in an App template: + // make(3)=6 + scale(1)=10 + RATE=100 + STEP=7 = 123. Group-form imports must reach the same + // function/const symbol maps as the single-import form, or the relocated body fatals on `make`. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/free_symbol_group_use_import/source', + 'free-symbol-group-use-import', + ); + try { + $fixture->registerAutoload('App', 'Vendor'); + require __DIR__ . '/../../fixture/compile/free_symbol_group_use_import/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testGapFilledCovariantMembersReQualifyTheirFreeSymbolsAtRuntime(): void + { + // A covariant diamond gap-fills `contains`/`indexOf` onto the concrete specs (Lst, Bag) AFTER the + // Phase 2.3 relocation sweep. Those erasable bodies call an in-unit free function `tally` and read + // an in-unit const `OFFSET`; without a re-qualify pass over the gap-filled members the bare names + // rebind to XPHP\Generated and fatal on the upcast call. contains(absent)=true, indexOf(absent)=1. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/covariant_gapfill_free_symbol/source', + 'covariant-gapfill-free-symbol', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } } diff --git a/test/Transpiler/Monomorphize/NamespaceContextTest.php b/test/Transpiler/Monomorphize/NamespaceContextTest.php index 037701b..34b1812 100644 --- a/test/Transpiler/Monomorphize/NamespaceContextTest.php +++ b/test/Transpiler/Monomorphize/NamespaceContextTest.php @@ -6,6 +6,7 @@ use PhpParser\Node\Identifier; use PhpParser\Node\Name; +use PhpParser\Node\Stmt\GroupUse; use PhpParser\Node\Stmt\Use_; use PhpParser\Node\UseItem; use PHPUnit\Framework\TestCase; @@ -296,6 +297,68 @@ public function testFunctionAndConstMapsResetOnNamespaceChange(): void self::assertSame('Other\\helper', $ctx->resolveFunctionName(new Name('helper'))); } + public function testIndexGroupUseFunctionImportsRouteToFunctionMap(): void + { + // `use function Vendor\{make, scale};` — statement-level FUNCTION type governs both members. + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexGroupUse(self::makeGroupUse('Vendor', [ + ['name' => 'make', 'alias' => null, 'type' => Use_::TYPE_UNKNOWN], + ['name' => 'scale', 'alias' => null, 'type' => Use_::TYPE_UNKNOWN], + ], Use_::TYPE_FUNCTION)); + + self::assertSame('Vendor\\make', $ctx->resolveFunctionName(new Name('make'))); + self::assertSame('Vendor\\scale', $ctx->resolveFunctionName(new Name('scale'))); + // A group `use function` must not leak into const resolution. + self::assertSame('App\\make', $ctx->resolveConstName(new Name('make'))); + } + + public function testIndexGroupUseMixedItemTypesRouteToTheRightMaps(): void + { + // `use Vendor\{function make, const RATE, Box};` — per-item types partition the maps. + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexGroupUse(self::makeGroupUse('Vendor', [ + ['name' => 'make', 'alias' => null, 'type' => Use_::TYPE_FUNCTION], + ['name' => 'RATE', 'alias' => null, 'type' => Use_::TYPE_CONSTANT], + ['name' => 'Box', 'alias' => null, 'type' => Use_::TYPE_NORMAL], + ], Use_::TYPE_UNKNOWN)); + + self::assertSame('Vendor\\make', $ctx->resolveFunctionName(new Name('make'))); + self::assertSame('Vendor\\RATE', $ctx->resolveConstName(new Name('RATE'))); + // The class item `Box` is neither a function nor a const import. + self::assertSame('App\\Box', $ctx->resolveFunctionName(new Name('Box'))); + self::assertSame('App\\Box', $ctx->resolveConstName(new Name('Box'))); + } + + public function testIndexGroupUseHonoursMemberAlias(): void + { + // `use function Vendor\{make as mk};` binds the alias, not the member name. + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexGroupUse(self::makeGroupUse('Vendor', [ + ['name' => 'make', 'alias' => 'mk', 'type' => Use_::TYPE_UNKNOWN], + ], Use_::TYPE_FUNCTION)); + + self::assertSame('Vendor\\make', $ctx->resolveFunctionName(new Name('mk'))); + // The un-aliased member name does not resolve to the import. + self::assertSame('App\\make', $ctx->resolveFunctionName(new Name('make'))); + } + + public function testIndexGroupUseClassImportPollutesNeitherSymbolMap(): void + { + // A plain class group-import `use Vendor\{Box, Crate};` contributes no callable/const alias. + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexGroupUse(self::makeGroupUse('Vendor', [ + ['name' => 'Box', 'alias' => null, 'type' => Use_::TYPE_UNKNOWN], + ['name' => 'Crate', 'alias' => null, 'type' => Use_::TYPE_UNKNOWN], + ], Use_::TYPE_NORMAL)); + + self::assertSame('App\\Box', $ctx->resolveFunctionName(new Name('Box'))); + self::assertSame('App\\Crate', $ctx->resolveConstName(new Name('Crate'))); + } + private static function makeUse(string $fqn, ?string $alias = null, int $type = Use_::TYPE_NORMAL): Use_ { $useItem = new UseItem( @@ -320,6 +383,25 @@ private static function makeMultiTypedUse(string $prefix, array $entries): Use_ return new Use_($items, type: Use_::TYPE_UNKNOWN); } + /** + * A `GroupUse` (`use PREFIX\{ ... }`). Each member name is the RELATIVE part; the + * per-item type governs a mixed group, else the statement type applies. + * + * @param list $members + */ + private static function makeGroupUse(string $prefix, array $members, int $type = Use_::TYPE_NORMAL): GroupUse + { + $items = array_map( + static fn (array $m): UseItem => new UseItem( + new Name($m['name']), + $m['alias'] !== null ? new Identifier($m['alias']) : null, + $m['type'], + ), + $members, + ); + return new GroupUse(new Name($prefix), $items, $type); + } + /** * @param list $entries */ diff --git a/test/fixture/compile/covariant_gapfill_free_symbol/source/AbstractColl.xphp b/test/fixture/compile/covariant_gapfill_free_symbol/source/AbstractColl.xphp new file mode 100644 index 0000000..fb942ea --- /dev/null +++ b/test/fixture/compile/covariant_gapfill_free_symbol/source/AbstractColl.xphp @@ -0,0 +1,21 @@ + implements Collection, Lookup +{ + /** @var list */ + protected array $items; + public function __construct(E ...$items) { $this->items = $items; } + // Erasable bodies reference an in-unit free function `tally` and an in-unit const `OFFSET`. + // After a covariant edge, `contains`/`indexOf` are gap-filled onto the concrete specs (Lst, Bag) + // in the relocated XPHP\Generated namespace; a bare `tally`/`OFFSET` would rebind there and fatal. + public function contains(S $element): bool + { + return tally(OFFSET) === 10 && \in_array($element, $this->items, true) === false; + } + public function indexOf(S $element): int + { + $i = \array_search($element, $this->items, true); + return $i === false ? -OFFSET + tally(3) : (int) $i; + } +} diff --git a/test/fixture/compile/covariant_gapfill_free_symbol/source/Bag.xphp b/test/fixture/compile/covariant_gapfill_free_symbol/source/Bag.xphp new file mode 100644 index 0000000..d184fec --- /dev/null +++ b/test/fixture/compile/covariant_gapfill_free_symbol/source/Bag.xphp @@ -0,0 +1,4 @@ + extends AbstractColl implements Collection, Lookup {} diff --git a/test/fixture/compile/covariant_gapfill_free_symbol/source/Book.xphp b/test/fixture/compile/covariant_gapfill_free_symbol/source/Book.xphp new file mode 100644 index 0000000..4c87df5 --- /dev/null +++ b/test/fixture/compile/covariant_gapfill_free_symbol/source/Book.xphp @@ -0,0 +1,4 @@ + +{ + public function contains(S $element): bool; +} diff --git a/test/fixture/compile/covariant_gapfill_free_symbol/source/Couple.xphp b/test/fixture/compile/covariant_gapfill_free_symbol/source/Couple.xphp new file mode 100644 index 0000000..3aa2790 --- /dev/null +++ b/test/fixture/compile/covariant_gapfill_free_symbol/source/Couple.xphp @@ -0,0 +1,9 @@ + implements Tuple +{ + public function __construct(private A $a, private B $b) {} + public function first(): A { return $this->a; } + public function second(): B { return $this->b; } +} diff --git a/test/fixture/compile/covariant_gapfill_free_symbol/source/Lookup.xphp b/test/fixture/compile/covariant_gapfill_free_symbol/source/Lookup.xphp new file mode 100644 index 0000000..0146582 --- /dev/null +++ b/test/fixture/compile/covariant_gapfill_free_symbol/source/Lookup.xphp @@ -0,0 +1,7 @@ + +{ + public function indexOf(S $element): int; +} diff --git a/test/fixture/compile/covariant_gapfill_free_symbol/source/Lst.xphp b/test/fixture/compile/covariant_gapfill_free_symbol/source/Lst.xphp new file mode 100644 index 0000000..0230069 --- /dev/null +++ b/test/fixture/compile/covariant_gapfill_free_symbol/source/Lst.xphp @@ -0,0 +1,4 @@ + extends AbstractColl implements Collection, Lookup {} diff --git a/test/fixture/compile/covariant_gapfill_free_symbol/source/Product.xphp b/test/fixture/compile/covariant_gapfill_free_symbol/source/Product.xphp new file mode 100644 index 0000000..1a4843f --- /dev/null +++ b/test/fixture/compile/covariant_gapfill_free_symbol/source/Product.xphp @@ -0,0 +1,4 @@ + +{ + public function first(): A; + public function second(): B; +} diff --git a/test/fixture/compile/covariant_gapfill_free_symbol/source/Use.xphp b/test/fixture/compile/covariant_gapfill_free_symbol/source/Use.xphp new file mode 100644 index 0000000..8a259f5 --- /dev/null +++ b/test/fixture/compile/covariant_gapfill_free_symbol/source/Use.xphp @@ -0,0 +1,26 @@ +>, Bag>) for the sibling element supertype +// Tuple AFTER the relocation sweep — so those bodies must still bind `tally`/`OFFSET` to App. +// The upcast calls below dispatch to exactly those gap-filled members (not the base-carried ones). +function has(Collection> $c): bool +{ + return $c->contains::>(new Couple::(new Book(), new Product())); +} +function at(Lookup> $c): int +{ + return $c->indexOf::>(new Couple::(new Book(), new Product())); +} +$lbb = new Lst::>(new Couple::(new Book(), new Book())); +$lbp = new Lst::>(new Couple::(new Book(), new Product())); +$lpb = new Lst::>(new Couple::(new Product(), new Book())); +$lpp = new Lst::>(new Couple::(new Product(), new Product())); +$bbb = new Bag::>(new Couple::(new Book(), new Book())); +$bbp = new Bag::>(new Couple::(new Book(), new Product())); +$bpb = new Bag::>(new Couple::(new Product(), new Book())); +$bpp = new Bag::>(new Couple::(new Product(), new Product())); +$found = has($lbb); +$index = at($bbb); diff --git a/test/fixture/compile/covariant_gapfill_free_symbol/source/helpers.xphp b/test/fixture/compile/covariant_gapfill_free_symbol/source/helpers.xphp new file mode 100644 index 0000000..30e477c --- /dev/null +++ b/test/fixture/compile/covariant_gapfill_free_symbol/source/helpers.xphp @@ -0,0 +1,5 @@ +targetDir . '/helpers.php'; +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($found, 'the upcast contains-call (via Lst) must bind the App free symbols and report the tuple absent'); +Assert::assertSame(1, $index, 'the upcast indexOf-call (via Bag) must compute -OFFSET + tally(3) = 1 for the absent tuple'); +echo "OK\n"; diff --git a/test/fixture/compile/free_symbol_group_use_import/source/Use.xphp b/test/fixture/compile/free_symbol_group_use_import/source/Use.xphp new file mode 100644 index 0000000..a755f5f --- /dev/null +++ b/test/fixture/compile/free_symbol_group_use_import/source/Use.xphp @@ -0,0 +1,24 @@ + +{ + public function compute(T $seed): int + { + return make(3) + scale(1) + RATE + STEP; + } +} + +$wrap = new Wrap::(); +$computed = $wrap->compute(0); diff --git a/test/fixture/compile/free_symbol_group_use_import/source/lib.xphp b/test/fixture/compile/free_symbol_group_use_import/source/lib.xphp new file mode 100644 index 0000000..073cc91 --- /dev/null +++ b/test/fixture/compile/free_symbol_group_use_import/source/lib.xphp @@ -0,0 +1,19 @@ +targetDir . '/lib.php'; +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame(123, $computed); From ab0e9ebd60d88fcc3848c36b0a15aa2ad0b709ad Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 8 Jul 2026 16:15:34 +0000 Subject: [PATCH 67/80] docs: record free-symbol re-qualification in relocated specializations Note in the runtime-semantics guide that a relocated specialization still binds the free functions and constants of its template's namespace, and add the matching changelog entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++++++++++++++ docs/guides/runtime-semantics.md | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a5d322..88a85f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,20 @@ _In progress on this branch — content still accumulating; date set at tag time ### Fixed +- **A specialized generic body keeps calling the free functions and constants it + named.** When a generic class specializes, its body is relocated into an internal + `XPHP\Generated\…` namespace. An unqualified free-function call or constant read in + that body — `helper($x)`, `FACTOR`, whether resolved through the enclosing namespace + or a `use function` / `use const` import (single **or** grouped, e.g. + `use function Lib\{make, scale}`) — used to rebind against the generated namespace + (then PHP's global fallback), silently calling the wrong symbol or fatalling at + runtime with `Call to undefined function XPHP\Generated\…\helper()` behind a clean + `compile` and `check`. Each such reference the compilation unit can resolve is now + fully-qualified to the symbol the template meant; built-in functions, magic constants, + and any name the unit does not define keep PHP's normal global resolution (functions + match case-insensitively, constants case-sensitively). The re-qualification also + covers members supplied by the covariant-upcast gap-fill, which are appended after the + main relocation pass. - **A generic clause on a `use` import is rejected instead of misfiring.** Writing a type argument on an import — `use App\Box;`, `use App\Box as B;`, `use const App\BOX;`, or the grouped `use App\{Box, Bag};` — has no diff --git a/docs/guides/runtime-semantics.md b/docs/guides/runtime-semantics.md index 8c76efc..9b3c727 100644 --- a/docs/guides/runtime-semantics.md +++ b/docs/guides/runtime-semantics.md @@ -167,6 +167,22 @@ args are spelled. Hash-collision detection at recording time fails loudly with both colliding instantiations, the current hash length, and a re-run command using a longer hash. +### Free functions and constants bind the template's namespace + +Because a specialized body moves into `XPHP\Generated\…`, an +unqualified free-function call or constant read inside it (`helper($x)`, +`FACTOR`) would, under PHP's normal rules, look in the *generated* +namespace and then fall back to global — never the template's own +namespace. The compiler prevents this: every unqualified free-function +call and constant fetch that the template's compilation unit defines +(directly, in another file of the same namespace, or via a +`use function` / `use const` import, single or grouped) is +fully-qualified to that symbol before the class is emitted. Built-in +functions, magic constants, and any name the unit does not define are +left untouched, so PHP's global resolution still applies to them. A +specialization therefore calls exactly the free functions and constants +its template did. + ## What monomorphization costs One class file per unique instantiation. A codebase instantiating From 5e50c5a3e37e23f6aecd0d17bcb0ab8959f0094c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 8 Jul 2026 17:05:08 +0000 Subject: [PATCH 68/80] fix(monomorphize): reject turbofish on a dynamically-named call instead of dropping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turbofish on a dynamically-named method or static call — `$o->$m::()`, its nullsafe `$o?->$m::()` and variable-variable `$$g::()` and static-dynamic `Foo::$m::()` siblings — recorded a `variableTurbofish` marker that no AST node could ever bind (those parse to a MethodCall/StaticCall or a dynamic-name FuncCall, never the `FuncCall(name: Variable)` the marker matches). The marker was silently discarded while the `::<…>` clause had already been stripped, leaving a bare dynamic call against a method that now exists only in its specialized `_T_` form — a runtime fatal behind a clean compile and check. Such a call is unmonomorphizable: the method name is a runtime value. The scanner now rejects it with a clear parse-stage diagnostic at the receiver's real line (collected by check, thrown by compile) rather than dropping the marker. The reject fires only when the variable is immediately preceded by `->`/`?->`/`::`/`$` — a standalone `$f::<…>()` (the generic-closure call shape) is untouched, so that keeps working. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 31 +++++++++++++++ .../Monomorphize/CheckPassIntegrationTest.php | 39 +++++++++++++++++++ .../source/Use.xphp | 10 +++++ .../source/Use.xphp | 10 +++++ .../dynamic_turbofish_static/source/Use.xphp | 10 +++++ .../dynamic_turbofish_varvar/source/Use.xphp | 10 +++++ 6 files changed, 110 insertions(+) create mode 100644 test/fixture/check/dynamic_turbofish_instance/source/Use.xphp create mode 100644 test/fixture/check/dynamic_turbofish_nullsafe/source/Use.xphp create mode 100644 test/fixture/check/dynamic_turbofish_static/source/Use.xphp create mode 100644 test/fixture/check/dynamic_turbofish_varvar/source/Use.xphp diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index eefa5d6..dfa70de 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -438,6 +438,37 @@ private function scanAndStrip(string $source): array } if ($parsed !== null) { [$args, $endIdx] = $parsed; + // A `variableTurbofish` marker only ever binds a standalone + // `$f::<…>(…)` call (nikic parses that as FuncCall(name: Variable)). + // When this T_VARIABLE is instead a DYNAMIC MEMBER NAME (`$o->$m`, + // `$o?->$m`, `Foo::$m`) or a VARIABLE-VARIABLE (`$$g`), the node is a + // MethodCall/StaticCall/dynamic-name FuncCall the marker can never + // bind — so it was silently dropped while the `::<…>` was stripped, + // leaving a bare dynamic call against a method that now exists only + // in its `_T_` form: a runtime fatal behind a clean gate. + // The name is a runtime value, so the turbofish is unmonomorphizable; + // reject it loudly (at its real line) instead. A standalone receiver + // (any other preceding token) is untouched — closure turbofish works. + $prevIdx = self::skipWsBack($tokens, $i - 1); + // @infection-ignore-all `>= 0` vs `> 0` is equivalent: index 0 is always the + // T_OPEN_TAG, which is never one of the reject tokens below, so the branch + // body can't fire when $prevIdx === 0 regardless. The guard only avoids a + // $tokens[-1] access when skipWsBack walks off the front. + if ($prevIdx >= 0) { + $prev = $tokens[$prevIdx]; + if ($prev->id === T_OBJECT_OPERATOR + || $prev->id === T_NULLSAFE_OBJECT_OPERATOR + || $prev->id === T_DOUBLE_COLON + || $prev->text === '$' + ) { + throw new XphpParseException( + 'A turbofish (`::<…>`) on a dynamically-named method or static ' + . 'call cannot be monomorphized; the method name must be a ' + . 'literal identifier, not a variable', + $tok->line, + ); + } + } $varName = substr($tok->text, 1); // strip the leading `$` $nameMarkers[] = [ 'line' => $tok->line, diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index a424d3e..9b8bdf2 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -6,6 +6,7 @@ use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard as StandardPrinter; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use RuntimeException; use XPHP\Diagnostics\DiagnosticCollector; @@ -679,6 +680,44 @@ public function testCompileStillThrowsOnVarianceOnMethod(): void $this->compileFixture('parse_line_variance_method'); } + /** + * A turbofish on a DYNAMICALLY-named method/static call (`$o->$m::()`, its nullsafe and + * variable-variable and static-dynamic siblings) cannot be monomorphized — the name is a runtime + * value. Each used to silently drop the marker and strip the clause, leaving a bare dynamic call + * against a method that now exists only in its `_T_` form (runtime fatal behind a clean + * gate). Each now draws ONE parse-stage diagnostic at the receiver's real line (9 in each fixture). + * + * @return iterable + */ + public static function dynamicTurbofishFixtures(): iterable + { + yield 'dynamic instance name' => ['dynamic_turbofish_instance']; + yield 'nullsafe dynamic name' => ['dynamic_turbofish_nullsafe']; + yield 'variable-variable' => ['dynamic_turbofish_varvar']; + yield 'static dynamic name' => ['dynamic_turbofish_static']; + } + + #[DataProvider('dynamicTurbofishFixtures')] + public function testDynamicNameTurbofishIsRejectedWithRealLine(string $fixture): void + { + $diagnostics = $this->check($fixture); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); + self::assertStringContainsString('dynamically-named method', $d->message); + self::assertStringContainsString('must be a literal identifier, not a variable', $d->message); + self::assertNotNull($d->location); + self::assertSame(9, $d->location->line); + } + + public function testCompileStillThrowsOnDynamicNameTurbofish(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('dynamically-named method'); + $this->compileFixture('dynamic_turbofish_instance'); + } + private function compileFixture(string $fixture): void { $work = sys_get_temp_dir() . '/xphp-check-compile-' . uniqid('', true); diff --git a/test/fixture/check/dynamic_turbofish_instance/source/Use.xphp b/test/fixture/check/dynamic_turbofish_instance/source/Use.xphp new file mode 100644 index 0000000..e12045c --- /dev/null +++ b/test/fixture/check/dynamic_turbofish_instance/source/Use.xphp @@ -0,0 +1,10 @@ +$m::(4); +} diff --git a/test/fixture/check/dynamic_turbofish_nullsafe/source/Use.xphp b/test/fixture/check/dynamic_turbofish_nullsafe/source/Use.xphp new file mode 100644 index 0000000..086d927 --- /dev/null +++ b/test/fixture/check/dynamic_turbofish_nullsafe/source/Use.xphp @@ -0,0 +1,10 @@ +$m::(4); +} diff --git a/test/fixture/check/dynamic_turbofish_static/source/Use.xphp b/test/fixture/check/dynamic_turbofish_static/source/Use.xphp new file mode 100644 index 0000000..de2be77 --- /dev/null +++ b/test/fixture/check/dynamic_turbofish_static/source/Use.xphp @@ -0,0 +1,10 @@ +(4); +} diff --git a/test/fixture/check/dynamic_turbofish_varvar/source/Use.xphp b/test/fixture/check/dynamic_turbofish_varvar/source/Use.xphp new file mode 100644 index 0000000..14ebc23 --- /dev/null +++ b/test/fixture/check/dynamic_turbofish_varvar/source/Use.xphp @@ -0,0 +1,10 @@ +(4); +} From e22cf58ae9fa0aa56733ab512ece2bd3190a10ec Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 8 Jul 2026 17:06:18 +0000 Subject: [PATCH 69/80] feat(monomorphize): support keyword-named generic methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP permits every semi-reserved keyword (`list`, `print`, …) as a method name, but a generic method whose name was a keyword could neither be declared nor statically called. The declaration scanner required a T_STRING name, so `public function list` left its `` clause to reach php-parser as a raw parse error; and the static call-site scanner rejected a keyword name after `::`, so `Foo::list::()` parse-errored too. (The instance call `$o->list::()` already worked, because PHP re-tokenizes `list` as T_STRING after `->`.) Both gates now accept a keyword whose text is a valid PHP label: the declaration gate records the generic-method marker, and a dedicated static-call branch strips the `Recv::keyword::<…>` turbofish into a `named` marker the resolver's StaticCall arm already binds. The static branch is kept separate from the name-token gate so keyword tokens never reach that gate's bare-`<`, closure-signature, or array-sugar sub-branches — `list($a, $b) = …` destructuring and keyword-named non-generic methods pass through untouched. A keyword-named generic method now declares, specializes, and runs through both call sites. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 72 ++++++++++++++++++- .../KeywordNamedGenericMethodTest.php | 57 +++++++++++++++ .../source/Use.xphp | 28 ++++++++ .../verify/runtime.php | 18 +++++ .../source/Use.xphp | 33 +++++++++ .../verify/runtime.php | 19 +++++ 6 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 test/Transpiler/Monomorphize/KeywordNamedGenericMethodTest.php create mode 100644 test/fixture/compile/keyword_named_generic_method/source/Use.xphp create mode 100644 test/fixture/compile/keyword_named_generic_method/verify/runtime.php create mode 100644 test/fixture/compile/keyword_nongeneric_passthrough/source/Use.xphp create mode 100644 test/fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index dfa70de..24bea01 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -332,7 +332,10 @@ private function scanAndStrip(string $source): array if ($tok->id === T_FUNCTION) { $j = self::skipWs($tokens, $i + 1); - if ($j < $n && $tokens[$j]->id === T_STRING) { + // The method name is a real name (T_STRING) OR a semi-reserved keyword PHP + // permits as a method name (`function list`) — the keyword id would otherwise + // fail the gate, leaving the `` clause to reach php-parser as a raw error. + if ($j < $n && ($tokens[$j]->id === T_STRING || self::isSemiReservedName($tokens[$j]))) { $methodName = $tokens[$j]->text; $methodLine = $tokens[$j]->line; $methodAnchorByte = $tokens[$j]->pos; @@ -490,6 +493,56 @@ private function scanAndStrip(string $source): array continue; } + // Keyword-named STATIC method turbofish: `Recv::list::<…>()`. A keyword method + // name stays a keyword token after `::` (unlike after `->`, where PHP re-tokenizes + // it to T_STRING so the name-token branch below already handles the instance form), + // so the name-token gate misses it and the `::<…>` clause used to reach php-parser + // raw. Match exactly this shape — a semi-reserved keyword, preceded by `::`, followed + // by its own `::<…>` — and record a plain `named` marker; the resolver's StaticCall + // arm binds it like any other. Kept as a dedicated branch (not folded into the + // name-token gate) so keyword tokens never reach that gate's bare-`<`, closure- + // signature, or array-sugar sub-branches, where they would mis-parse constructs like + // `list($a, $b) = …`. + if (self::isSemiReservedName($tok)) { + $prevSig = self::skipWsBack($tokens, $i - 1); + if ($prevSig >= 0 && $tokens[$prevSig]->id === T_DOUBLE_COLON) { + $j = self::skipWs($tokens, $i + 1); + if ($j < $n && $tokens[$j]->id === T_DOUBLE_COLON) { + $dcTok = $tokens[$j]; + $afterDc = $j + 1; + $isEmptyTurbofish = $afterDc < $n + && $tokens[$afterDc]->id === T_IS_NOT_EQUAL + && $tokens[$afterDc]->pos === $dcTok->pos + 2; + $parsed = null; + if ($isEmptyTurbofish) { + $parsed = [[], $afterDc]; + } elseif ($afterDc < $n + && $tokens[$afterDc]->text === '<' + && $tokens[$afterDc]->pos === $dcTok->pos + 2 + ) { + $parsed = self::parseTypeArgList($tokens, $afterDc); + } + if ($parsed !== null) { + [$args, $endIdx] = $parsed; + $nameMarkers[] = [ + 'line' => $tok->line, + 'anchorLine' => self::memberAccessReceiverLine($tokens, $i) ?? $tok->line, + 'name' => self::markerNameSpelling($tok->text), + 'kind' => 'named', + 'bytePosition' => $tok->pos, + 'args' => $args, + ]; + $startByte = $dcTok->pos; + $endByte = $tokens[$endIdx]->pos + strlen($tokens[$endIdx]->text); + $length = $endByte - $startByte; + $replacements[] = [$startByte, $length, self::blank(substr($source, $startByte, $length))]; + $i = $endIdx + 1; + continue; + } + } + } + } + // `static` is a PHP keyword (T_STATIC), not a name token, but the // RFC treats `static` (and the sibling `self` / `parent` // pseudo-types) as valid type-hint positions. `self` / `parent` are @@ -2389,6 +2442,23 @@ private static function isNameToken(PhpToken $tok): bool || $tok->id === T_NAME_RELATIVE; } + /** + * A semi-reserved keyword usable as a method NAME. PHP has permitted every keyword + * (`list`, `print`, `for`, `default`, …) as a method name since 7.0, so a keyword-named + * generic method — `public function list(…)` and its static call `Recv::list::<…>()` — + * must be recognized where `isNameToken` (which covers only real name tokens) would miss it. + * Deliberately excludes name tokens (handled by the isNameToken paths) and variables, and + * tests the token's TEXT against PHP's label grammar so punctuation (`(`, `&`, `<`) and + * `$vars` never qualify. The instance call `$o->list::<…>()` needs no help — PHP re-tokenizes + * `list` as T_STRING after `->`; only the after-`::` and declaration positions keep the keyword id. + */ + private static function isSemiReservedName(PhpToken $tok): bool + { + return !self::isNameToken($tok) + && $tok->id !== T_VARIABLE + && preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $tok->text) === 1; + } + /** * `self` / `static` / `parent` are PHP keywords that resolve dynamically * at runtime against the currently-executing class. xphp's scanner sees diff --git a/test/Transpiler/Monomorphize/KeywordNamedGenericMethodTest.php b/test/Transpiler/Monomorphize/KeywordNamedGenericMethodTest.php new file mode 100644 index 0000000..793b2e4 --- /dev/null +++ b/test/Transpiler/Monomorphize/KeywordNamedGenericMethodTest.php @@ -0,0 +1,57 @@ +` clause after a keyword name was never stripped and reached php-parser as a raw parse + * error; and the static call-site scanner rejected a keyword name after `::`, so `Reg::print::()` + * parse-errored too (the instance form `$r->list::()` already worked, because `list` + * re-tokenizes as `T_STRING` after `->`). Both gates now accept keyword names. This test compiles AND + * executes the output to prove the specialized keyword-named methods are declared and callable. + */ +final class KeywordNamedGenericMethodTest extends TestCase +{ + #[RunInSeparateProcess] + public function testKeywordNamedGenericMethodSpecializesAndRunsViaBothCallSites(): void + { + // Instance `$r->list::(41)` returns 41; static `Reg::print::(7)` returns 7. A gate + // that still rejected the keyword name would parse-error at compile, not reach this assertion. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/keyword_named_generic_method/source', + 'keyword-named-generic-method', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/keyword_named_generic_method/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testKeywordNamedNonGenericMethodsAndListDestructuringPassThroughUnchanged(): void + { + // Must-keep: the keyword-turbofish support fires only for `keyword::<…>` after `::`, so an + // ordinary keyword-named non-generic method (`R::print(20)`, `$r->list(10)`) and a `list()` + // destructuring must be untouched — never routed into the closure-signature / array-sugar + // sub-branches, which would throw or mis-parse. list(10)+1=11, print(20)+2=22, unpack=7. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/keyword_nongeneric_passthrough/source', + 'keyword-nongeneric-passthrough', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } +} diff --git a/test/fixture/compile/keyword_named_generic_method/source/Use.xphp b/test/fixture/compile/keyword_named_generic_method/source/Use.xphp new file mode 100644 index 0000000..fcb6940 --- /dev/null +++ b/test/fixture/compile/keyword_named_generic_method/source/Use.xphp @@ -0,0 +1,28 @@ +` reached php-parser) AND both call sites must strip + specialize: +// - instance `$r->list::()` — `list` re-tokenizes as T_STRING after `->` (already worked); +// - static `Reg::print::()` — `print` stays T_PRINT after `::`, the case the call-site gate +// rejected, leaving a raw parse error until this WI. +class Reg +{ + public function list(T $x): T + { + return $x; + } + + public static function print(T $x): T + { + return $x; + } +} + +$r = new Reg(); +$instanceResult = $r->list::(41); +$staticResult = Reg::print::(7); diff --git a/test/fixture/compile/keyword_named_generic_method/verify/runtime.php b/test/fixture/compile/keyword_named_generic_method/verify/runtime.php new file mode 100644 index 0000000..4028497 --- /dev/null +++ b/test/fixture/compile/keyword_named_generic_method/verify/runtime.php @@ -0,0 +1,18 @@ +(41)` returns 41; static `print::(7)` returns 7. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame(41, $instanceResult); +Assert::assertSame(7, $staticResult); diff --git a/test/fixture/compile/keyword_nongeneric_passthrough/source/Use.xphp b/test/fixture/compile/keyword_nongeneric_passthrough/source/Use.xphp new file mode 100644 index 0000000..5d38abb --- /dev/null +++ b/test/fixture/compile/keyword_nongeneric_passthrough/source/Use.xphp @@ -0,0 +1,33 @@ +` after `::`, so it must never route these ordinary +// keyword uses into the scanner's bare-`<`, closure-signature, or array-sugar sub-branches. +class R +{ + public function list(int $z): int + { + return $z + 1; + } + + public static function print(int $z): int + { + return $z + 2; + } +} + +function unpack(array $a): int +{ + list($x, $y) = $a; + return $x + $y; +} + +$r = new R(); +$instance = $r->list(10); +$static = R::print(20); +$destructured = unpack([3, 4]); diff --git a/test/fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php b/test/fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php new file mode 100644 index 0000000..3d9a8cb --- /dev/null +++ b/test/fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php @@ -0,0 +1,19 @@ +targetDir . '/Use.php'; + +Assert::assertSame(11, $instance); +Assert::assertSame(22, $static); +Assert::assertSame(7, $destructured); From cecd0cac1010224466f201a32e92eaa1bf055c82 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 8 Jul 2026 17:38:05 +0000 Subject: [PATCH 70/80] refactor(monomorphize): keep the dynamic-name reject discriminator mutation-visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dynamically-named-turbofish reject carried a defensive `$prevIdx >= 0` bounds guard annotated `@infection-ignore-all`. skipWsBack can never actually walk off the front here (index 0 is always the open tag, never whitespace, and never a reject token), so the guard is dead — but a block-scoped ignore risked masking mutants on the reject discriminator itself. Fold the bounds check into a `$tokens[$idx] ?? null` lookup and drop the annotation, so every clause of the `->`/`?->`/`::`/`$` discriminator stays exposed to mutation testing (each is killed by its own reject fixture). Behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 24bea01..c729cb4 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -452,25 +452,24 @@ private function scanAndStrip(string $source): array // The name is a runtime value, so the turbofish is unmonomorphizable; // reject it loudly (at its real line) instead. A standalone receiver // (any other preceding token) is untouched — closure turbofish works. - $prevIdx = self::skipWsBack($tokens, $i - 1); - // @infection-ignore-all `>= 0` vs `> 0` is equivalent: index 0 is always the - // T_OPEN_TAG, which is never one of the reject tokens below, so the branch - // body can't fire when $prevIdx === 0 regardless. The guard only avoids a - // $tokens[-1] access when skipWsBack walks off the front. - if ($prevIdx >= 0) { - $prev = $tokens[$prevIdx]; - if ($prev->id === T_OBJECT_OPERATOR + // Defensive bounds only: skipWsBack can't actually walk off the front here + // (index 0 is always the T_OPEN_TAG, never whitespace), and even if it did, + // T_OPEN_TAG is none of the reject tokens — so this guard never changes the + // outcome and is left un-annotated, keeping the reject discriminator below + // fully exposed to mutation testing (each clause is killed by its own fixture). + $prev = $tokens[self::skipWsBack($tokens, $i - 1)] ?? null; + if ($prev !== null + && ($prev->id === T_OBJECT_OPERATOR || $prev->id === T_NULLSAFE_OBJECT_OPERATOR || $prev->id === T_DOUBLE_COLON - || $prev->text === '$' - ) { - throw new XphpParseException( - 'A turbofish (`::<…>`) on a dynamically-named method or static ' - . 'call cannot be monomorphized; the method name must be a ' - . 'literal identifier, not a variable', - $tok->line, - ); - } + || $prev->text === '$') + ) { + throw new XphpParseException( + 'A turbofish (`::<…>`) on a dynamically-named method or static ' + . 'call cannot be monomorphized; the method name must be a ' + . 'literal identifier, not a variable', + $tok->line, + ); } $varName = substr($tok->text, 1); // strip the leading `$` $nameMarkers[] = [ From 11ec5aaf7efa8c3ce8e73f92ddfd014279da3c76 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 8 Jul 2026 17:45:10 +0000 Subject: [PATCH 71/80] docs: record dynamic-name turbofish rejection and keyword-named generic methods Add changelog entries and a turbofish rule: a turbofish on a dynamically-named method or static call is rejected (the specialized name can't come from a runtime value), while a generic method may be named with a PHP keyword and called through both the instance and static turbofish. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 18 ++++++++++++++++++ docs/syntax/turbofish.md | 9 +++++++++ 2 files changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a85f6..8af8b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,24 @@ _In progress on this branch — content still accumulating; date set at tag time ### Fixed +- **A turbofish on a dynamically-named call is rejected instead of silently dropped.** + A type-argument turbofish on a method or static call whose name is a runtime value — + `$o->$m::()`, the nullsafe `$o?->$m::()`, the variable-variable + `$$g::()`, or the static `Foo::$m::()` — used to be silently discarded (the + marker bound no AST node and the `::<…>` clause was stripped anyway), leaving a bare + dynamic call against a method that only exists in its specialized `_T_` form: a + runtime fatal behind a clean `compile` and `check`. Such a call cannot be + monomorphized — the method name is not known until runtime — so it now draws a clear + diagnostic at its real line (collected by `check`, thrown by `compile`). A turbofish on + a standalone variable holding a generic closure (`$f::()`) is unaffected. +- **A generic method may be named with a PHP keyword.** PHP permits every keyword + (`list`, `print`, …) as a method name, but a generic one could not be used: the + declaration `public function list(…)` reached php-parser as a raw parse error, and + the static call `Foo::list::()` did too (the instance call `$o->list::()` + happened to work, because PHP re-tokenizes the name after `->`). Keyword-named generic + methods now declare, specialize, and are callable through both the instance and static + turbofish. Keyword-named non-generic methods and `list(...)` destructuring are + unchanged. - **A specialized generic body keeps calling the free functions and constants it named.** When a generic class specializes, its body is relocated into an internal `XPHP\Generated\…` namespace. An unqualified free-function call or constant read in diff --git a/docs/syntax/turbofish.md b/docs/syntax/turbofish.md index b7b3c46..36f4b6b 100644 --- a/docs/syntax/turbofish.md +++ b/docs/syntax/turbofish.md @@ -70,6 +70,15 @@ $id('T_', 42); scanner requires `::<` to be adjacent. - **Anchored to a name**: `Foo(...)` (no `::`) is a PHP-side ambiguity (`Foo < T` could be comparison) and is rejected. +- **The method name must be a literal.** A turbofish on a + *dynamically-named* method or static call — `$o->$m::()`, the + nullsafe `$o?->$m::()`, the static `Foo::$m::()`, or a + variable-variable `$$g::()` — is **rejected** with a diagnostic: + the specialized method name cannot be resolved from a value known only + at runtime. The name may be any literal identifier, **including a PHP + keyword** — `$o->list::()`, `Foo::print::()`, and the + matching declaration `public function list(...)` all work, since PHP + permits keywords as method names. - All call-site shapes can carry empty turbofish `::<>` when the template is all-defaulted. - Bare `new Foo;` (no `(` or `::<>`) also works for all-defaulted From c4b3169094402a505b5727eee1a70c151fd0ea78 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 10 Jul 2026 17:30:29 +0000 Subject: [PATCH 72/80] fix(monomorphize): index group-imported class members into the use-map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generic class body is relocated out of its origin namespace into XPHP\Generated\…, so every class reference inside it is re-qualified against the enclosing use-map. `indexUse` stored each single import there, but `indexGroupUse` never did — so a group-imported class (`use Vendor\{Tool};`) referenced in a relocated body fell back to the current namespace (`\App\Tool`), compiled and linted clean, then fataled at runtime with "Class App\Tool not found". `indexGroupUse` now populates the class/namespace use-map for every group member, mirroring `indexUse`, so a group-imported class re-qualifies to its import target exactly like its single-import form. `use function` / `use const` members continue to route additionally into their own symbol maps. Covered by a compile-and-execute fixture (a group import and a single import side by side in one relocated Box body) pinned against the pre-fix fatal, plus NamespaceContext unit tests asserting the group class import lands in the class map (and honours an alias) without polluting the function/const maps. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/NamespaceContext.php | 18 +++++--- .../GroupImportClassRequalifyTest.php | 37 ++++++++++++++++ .../Monomorphize/NamespaceContextTest.php | 43 ++++++++++++++++++- .../source/Use.xphp | 22 ++++++++++ .../source/lib.xphp | 21 +++++++++ .../verify/runtime.php | 22 ++++++++++ 6 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 test/Transpiler/Monomorphize/GroupImportClassRequalifyTest.php create mode 100644 test/fixture/compile/group_import_class_requalify/source/Use.xphp create mode 100644 test/fixture/compile/group_import_class_requalify/source/lib.xphp create mode 100644 test/fixture/compile/group_import_class_requalify/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/NamespaceContext.php b/src/Transpiler/Monomorphize/NamespaceContext.php index 02580a0..0d6f34d 100644 --- a/src/Transpiler/Monomorphize/NamespaceContext.php +++ b/src/Transpiler/Monomorphize/NamespaceContext.php @@ -70,13 +70,14 @@ public function indexUse(Use_ $use): void } /** - * Index a `GroupUse` (`use N\{a, function b, const C}`) into the function/const - * symbol maps. Only `use function` / `use const` members contribute a callable or - * const alias, so a class/namespace group-import routes nothing — and (unlike - * indexUse) the class/namespace useMap is left untouched, keeping class resolution - * exactly as it was before group-imported free symbols were recognised. Each member - * FQN is the group prefix joined with the member name; the alias is the member's - * `as` name, else the last segment of the member name. + * Index a `GroupUse` (`use N\{a, function b, const C}`). Each member is stored in the + * class/namespace useMap (as {@see indexUse} does for every single import), so a + * group-imported CLASS or namespace resolves identically to its single-import form — + * a relocated generic body that references it re-qualifies to the import target instead + * of falling back to the current namespace (and fatalling). `use function` / `use const` + * members are ADDITIONALLY routed into their own symbol maps, honouring PHP's separate + * symbol namespaces. Each member FQN is the group prefix joined with the member name; + * the alias is the member's `as` name, else the last segment of the member name. */ public function indexGroupUse(GroupUse $use): void { @@ -89,6 +90,9 @@ public function indexGroupUse(GroupUse $use): void $member = $u->name->toString(); $fqn = $prefix . '\\' . $member; $alias = $u->alias?->toString() ?? self::lastSegment($member); + // The class/namespace map keeps EVERY member, exactly as indexUse does for a + // single import — this is what a relocated body's class-name resolution reads. + $this->useMap[$alias] = $fqn; // The item's own type wins when set (mixed `use N\{function a, const B}`), // else the group statement's type (`use function N\{a, b}`). $type = $u->type !== Use_::TYPE_UNKNOWN ? $u->type : $use->type; diff --git a/test/Transpiler/Monomorphize/GroupImportClassRequalifyTest.php b/test/Transpiler/Monomorphize/GroupImportClassRequalifyTest.php new file mode 100644 index 0000000..9925dd0 --- /dev/null +++ b/test/Transpiler/Monomorphize/GroupImportClassRequalifyTest.php @@ -0,0 +1,37 @@ +::run() calls a group-imported Tool::ping() ('pong') and a single-imported + // Widget::spin() ('spin') -> 'pongspin'. A group form that fell back to \App\Tool would fatal. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/group_import_class_requalify/source', + 'group-import-class-requalify', + ); + try { + $fixture->registerAutoload('App', 'Vendor'); + require __DIR__ . '/../../fixture/compile/group_import_class_requalify/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } +} diff --git a/test/Transpiler/Monomorphize/NamespaceContextTest.php b/test/Transpiler/Monomorphize/NamespaceContextTest.php index 34b1812..850b9a7 100644 --- a/test/Transpiler/Monomorphize/NamespaceContextTest.php +++ b/test/Transpiler/Monomorphize/NamespaceContextTest.php @@ -345,9 +345,12 @@ public function testIndexGroupUseHonoursMemberAlias(): void self::assertSame('App\\make', $ctx->resolveFunctionName(new Name('make'))); } - public function testIndexGroupUseClassImportPollutesNeitherSymbolMap(): void + public function testIndexGroupUseClassImportRoutesToClassMapNotSymbolMaps(): void { - // A plain class group-import `use Vendor\{Box, Crate};` contributes no callable/const alias. + // A plain class group-import `use Vendor\{Box, Crate};` is stored in the class/namespace + // useMap (so a relocated body re-qualifies it to the import target, exactly like a single + // `use Vendor\Box;`), but contributes NO callable/const alias — the separate-symbol-namespace + // invariant still holds. $ctx = new NamespaceContext(); $ctx->enterNamespace('App'); $ctx->indexGroupUse(self::makeGroupUse('Vendor', [ @@ -355,10 +358,46 @@ public function testIndexGroupUseClassImportPollutesNeitherSymbolMap(): void ['name' => 'Crate', 'alias' => null, 'type' => Use_::TYPE_UNKNOWN], ], Use_::TYPE_NORMAL)); + // Class map: the import target, and counts as imported. + self::assertSame('Vendor\\Box', $ctx->resolveName(new Name('Box'))); + self::assertSame('Vendor\\Crate', $ctx->resolveName(new Name('Crate'))); + self::assertTrue($ctx->isImported('Box')); + self::assertTrue($ctx->isImported('Crate')); + // Symbol maps: untouched — an unqualified function/const name still falls to the current ns. self::assertSame('App\\Box', $ctx->resolveFunctionName(new Name('Box'))); self::assertSame('App\\Crate', $ctx->resolveConstName(new Name('Crate'))); } + public function testIndexGroupUseClassImportHonoursAliasInClassMap(): void + { + // `use Vendor\{Tool as T};` — the alias resolves to the import target in the class map. + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexGroupUse(self::makeGroupUse('Vendor', [ + ['name' => 'Tool', 'alias' => 'T', 'type' => Use_::TYPE_UNKNOWN], + ], Use_::TYPE_NORMAL)); + + self::assertSame('Vendor\\Tool', $ctx->resolveName(new Name('T'))); + self::assertTrue($ctx->isImported('T')); + // The un-aliased member name is NOT imported. + self::assertSame('App\\Tool', $ctx->resolveName(new Name('Tool'))); + self::assertFalse($ctx->isImported('Tool')); + } + + public function testIndexGroupUseNamespaceImportResolvesQualifiedNames(): void + { + // `use Vendor\{Sub};` then `Sub\Thing` resolves through the class/namespace map — the case a + // class-only split would have missed. + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexGroupUse(self::makeGroupUse('Vendor', [ + ['name' => 'Sub', 'alias' => null, 'type' => Use_::TYPE_UNKNOWN], + ], Use_::TYPE_NORMAL)); + + self::assertSame('Vendor\\Sub\\Thing', $ctx->resolveName(new Name('Sub\\Thing'))); + self::assertTrue($ctx->isImported('Sub')); + } + private static function makeUse(string $fqn, ?string $alias = null, int $type = Use_::TYPE_NORMAL): Use_ { $useItem = new UseItem( diff --git a/test/fixture/compile/group_import_class_requalify/source/Use.xphp b/test/fixture/compile/group_import_class_requalify/source/Use.xphp new file mode 100644 index 0000000..a160adf --- /dev/null +++ b/test/fixture/compile/group_import_class_requalify/source/Use.xphp @@ -0,0 +1,22 @@ + relocates into XPHP\Generated\App\Box, out of App's namespace + use scope. Both the +// group-imported Tool and the single-imported Widget must re-qualify to \Vendor\… in the relocated +// body — before the fix, the group form emitted \App\Tool::ping() and fataled ("Class App\Tool"). +class Box +{ + public function run(): string + { + return Tool::ping() . Widget::spin(); + } +} + +$b = new Box::(); +$result = $b->run(); diff --git a/test/fixture/compile/group_import_class_requalify/source/lib.xphp b/test/fixture/compile/group_import_class_requalify/source/lib.xphp new file mode 100644 index 0000000..9b21dfc --- /dev/null +++ b/test/fixture/compile/group_import_class_requalify/source/lib.xphp @@ -0,0 +1,21 @@ + body. Tool::ping()='pong' . Widget::spin()='spin' -> 'pongspin'. A group form + * that fell back to the current namespace would fatal with "Class App\Tool not found". + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +// Vendor's two classes share one emitted lib.php (not one-class-per-file), so PSR-4 can't autoload +// them — require it before Use.php runs its top-level instantiation. The specialized Box autoloads +// via XPHP\Generated. +require $fixture->targetDir . '/lib.php'; +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame('pongspin', $result); From 495a78e8caabe5485aa69f7b9b5aee48990d2765 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 10 Jul 2026 17:34:30 +0000 Subject: [PATCH 73/80] docs: changelog entry for group-imported class re-qualification Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af8b99..01fcc1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -215,6 +215,16 @@ _In progress on this branch — content still accumulating; date set at tag time match case-insensitively, constants case-sensitively). The re-qualification also covers members supplied by the covariant-upcast gap-fill, which are appended after the main relocation pass. +- **A specialized generic body keeps resolving the classes it group-imported.** The + relocation into `XPHP\Generated\…` also re-qualifies class references against the + unit's imports, but a class brought in through a grouped import — `use Vendor\{Tool};`, + or a mixed `use Vendor\{Tool, function make};` — was not recorded in the class + import map (only single `use Vendor\Tool;` imports were), so a reference to it in the + relocated body fell back to the generated namespace (`\App\Tool`) and fatalled at class + load with `Class "App\Tool" not found` behind a clean `compile` and `check`. A + group-imported class now re-qualifies to its import target exactly like its + single-import form; `use function` / `use const` group members keep resolving through + their own symbol namespaces. - **A generic clause on a `use` import is rejected instead of misfiring.** Writing a type argument on an import — `use App\Box;`, `use App\Box as B;`, `use const App\BOX;`, or the grouped `use App\{Box, Bag};` — has no From f3cb8802c432af37c927e4437cc0c146feeb551f Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 10 Jul 2026 17:55:26 +0000 Subject: [PATCH 74/80] fix(monomorphize): specialize generic-trait adaptation operands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generic trait used with an `insteadof` / `as` adaptation block specialized its `use`-list names (`use A, B`) to their `\XPHP\Generated\…` form but left the adaptation operand names bare (`A::m insteadof B; B::m as bm;`). The bare operands resolved to the now-removed template (`App\A`) and fataled at class load with "Trait App\A not found" behind a clean compile and check. The resolver visitor now, on entering each class-like body, builds a class-scoped map of every generic trait-use list entry (keyed on resolved template FQN) and rewrites each adaptation operand that names a generic trait to the same specialization as its list entry — so the adapted class loads and runs. The map is class-scoped, not per-`use`-statement, because an adaptation may reference a trait brought in by a different `use` of the same class (`use A; use B { A::m insteadof B; }`). Operands are matched by resolved FQN, so a qualified or aliased spelling still binds its bare list entry, and the two records dedupe to one generated trait. A non-generic trait operand (or one the class does not use generically) is left exactly as written — it is a real, still-existing trait. A bare operand that matches two different specializations of one trait (`use A, A { A::m insteadof B; }`) cannot be disambiguated in an adaptation clause and draws a clear diagnostic (collected by check, thrown by compile) instead of silently picking one. Covered by compile-and-execute fixtures — a cross-`use`-statement insteadof/as pair and a mixed generic/plain block with a two-trait insteadof — both pinned against the pre-fix class-load fatal, plus a collected/thrown ambiguity reject. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 159 ++++++++++++++++++ .../Monomorphize/CheckPassIntegrationTest.php | 23 +++ .../GenericTraitAdaptationTest.php | 54 ++++++ .../source/Use.xphp | 15 ++ .../generic_trait_adaptation/source/Use.xphp | 53 ++++++ .../verify/runtime.php | 24 +++ .../source/Use.xphp | 53 ++++++ .../verify/runtime.php | 22 +++ 8 files changed, 403 insertions(+) create mode 100644 test/Transpiler/Monomorphize/GenericTraitAdaptationTest.php create mode 100644 test/fixture/check/generic_trait_ambiguous_operand/source/Use.xphp create mode 100644 test/fixture/compile/generic_trait_adaptation/source/Use.xphp create mode 100644 test/fixture/compile/generic_trait_adaptation/verify/runtime.php create mode 100644 test/fixture/compile/generic_trait_adaptation_mixed/source/Use.xphp create mode 100644 test/fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index c729cb4..b35aa2d 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -2673,6 +2673,16 @@ public function enterNode(Node $node): null } } + if ($node instanceof ClassLike) { + // Rewrite generic-trait adaptation operands (`insteadof` / `as`) + // to the same specialized FQN as their `use`-list entry. Runs + // after the namespace/use context is set (Namespace_/Use_ branches + // above) and this class's type params are pushed, so the list + // entry's args resolve exactly as the blanket Name branch that + // specializes the list itself. + $this->markTraitUseAdaptations($node); + } + if ($node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_ || $node instanceof Node\Expr\Closure @@ -3138,6 +3148,155 @@ private function markName(Name $node): void } } + /** + * Rewrite the trait operands of every `insteadof` / `as` adaptation in + * this class to the specialized generated FQN of their `use`-list entry, + * so `use A, B { A::m insteadof B; B::m as bm; }` loads instead + * of fataling on the removed template `App\A`. The list entries specialize + * through the blanket Name branch (they carry a `<…>` marker); the operand + * Names carry none, so without this pass they emit bare and resolve to the + * stripped template. + * + * CLASS-SCOPED, not per-`use`-statement: PHP lets an adaptation name a + * trait brought in by a DIFFERENT `use` of the same class + * (`use A; use B { A::m insteadof B; }` — operand `A` is not in + * the `B` statement's trait list), so the operand→specialization map is + * built from the generic list entries of ALL `Stmt\TraitUse` in the body, + * keyed on resolved template FQN. + * + * Only operands matching a GENERIC list entry are rewritten; a plain + * (non-generic) trait, or an operand naming a trait this class does not use + * generically, is left exactly as written — it is a real trait that still + * exists (the over-qualification lesson: never mis-qualify a live name). An + * operand whose short name maps to more than one distinct specialization in + * the class cannot be disambiguated in an adaptation clause, so it is a + * loud compile error rather than an arbitrary pick. + */ + private function markTraitUseAdaptations(ClassLike $node): void + { + /** @var array> $genericArgs template FQN => resolved list-entry args (first seen) */ + $genericArgs = []; + /** @var array $firstKey template FQN => canonical arg key first seen */ + $firstKey = []; + /** @var array $ambiguous template FQN => a second, differently-specialized use appeared */ + $ambiguous = []; + foreach ($node->stmts as $stmt) { + if (!$stmt instanceof Node\Stmt\TraitUse) { + continue; + } + foreach ($stmt->traits as $traitName) { + $args = $this->peekGenericArgs($traitName); + if ($args === null) { + // A plain (non-generic) trait use — its operands stay bare. + continue; + } + $fqn = $this->resolveNameOnly($traitName->toCodeString()); + $key = $this->argsKey($args); + if (!isset($genericArgs[$fqn])) { + $genericArgs[$fqn] = $args; + $firstKey[$fqn] = $key; + } elseif ($firstKey[$fqn] !== $key) { + $ambiguous[$fqn] = true; + } + } + } + if ($genericArgs === []) { + return; + } + foreach ($node->stmts as $stmt) { + if (!$stmt instanceof Node\Stmt\TraitUse) { + continue; + } + foreach ($stmt->adaptations as $adaptation) { + if ($adaptation instanceof Node\Stmt\TraitUseAdaptation\Precedence) { + // `A::m insteadof B, C;` — the winning trait (`->trait`) and + // every excluded trait (`->insteadof`, a Name[]) are operands. + $this->markTraitOperand($adaptation->trait, $genericArgs, $ambiguous); + foreach ($adaptation->insteadof as $excluded) { + $this->markTraitOperand($excluded, $genericArgs, $ambiguous); + } + } elseif ($adaptation instanceof Node\Stmt\TraitUseAdaptation\Alias) { + // `B::m as bm;` — only `->trait` is a trait name; the + // `newModifier` / `newName` subnodes stay untouched. A bare + // `m as bm;` (no source trait) has a null `->trait`. + $this->markTraitOperand($adaptation->trait, $genericArgs, $ambiguous); + } + } + } + } + + /** + * Tag one adaptation operand with the generic attributes of its matching + * list entry so CallSiteRewriter rewrites it to the specialization's FQN + * (identical to the list entry — `recordInstantiation` dedupes to one + * generated trait). A null operand (`m as bm;` with no source trait) or one + * matching no generic list entry is left untouched; an ambiguous match is a + * loud error. + * + * @param array> $genericArgs + * @param array $ambiguous + */ + private function markTraitOperand(?Name $operand, array $genericArgs, array $ambiguous): void + { + if ($operand === null) { + return; + } + $fqn = $this->resolveNameOnly($operand->toCodeString()); + if (isset($ambiguous[$fqn])) { + throw new XphpParseException(sprintf( + 'Ambiguous generic trait operand `%s` in an adaptation clause: ' + . 'this class uses more than one specialization of that trait, and ' + . 'an `insteadof` / `as` clause cannot say which one is meant. Give ' + . 'the conflicting traits distinct names to disambiguate.', + $operand->toCodeString(), + ), $operand->getStartLine()); + } + if (!isset($genericArgs[$fqn])) { + // Not a generic trait this class uses — a real, still-existing trait + // (or an unrelated operand). Leave it exactly as written. + return; + } + $operand->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, $genericArgs[$fqn]); + $operand->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, $fqn); + } + + /** + * Peek (WITHOUT consuming) the resolved generic args recorded for a + * trait-use LIST entry Name. Returns null when the trait carries no `<…>` + * marker (a plain, non-generic trait use). Mirrors the byte+name match of + * the blanket Name branch but leaves the marker in place — that branch + * still consumes it when the traversal reaches the list entry. + * + * @return list|null + */ + private function peekGenericArgs(Name $traitName): ?array + { + $byte = $this->originalByteOf($traitName); + $nameStr = $traitName->toCodeString(); + foreach ($this->nameMarkers as $marker) { + if ($marker['bytePosition'] === $byte && $marker['name'] === $nameStr) { + return $this->resolveTypeRefList($marker['args']); + } + } + return null; + } + + /** + * Canonical, order-sensitive key for a resolved arg list — the same + * per-arg canonical form the registry hashes on. Used to detect when one + * trait short-name is used with two DIFFERENT specializations in the same + * class (`use A, A`), which a bare operand cannot pick between. + * + * @param list $args + */ + private function argsKey(array $args): string + { + return implode(',', array_map( + static fn (TypeRef $a): string => $a->canonical(), + $args, + )); + } + /** * A bare class/interface Name is fully-qualifiable only when it is not * already absolute, carries no generic args (those are rewritten by the diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 9b8bdf2..149a109 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -718,6 +718,29 @@ public function testCompileStillThrowsOnDynamicNameTurbofish(): void $this->compileFixture('dynamic_turbofish_instance'); } + public function testAmbiguousGenericTraitOperandIsCollectedByCheck(): void + { + // `use A, A, B { A::m insteadof B; }` — the bare operand `A` + // matches two different specializations of `A`, which an `insteadof` clause + // cannot disambiguate. Rewriting to an arbitrary one would silently pick a + // trait; instead it draws ONE parse-stage diagnostic at the operand's line. + $diagnostics = $this->check('generic_trait_ambiguous_operand'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); + self::assertStringContainsString('Ambiguous generic trait operand `A`', $d->message); + self::assertNotNull($d->location); + self::assertSame(13, $d->location->line); + } + + public function testCompileStillThrowsOnAmbiguousGenericTraitOperand(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Ambiguous generic trait operand'); + $this->compileFixture('generic_trait_ambiguous_operand'); + } + private function compileFixture(string $fixture): void { $work = sys_get_temp_dir() . '/xphp-check-compile-' . uniqid('', true); diff --git a/test/Transpiler/Monomorphize/GenericTraitAdaptationTest.php b/test/Transpiler/Monomorphize/GenericTraitAdaptationTest.php new file mode 100644 index 0000000..7c158d0 --- /dev/null +++ b/test/Transpiler/Monomorphize/GenericTraitAdaptationTest.php @@ -0,0 +1,54 @@ +registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/generic_trait_adaptation/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testMixedGenericAndPlainTraitAdaptationResolvesAtRuntime(): void + { + // A plain trait mixed with two generic traits, both generics excluded in one + // `insteadof`. The plain operand must stay bare (a real trait); the generic + // operands rewrite to their specializations. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_trait_adaptation_mixed/source', + 'generic-trait-adaptation-mixed', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } +} diff --git a/test/fixture/check/generic_trait_ambiguous_operand/source/Use.xphp b/test/fixture/check/generic_trait_ambiguous_operand/source/Use.xphp new file mode 100644 index 0000000..045ea51 --- /dev/null +++ b/test/fixture/check/generic_trait_ambiguous_operand/source/Use.xphp @@ -0,0 +1,15 @@ + { public function m(T $x): T { return $x; } } +trait B { public function m(T $x): T { return $x; } } + +class C +{ + use A, A, B { + A::m insteadof B; + } +} diff --git a/test/fixture/compile/generic_trait_adaptation/source/Use.xphp b/test/fixture/compile/generic_trait_adaptation/source/Use.xphp new file mode 100644 index 0000000..f7e7cbf --- /dev/null +++ b/test/fixture/compile/generic_trait_adaptation/source/Use.xphp @@ -0,0 +1,53 @@ + +// and used in SEPARATE `use` statements. The conflict is resolved in the second +// statement's adaptation block, whose `insteadof` / `as` operands name a trait +// from BOTH statements -- so the operand->specialization map must be class-scoped, +// not per-`use`-node. Every operand (`A`, `B`) is a generic trait that specializes +// out of `App\...` into `XPHP\Generated\...`; leaving an operand bare fatals at +// class load with "Trait App\A not found". +trait A +{ + public function pick(T $x): string + { + return 'A:' . $x; + } + + public function aOnly(T $x): T + { + return $x; + } +} + +trait B +{ + public function pick(T $x): string + { + return 'B:' . $x; + } + + public function bOnly(T $x): T + { + return $x; + } +} + +class C +{ + use A; + use B { + A::pick insteadof B; + B::pick as bpick; + } +} + +$c = new C(); +$pick = $c->pick(5); +$bpick = $c->bpick(6); +$aOnly = $c->aOnly(3); +$bOnly = $c->bOnly(4); diff --git a/test/fixture/compile/generic_trait_adaptation/verify/runtime.php b/test/fixture/compile/generic_trait_adaptation/verify/runtime.php new file mode 100644 index 0000000..6a2e7e4 --- /dev/null +++ b/test/fixture/compile/generic_trait_adaptation/verify/runtime.php @@ -0,0 +1,24 @@ +targetDir . '/Use.php'; + +Assert::assertSame('A:5', $pick); // insteadof: A's pick wins +Assert::assertSame('B:6', $bpick); // as: B's pick reachable under the alias +Assert::assertSame(3, $aOnly); // A's distinct method +Assert::assertSame(4, $bOnly); // B's distinct method diff --git a/test/fixture/compile/generic_trait_adaptation_mixed/source/Use.xphp b/test/fixture/compile/generic_trait_adaptation_mixed/source/Use.xphp new file mode 100644 index 0000000..4469716 --- /dev/null +++ b/test/fixture/compile/generic_trait_adaptation_mixed/source/Use.xphp @@ -0,0 +1,53 @@ + +{ + public function val(T $x): string + { + return 'G:' . $x; + } +} + +trait H +{ + public function val(T $x): string + { + return 'H:' . $x; + } +} + +trait Plain +{ + public function val(int $x): string + { + return 'P:' . $x; + } + + public function plainOnly(): string + { + return 'plain'; + } +} + +class C +{ + use G, H, Plain { + Plain::val insteadof G, H; + G::val as gval; + H::val as hval; + } +} + +$c = new C(); +$val = $c->val(1); +$gval = $c->gval(2); +$hval = $c->hval(3); +$plainOnly = $c->plainOnly(); diff --git a/test/fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php b/test/fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php new file mode 100644 index 0000000..2580c51 --- /dev/null +++ b/test/fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php @@ -0,0 +1,22 @@ +targetDir . '/Use.php'; + +Assert::assertSame('P:1', $val); // insteadof: Plain's val wins over G, H +Assert::assertSame('G:2', $gval); // as: excluded generic G::val under alias +Assert::assertSame('H:3', $hval); // as: excluded generic H::val under alias +Assert::assertSame('plain', $plainOnly); // Plain's distinct method From d79bbf2dd0f99673718a3ca9e5734799ffcd7d77 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 10 Jul 2026 18:05:07 +0000 Subject: [PATCH 75/80] refactor(monomorphize): make the ambiguous-trait-operand remedy actionable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rejection pointed users at "give the conflicting traits distinct names", but an `insteadof` / `as` clause renames methods, not traits — there is no trait-level rename to make. Reword the remedy to the real recourse (use a single specialization of the trait in an adapted class) and pin the corrected phrasing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/XphpSourceParser.php | 9 +++++---- .../Transpiler/Monomorphize/CheckPassIntegrationTest.php | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index b35aa2d..fcad6cf 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -3244,10 +3244,11 @@ private function markTraitOperand(?Name $operand, array $genericArgs, array $amb $fqn = $this->resolveNameOnly($operand->toCodeString()); if (isset($ambiguous[$fqn])) { throw new XphpParseException(sprintf( - 'Ambiguous generic trait operand `%s` in an adaptation clause: ' - . 'this class uses more than one specialization of that trait, and ' - . 'an `insteadof` / `as` clause cannot say which one is meant. Give ' - . 'the conflicting traits distinct names to disambiguate.', + 'Ambiguous generic trait operand `%s` in an adaptation clause: this ' + . 'class uses more than one specialization of that trait, and an ' + . '`insteadof` / `as` clause names a trait, not a specialization, so ' + . 'it cannot say which one is meant. Use a single specialization of ' + . 'that trait in an adapted class.', $operand->toCodeString(), ), $operand->getStartLine()); } diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 149a109..068ca08 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -730,6 +730,8 @@ public function testAmbiguousGenericTraitOperandIsCollectedByCheck(): void $d = $diagnostics->all()[0]; self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); self::assertStringContainsString('Ambiguous generic trait operand `A`', $d->message); + // The remedy names an actionable recourse (a trait-level rename is NOT possible). + self::assertStringContainsString('Use a single specialization of that trait', $d->message); self::assertNotNull($d->location); self::assertSame(13, $d->location->line); } From 68cf60740508265165546f93669f7a84ad15b0f6 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 10 Jul 2026 18:05:11 +0000 Subject: [PATCH 76/80] docs: record generic-trait adaptation-operand specialization Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 +++++++++++++ docs/syntax/classes-and-interfaces.md | 8 ++++++++ 2 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01fcc1d..59d31f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -238,6 +238,19 @@ _In progress on this branch — content still accumulating; date set at tag time (`use App\Box;`) and apply the type arguments at the use site. A generic **trait-use** (`class C { use Holder; }`) is unaffected — it still specializes the trait. +- **A generic trait used with an `insteadof` / `as` adaptation loads instead of + fataling.** A generic trait combined with an adaptation block — `use A, B + { A::m insteadof B; B::m as bm; }` — specialized the `use`-**list** names to their + `\XPHP\Generated\…` form but left the `insteadof` / `as` **operand** names bare, so + they resolved to the now-removed template (`App\A`) and fataled at class load + (`Trait "App\A" not found`) behind a clean `compile` and `check`. Each adaptation + operand that names a generic trait the class uses is now rewritten to the **same** + specialization as its list entry, including operands that reference a trait brought + in by a different `use` statement of the same class. A non-generic trait operand (or + one the class does not use generically) stays untouched — it is a real trait. A bare + operand that matches two different specializations of one trait (`use A, + A { A::m insteadof B; }`) cannot be disambiguated in an adaptation clause and + now draws a clear diagnostic instead of silently picking one. - **`check` reports parse-stage rejections at their real source line.** Syntax the scanner rejects before the AST exists — a variance marker on a method or closure (`out T` / `in T`), the legacy `+T` / `-T` glyphs, a malformed or diff --git a/docs/syntax/classes-and-interfaces.md b/docs/syntax/classes-and-interfaces.md index 3417c01..3c2997a 100644 --- a/docs/syntax/classes-and-interfaces.md +++ b/docs/syntax/classes-and-interfaces.md @@ -73,6 +73,13 @@ implementation detail. - **Traits don't get markers** — `instanceof SomeTrait` doesn't work in PHP, so generic traits are dropped from the emit after specialization. +- **Generic traits work with `insteadof` / `as`** — a conflict + resolution block (`use A, B { A::m insteadof B; B::m as + bm; }`) rewrites its operand names to the same specializations as + the `use` list, so the adapted class loads and runs. A bare operand + that matches two different specializations of one trait (`use + A, A`) can't be disambiguated in an adaptation clause + and is rejected. - **Variance edges** — when `out T` or `in T` is declared, specializations get real `extends` chains. See [variance](variance.md). @@ -81,5 +88,6 @@ implementation detail. - Test fixture: `test/fixture/compile/box_generic/` - Test fixture: `test/fixture/compile/generic_interface/` - Test fixture: `test/fixture/compile/nested_instantiation/` +- Test fixture: `test/fixture/compile/generic_trait_adaptation/` - Related: [methods and functions](methods-and-functions.md), [turbofish](turbofish.md) From 6f1bccae47ced261a9a8bdace4552c8c8b85ce02 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 10 Jul 2026 22:18:05 +0000 Subject: [PATCH 77/80] test: replace weak substring assertions with exact whole-value checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Across the diagnostic, parser, closure-conformance, variance, registry, renderer, and static-analysis suites, ~90 `assertStringContainsString` substring checks are upgraded to exact assertions: full-message `assertSame` on diagnostic/exception text (sourced from the production message builders), whole-string `assertSame` on deterministic renderer/config output and on the byte-length-preserving closure-signature erasure, and structured field assertions (violation `->kind`, generated-FQN prefixes) where a typed accessor exists. Several tests that fished multiple substrings (plus a companion `assertStringNotContainsString`) out of one value collapse into a single exact assertion that subsumes both the positive and negative coverage. Genuinely variable haystacks — PHPStan's own output, CommandTester display chrome, and hash-bearing emitted PHP already pinned by snapshots — are left as substring/anchored checks, where an exact match would couple to third-party or non-deterministic formatting. No production code changes. Full suite green (1276), PHPStan L9 clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Console/BinAutoloadBootstrapTest.php | 6 +- test/Console/CheckCommandPhpStanTest.php | 6 +- test/Diagnostics/Renderer/RendererTest.php | 64 ++++++------- .../PhpStanOutputParserTest.php | 3 +- .../PhpStanRunnerConfigTest.php | 40 +++++++-- .../StaticAnalysis/StaticAnalysisGateTest.php | 6 +- .../Monomorphize/CheckPassIntegrationTest.php | 89 ++++++++++++++----- .../ClosureConformanceValidatorTest.php | 39 ++++---- .../ClosureSignatureConformanceTest.php | 24 +++-- .../ClosureSignatureParseTest.php | 36 +++----- .../EnclosingParamBoundIntegrationTest.php | 12 ++- .../EnclosingParamBoundLimitationTest.php | 40 +++++++-- .../RegistryBoundsDiagnosticTest.php | 12 ++- .../RegistryInnerVarianceTest.php | 5 +- test/Transpiler/Monomorphize/RegistryTest.php | 14 ++- .../RegistryVarianceEdgeDiagnosticTest.php | 55 ++++++++++-- .../UseImportGenericClauseTest.php | 20 ++++- .../VariancePositionPhaseTest.php | 81 +++++++++-------- .../Monomorphize/VisitorGuardsTest.php | 5 +- .../Monomorphize/XphpSourceParserTest.php | 42 +++++---- .../verify/runtime.php | 6 +- 21 files changed, 397 insertions(+), 208 deletions(-) diff --git a/test/Console/BinAutoloadBootstrapTest.php b/test/Console/BinAutoloadBootstrapTest.php index c935031..8133f34 100644 --- a/test/Console/BinAutoloadBootstrapTest.php +++ b/test/Console/BinAutoloadBootstrapTest.php @@ -75,8 +75,10 @@ public function testFailsGracefullyWhenNoAutoloaderFound(): void self::assertSame(1, $result['exit']); // The diagnostic must go to STDERR (separate pipe), not STDOUT. - self::assertStringContainsString('could not locate', $result['stderr']); - self::assertStringContainsString('composer install', $result['stderr']); + self::assertSame( + "xphp: could not locate Composer's autoloader. Run `composer install`." . PHP_EOL, + $result['stderr'], + ); } /** diff --git a/test/Console/CheckCommandPhpStanTest.php b/test/Console/CheckCommandPhpStanTest.php index 645ac77..ef76173 100644 --- a/test/Console/CheckCommandPhpStanTest.php +++ b/test/Console/CheckCommandPhpStanTest.php @@ -119,7 +119,11 @@ public function testMissingBinaryWarnsButDoesNotFailTheGate(): void ]); self::assertSame(0, $exit); // a missing optional tool is a Warning, not a failure - self::assertStringContainsString('PHPStan was not found', $tester->getDisplay()); + self::assertStringContainsString( + 'PHPStan was not found (looked for --phpstan-bin, then vendor/bin/phpstan, then $PATH); ' + . 'skipping static analysis. Pass --no-phpstan to silence this.', + $tester->getDisplay(), + ); } public function testExplicitConfigIsPassedThroughToPhpStan(): void diff --git a/test/Diagnostics/Renderer/RendererTest.php b/test/Diagnostics/Renderer/RendererTest.php index e64f2c6..735f4c4 100644 --- a/test/Diagnostics/Renderer/RendererTest.php +++ b/test/Diagnostics/Renderer/RendererTest.php @@ -55,41 +55,45 @@ public function testTextRender(): void public function testTextOmitsColumnWhenAbsent(): void { $d = [new Diagnostic(Severity::Error, 'c', 'm', new SourceLocation('/a.xphp', 4))]; - self::assertStringContainsString('at /a.xphp:4 [c]', (new TextRenderer())->render($d)); + self::assertSame( + 'error: m' . PHP_EOL . ' at /a.xphp:4 [c]' . PHP_EOL, + (new TextRenderer())->render($d), + ); } public function testJsonContract(): void { $out = (new JsonRenderer())->render($this->sample()); - // Pin the raw formatting: pretty-printed (space after key) and slashes unescaped. - self::assertStringContainsString('"file": "/src/Box.xphp"', $out); - /** @var array{diagnostics: list>} $decoded */ - $decoded = json_decode($out, true, flags: JSON_THROW_ON_ERROR); - - self::assertSame([ - 'diagnostics' => [ - [ - 'severity' => 'error', - 'code' => 'xphp.bound_violation', - 'message' => 'bad bound', - 'source' => 'xphp', - 'triggeredBy' => null, - 'file' => '/src/Box.xphp', - 'line' => 7, - 'column' => 3, - ], - [ - 'severity' => 'warning', - 'code' => 'phpstan.return', - 'message' => 'maybe', - 'source' => 'phpstan', - 'triggeredBy' => 'App\\Box', - 'file' => null, - 'line' => null, - 'column' => null, - ], - ], - ], $decoded); + // Pin the whole raw formatting: pretty-printed (space after key), slashes + // unescaped, and a trailing newline. + $expected = <<<'JSON' + { + "diagnostics": [ + { + "severity": "error", + "code": "xphp.bound_violation", + "message": "bad bound", + "source": "xphp", + "triggeredBy": null, + "file": "/src/Box.xphp", + "line": 7, + "column": 3 + }, + { + "severity": "warning", + "code": "phpstan.return", + "message": "maybe", + "source": "phpstan", + "triggeredBy": "App\\Box", + "file": null, + "line": null, + "column": null + } + ] + } + JSON . PHP_EOL; + + self::assertSame($expected, $out); } public function testJsonEmpty(): void diff --git a/test/StaticAnalysis/PhpStanOutputParserTest.php b/test/StaticAnalysis/PhpStanOutputParserTest.php index 75f77e1..2f282e2 100644 --- a/test/StaticAnalysis/PhpStanOutputParserTest.php +++ b/test/StaticAnalysis/PhpStanOutputParserTest.php @@ -155,8 +155,7 @@ public function testTopLevelErrorsWithNoFileFindingsIsAFailedRun(): void $result = PhpStanOutputParser::parse($json, ''); self::assertFalse($result->ranOk); - self::assertStringContainsString('Ignored error pattern was not matched', $result->errorOutput ?? ''); - self::assertStringContainsString('Another general error', $result->errorOutput ?? ''); + self::assertSame("Ignored error pattern was not matched\nAnother general error", $result->errorOutput); } public function testFileFindingsWinOverTopLevelErrors(): void diff --git a/test/StaticAnalysis/PhpStanRunnerConfigTest.php b/test/StaticAnalysis/PhpStanRunnerConfigTest.php index 7fb70a7..cf2c6f9 100644 --- a/test/StaticAnalysis/PhpStanRunnerConfigTest.php +++ b/test/StaticAnalysis/PhpStanRunnerConfigTest.php @@ -19,20 +19,30 @@ public function testWithConsumerConfigIncludesItAndOmitsLevel(): void '/project/phpstan.neon', ); - self::assertStringContainsString("includes:\n - \"/project/phpstan.neon\"", $config); - self::assertStringContainsString('parameters:', $config); - self::assertStringContainsString(" scanDirectories:\n - \"/abs/dist\"\n - \"/abs/Generated\"", $config); // Level is inherited from the consumer config, so it must NOT be set here. - self::assertStringNotContainsString('level:', $config); + self::assertSame( + "includes:\n" + . " - \"/project/phpstan.neon\"\n" + . "parameters:\n" + . " scanDirectories:\n" + . " - \"/abs/dist\"\n" + . " - \"/abs/Generated\"\n", + $config, + ); } public function testWithoutConsumerConfigSetsDefaultLevelAndNoIncludes(): void { $config = PhpStanRunner::buildConfig(['/abs/dist'], null); - self::assertStringNotContainsString('includes:', $config); - self::assertStringContainsString(' level: 5', $config); - self::assertStringContainsString(" scanDirectories:\n - \"/abs/dist\"", $config); + // No consumer config, so a default level is set and there are no includes. + self::assertSame( + "parameters:\n" + . " level: 5\n" + . " scanDirectories:\n" + . " - \"/abs/dist\"\n", + $config, + ); } public function testPathsAreNeonQuotedAndEscaped(): void @@ -40,7 +50,13 @@ public function testPathsAreNeonQuotedAndEscaped(): void $config = PhpStanRunner::buildConfig(['/weird/pa"th\\x'], null); // Double quote and backslash must be escaped inside the NEON double-quoted string. - self::assertStringContainsString('- "/weird/pa\\"th\\\\x"', $config); + self::assertSame( + "parameters:\n" + . " level: 5\n" + . " scanDirectories:\n" + . " - \"/weird/pa\\\"th\\\\x\"\n", + $config, + ); } public function testConfigEndsWithNewline(): void @@ -71,6 +87,12 @@ public function testPathsWithSpacesAreNeonQuoted(): void { $config = PhpStanRunner::buildConfig(['/has a space/dist'], null); - self::assertStringContainsString('- "/has a space/dist"', $config); + self::assertSame( + "parameters:\n" + . " level: 5\n" + . " scanDirectories:\n" + . " - \"/has a space/dist\"\n", + $config, + ); } } diff --git a/test/StaticAnalysis/StaticAnalysisGateTest.php b/test/StaticAnalysis/StaticAnalysisGateTest.php index 85f3e2d..117a2d6 100644 --- a/test/StaticAnalysis/StaticAnalysisGateTest.php +++ b/test/StaticAnalysis/StaticAnalysisGateTest.php @@ -93,7 +93,11 @@ public function testMissingBinaryYieldsNonFailingWarning(): void self::assertSame(StaticAnalysisGate::CODE_UNAVAILABLE, $diagnostics[0]->code); self::assertSame(Severity::Warning, $diagnostics[0]->severity); self::assertFalse($diagnostics[0]->severity->isFailing()); - self::assertStringContainsString('PHPStan was not found', $diagnostics[0]->message); + self::assertSame( + 'PHPStan was not found (looked for --phpstan-bin, then vendor/bin/phpstan, then $PATH); ' + . 'skipping static analysis. Pass --no-phpstan to silence this.', + $diagnostics[0]->message, + ); } public function testFailedRunYieldsNonFailingWarning(): void diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 068ca08..6e83529 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -75,8 +75,14 @@ public function testVarianceEdgeUnprovableIsReportedAsNonFailingWarning(): void self::assertSame(\XPHP\Diagnostics\Severity::Warning, $d->severity); self::assertNotNull($d->location); self::assertStringEndsWith('Use.xphp', $d->location->file); - self::assertStringContainsString('Book', $d->message); - self::assertStringContainsString('not in the source set', $d->message); + self::assertSame( + 'Variance edge cannot be proven while instantiating App\Check\VarianceEdgeUnprovable\Producer. + type parameter out T is covariant, but App\Check\VarianceEdgeUnprovable\Book is not in the source set the hierarchy was built from (and is not a recognized PHP built-in), + so the compiler cannot prove its subtype edges — this specialization is not linked to related ones and the covariant relationship silently does not apply at runtime. + + Add App\Check\VarianceEdgeUnprovable\Book to the source set the hierarchy is built from to enable the edge.', + $d->message, + ); } public function testVarianceEdgeProvableTypesProduceNoWarning(): void @@ -297,10 +303,12 @@ public function testUnspecializedGenericClosuresAreCollectedByCheck(): void $all = $diagnostics->all(); self::assertCount(7, $all); + $arrowMsg = 'Generic arrow function is declared with type parameters but never specialized: no `$var::<...>(...)` call grounds them, so its type-parameter hints would reach the emitted code as references to non-existent classes. Call it with an explicit turbofish, or remove the `<...>` clause.'; + $closureMsg = 'Generic closure is declared with type parameters but never specialized: no `$var::<...>(...)` call grounds them, so its type-parameter hints would reach the emitted code as references to non-existent classes. Call it with an explicit turbofish, or remove the `<...>` clause.'; $byLine = []; foreach ($all as $d) { self::assertSame(GenericMethodCompiler::CODE_UNSPECIALIZED_GENERIC_CLOSURE, $d->code); - self::assertStringContainsString('never specialized', $d->message); + self::assertContains($d->message, [$arrowMsg, $closureMsg]); self::assertNotNull($d->location); $byLine[$d->location->line] = $d->message; } @@ -309,8 +317,8 @@ public function testUnspecializedGenericClosuresAreCollectedByCheck(): void // use-capturing closure, defaulted closure, unused-param arrow, and // the conditional else-arm arrow — NOT the called twins. self::assertSame([12, 16, 19, 22, 24, 27, 30], array_keys($byLine)); - self::assertStringContainsString('Generic arrow function', $byLine[12]); - self::assertStringContainsString('Generic closure', $byLine[19]); + self::assertSame($arrowMsg, $byLine[12]); + self::assertSame($closureMsg, $byLine[19]); } public function testCompileThrowsOnUnspecializedGenericClosure(): void @@ -332,7 +340,10 @@ public function testUnresolvedGenericMethodTurbofishIsCollectedByCheck(): void self::assertSame(GenericMethodCompiler::CODE_UNRESOLVED_GENERIC_CALL, $d->code); self::assertNotNull($d->location); self::assertSame(16, $d->location->line); - self::assertStringContainsString('nope', $d->message); + self::assertSame( + 'Generic method `App\Check\UnresolvedGenericMethod\Box::nope::<...>()` could not be resolved to a declared generic method on `App\Check\UnresolvedGenericMethod\Box`. Check the method name or the receiver\'s type.', + $d->message, + ); } public function testCompileStillThrowsOnUnresolvedGenericMethodTurbofish(): void @@ -428,7 +439,10 @@ public function testGenericMethodInsideGenericTemplateIsReportedExactlyOnce(): v self::assertCount(1, $diagnostics->all()); self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $diagnostics->all()[0]->code); - self::assertStringContainsString('Type `Stray`', $diagnostics->all()[0]->message); + self::assertSame( + 'Type `Stray` used in template `App\NestedMethod\Box` is not a declared type parameter and does not resolve to a known class, interface, or trait. Declare it as a type parameter, or import (`use`) / fully-qualify it if it names a real type.', + $diagnostics->all()[0]->message, + ); } public function testCompileStillThrowsOnUndeclaredTypeInGenericFunction(): void @@ -445,7 +459,10 @@ public function testUndeclaredNameInABoundIsCollected(): void self::assertCount(1, $diagnostics->all()); self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $diagnostics->all()[0]->code); - self::assertStringContainsString('Type `Nonexistent`', $diagnostics->all()[0]->message); + self::assertSame( + 'Type `Nonexistent` used in template `App\BadBound\Box` is not a declared type parameter and does not resolve to a known class, interface, or trait. Declare it as a type parameter, or import (`use`) / fully-qualify it if it names a real type.', + $diagnostics->all()[0]->message, + ); } public function testUndeclaredNameInADefaultIsCollected(): void @@ -454,7 +471,10 @@ public function testUndeclaredNameInADefaultIsCollected(): void self::assertCount(1, $diagnostics->all()); self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $diagnostics->all()[0]->code); - self::assertStringContainsString('Type `Nonexistent`', $diagnostics->all()[0]->message); + self::assertSame( + 'Type `Nonexistent` used in template `App\BadDefault\Pair` is not a declared type parameter and does not resolve to a known class, interface, or trait. Declare it as a type parameter, or import (`use`) / fully-qualify it if it names a real type.', + $diagnostics->all()[0]->message, + ); } public function testUndeclaredNamesInIntersectionBoundAndGenericArgAreCollected(): void @@ -486,7 +506,10 @@ public function testSameUndeclaredNameInBoundAndDefaultIsReportedOnce(): void $diagnostics = $this->check('undeclared_bound_dedup'); self::assertCount(1, $diagnostics->all()); - self::assertStringContainsString('Type `Bad`', $diagnostics->all()[0]->message); + self::assertSame( + 'Type `Bad` used in template `App\DupBound\Dup` is not a declared type parameter and does not resolve to a known class, interface, or trait. Declare it as a type parameter, or import (`use`) / fully-qualify it if it names a real type.', + $diagnostics->all()[0]->message, + ); } public function testCompileStillThrowsOnUndeclaredBound(): void @@ -594,7 +617,10 @@ public function testParseTimeVarianceOnMethodReportsRealLine(): void self::assertCount(1, $diagnostics->all()); $d = $diagnostics->all()[0]; self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); - self::assertStringContainsString('Variance markers', $d->message); + self::assertSame( + 'Variance markers `out T` / `in T` are not supported on methods, functions, closures, or arrow functions — variance is a class-level-only feature by design: a function or closure specialization has no stable class identity to anchor a subtype `extends` edge to. Move the generic to a class-level type parameter.', + $d->message, + ); self::assertNotNull($d->location); self::assertSame(9, $d->location->line); } @@ -608,7 +634,10 @@ public function testParseTimeLegacyVarianceGlyphReportsRealLine(): void self::assertCount(1, $diagnostics->all()); $d = $diagnostics->all()[0]; self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); - self::assertStringContainsString('`+T` / `-T` variance syntax', $d->message); + self::assertSame( + 'The `+T` / `-T` variance syntax was replaced by `out T` / `in T`. Write `out` for covariance and `in` for contravariance, e.g. `class Box` or `class Consumer`.', + $d->message, + ); self::assertNotNull($d->location); self::assertSame(7, $d->location->line); } @@ -622,7 +651,10 @@ public function testParseTimeDefaultOrderingReportsRealLine(): void self::assertCount(1, $diagnostics->all()); $d = $diagnostics->all()[0]; self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); - self::assertStringContainsString('Required type parameters must precede', $d->message); + self::assertSame( + 'Generic parameter `U` has no default but follows a parameter with a default. Required type parameters must precede defaulted ones.', + $d->message, + ); self::assertNotNull($d->location); self::assertSame(9, $d->location->line); } @@ -636,7 +668,10 @@ public function testParseTimeInvalidDefaultReportsRealLine(): void self::assertCount(1, $diagnostics->all()); $d = $diagnostics->all()[0]; self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); - self::assertStringContainsString('has an invalid default', $d->message); + self::assertSame( + 'Generic parameter `T` has an invalid default; only a single concrete or generic type is allowed after `=` (no nullable or union shapes).', + $d->message, + ); self::assertNotNull($d->location); self::assertSame(7, $d->location->line); } @@ -650,7 +685,10 @@ public function testParseTimeUnionDefaultReportsRealLine(): void self::assertCount(1, $diagnostics->all()); $d = $diagnostics->all()[0]; self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); - self::assertStringContainsString('has an invalid default', $d->message); + self::assertSame( + 'Generic parameter `T` has an invalid default; only a single concrete or generic type is allowed after `=` (no nullable or union shapes).', + $d->message, + ); self::assertNotNull($d->location); self::assertSame(9, $d->location->line); } @@ -666,7 +704,10 @@ public function testParseTimePositionlessRejectionFallsBackToLineOne(): void self::assertCount(1, $diagnostics->all()); $d = $diagnostics->all()[0]; self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); - self::assertStringContainsString('cannot use itself as a bound', $d->message); + self::assertSame( + 'Generic parameter `T` cannot use itself as a bound (self-reference detected in the bound expression). Use a nested form like `T : Box` for F-bounded recursion, or remove the bound.', + $d->message, + ); self::assertNotNull($d->location); self::assertSame(1, $d->location->line); } @@ -705,8 +746,10 @@ public function testDynamicNameTurbofishIsRejectedWithRealLine(string $fixture): self::assertCount(1, $diagnostics->all()); $d = $diagnostics->all()[0]; self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); - self::assertStringContainsString('dynamically-named method', $d->message); - self::assertStringContainsString('must be a literal identifier, not a variable', $d->message); + self::assertSame( + 'A turbofish (`::<…>`) on a dynamically-named method or static call cannot be monomorphized; the method name must be a literal identifier, not a variable', + $d->message, + ); self::assertNotNull($d->location); self::assertSame(9, $d->location->line); } @@ -729,9 +772,11 @@ public function testAmbiguousGenericTraitOperandIsCollectedByCheck(): void self::assertCount(1, $diagnostics->all()); $d = $diagnostics->all()[0]; self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); - self::assertStringContainsString('Ambiguous generic trait operand `A`', $d->message); // The remedy names an actionable recourse (a trait-level rename is NOT possible). - self::assertStringContainsString('Use a single specialization of that trait', $d->message); + self::assertSame( + 'Ambiguous generic trait operand `A` in an adaptation clause: this class uses more than one specialization of that trait, and an `insteadof` / `as` clause names a trait, not a specialization, so it cannot say which one is meant. Use a single specialization of that trait in an adapted class.', + $d->message, + ); self::assertNotNull($d->location); self::assertSame(13, $d->location->line); } @@ -762,8 +807,8 @@ public function testClosureConformanceViolationIsCollectedByCheck(): void self::assertCount(1, $diagnostics->all()); self::assertSame(ClosureConformanceValidator::CODE, $diagnostics->all()[0]->code); - self::assertStringContainsString( - 'parameter 1: string is not wider than int', + self::assertSame( + 'Closure literal does not conform to the declared `Closure(...)` type: parameter 1: string is not wider than int', $diagnostics->all()[0]->message, ); } diff --git a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php index c9c4a5e..2e96f2d 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceValidatorTest.php @@ -27,6 +27,9 @@ */ final class ClosureConformanceValidatorTest extends TestCase { + /** The fixed human-readable prefix {@see ClosureConformanceValidator::message()} prepends to every violation detail. */ + private const MESSAGE_PREFIX = 'Closure literal does not conform to the declared `Closure(...)` type: '; + /** * A conforming literal at any site is silent in both modes. * @@ -166,16 +169,11 @@ public function testViolatingLiteralIsRejected(string $source, int $line, string self::assertSame(ClosureConformanceValidator::CODE, $diagnostic->code); self::assertNotNull($diagnostic->location); self::assertSame($line, $diagnostic->location->line, 'diagnostic points at the literal'); - self::assertStringStartsWith( - 'Closure literal does not conform to the declared `Closure(...)` type: ', - $diagnostic->message, - 'the human-readable prefix precedes the violation detail', - ); - self::assertStringContainsString($detailNeedle, $diagnostic->message); + self::assertSame(self::MESSAGE_PREFIX . $detailNeedle, $diagnostic->message); $thrown = $this->throwMode($source); self::assertInstanceOf(RuntimeException::class, $thrown); - self::assertStringContainsString($detailNeedle, $thrown->getMessage()); + self::assertSame(self::MESSAGE_PREFIX . $detailNeedle, $thrown->getMessage()); } /** @@ -206,12 +204,12 @@ public static function violatingSites(): iterable yield 'S-A method return: by-ref mismatch' => [ " null; }\n}", 2, - 'by-reference-ness must match exactly', + 'parameter 1: by-reference-ness must match exactly (target by-value, candidate by-ref)', ]; yield 'S-A union return: outside the union' => [ " 0.0;\n}", 2, - 'float is not a subtype of int|string', + 'return type: float is not a subtype of int|string', ]; yield 'S-A union parameter: candidate too narrow' => [ " null;\n}", @@ -225,21 +223,21 @@ public static function violatingSites(): iterable // ANY built-in target used to go gradual.) " new Apple();\n}", 2, - 'App\\Apple is not a subtype of Throwable', + 'return type: App\\Apple is not a subtype of Throwable', ]; yield 'S-A return: relative-named TARGET violation is caught' => [ // Pre-fix, `namespace\Apple` resolved to `App\namespace\Apple` // (undeclared ⇒ gradual) and this provable violation was missed. " new Fruit();\n}", 2, - 'App\\Fruit is not a subtype of App\\Apple', + 'return type: App\\Fruit is not a subtype of App\\Apple', ]; yield 'S-A return: fully-qualified literal violation is caught' => [ // Pre-fix, `\App\Fruit` flattened to the relative `App\App\Fruit` // (undeclared ⇒ gradual) and this provable violation was missed. " new Fruit();\n}", 2, - 'App\\Fruit is not a subtype of App\\Apple', + 'return type: App\\Fruit is not a subtype of App\\Apple', ]; yield 'S-A array-sugar parameter: wrong arity still rejected' => [ // The lowered `array` leaf is gradual but the ARITY is not: the @@ -310,8 +308,8 @@ function make(): Closure(): Apple { return fn(): Fruit => new Fruit(); } $collected = $diagnostics->all(); self::assertCount(1, $collected); - self::assertStringContainsString( - 'App\\Fruit is not a subtype of App\\Apple', + self::assertSame( + self::MESSAGE_PREFIX . 'return type: App\\Fruit is not a subtype of App\\Apple', $collected[0]->message, ); } @@ -358,8 +356,8 @@ function make(): Closure(): Apple|Orange { return fn(): Fruit => new Fruit(); } self::validator($ast)->validateFile($ast, 'test.xphp', $diagnostics); self::assertCount(1, $diagnostics->all()); - self::assertStringContainsString( - 'is not a subtype of App\\Apple|App\\Orange', + self::assertSame( + self::MESSAGE_PREFIX . 'return type: App\\Fruit is not a subtype of App\\Apple|App\\Orange', $diagnostics->all()[0]->message, ); } @@ -382,8 +380,8 @@ function make(): Closure(): Apple&Orange { return fn(): Fruit => new Fruit(); } self::validator($ast)->validateFile($ast, 'test.xphp', $diagnostics); self::assertCount(1, $diagnostics->all()); - self::assertStringContainsString( - 'is not a subtype of App\\Apple&App\\Orange', + self::assertSame( + self::MESSAGE_PREFIX . 'return type: App\\Fruit is not a subtype of App\\Apple&App\\Orange', $diagnostics->all()[0]->message, ); } @@ -400,7 +398,10 @@ public function testGlobalBracedNamespaceIsHandledWithoutFatal(): void self::validator($ast)->validateFile($ast, 'test.xphp', $diagnostics); self::assertCount(1, $diagnostics->all()); - self::assertStringContainsString('is not wider than int', $diagnostics->all()[0]->message); + self::assertSame( + self::MESSAGE_PREFIX . 'parameter 1: string is not wider than int', + $diagnostics->all()[0]->message, + ); } public function testReturnLiteralIsPairedWithItsInnermostEnclosingFunction(): void diff --git a/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php b/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php index 1cee0b6..56f8f10 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureConformanceTest.php @@ -252,7 +252,7 @@ public function testNestedClosureParameterFlipsVariance(): void ); self::assertNotNull($violation); self::assertSame(ClosureConformanceViolation::KIND_PARAM_TYPE, $violation->kind); - self::assertStringContainsString('Closure(...)', $violation->detail, 'a nested-closure leaf renders as Closure(...) in the message'); + self::assertSame('parameter 1: Closure(...) is not wider than Closure(...)', $violation->detail); } public function testNestedClosureReturnConforms(): void @@ -565,7 +565,8 @@ public function testViolationDetailRendersUnionMembers(): void self::sig([], self::union(self::ref('int'), self::ref('string'))), ); self::assertNotNull($violation); - self::assertStringContainsString('int|string', $violation->detail); + self::assertSame(ClosureConformanceViolation::KIND_RETURN_TYPE, $violation->kind); + self::assertSame('return type: float is not a subtype of int|string', $violation->detail); } public function testViolationDetailRendersIntersectionMembers(): void @@ -575,7 +576,7 @@ public function testViolationDetailRendersIntersectionMembers(): void self::sig([], self::intersection(self::ref('App\\Apple'), self::ref('App\\Closurish'))), ); self::assertNotNull($violation); - self::assertStringContainsString('App\\Apple&App\\Closurish', $violation->detail); + self::assertSame('return type: App\\Fruit is not a subtype of App\\Apple&App\\Closurish', $violation->detail); } public function testViolationDetailNamesThePositionAndBothTypes(): void @@ -585,9 +586,8 @@ public function testViolationDetailNamesThePositionAndBothTypes(): void self::sig([self::p(self::ref('int'))], self::ref('int')), ); self::assertNotNull($violation); - self::assertStringContainsString('parameter 1', $violation->detail); - self::assertStringContainsString('string', $violation->detail); - self::assertStringContainsString('int', $violation->detail); + self::assertSame(ClosureConformanceViolation::KIND_PARAM_TYPE, $violation->kind); + self::assertSame('parameter 1: string is not wider than int', $violation->detail); } public function testReturnViolationDetailNamesBothTypes(): void @@ -597,9 +597,8 @@ public function testReturnViolationDetailNamesBothTypes(): void self::sig([], self::ref('App\\Apple')), ); self::assertNotNull($violation); - self::assertStringContainsString('return type', $violation->detail); - self::assertStringContainsString('Fruit', $violation->detail); - self::assertStringContainsString('Apple', $violation->detail); + self::assertSame(ClosureConformanceViolation::KIND_RETURN_TYPE, $violation->kind); + self::assertSame('return type: App\\Fruit is not a subtype of App\\Apple', $violation->detail); } public function testByRefViolationDetailNamesTheDirections(): void @@ -609,11 +608,8 @@ public function testByRefViolationDetailNamesTheDirections(): void self::sig([self::p(self::ref('int'))], self::ref('int')), ); self::assertNotNull($violation); - self::assertStringContainsString('parameter 1', $violation->detail); - // Exact phrases (not bare 'by-ref', which is a substring of the fixed - // "by-reference-ness" text and would match vacuously). - self::assertStringContainsString('target by-value', $violation->detail); - self::assertStringContainsString('candidate by-ref', $violation->detail); + self::assertSame(ClosureConformanceViolation::KIND_BYREF, $violation->kind); + self::assertSame('parameter 1: by-reference-ness must match exactly (target by-value, candidate by-ref)', $violation->detail); } // ---- Helpers --------------------------------------------------------- diff --git a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php index c7cf965..5ecb154 100644 --- a/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php +++ b/test/Transpiler/Monomorphize/ClosureSignatureParseTest.php @@ -262,11 +262,7 @@ public function testErasesToBareClosureLeavingNoSignatureResidue(): void { $stripped = self::strip('[] {}'); - self::assertStringContainsString('[]', $stripped); + self::assertSame('(U $value): bool { return true; } ]); self::fail('Expected a bound violation for Box::contains.'); } catch (RuntimeException $e) { - $msg = $e->getMessage(); - self::assertStringContainsString('Generic bound violated', $msg); - self::assertStringContainsString('extend/implement "App\\Banana"', $msg, 'bound must be grounded to the receiver arg Banana'); - self::assertStringNotContainsString('"E"', $msg, 'must not report the literal type parameter E'); + self::assertSame( + "Generic bound violated while instantiating App\\Box::contains.\n" + . " type parameter U is bounded by App\\Banana\n" + . " but the supplied concrete type is App\\Fruit\n" + . "\n" + . " \"App\\Fruit\" does not extend/implement \"App\\Banana\".", + $e->getMessage(), + ); } } diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php index 94b91d5..b314f5d 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php @@ -47,8 +47,14 @@ public function testBranchMergedReceiverIsGroundedAndRejectsAnElementViolation() $this->compileFixture('enclosing_param_bound_lenient_drop'); self::fail('expected a bound violation for contains:: on a branch-merged Box'); } catch (RuntimeException $e) { - self::assertStringContainsString('Generic bound violated', $e->getMessage()); - self::assertStringContainsString('extend/implement "App\\Fruit"', $e->getMessage()); + self::assertSame( + "Generic bound violated while instantiating App\\Box::contains.\n" + . " type parameter U is bounded by App\\Fruit\n" + . " but the supplied concrete type is App\\Rock\n" + . "\n" + . " \"App\\Rock\" does not extend/implement \"App\\Fruit\".", + $e->getMessage(), + ); } } @@ -70,8 +76,14 @@ public function testTheSameElementViolationIsCaughtWhenTheReceiverIsGroundable() ]); self::fail('expected a bound violation for contains:: on Box'); } catch (RuntimeException $e) { - self::assertStringContainsString('Generic bound violated', $e->getMessage()); - self::assertStringContainsString('extend/implement "App\\Fruit"', $e->getMessage()); + self::assertSame( + "Generic bound violated while instantiating App\\Box::contains.\n" + . " type parameter U is bounded by App\\Fruit\n" + . " but the supplied concrete type is App\\Rock\n" + . "\n" + . " \"App\\Rock\" does not extend/implement \"App\\Fruit\".", + $e->getMessage(), + ); } } @@ -87,8 +99,14 @@ public function testBranchMergedReceiverIsGroundedAndRejectsACompoundViolation() $this->compileFixture('enclosing_param_bound_compound_drop'); self::fail('expected a bound violation for register:: on a branch-merged Box'); } catch (RuntimeException $e) { - self::assertStringContainsString('Generic bound violated', $e->getMessage()); - self::assertStringContainsString('Stringable', $e->getMessage()); + self::assertSame( + "Generic bound violated while instantiating App\\Box::register.\n" + . " type parameter U is bounded by Stringable & App\\Fruit\n" + . " but the supplied concrete type is App\\Banana\n" + . "\n" + . " \"App\\Banana\" does not satisfy \"Stringable & App\\Fruit\".", + $e->getMessage(), + ); } } @@ -111,8 +129,14 @@ public function testTheStringableOperandIsEnforcedWhenTheReceiverIsGroundable(): ]); self::fail('expected a bound violation for register:: on Box'); } catch (RuntimeException $e) { - self::assertStringContainsString('Generic bound violated', $e->getMessage()); - self::assertStringContainsString('Stringable', $e->getMessage()); + self::assertSame( + "Generic bound violated while instantiating App\\Box::register.\n" + . " type parameter U is bounded by Stringable & App\\Fruit\n" + . " but the supplied concrete type is App\\Banana\n" + . "\n" + . " \"App\\Banana\" does not satisfy \"Stringable & App\\Fruit\".", + $e->getMessage(), + ); } } diff --git a/test/Transpiler/Monomorphize/RegistryBoundsDiagnosticTest.php b/test/Transpiler/Monomorphize/RegistryBoundsDiagnosticTest.php index a7e28d3..0f113d0 100644 --- a/test/Transpiler/Monomorphize/RegistryBoundsDiagnosticTest.php +++ b/test/Transpiler/Monomorphize/RegistryBoundsDiagnosticTest.php @@ -39,8 +39,16 @@ public function testBoundViolationIsCollectedNotThrownWhenCollectorPresent(): vo self::assertSame(Registry::CODE_BOUND_VIOLATION, $d->code); self::assertSame(DiagnosticSource::Xphp, $d->source); self::assertSame($loc, $d->location); - self::assertStringContainsString('Generic bound violated', $d->message); - self::assertStringContainsString('"int" does not extend/implement "Stringable"', $d->message); + self::assertSame( + <<<'TXT' + Generic bound violated while instantiating App\Box. + type parameter T is bounded by Stringable + but the supplied concrete type is int + + "int" does not extend/implement "Stringable". + TXT, + $d->message, + ); // Recording still completed (continue-safely): the instantiation is on file. self::assertCount(1, $registry->instantiations()); diff --git a/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php b/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php index 013304e..9b75293 100644 --- a/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php +++ b/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php @@ -150,7 +150,10 @@ public function testCollectModeGathersInnerVarianceDiagnosticInsteadOfThrowing() self::assertCount(1, $collector->all()); self::assertSame(InnerVarianceValidator::CODE_INNER_VARIANCE, $collector->all()[0]->code); - self::assertStringContainsString('Variance violation in template P', $collector->all()[0]->message); + self::assertSame( + 'Variance violation in template P: type-parameter out T appears in invariant-only position (via slot 0 of Container).', + $collector->all()[0]->message, + ); } public function testCovariantOuterInInvariantInnerSlotIsRejected(): void diff --git a/test/Transpiler/Monomorphize/RegistryTest.php b/test/Transpiler/Monomorphize/RegistryTest.php index 74efa0f..e9628ee 100644 --- a/test/Transpiler/Monomorphize/RegistryTest.php +++ b/test/Transpiler/Monomorphize/RegistryTest.php @@ -30,8 +30,8 @@ public function testDifferentTemplateNamespacesProduceDifferentFqns(): void $b = Registry::generatedFqn('App\\RegistryTest\\Other\\Box', [new TypeRef('App\\RegistryTest\\Models\\Plastic')]); self::assertNotSame($a, $b); - self::assertStringContainsString('App\\RegistryTest\\Containers\\Box', $a); - self::assertStringContainsString('App\\RegistryTest\\Other\\Box', $b); + self::assertStringStartsWith('XPHP\\Generated\\App\\RegistryTest\\Containers\\Box\\T_', $a); + self::assertStringStartsWith('XPHP\\Generated\\App\\RegistryTest\\Other\\Box\\T_', $b); } public function testDifferentArgsProduceDifferentHashes(): void @@ -300,7 +300,15 @@ public function testCollisionMessageSuggestsMaxWhenAlreadyAtHigherLength(): void self::fail('expected collision exception'); } catch (\RuntimeException $e) { // 48 * 2 = 96, clamped to MAX (64) - self::assertStringContainsString('XPHP_HASH_LENGTH=64 bin/xphp compile', $e->getMessage()); + $expected = "Hash collision detected while monomorphizing generics.\n\n" + . "Two distinct instantiations produced the same specialized FQCN:\n" + . " existing : App\\RegistryTest\\Containers\\Box\n" + . " new : App\\RegistryTest\\Containers\\Box\n" + . " collision: {$collidingFqn}\n\n" + . "The current XPHP_HASH_LENGTH = 48 is too short for this codebase.\n" + . "Increase it (max 64, the full sha256 digest) and re-run, e.g.:\n\n" + . " XPHP_HASH_LENGTH=64 bin/xphp compile \n"; + self::assertSame($expected, $e->getMessage()); } } diff --git a/test/Transpiler/Monomorphize/RegistryVarianceEdgeDiagnosticTest.php b/test/Transpiler/Monomorphize/RegistryVarianceEdgeDiagnosticTest.php index fd26f0c..0060c08 100644 --- a/test/Transpiler/Monomorphize/RegistryVarianceEdgeDiagnosticTest.php +++ b/test/Transpiler/Monomorphize/RegistryVarianceEdgeDiagnosticTest.php @@ -63,7 +63,16 @@ public function testContravariantOverUnprovableLeafIsWarnedWithInMarker(): void $this->registry($collector)->recordInstantiation('App\\Consumer', [new TypeRef('App\\Book')]); self::assertCount(1, $collector->all()); - self::assertStringContainsString('type parameter in T is contravariant', $collector->all()[0]->message); + self::assertSame( + <<<'TXT' + Variance edge cannot be proven while instantiating App\Consumer. + type parameter in T is contravariant, but App\Book is not in the source set the hierarchy was built from (and is not a recognized PHP built-in), + so the compiler cannot prove its subtype edges — this specialization is not linked to related ones and the contravariant relationship silently does not apply at runtime. + + Add App\Book to the source set the hierarchy is built from to enable the edge. + TXT, + $collector->all()[0]->message, + ); } public function testProvableDeclaredLeafIsSilent(): void @@ -138,7 +147,16 @@ public function testLeadingBackslashTemplateNameResolvesAndIsNormalisedInMessage $this->registry($collector)->recordInstantiation('\\App\\Producer', [new TypeRef('App\\Book')]); self::assertCount(1, $collector->all()); - self::assertStringContainsString('instantiating App\\Producer', $collector->all()[0]->message); + self::assertSame( + <<<'TXT' + Variance edge cannot be proven while instantiating App\Producer. + type parameter out T is covariant, but App\Book is not in the source set the hierarchy was built from (and is not a recognized PHP built-in), + so the compiler cannot prove its subtype edges — this specialization is not linked to related ones and the covariant relationship silently does not apply at runtime. + + Add App\Book to the source set the hierarchy is built from to enable the edge. + TXT, + $collector->all()[0]->message, + ); } public function testEarlierInvariantPositionDoesNotShortCircuitLaterVariant(): void @@ -152,7 +170,16 @@ public function testEarlierInvariantPositionDoesNotShortCircuitLaterVariant(): v ); self::assertCount(1, $collector->all()); - self::assertStringContainsString('App\\Author', $collector->all()[0]->message); + self::assertSame( + <<<'TXT' + Variance edge cannot be proven while instantiating App\Mixed. + type parameter out B is covariant, but App\Author is not in the source set the hierarchy was built from (and is not a recognized PHP built-in), + so the compiler cannot prove its subtype edges — this specialization is not linked to related ones and the covariant relationship silently does not apply at runtime. + + Add App\Author to the source set the hierarchy is built from to enable the edge. + TXT, + $collector->all()[0]->message, + ); } public function testEarlierScalarPositionDoesNotShortCircuitLaterVariant(): void @@ -166,7 +193,16 @@ public function testEarlierScalarPositionDoesNotShortCircuitLaterVariant(): void ); self::assertCount(1, $collector->all()); - self::assertStringContainsString('App\\Book', $collector->all()[0]->message); + self::assertSame( + <<<'TXT' + Variance edge cannot be proven while instantiating App\Pair. + type parameter out B is covariant, but App\Book is not in the source set the hierarchy was built from (and is not a recognized PHP built-in), + so the compiler cannot prove its subtype edges — this specialization is not linked to related ones and the covariant relationship silently does not apply at runtime. + + Add App\Book to the source set the hierarchy is built from to enable the edge. + TXT, + $collector->all()[0]->message, + ); } public function testEarlierDeclaredPositionDoesNotShortCircuitLaterVariant(): void @@ -180,7 +216,16 @@ public function testEarlierDeclaredPositionDoesNotShortCircuitLaterVariant(): vo ); self::assertCount(1, $collector->all()); - self::assertStringContainsString('App\\Book', $collector->all()[0]->message); + self::assertSame( + <<<'TXT' + Variance edge cannot be proven while instantiating App\Pair. + type parameter out B is covariant, but App\Book is not in the source set the hierarchy was built from (and is not a recognized PHP built-in), + so the compiler cannot prove its subtype edges — this specialization is not linked to related ones and the covariant relationship silently does not apply at runtime. + + Add App\Book to the source set the hierarchy is built from to enable the edge. + TXT, + $collector->all()[0]->message, + ); } public function testCompileModeNeverThrowsAndEmitsNothing(): void diff --git a/test/Transpiler/Monomorphize/UseImportGenericClauseTest.php b/test/Transpiler/Monomorphize/UseImportGenericClauseTest.php index b0c76c3..af3e50f 100644 --- a/test/Transpiler/Monomorphize/UseImportGenericClauseTest.php +++ b/test/Transpiler/Monomorphize/UseImportGenericClauseTest.php @@ -53,9 +53,18 @@ public function testCheckRejectsGenericClauseOnImportWithRightSymbolAndLine( self::assertCount(1, $diagnostics->all()); $d = $diagnostics->all()[0]; self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); - self::assertStringContainsString('not allowed on a `use` import', $d->message); // Names the source spelling, never the requalified phantom (`Main\App\Box`). - self::assertStringContainsString('`use ' . $symbol . ';`', $d->message); + self::assertSame( + sprintf( + 'A generic clause is not allowed on a `use` import. Import the template with a ' + . 'plain `use %s;` and apply the type arguments at the use site (the hint ' + . '`%s<...>` or the call `%s::<...>()`).', + $symbol, + $symbol, + $symbol, + ), + $d->message, + ); self::assertStringNotContainsString('Main\\App', $d->message); self::assertNotNull($d->location); self::assertSame($line, $d->location->line); @@ -90,7 +99,12 @@ public function testAClauselessImportSharingASpellingIsNotRejected(): void self::assertSame(Compiler::CODE_PARSE_ERROR, $d->code); self::assertNotNull($d->location); self::assertSame(9, $d->location->line); - self::assertStringContainsString('`use App\\Box;`', $d->message); + self::assertSame( + 'A generic clause is not allowed on a `use` import. Import the template with a ' + . 'plain `use App\\Box;` and apply the type arguments at the use site (the hint ' + . '`App\\Box<...>` or the call `App\\Box::<...>()`).', + $d->message, + ); } public function testCompileThrowsOnGenericClauseImport(): void diff --git a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php index 9c1bb9c..feeda9e 100644 --- a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php +++ b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php @@ -20,25 +20,25 @@ final class VariancePositionPhaseTest extends TestCase { /** - * @return iterable}> + * @return iterable */ public static function rejectedSources(): iterable { yield 'covariant in method parameter' => [ "\n{\n public function set(T \$x): void {}\n}\n", - ['out T', 'method parameter'], + 'Generic parameter `out T` appears in method parameter position, which is not allowed for covariant variance.', ]; yield 'contravariant in method return' => [ "\n{\n public function get(): T { throw new \\LogicException; }\n}\n", - ['in T', 'method return'], + 'Generic parameter `in T` appears in method return position, which is not allowed for contravariant variance.', ]; yield 'covariant in mutable property' => [ "\n{\n public T \$item;\n}\n", - ['mutable property'], + 'Generic parameter `out T` appears in mutable property position, which is not allowed for covariant variance.', ]; yield 'covariant in readonly property' => [ "\n{\n public readonly T \$item;\n public function get(): T { return \$this->item; }\n}\n", - ['readonly property'], + 'Generic parameter `out T` appears in readonly property position, which is not allowed for covariant variance.', ]; // NOTE: a NESTED type-param in a bound (`out T : Box`) is owned by the composing // inner-variance pass, not this direct-position pass — see @@ -48,7 +48,7 @@ public static function rejectedSources(): iterable // supported method-level `contains` shape, where U is a *method* type parameter.) yield 'covariant in sibling bare bound' => [ "\n{\n public function get(): T { throw new \\LogicException; }\n}\n", - ['bound'], + 'Generic parameter `out T` appears inside the bound of generic parameter `U`, which is not allowed for covariant variance.', ]; // NOTE: `out T` in a *non-promoted* constructor parameter of a variant class is // ALLOWED — a constructor parameter is variance-exempt (constructors aren't @@ -58,7 +58,7 @@ public static function rejectedSources(): iterable // strictly invariant (a `T`-typed property would PHP-fatal across the chain): yield 'covariant in promoted constructor property' => [ "\n{\n public function __construct(public T \$item) {}\n}\n", - ['constructor parameter'], + 'Generic parameter `out T` appears in constructor parameter position, which is not allowed for covariant variance.', ]; // A *protected* property/promoted property is also a visible property (PHP // enforces invariant types across the chain for it), so it stays rejected — @@ -66,11 +66,11 @@ public static function rejectedSources(): iterable // mutant that swaps the visibility bit for PROTECTED. yield 'covariant in protected promoted constructor property' => [ "\n{\n public function __construct(protected T \$item) {}\n}\n", - ['constructor parameter'], + 'Generic parameter `out T` appears in constructor parameter position, which is not allowed for covariant variance.', ]; yield 'covariant in protected mutable property' => [ "\n{\n protected T \$item;\n}\n", - ['mutable property'], + 'Generic parameter `out T` appears in mutable property position, which is not allowed for covariant variance.', ]; // Asymmetric visibility (PHP 8.4): a `public private(set)` property is // externally *readable* through an upcast reference (PRIVATE_SET sets a @@ -79,45 +79,45 @@ public static function rejectedSources(): iterable // pin the PRIVATE-bit detection against a mutant that swaps it for PRIVATE_SET. yield 'covariant in public private(set) promoted constructor property' => [ "\n{\n public function __construct(public private(set) T \$item) {}\n}\n", - ['constructor parameter'], + 'Generic parameter `out T` appears in constructor parameter position, which is not allowed for covariant variance.', ]; yield 'covariant in public private(set) declared property' => [ "\n{\n public private(set) T \$item;\n}\n", - ['mutable property'], + 'Generic parameter `out T` appears in mutable property position, which is not allowed for covariant variance.', ]; yield 'covariant in nested closure parameter' => [ "\n{\n public function emit(): array\n {\n \$f = function (T \$x) {};\n return [];\n }\n}\n", - ['nested closure/arrow parameter'], + 'Generic parameter `out T` appears in nested closure/arrow parameter position, which is not allowed for covariant variance.', ]; yield 'contravariant in nested arrow return' => [ "\n{\n public function pipe(): array\n {\n \$f = fn (): T => null;\n return [];\n }\n}\n", - ['nested closure/arrow return'], + 'Generic parameter `in T` appears in nested closure/arrow return position, which is not allowed for contravariant variance.', ]; // NOTE: a NESTED type-param in a method parameter/return (`Box`) is owned by the // composing inner-variance pass — see testNestedTypeParamIsRejectedByTheComposingPass. yield 'interface method signature' => [ "\n{\n public function feed(T \$x): void;\n}\n", - ['out T'], + 'Generic parameter `out T` appears in method parameter position, which is not allowed for covariant variance.', ]; // A by-reference parameter is read AND written back, so it is an // invariant position — neither +T nor -T is allowed there. yield 'contravariant in by-reference parameter' => [ "\n{\n public function swap(T &\$x): void {}\n}\n", - ['in T', 'by-reference parameter'], + 'Generic parameter `in T` appears in by-reference parameter position, which is not allowed for contravariant variance.', ]; yield 'covariant in by-reference parameter' => [ "\n{\n public function swap(T &\$x): void {}\n}\n", - ['out T', 'by-reference parameter'], + 'Generic parameter `out T` appears in by-reference parameter position, which is not allowed for covariant variance.', ]; yield 'contravariant in nested closure by-reference parameter' => [ "\n{\n public function pipe(): array\n {\n \$f = function (T &\$x) {};\n return [];\n }\n}\n", - ['by-reference parameter'], + 'Generic parameter `in T` appears in by-reference parameter position, which is not allowed for contravariant variance.', ]; // A variant class can't be `final`: its specializations are linked by // real `extends` edges, which a `final` class can't anchor. yield 'final variant class' => [ "\n{\n public function get(): T { throw new \\LogicException; }\n}\n", - ['cannot be declared `final`'], + 'A variant class cannot be declared `final`: its specializations participate in `extends` subtype edges that a `final` class cannot anchor. Remove `final`.', ]; } @@ -157,11 +157,8 @@ public static function allowedSources(): iterable ]; } - /** - * @param list $fragments - */ #[DataProvider('rejectedSources')] - public function testVariancePositionIsRejectedInCompileMode(string $source, array $fragments): void + public function testVariancePositionIsRejectedInCompileMode(string $source, string $expectedMessage): void { $registry = $this->registryFor($source); @@ -169,9 +166,7 @@ public function testVariancePositionIsRejectedInCompileMode(string $source, arra $registry->validateVariancePositions(); self::fail('expected a variance-position violation'); } catch (RuntimeException $e) { - foreach ($fragments as $fragment) { - self::assertStringContainsString($fragment, $e->getMessage()); - } + self::assertSame($expectedMessage, $e->getMessage()); } } @@ -183,10 +178,9 @@ public function testVariancePositionIsRejectedInCompileMode(string $source, arra * into type-constructor args, so these are reported by `validateInnerVariance` with the composing * "via slot N of …" message — never double-reported by both passes. * - * @param list $fragments */ #[DataProvider('nestedComposingRejections')] - public function testNestedTypeParamIsRejectedByTheComposingPass(string $source, array $fragments): void + public function testNestedTypeParamIsRejectedByTheComposingPass(string $source, string $expectedMessage): void { $registry = $this->registryFor($source); // The direct-position pass must stay SILENT on a purely-nested violation (no double-report). @@ -196,28 +190,26 @@ public function testNestedTypeParamIsRejectedByTheComposingPass(string $source, $registry->validateInnerVariance(); self::fail('expected an inner-variance composition violation'); } catch (RuntimeException $e) { - foreach ($fragments as $fragment) { - self::assertStringContainsString($fragment, $e->getMessage()); - } + self::assertSame($expectedMessage, $e->getMessage()); } } /** - * @return iterable}> + * @return iterable */ public static function nestedComposingRejections(): iterable { yield 'covariant nested in method parameter' => [ "\n{\n public function set(Box \$x): void {}\n}\n", - ['out T', 'invariant-only position', 'via slot 0 of'], + 'Variance violation in template Producer: type-parameter out T appears in invariant-only position (via slot 0 of Box).', ]; yield 'contravariant nested in method return' => [ "\n{\n public function fetch(): Box { throw new \\LogicException; }\n}\n", - ['in T', 'invariant-only position', 'via slot 0 of'], + 'Variance violation in template Consumer: type-parameter in T appears in invariant-only position (via slot 0 of Box).', ]; yield 'covariant nested in bound' => [ ">\n{\n public function get(): T { throw new \\LogicException; }\n}\n", - ['out T', 'invariant-only position', 'via slot 0 of'], + 'Variance violation in template Sortable: type-parameter out T appears in invariant-only position (via slot 0 of App\Box).', ]; // A NESTED type-param in a VISIBLE (non-promoted) declared property — `public Box $item` on // `out T`. The property's outer position is invariant; Box's invariant slot composes to invariant, @@ -225,20 +217,20 @@ public static function nestedComposingRejections(): iterable // would be exempt — only visible ones are walked). yield 'covariant nested in visible declared property' => [ "\n{\n public Box \$item;\n}\n", - ['out T', 'invariant-only position', 'via slot 0 of'], + 'Variance violation in template Producer: type-parameter out T appears in invariant-only position (via slot 0 of Box).', ]; // Composition through a NON-invariant inner slot. `Producer` as a method PARAMETER: // compose(contravariant param, covariant slot) = contravariant → a covariant `out E` is rejected. yield 'covariant Producer param composes to contravariant' => [ " { public function get(): X; }\nclass Box\n{\n public function take(Producer \$p): void {}\n}\n", - ['out E', 'contravariant-only position', 'via slot 0 of'], + 'Variance violation in template Box: type-parameter out E appears in contravariant-only position (via slot 0 of Producer).', ]; // The mirror that confirms the composing pass owns the unsound direction too: `Sink` with a // `Comparator` parameter — compose(contravariant param, contravariant slot) = covariant → a // contravariant `in E` is rejected. (The sound covariant case is the accept test below.) yield 'contravariant Sink with Comparator composes to covariant' => [ " { public function compare(T \$a, T \$b): int; }\nclass Sink\n{\n public function pick(Comparator \$c): void {}\n}\n", - ['in E', 'covariant-only position', 'via slot 0 of'], + 'Variance violation in template Sink: type-parameter in E appears in covariant-only position (via slot 0 of Comparator).', ]; } @@ -359,7 +351,10 @@ public function testViolationIsCollectedWithMemberLineInCheckMode(): void self::assertNotNull($d->location); self::assertSame('/T.xphp', $d->location->file); self::assertSame(5, $d->location->line); - self::assertStringContainsString('method parameter', $d->message); + self::assertSame( + 'Generic parameter `out T` appears in method parameter position, which is not allowed for covariant variance.', + $d->message, + ); } public function testAllViolationsAcrossDefinitionsCollectedInOneRun(): void @@ -402,9 +397,13 @@ public function testByReferenceParamDoesNotShortCircuitLaterParams(): void $registry->validateVariancePositions(); $messages = array_map(static fn ($d): string => $d->message, $collector->all()); - self::assertCount(2, $messages); - self::assertStringContainsString('by-reference parameter', implode("\n", $messages)); - self::assertStringContainsString('method parameter', implode("\n", $messages)); + self::assertEqualsCanonicalizing( + [ + 'Generic parameter `out T` appears in by-reference parameter position, which is not allowed for covariant variance.', + 'Generic parameter `out T` appears in method parameter position, which is not allowed for covariant variance.', + ], + $messages, + ); } private function registryFor(string $source, ?DiagnosticCollector $collector = null): Registry diff --git a/test/Transpiler/Monomorphize/VisitorGuardsTest.php b/test/Transpiler/Monomorphize/VisitorGuardsTest.php index c1ce773..512737f 100644 --- a/test/Transpiler/Monomorphize/VisitorGuardsTest.php +++ b/test/Transpiler/Monomorphize/VisitorGuardsTest.php @@ -208,7 +208,10 @@ public function testCollectorBareNewOnNonDefaultsTemplateThrowsInCompileMode(): (new RegistryCollector($registry))->collect(self::classAndBareNew(), '/x.xphp'); self::fail('expected a RuntimeException for a bare new of a non-defaults generic'); } catch (RuntimeException $e) { - self::assertStringContainsString('has no default', $e->getMessage()); + self::assertSame( + 'Generic template "Box" was instantiated with 0 type argument(s) but parameter `T` (position 1) has no default; supply it explicitly or add defaults to every preceding required parameter.', + $e->getMessage(), + ); } self::assertSame([], $registry->instantiations(), 'a rejected bare new records no instantiation'); diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index b3b0b0b..d9086b6 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -560,7 +560,7 @@ public function testAnonymousClassWithAngleBracketsIsNotRecognizedAsTemplate(): // survive into the cleaned source so any downstream tooling sees the // form as invalid PHP rather than xphp silently specializing it. $stripped = $parser->strip($source); - self::assertStringContainsString('class', $stripped, 'anon-class `` must be left un-stripped'); + self::assertSame($source, $stripped); } public function testMultiLineTypeParamClauseKeepsLaterLineKeyedMarkersAligned(): void @@ -697,7 +697,6 @@ public function __construct(public T $v) {} PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $stripped = $parser->strip($source); - self::assertStringContainsString('array', $stripped); self::assertSame( substr_count($source, "\n"), substr_count($stripped, "\n"), @@ -1911,7 +1910,7 @@ public function testBareNewCallSiteIsRejectedAndLeftUnstripped(): void $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $stripped = $parser->strip($source); - self::assertStringContainsString('', $stripped, 'bare `new Name<…>()` must be left un-stripped'); + self::assertSame($source, $stripped); } public function testBareNewWithoutParensIsRejectedAndLeftUnstripped(): void @@ -1930,7 +1929,7 @@ public function testBareNewWithoutParensIsRejectedAndLeftUnstripped(): void $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $stripped = $parser->strip($source); - self::assertStringContainsString('', $stripped, 'parenless `new Name<…>` must be left un-stripped'); + self::assertSame($source, $stripped); } public function testBareFreeFunctionCallIsRejectedAndLeftUnstripped(): void @@ -1942,7 +1941,7 @@ public function testBareFreeFunctionCallIsRejectedAndLeftUnstripped(): void $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $stripped = $parser->strip($source); - self::assertStringContainsString('', $stripped, 'bare `name<…>()` free-function call must be left un-stripped'); + self::assertSame($source, $stripped); } public function testBareStaticMethodCallIsRejectedAndLeftUnstripped(): void @@ -1954,7 +1953,7 @@ public function testBareStaticMethodCallIsRejectedAndLeftUnstripped(): void $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $stripped = $parser->strip($source); - self::assertStringContainsString('', $stripped, 'bare `Recv::method<…>()` static call must be left un-stripped'); + self::assertSame($source, $stripped); } public function testWhitespaceBetweenDoubleColonAndAngleDefeatsTurbofish(): void @@ -1970,7 +1969,7 @@ public function testWhitespaceBetweenDoubleColonAndAngleDefeatsTurbofish(): void $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $stripped = $parser->strip($source); - self::assertStringContainsString('', $stripped, 'whitespace between `::` and `<` must defeat turbofish recognition'); + self::assertSame($source, $stripped); } public function testTypeHintPositionAcceptsFullyQualifiedOuterName(): void @@ -3296,25 +3295,38 @@ public function testUnboundMethodAndClosureMarkersProduceTheLoudBackstopError(): [], [['line' => 3, 'name' => 'wrap']], ); - self::assertNotNull($named); - self::assertStringContainsString('`wrap`', $named); - self::assertStringContainsString('line 3', $named); + self::assertSame( + 'The generic type-parameter clause for `wrap` (line 3) was recognized but never bound to ' + . 'its declaration — compiling on would silently drop the type parameters from the ' + . 'emitted code. This is a transpiler bug; please report it. As a workaround, keep the ' + . 'declaration header (attributes, modifiers, and name) on a single line.', + $named, + ); $anonymous = XphpSourceParser::unboundDeclarationMarkerMessage( [], [['line' => 9, 'name' => '']], ); - self::assertNotNull($anonymous); - self::assertStringContainsString('an anonymous closure', $anonymous); - self::assertStringContainsString('line 9', $anonymous); + self::assertSame( + 'The generic type-parameter clause for an anonymous closure (line 9) was recognized but ' + . 'never bound to its declaration — compiling on would silently drop the type parameters ' + . 'from the emitted code. This is a transpiler bug; please report it. As a workaround, ' + . 'keep the declaration header (attributes, modifiers, and name) on a single line.', + $anonymous, + ); // Class markers are reported first when both kinds survive. $both = XphpSourceParser::unboundDeclarationMarkerMessage( [['line' => 1, 'name' => 'A']], [['line' => 2, 'name' => 'b']], ); - self::assertNotNull($both); - self::assertStringContainsString('`A`', $both); + self::assertSame( + 'The generic type-parameter clause for `A` (line 1) was recognized but never bound to ' + . 'its declaration — compiling on would silently drop the type parameters from the ' + . 'emitted code. This is a transpiler bug; please report it. As a workaround, keep the ' + . 'declaration header (attributes, modifiers, and name) on a single line.', + $both, + ); } public function testGenericClosureDefaultIsAccepted(): void diff --git a/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php b/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php index b1e8c92..fc2b962 100644 --- a/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php +++ b/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php @@ -25,4 +25,8 @@ // $a and $b are the same App-side specialization; $c is Other's. Assert::assertSame(get_class($a), get_class($b)); Assert::assertNotSame(get_class($a), get_class($c)); -Assert::assertStringContainsString('Other', get_class($c)); +// $c is a relocated specialization: emitted under Other's Generated namespace. +Assert::assertSame( + 'XPHP\\Generated\\Other\\Box\\T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8', + get_class($c), +); From 1ea7c56ebdefa301c20a33b4152c0d4df9547111 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 11 Jul 2026 09:28:46 +0000 Subject: [PATCH 78/80] docs: restructure the how-it-works guide with per-stage pages and diagrams Split the single 600-line guide into an index (intro, pipeline-overview diagrams, a stage table-of-contents, cross-cutting concerns, and the class roster) plus one page per stage under docs/guides/how-it-works/: source parsing, hierarchy/registry, method-function specialization, the fixed-point loop, variance edges, rewriting/emission, bound validation, and the validation gate. Each stage page keeps its diagram and gains prev/index/next navigation. The index keeps its path, so existing inbound links are unaffected; relative links inside the moved content were re-based for the deeper directory. Add Mermaid diagrams for the validation gate (how check and a safe compile share one gate), closure-signature checking (erase to a bare Closure, check only the statically-decidable return-position literal), and variance extends-edge emission (the real subtype links wired between specializations of a variant template). Reword the phase-labels note for the split layout: drop the stale "six narrative stages below" phrasing, complete the internal phase list (2.3, 2.4, 3.5), call out variance-edge emission as Phase 2.5 / Stage 4.5, and note the validation gate as command-level orchestration rather than a numbered phase. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guides/how-it-works.md | 354 ++++-------------- docs/guides/how-it-works/01-source-parsing.md | 53 +++ .../how-it-works/02-hierarchy-and-registry.md | 49 +++ .../03-method-function-specialization.md | 46 +++ .../04-fixed-point-specialization.md | 49 +++ .../guides/how-it-works/04b-variance-edges.md | 44 +++ .../how-it-works/05-rewriting-and-emission.md | 37 ++ .../how-it-works/06-bound-validation.md | 50 +++ .../guides/how-it-works/07-validation-gate.md | 58 +++ 9 files changed, 452 insertions(+), 288 deletions(-) create mode 100644 docs/guides/how-it-works/01-source-parsing.md create mode 100644 docs/guides/how-it-works/02-hierarchy-and-registry.md create mode 100644 docs/guides/how-it-works/03-method-function-specialization.md create mode 100644 docs/guides/how-it-works/04-fixed-point-specialization.md create mode 100644 docs/guides/how-it-works/04b-variance-edges.md create mode 100644 docs/guides/how-it-works/05-rewriting-and-emission.md create mode 100644 docs/guides/how-it-works/06-bound-validation.md create mode 100644 docs/guides/how-it-works/07-validation-gate.md diff --git a/docs/guides/how-it-works.md b/docs/guides/how-it-works.md index 1e92857..8d8b9cc 100644 --- a/docs/guides/how-it-works.md +++ b/docs/guides/how-it-works.md @@ -2,9 +2,9 @@ Narrative walkthrough of the compile pipeline -- what `bin/xphp compile` does between reading `.xphp` source and writing vanilla `.php` files -that any stock PHP 8.4 runtime can execute. Each stage is paired with -a Mermaid diagram so the same information is available both -visually and in prose. +that any stock PHP 8.4 runtime can execute. Each stage has its own page +below, paired with a Mermaid diagram so the same information is +available both visually and in prose. For the feature inventory ("what does xphp support today?") see the [syntax tour](../syntax/index.md). For the strategic comparison against @@ -26,11 +26,14 @@ directory while specialized classes land in a cache directory at `/Generated/