From 6efc446b29c8536bf99ce3bbc498413e7baeec22 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 25 Jul 2026 23:44:06 +0000 Subject: [PATCH 1/9] feat(specializer): substitute method-generic turbofish args on all call-node kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The specialization substitution visitor grounded ATTR_METHOD_GENERIC_ARGS type refs only on FuncCall nodes (the variable-turbofish shape), so a static, instance, or nullsafe method turbofish inside a generic template body (`Maker::wrap::`, `$this->m::`) kept an abstract type-param ref after the enclosing class specialized. Widen the arm to StaticCall, MethodCall, and NullsafeMethodCall. Behavior-inert on its own: nothing consumes the grounded marker on those node kinds yet, so the emit leak guard still rejects the shape — this is the enabling primitive for dispatching it. Co-Authored-By: Claude Fable 5 --- src/Transpiler/Monomorphize/Specializer.php | 29 +++-- .../SpecializerMethodGenericArgsTest.php | 105 ++++++++++++++++-- 2 files changed, 119 insertions(+), 15 deletions(-) diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index bc2c28d6..f4301bcd 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -9,6 +9,7 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\NullsafeMethodCall; +use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; use PhpParser\Node\Name; @@ -331,15 +332,27 @@ public function __construct(private Substitution $substitution) public function leaveNode(Node $node): ?Node { - // Ground a variable-turbofish call's type arguments in place. An inner + // Ground a method-generic 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) { + // a Variable whose type args live in ATTR_METHOD_GENERIC_ARGS; a static / + // instance / nullsafe method turbofish (`Maker::wrap::`, `$this->m::`) + // carries the same attribute on its call node. When the enclosing generic + // specializes (`T → int`) those recorded args must ground too, so a + // downstream grounding pass sees `::` and can dispatch it. Left + // un-substituted the call keeps a raw `T` ref and is rejected by the emit + // leak guard. (This substitution is e2e-inert until a grounding pass + // consumes it — it only rewrites the recorded type args, never emits.) + // @infection-ignore-all LogicalOrAllSubExprNegation — node classes are + // mutually exclusive, so the all-negated disjunction is a tautology + // (every node passes); the arm is still gated on the marker attribute, + // which only call nodes carry, so the mutant is observationally + // equivalent. The per-kind instanceof checks are pinned positively by + // SpecializerMethodGenericArgsTest's call-kind provider. + if ($node instanceof FuncCall + || $node instanceof StaticCall + || $node instanceof MethodCall + || $node instanceof NullsafeMethodCall + ) { $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). */ diff --git a/test/Transpiler/Monomorphize/SpecializerMethodGenericArgsTest.php b/test/Transpiler/Monomorphize/SpecializerMethodGenericArgsTest.php index 3ce5e69c..81c86d17 100644 --- a/test/Transpiler/Monomorphize/SpecializerMethodGenericArgsTest.php +++ b/test/Transpiler/Monomorphize/SpecializerMethodGenericArgsTest.php @@ -5,24 +5,35 @@ namespace XPHP\Transpiler\Monomorphize; use PhpParser\Node\Arg; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Expr\NullsafeMethodCall; +use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Identifier; +use PhpParser\Node\Name; use PhpParser\Node\Param; +use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Function_; use PhpParser\Node\Stmt\Return_; use PhpParser\NodeFinder; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** - * When a generic template specializes, an inner variable-turbofish call - * (`$inner::(...)`) 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. + * When a generic template specializes, a method-generic turbofish call inside its body + * carries the enclosing type parameter in ATTR_METHOD_GENERIC_ARGS — a variable turbofish + * (`$inner::(...)`, a FuncCall), a static call (`Maker::wrap::`), an instance call + * (`$this->m::`), or a nullsafe call (`$obj?->m::`). The Specializer's shared + * substituting visitor must ground the recorded type arguments (`T` → `int`) on every one + * of those call-node kinds so a downstream grounding pass sees `::` rather than the + * abstract `::` — a call kind the substitution skips keeps a raw type-param ref and is + * rejected by the emit leak guard. * * 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. + * until a grounding pass dispatches them); the assertion is on the recorded attribute + * directly. */ final class SpecializerMethodGenericArgsTest extends TestCase { @@ -79,4 +90,84 @@ public function testConcreteInnerTurbofishArgsSurviveSpecializationUnchanged(): self::assertIsArray($grounded); self::assertSame('int', $grounded[0]->name); } + + /** + * @return iterable}> + */ + public static function methodTurbofishCallKinds(): iterable + { + $args = [new Arg(new Variable('v'))]; + yield 'static call (Maker::wrap::)' => [ + new StaticCall(new Name('Maker'), new Identifier('wrap'), $args), + StaticCall::class, + ]; + yield 'instance call ($this->m::)' => [ + new MethodCall(new Variable('this'), new Identifier('m'), $args), + MethodCall::class, + ]; + yield 'nullsafe call ($obj?->m::)' => [ + new NullsafeMethodCall(new Variable('obj'), new Identifier('m'), $args), + NullsafeMethodCall::class, + ]; + } + + /** + * @param class-string $nodeClass + */ + #[DataProvider('methodTurbofishCallKinds')] + public function testMethodTurbofishArgsAreGroundedOnEveryCallNodeKind(Expr $call, string $nodeClass): void + { + $call->setAttribute( + XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, + [new TypeRef('T', [], isScalar: false, isTypeParam: true)], + ); + $template = new ClassMethod('make', [ + 'params' => [new Param(new Variable('v'))], + 'stmts' => [new Return_($call)], + ]); + + $specialized = (new Specializer())->specializeMethod( + $template, + Substitution::of(['T' => new TypeRef('int', [], isScalar: true)]), + 'make_T_int', + ); + + $calls = (new NodeFinder())->findInstanceOf($specialized, $nodeClass); + 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'); + } + + #[DataProvider('methodTurbofishCallKinds')] + public function testNestedTurbofishArgsGroundOnEveryCallNodeKind(Expr $call, string $nodeClass): void + { + // A nested-generic turbofish arg (`Maker::wrap::>`) grounds its leaves in + // place: the outer ref stays `Box`, the inner `T` leaf becomes concrete. + $call->setAttribute( + XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, + [new TypeRef('Box', [new TypeRef('T', [], isScalar: false, isTypeParam: true)], isScalar: false)], + ); + $template = new ClassMethod('make', [ + 'params' => [new Param(new Variable('v'))], + 'stmts' => [new Return_($call)], + ]); + + $specialized = (new Specializer())->specializeMethod( + $template, + Substitution::of(['T' => new TypeRef('string', [], isScalar: true)]), + 'make_T_string', + ); + + $grounded = (new NodeFinder())->findInstanceOf($specialized, $nodeClass)[0] + ->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + self::assertIsArray($grounded); + self::assertSame('Box', $grounded[0]->name); + self::assertSame('string', $grounded[0]->args[0]->name); + self::assertFalse($grounded[0]->args[0]->isTypeParam); + self::assertTrue($grounded[0]->isConcrete(), 'the whole nested ref is concrete after grounding'); + } } From 1aaaba9b91d0b892610b4ae1ce1d41bdebb7785d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 26 Jul 2026 00:12:53 +0000 Subject: [PATCH 2/9] feat(monomorphize): ground named-forward turbofish via an append drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generic function forwarding to a named generic (`identity::($v)` inside `wrap`) was rejected with xphp.unspecialized_generic_leak: specialization substituted the inner marker to a concrete `identity::`, but the append flush attached the specialized body without re-walking it, so nothing dispatched the now-groundable call and the leak backstop tripped. Replace the flat flush with a worklist drain: each buffered specialized function/method is attached (compile mode), then re-traversed with the same rewrite visitor in a markers-only mode that touches ONLY named-call turbofish markers — plain-call sweeps, closure-dispatcher tracking, and static/instance markers (whose resolution is not drain-safe; they keep falling to the leak guard) are skipped. Freshly minted appends re-enter the queue: multi-hop chains ground to the bottom, same-args cycles terminate through the alreadyGenerated dedup, and a strictly-growing chain (`grow::>`) is cut off at a 16-hop cap with the new xphp.unconverged_method_specialization error. Appends now carry their declaring-class/namespace context so a detached body resolves against its own scope, not the last-walked file. The drain also runs in check mode (validate-only, nothing attached): bound violations only provable after substitution, non-convergence, and surviving call markers are collected as diagnostics — closing the gap where `xphp check` silently passed shapes `compile` rejects. Sites the source seam already reported are not double-reported (same-position dedupe, and the guard scan's closure-template arm is excluded in check mode via the new GenericMarkerLeakGuard::findLeak/leakMessage API). The generic_function_named_forward fixture (formerly a reject pin) now compiles and runs end to end; new fixtures pin the chain/cycle accepts, the growing rejects (namespaced + bare top-level), the grounded-bound reject, and check/compile parity for each. Co-Authored-By: Claude Fable 5 --- .../Monomorphize/GenericMarkerLeakGuard.php | 54 +++- .../Monomorphize/GenericMethodCompiler.php | 299 ++++++++++++++++-- .../Monomorphize/CheckPassIntegrationTest.php | 58 ++++ .../GenericFunctionIntegrationTest.php | 114 +++++++ .../GenericMarkerLeakGuardTest.php | 41 +++ .../GenericMarkerLeakIntegrationTest.php | 25 +- .../check/forward_growth/source/Use.xphp | 23 ++ .../check/forward_growth_pair/source/Use.xphp | 28 ++ .../source/Use.xphp | 17 + .../check/forward_named_bound/source/Use.xphp | 26 ++ .../check/forward_named_clean/source/Use.xphp | 23 ++ .../source/Use.xphp | 26 ++ .../source/Use.xphp | 40 +++ .../verify/runtime.php | 38 +++ .../source/Use.xphp | 21 ++ .../source/Use.xphp | 23 ++ .../source/Use.xphp | 23 ++ .../verify/runtime.php | 39 +++ .../Use.expected.php | 33 ++ .../source/Use.xphp | 22 -- 20 files changed, 895 insertions(+), 78 deletions(-) create mode 100644 test/fixture/check/forward_growth/source/Use.xphp create mode 100644 test/fixture/check/forward_growth_pair/source/Use.xphp create mode 100644 test/fixture/check/forward_inner_closure_leak/source/Use.xphp create mode 100644 test/fixture/check/forward_named_bound/source/Use.xphp create mode 100644 test/fixture/check/forward_named_clean/source/Use.xphp create mode 100644 test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp create mode 100644 test/fixture/compile/generic_function_forward_chain/source/Use.xphp create mode 100644 test/fixture/compile/generic_function_forward_chain/verify/runtime.php create mode 100644 test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp create mode 100644 test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp create mode 100644 test/fixture/compile/generic_function_named_forward/source/Use.xphp create mode 100644 test/fixture/compile/generic_function_named_forward/verify/runtime.php create mode 100644 test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php delete mode 100644 test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp diff --git a/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php b/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php index 9d259833..70edda3d 100644 --- a/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php +++ b/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php @@ -45,17 +45,26 @@ final class GenericMarkerLeakGuard public const CODE = 'xphp.unspecialized_generic_leak'; /** - * Throw if any generic marker survives into a specialized AST subtree. + * Find the first surviving generic marker in a specialized AST subtree, or null when + * the subtree is clean. The scan is the guard's single source of truth — `assertNoLeak` + * throws on it, and `check`-mode callers degrade it to a collected diagnostic so the + * validate-only pass reports the same shapes the compile-time backstop rejects. + * + * `$includeClosureTemplates` toggles the defense-in-depth arm. The compile-time + * assert keeps it on. The check-mode drain turns it off: an un-specialized closure + * template inside a drained body always accompanies either a source-seam diagnostic + * on its call site (a different line — the template node's own line would dodge the + * caller's already-reported dedupe) or an orphan diagnostic from the + * declared-but-never-specialized check, so re-flagging the template node itself only + * double-reports; the call-site marker arm is what carries new information there. * * @param Node|list $specialized the emitted specialized node(s) - * @param string $label the specialization's identity, for the error message */ - public static function assertNoLeak(Node|array $specialized, string $label): void + public static function findLeak(Node|array $specialized, bool $includeClosureTemplates = true): ?Node { $nodes = is_array($specialized) ? $specialized : [$specialized]; - $finder = new NodeFinder(); - $leak = $finder->findFirst($nodes, static function (Node $n): bool { + return (new NodeFinder())->findFirst($nodes, static function (Node $n) use ($includeClosureTemplates): bool { if ($n instanceof FuncCall || $n instanceof MethodCall || $n instanceof StaticCall @@ -63,21 +72,25 @@ public static function assertNoLeak(Node|array $specialized, string $label): voi ) { return $n->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) !== null; } - if ($n instanceof Closure || $n instanceof ArrowFunction) { + if ($includeClosureTemplates && ($n instanceof Closure || $n instanceof ArrowFunction)) { return is_array($n->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)); } return false; }); + } - if ($leak === null) { - return; - } - + /** + * Build the guard's diagnostic message for a found leak. Shared verbatim between the + * compile-time throw and the check-mode collected diagnostic so both modes name the + * same site the same way. + */ + public static function leakMessage(Node $leak, string $label): string + { // @infection-ignore-all Concat ConcatOperandRemoval — the diagnostic wording is not // behavior: the tests pin that a leak throws and that the message names the label, the // line, and the code; reordering or dropping a prose clause changes none of those. - throw new RuntimeException(sprintf( + return sprintf( 'A generic turbofish/closure marker survived specialization into the emitted output for %s ' . '(near line %d). This site could not be grounded to a concrete type, so its type-parameter ' . 'hints would reach the emitted PHP as references to non-existent classes — a runtime TypeError. ' @@ -87,6 +100,23 @@ public static function assertNoLeak(Node|array $specialized, string $label): voi $label, $leak->getStartLine(), self::CODE, - )); + ); + } + + /** + * Throw if any generic marker survives into a specialized AST subtree. + * + * @param Node|list $specialized the emitted specialized node(s) + * @param string $label the specialization's identity, for the error message + */ + public static function assertNoLeak(Node|array $specialized, string $label): void + { + $leak = self::findLeak($specialized); + + if ($leak === null) { + return; + } + + throw new RuntimeException(self::leakMessage($leak, $label)); } } diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 1cbb6adf..481ffb03 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -94,6 +94,18 @@ final class GenericMethodCompiler 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'; + public const CODE_UNCONVERGED_METHOD_SPECIALIZATION = 'xphp.unconverged_method_specialization'; + + /** + * Cap on the append-drain's specialization chain depth. A freshly specialized + * function/method body may itself carry a now-concrete turbofish that mints a further + * specialization (`wrap` forwarding to `mid::` forwarding to `identity::`); + * same-args cycles terminate via the alreadyGenerated dedup, but a strictly-growing + * chain (`grow` calling `grow::>`) mints a new mangled name every hop and + * would never converge. Sixteen mirrors Compiler::MAX_SPECIALIZATION_DEPTH (kept as a + * separate constant — this pass must stay independent of the class-level pipeline). + */ + private const MAX_METHOD_SPECIALIZATION_HOPS = 16; /** * @param ?DiagnosticCollector $diagnostics When null (the default — `xphp compile`), every @@ -410,9 +422,30 @@ private function rewriteCallSites( */ private NamespaceContext $nsContext; - /** @var list */ + /** + * Buffered specialized-member appends, flushed (and drained for freshly + * grounded markers) after the traversal. Slot 2 is the drain context: the + * declaring class FQN (methods; null for functions) and the namespace the + * specialized body resolves against — a drained stmt is traversed DETACHED, + * so the visitor's namespace/class state must be primed per item rather + * than inherited from whatever file the main walk last visited. + * + * @var list + */ public array $pendingAppends = []; + /** + * Markers-only mode for drain re-traversals of freshly specialized bodies. + * When true the rewrite pass touches ONLY named-call turbofish markers that + * substitution has made concrete; everything else — plain-call closure-arg + * sweeps, closure-dispatcher tracking (finalize has already run), orphan + * re-checks, static/instance marker rewrites (their name resolution is not + * drain-safe yet; a kept marker falls to the leak guard exactly as before) — + * is skipped so a drained body can neither duplicate diagnostics already + * reported against the template nor mis-ground through stale file state. + */ + public bool $markersOnly = false; + /** Receiver-type analysis state. Pushed on entering ClassLike, popped on leave. */ private ?string $currentClassFqn = null; /** @@ -582,6 +615,34 @@ public function __construct( $this->nsContext = new NamespaceContext(); } + /** + * Reset the visitor's lexical state for one drained (detached) specialized + * stmt. The stmt is traversed outside any Namespace_/Use_/ClassLike parent, + * so enterNode never primes this state — left stale it would resolve names + * against whatever file the main traversal last walked. The use-alias map is + * cleared rather than reconstructed: names the drain needs are attribute- + * resolved (ATTR_TEMPLATE_FQN / ATTR_RESOLVED_FQN at parse time), so aliases + * are never consulted on the markers-only path. + */ + public function primeDrainScope(?string $classFqn, string $namespace): void + { + $this->currentClassFqn = $classFqn; + $this->currentNamespace = $namespace; + $this->currentNamespaceNode = null; + $this->useMap = []; + $this->nsContext = new NamespaceContext(); + $this->nsContext->enterNamespace($namespace !== '' ? $namespace : null); + $this->currentScopeParamTypes = []; + $this->currentScopeLocalTypes = []; + $this->currentScopeParamTypeArgs = []; + $this->currentScopeLocalTypeArgs = []; + $this->branchSnapshots = []; + $this->scopeSnapshots = []; + $this->currentScopeClosureTemplates = []; + $this->currentScopeClosureContexts = []; + $this->callReturnCache = []; + } + public function enterNode(Node $node): null { if ($node instanceof Namespace_) { @@ -856,12 +917,34 @@ public function enterNode(Node $node): null public function leaveNode(Node $node): ?Node { if ($node instanceof StaticCall) { + // Drain traversals leave static markers untouched: their class-name + // resolution is not drain-safe yet, and a kept marker falls to the + // leak guard exactly as it did before the drain existed. + if ($this->markersOnly) { + return null; + } return $this->rewriteStaticCall($node); } if ($node instanceof FuncCall) { + // Drain traversals rewrite ONLY named-call turbofish markers: dispatch + // is ATTR_TEMPLATE_FQN-driven (no lexical resolution), so a detached + // body grounds safely. Bare calls and variable turbofish (`$f::<...>`) + // are skipped — dispatcher finalize has already run, and a surviving + // variable marker stays for the leak guard's closure arm. + if ($this->markersOnly + && (!$node->name instanceof Name + || $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) === null) + ) { + return null; + } return $this->rewriteFuncCall($node); } if ($node instanceof MethodCall || $node instanceof NullsafeMethodCall) { + // Same as StaticCall: instance-marker grounding inside drained bodies + // is not supported here; the marker survives for the leak guard. + if ($this->markersOnly) { + return null; + } return $this->rewriteInstanceMethodCall($node); } if ($node instanceof ClassLike) { @@ -1285,7 +1368,10 @@ private function rewriteStaticCall(StaticCall $node): ?Node $owner = $this->index->classLike($declaringFqn); if ($owner !== null) { // Buffer the append (see rewriteFuncCall for the rationale). - $this->pendingAppends[] = [$owner, $specialized]; + $this->pendingAppends[] = [$owner, $specialized, [ + 'classFqn' => $declaringFqn, + 'namespace' => self::namespaceOf($declaringFqn), + ]]; $this->alreadyGenerated[$generatedKey] = true; } } @@ -1480,7 +1566,10 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): $specialized = (new Specializer())->specializeMethod($template, Substitution::of($overlay), $mangled); $owner = $this->index->classLike($declaringFqn); if ($owner !== null) { - $this->pendingAppends[] = [$owner, $specialized]; + $this->pendingAppends[] = [$owner, $specialized, [ + 'classFqn' => $declaringFqn, + 'namespace' => self::namespaceOf($declaringFqn), + ]]; $this->alreadyGenerated[$generatedKey] = true; } } @@ -2368,7 +2457,10 @@ private function rewriteFuncCall(FuncCall $node): ?Node // Buffer the append — modifying $namespaceNode->stmts mid-traversal // doesn't reliably propagate through nikic's NodeTraverser. The // outer process() loop flushes pendingAppends after the walk. - $this->pendingAppends[] = [$namespaceNode, $specialized]; + $this->pendingAppends[] = [$namespaceNode, $specialized, [ + 'classFqn' => null, + 'namespace' => $namespace, + ]]; } else { // Bare top-level template (no enclosing `namespace { }` block): // there's no container to append to, so route the specialized @@ -2737,6 +2829,13 @@ private static function lastSegment(string $name): string $pos = strrpos($name, '\\'); return $pos === false ? $name : substr($name, $pos + 1); } + + /** The namespace part of an FQN ('' for a global-namespace symbol). */ + private static function namespaceOf(string $fqn): string + { + $pos = strrpos($fqn, '\\'); + return $pos === false ? '' : substr($fqn, 0, $pos); + } }; $traverser = new NodeTraverser(); @@ -2747,33 +2846,180 @@ private static function lastSegment(string $name): string // diagnostics too (this is a validation, not an emission side-effect). $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) { - return; - } - // Pass 2 of the closure-dispatcher pipeline: materialize a dispatcher // closure per recorded template, replace the original Assign's RHS, // append specialized declarations, and rewrite each collected call - // site to inject the tag arg. - $this->finalizeClosureDispatchers($visitor, $hashLength); - - // Apply buffered appends now that the traversal has finished, so we don't fight - // nikic's NodeTraverser's child-array iteration semantics mid-walk. Each appended - // node is a fully specialized function/method: guard it against a surviving generic - // marker (a site that could not be grounded) before it reaches emitted output — the - // function-shaped counterpart to the specialized-class backstop in Compiler's emit - // loop. Compile-only: the `!$emit` gate above already returned for `check`. - foreach ($visitor->pendingAppends as [$container, $stmt]) { - $container->stmts[] = $stmt; - GenericMarkerLeakGuard::assertNoLeak($stmt, $currentFile . ' (' . $stmt->name->toString() . ')'); + // site to inject the tag arg. Compile-only: it mutates shared Assign + // nodes and call sites, which check's discarded walk must not do. + if ($emit) { + $this->finalizeClosureDispatchers($visitor, $hashLength); } - foreach ($topLevelAppends as $stmt) { - GenericMarkerLeakGuard::assertNoLeak($stmt, $currentFile . ' (' . $stmt->name->toString() . ')'); + + // Drain the buffered appends now that the traversal has finished, so we don't + // fight nikic's NodeTraverser's child-array iteration semantics mid-walk. Runs + // in BOTH modes: compile attaches, grounds, and backstops each appended body; + // check re-traverses the (discarded) bodies validate-only so diagnostics that + // only become provable after substitution are collected — keeping check and + // compile verdicts aligned. + $this->drainSpecializedAppends($visitor, $currentFile, $emit); + } + + /** + * Flush the buffered specialized appends as a grounding worklist. + * + * Each buffered stmt is a freshly specialized function/method whose body may itself + * carry method-generic turbofish markers that substitution has made concrete + * (`identity::` inside `wrap` becomes `identity::` inside the buffered + * `wrap_T_`). A flat flush would emit those bodies ungrounded — the leak-guard + * backstop tripped on exactly that shape. Instead each stmt is attached (compile + * mode), then re-traversed with the same rewrite visitor in markers-only mode so a + * named-forward marker dispatches and may buffer further appends; the loop repeats + * until both queues drain. Same-args cycles (`a` forwarding to `b` forwarding + * back) terminate through the shared alreadyGenerated dedup — the second visit finds + * the key set and only rewrites the call. A strictly-growing chain mints a fresh + * mangled name every hop and is cut off at MAX_METHOD_SPECIALIZATION_HOPS with a + * loud non-convergence error instead of an endless compile. + * + * Check mode traverses without attaching (the walked ASTs are discarded) and + * degrades both the non-convergence error and the leak backstop to collected + * diagnostics, so `xphp check` reports the shapes `compile` rejects. + */ + private function drainSpecializedAppends(object $visitor, string $currentFile, bool $emit): void + { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $visitor->markersOnly = true; + // @infection-ignore-all UnwrapFinally — the reset is defensive hygiene: this drain is + // the visitor's last use (one visitor per rewriteCallSites call), so a leftover + // markersOnly=true is dead state today; the finally guards future reuse, not behavior. + try { + $pendingIdx = 0; + $topLevelIdx = 0; + /** @var array $hopDepth spl_object_id(stmt) => chain depth; absent = 1 (buffered by the user-code walk) */ + $hopDepth = []; + while (true) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + if ($pendingIdx < count($visitor->pendingAppends)) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + [$container, $stmt, $context] = $visitor->pendingAppends[$pendingIdx++]; + if ($emit) { + $container->stmts[] = $stmt; + } + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + } elseif ($topLevelIdx < count($visitor->topLevelAppends)) { + // Top-level (null-namespace) functions have no container node here; + // process() flushes them into the top-level AST array after this + // method returns. They are still grounded + leak-checked like any + // other append. + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $stmt = $visitor->topLevelAppends[$topLevelIdx++]; + $context = ['classFqn' => null, 'namespace' => '']; + } else { + break; + } + + // @infection-ignore-all IncrementInteger DecrementInteger — shifting the + // initial depth by one only offsets where the cap lands (16±1 hops); the + // behavior — a growing chain halts loudly with the unconverged code — is + // pinned by the growth fixtures, and the exact allowance is not contract. + $depth = $hopDepth[spl_object_id($stmt)] ?? 1; + if ($depth > self::MAX_METHOD_SPECIALIZATION_HOPS) { + $message = sprintf( + 'Generic method/function specialization did not converge: grounding "%s" ' + . '(in %s) is %d specialization hops deep — each hop mints a new type argument ' + . '(e.g. a generic forwarding to itself with a nested `Box`), so the chain ' + . 'would never terminate. Break the growth by forwarding a concrete turbofish. [%s]', + $stmt->name->toString(), + $currentFile, + $depth, + self::CODE_UNCONVERGED_METHOD_SPECIALIZATION, + ); + if (!$emit && $this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_UNCONVERGED_METHOD_SPECIALIZATION, + $message, + new SourceLocation($currentFile, $stmt->getStartLine()), + )); + continue; + } + throw new RuntimeException($message); + } + + // @phpstan-ignore-next-line method.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $visitor->primeDrainScope($context['classFqn'], $context['namespace']); + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $beforePending = count($visitor->pendingAppends); + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $beforeTopLevel = count($visitor->topLevelAppends); + + $traverser = new NodeTraverser(); + assert($visitor instanceof NodeVisitorAbstract); + $traverser->addVisitor($visitor); + $traverser->traverse([$stmt]); + + // @infection-ignore-all IncrementInteger Plus — a coarser per-hop increment + // only halves/offsets the cap allowance; growth still halts loudly with the + // unconverged code (pinned by the growth fixtures) at the same reported depth. + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + for ($i = $beforePending; $i < count($visitor->pendingAppends); $i++) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $hopDepth[spl_object_id($visitor->pendingAppends[$i][1])] = $depth + 1; + } + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + for ($i = $beforeTopLevel; $i < count($visitor->topLevelAppends); $i++) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $hopDepth[spl_object_id($visitor->topLevelAppends[$i])] = $depth + 1; + } + + $label = $currentFile . ' (' . $stmt->name->toString() . ')'; + if ($emit) { + GenericMarkerLeakGuard::assertNoLeak($stmt, $label); + } elseif ($this->diagnostics !== null) { + // Call-marker arm only: an un-specialized closure template in a + // drained body is always reported elsewhere (seam or orphan check). + $leak = GenericMarkerLeakGuard::findLeak($stmt, includeClosureTemplates: false); + // Suppress the backstop when the site already carries a diagnostic: + // the cloned body preserves the template's line numbers, so a shape + // the source seam rejected with a precise error (e.g. a non-concrete + // variable turbofish, CODE_UNSPECIALIZED_GENERIC_CLOSURE) would + // otherwise double-report here under the vaguer leak code. + if ($leak !== null && !$this->alreadyReportedAt($currentFile, $leak->getStartLine())) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMarkerLeakGuard::CODE, + GenericMarkerLeakGuard::leakMessage($leak, $label), + new SourceLocation($currentFile, $leak->getStartLine()), + )); + } + } + } + } finally { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $visitor->markersOnly = false; } } + /** + * Whether the collector already holds a diagnostic at this exact source position. + * Used by the drain's check-mode backstop to avoid re-reporting a site the source + * seam rejected with a more precise code. + */ + private function alreadyReportedAt(string $file, int $line): bool + { + if ($this->diagnostics === null) { + return false; + } + foreach ($this->diagnostics->all() as $diagnostic) { + if ($diagnostic->location !== null + && $diagnostic->location->file === $file + && $diagnostic->location->line === $line + ) { + return true; + } + } + return false; + } + /** * Reject every generic closure/arrow template no call site attempted to * specialize. Specialization is call-site-driven: with no in-scope @@ -2857,7 +3103,10 @@ private function finalizeClosureDispatchers(object $visitor, int $hashLength): v foreach ($result['declarations'] as $specialized) { if ($entry['namespaceNode'] !== null) { // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. - $visitor->pendingAppends[] = [$entry['namespaceNode'], $specialized]; + $visitor->pendingAppends[] = [$entry['namespaceNode'], $specialized, [ + 'classFqn' => null, + 'namespace' => $entry['namespace'], + ]]; } else { // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. $visitor->topLevelAppends[] = $specialized; diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index cd46e6a5..3ce0316b 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -43,6 +43,64 @@ public function testCleanSourcesProduceNoDiagnostics(): void self::assertSame([], $diagnostics->all()); } + public function testNamedForwardGroundedByEnclosingParamIsCleanInCheck(): void + { + // `wrap` forwarding `identity::` is grounded per specialization by the + // append-drain; the validate-only walk must agree with compile and report + // nothing — no duplicate diagnostics from the drain's re-traversal either. + $diagnostics = $this->check('forward_named_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testGroundedForwardBoundViolationIsCollectedByCheck(): void + { + // `need` forwarded `T = int`: provable only after `wrap::` + // substitutes, so it surfaces from the drain's validate-only traversal — + // matching the compile-side throw (check/compile parity). + $diagnostics = $this->check('forward_named_bound'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_BOUND_VIOLATION, $diagnostics->all()[0]->code); + } + + public function testUnconvergedForwardChainIsCollectedByCheck(): void + { + // `grow` forwarding `grow::>` never converges; check collects the + // drain's hop-cap diagnostic instead of hanging or throwing. + $diagnostics = $this->check('forward_growth'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION, $codes); + } + + public function testEachUnconvergedChainGetsItsOwnDiagnosticInCheck(): void + { + // Two independent growing chains: hitting the cap on the first stops that + // chain only — the drain keeps going and the second chain reports too. + $diagnostics = $this->check('forward_growth_pair'); + + $unconverged = array_values(array_filter( + $diagnostics->all(), + static fn ($d) => $d->code === GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION, + )); + self::assertCount(2, $unconverged); + } + + public function testInnerClosureTurbofishLeakIsCollectedByCheck(): void + { + // A concrete inner closure turbofish (`$f::` inside `outer`) survives + // the drain (variable turbofish stays out of the markers-only pass) and is + // degraded from the compile-time leak throw to a collected diagnostic here. + $diagnostics = $this->check('forward_inner_closure_leak'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(GenericMarkerLeakGuard::CODE, $codes); + } + public function testDefaultBoundViolationIsCollectedByCheck(): void { // Exercises the validateDefaultsAgainstBounds() step of check(). diff --git a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php index d95b5250..4cc61d4e 100644 --- a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php @@ -356,6 +356,120 @@ function bareId(T $x): T } } + public function testNamedForwardGroundedByEnclosingParamSpecializes(): void + { + // `wrap` forwards `identity::($v)` — abstract in the template, concrete + // after `wrap::` / `wrap::` specialize. The append-drain grounds + // each specialized body and dispatches the forward into a real + // `identity_T_` declaration; no marker (and no raw `identity(`) survives. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_named_forward/source', + 'genfn-named-forward', + ); + try { + $content = file_get_contents($fixture->targetDir . '/Use.php'); + self::assertIsString($content); + + // Two instantiations → two wrap + two forwarded identity specializations. + self::assertSame(2, preg_match_all('/function wrap_T_[0-9a-f]+\(/', $content)); + self::assertSame(2, preg_match_all('/function identity_T_[0-9a-f]+\(/', $content)); + // Each specialized wrap body calls the matching identity specialization. + self::assertSame(2, preg_match_all('/return \\\\App\\\\NamedForward\\\\identity_T_[0-9a-f]+\(/', $content)); + // Negative invariants: templates stripped, no un-rewritten forward survives + // (`identity::<` only appears in the carried-over source comment's prose). + self::assertStringNotContainsString('function wrap(', $content); + self::assertStringNotContainsString('function identity(', $content); + self::assertStringNotContainsString('return identity::<', $content); + SnapshotHash::assertMatches( + __DIR__ . '/../../fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php', + $content, + ); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testNamedForwardRuntimeExecution(): void + { + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_named_forward/source', + 'genfn-named-forward-runtime', + ); + try { + $runtime = require __DIR__ . '/../../fixture/compile/generic_function_named_forward/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testForwardChainAndSameArgsCycleRuntimeExecution(): void + { + // 2-hop chain (`wrap` → `mid` → `identity`) and same-args mutual recursion + // (`ping` ↔ `pong`): the drain keeps grounding freshly appended bodies until + // the queue empties, and the specialization dedup terminates the cycle. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_chain/source', + 'genfn-forward-chain', + ); + try { + $runtime = require __DIR__ . '/../../fixture/compile/generic_function_forward_chain/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testGrowingForwardChainIsRejectedAsUnconverged(): void + { + // `grow` forwards `grow::>` — every hop mints a deeper type argument, + // so the chain can never converge. The drain's hop cap rejects it loudly. The + // reported depth pins the cap boundary exactly: sixteen allowed hops, failing + // on the seventeenth. + try { + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_growth_reject/source', + 'genfn-forward-growth', + ); + self::fail('expected the growing chain to be rejected'); + } catch (RuntimeException $e) { + self::assertStringContainsString(GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION, $e->getMessage()); + self::assertStringContainsString('is 17 specialization hops deep', $e->getMessage()); + } + } + + #[RunInSeparateProcess] + public function testGrowingBareTopLevelForwardChainIsRejectedAsUnconverged(): void + { + // Top-level variant: specializations route through the top-level append bag + // (no Namespace_ container), whose queue the hop cap must bound identically — + // an unbounded bare-file chain would otherwise specialize forever. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_growth_bare_reject/source', + 'genfn-forward-growth-bare', + ); + } + + #[RunInSeparateProcess] + public function testGroundedForwardBoundViolationFailsCompilation(): void + { + // `need` forwarded `T = int` — the violation is only provable + // after `wrap::` substitutes, so it must fail at the grounding drain. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated while instantiating App\ForwardBound\need'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_bound_reject/source', + 'genfn-forward-bound', + ); + } + private function compile(): void { $compiler = $this->buildCompiler(); diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php index 0440700e..ff6e68e0 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php @@ -140,4 +140,45 @@ public function testAcceptsAListOfNodesAndScansEach(): void $this->expectException(RuntimeException::class); GenericMarkerLeakGuard::assertNoLeak([$clean, new Expression($leaking)], 'list'); } + + public function testFindLeakReturnsTheLeakingNodeAndNullOnCleanInput(): void + { + // The check-mode drain consumes the scan directly (degrading to a diagnostic + // instead of a throw), so the found node — not just the boolean outcome — is API. + $leaking = new StaticCall(new Name('self'), new Identifier('gen')); + $leaking->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + + self::assertSame($leaking, GenericMarkerLeakGuard::findLeak(new Expression($leaking))); + self::assertNull(GenericMarkerLeakGuard::findLeak(new Expression(new FuncCall(new Variable('a'))))); + } + + public function testFindLeakCanExcludeTheClosureTemplateArm(): void + { + // With $includeClosureTemplates=false only call-node markers count: an + // un-specialized closure template is reported elsewhere (source seam / orphan + // check), so the check-mode drain must not re-flag the template node itself. + $closure = new Closure(['stmts' => []]); + $closure->setAttribute(self::PARAMS_MARKER, [new Identifier('I')]); + $body = new Expression($closure); + + self::assertSame($closure, GenericMarkerLeakGuard::findLeak($body)); + self::assertNull(GenericMarkerLeakGuard::findLeak($body, includeClosureTemplates: false)); + + // A call-node marker still counts with the closure arm off. + $call = new FuncCall(new Variable('f')); + $call->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + self::assertSame($call, GenericMarkerLeakGuard::findLeak(new Expression($call), includeClosureTemplates: false)); + } + + public function testLeakMessageNamesTheLabelTheLineAndTheCode(): void + { + $call = new FuncCall(new Variable('inner'), [], ['startLine' => 7]); + $call->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + + $message = GenericMarkerLeakGuard::leakMessage($call, 'wrap_T_cafe'); + + self::assertStringContainsString('wrap_T_cafe', $message); + self::assertStringContainsString('7', $message); + self::assertStringContainsString(GenericMarkerLeakGuard::CODE, $message); + } } diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php index 9944ad0c..100421c9 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php @@ -19,9 +19,12 @@ * `TypeError`/`Error`) behind an otherwise clean compile. The backstop turns each into a loud * compile failure carrying `xphp.unspecialized_generic_leak` before any output is written. * - * (The third shape — a generic closure grounded by an enclosing *function* parameter, `relay` — - * is a non-concrete variable turbofish caught earlier at the source seam in both modes; see - * {@see ClosureDispatcherIntegrationTest} and {@see CheckPassIntegrationTest}.) + * (Two adjacent shapes are handled elsewhere: a generic closure grounded by an enclosing + * *function* parameter, `relay`, is a non-concrete variable turbofish caught earlier at the + * source seam in both modes — see {@see ClosureDispatcherIntegrationTest} and + * {@see CheckPassIntegrationTest}; and a NAMED free-function forward (`identity::` inside + * `wrap`) is grounded and dispatched by the append-drain rather than rejected — see the + * `generic_function_named_forward` runtime fixture in {@see GenericFunctionIntegrationTest}.) * * The must-keep side — a working top-level `$g::` dispatcher and the `contains` * enclosing-bound forward — is proven zero-false-reject by the existing `closure_dispatcher_arrow` @@ -54,20 +57,4 @@ public function testMethodTurbofishGroundedByEnclosingClassParamIsRejected(): vo ); } - #[RunInSeparateProcess] - public function testNamedFreeFunctionForwardGroundedByEnclosingParamIsRejected(): void - { - // A named generic free function forwarded a non-concrete type argument from an - // enclosing function parameter (`identity::($v)` inside `wrap`). The named-call - // turbofish is not a variable turbofish, so it slips past the source seam and reaches - // emit as an appended `wrap_T_` with the marker still present — caught by the - // backstop. Same class of shape as the concrete-inner-turbofish case. - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); - - CompiledFixture::compile( - __DIR__ . '/../../fixture/compile/generic_function_named_forward_leak_reject/source', - 'generic-function-named-forward-leak', - ); - } } diff --git a/test/fixture/check/forward_growth/source/Use.xphp b/test/fixture/check/forward_growth/source/Use.xphp new file mode 100644 index 00000000..fd3f9608 --- /dev/null +++ b/test/fixture/check/forward_growth/source/Use.xphp @@ -0,0 +1,23 @@ + +{ + public function __construct(public readonly G $value) + { + } +} + +// A strictly-growing forward: every specialization of `grow` mints a deeper type +// argument (`grow` calls `grow::>`, which calls `grow::>>`, +// ...), so the specialization chain can never converge. Rejected loudly by the drain's +// hop cap instead of compiling forever. +function grow(T $v): int +{ + return grow::>(new Box::($v)); +} + +grow::(1); diff --git a/test/fixture/check/forward_growth_pair/source/Use.xphp b/test/fixture/check/forward_growth_pair/source/Use.xphp new file mode 100644 index 00000000..af574159 --- /dev/null +++ b/test/fixture/check/forward_growth_pair/source/Use.xphp @@ -0,0 +1,28 @@ + +{ + public function __construct(public readonly G $value) + { + } +} + +// TWO independent strictly-growing forwards. Check must collect one unconverged +// diagnostic PER chain: hitting the hop cap on the first chain stops that chain only, +// not the whole drain — the second chain still gets its own report. +function growA(T $v): int +{ + return growA::>(new Box::($v)); +} + +function growB(S $v): int +{ + return growB::>(new Box::($v)); +} + +growA::(1); +growB::('x'); diff --git a/test/fixture/check/forward_inner_closure_leak/source/Use.xphp b/test/fixture/check/forward_inner_closure_leak/source/Use.xphp new file mode 100644 index 00000000..a4381920 --- /dev/null +++ b/test/fixture/check/forward_inner_closure_leak/source/Use.xphp @@ -0,0 +1,17 @@ +`) that works at top-level or in a +// plain function, but NOT inside a generic function body: the variable-turbofish +// dispatch does not run for a closure enclosed by a generic function, so the emitted +// `outer_T_` keeps `fn(U $x): U` referencing the non-existent `App\U`. Rejected. +function outer(T $seed): int +{ + $f = fn(U $x): U => $x; + return $f::(41); +} + +outer::('hi'); diff --git a/test/fixture/check/forward_named_bound/source/Use.xphp b/test/fixture/check/forward_named_bound/source/Use.xphp new file mode 100644 index 00000000..0de306da --- /dev/null +++ b/test/fixture/check/forward_named_bound/source/Use.xphp @@ -0,0 +1,26 @@ +` receives `T = int` once `wrap::` specializes. The violation +// is only provable after substitution, so it must fail at the grounding drain — not +// slip through as an unchecked emitted call. +function need(U $x): U +{ + return $x; +} + +function wrap(T $v): T +{ + return need::($v); +} + +wrap::(1); diff --git a/test/fixture/check/forward_named_clean/source/Use.xphp b/test/fixture/check/forward_named_clean/source/Use.xphp new file mode 100644 index 00000000..95ef2b4f --- /dev/null +++ b/test/fixture/check/forward_named_clean/source/Use.xphp @@ -0,0 +1,23 @@ +($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity(U $x): U +{ + return $x; +} + +function wrap(T $v): T +{ + return identity::($v); +} + +$i = wrap::(3); +$s = wrap::('hi'); diff --git a/test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp b/test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp new file mode 100644 index 00000000..0de306da --- /dev/null +++ b/test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp @@ -0,0 +1,26 @@ +` receives `T = int` once `wrap::` specializes. The violation +// is only provable after substitution, so it must fail at the grounding drain — not +// slip through as an unchecked emitted call. +function need(U $x): U +{ + return $x; +} + +function wrap(T $v): T +{ + return need::($v); +} + +wrap::(1); diff --git a/test/fixture/compile/generic_function_forward_chain/source/Use.xphp b/test/fixture/compile/generic_function_forward_chain/source/Use.xphp new file mode 100644 index 00000000..3a1058a4 --- /dev/null +++ b/test/fixture/compile/generic_function_forward_chain/source/Use.xphp @@ -0,0 +1,40 @@ +` → `mid::` → `identity::`), so the append-drain must keep grounding +// until the chain bottoms out. +function identity(U $x): U +{ + return $x; +} + +function mid(V $x): V +{ + return identity::($x); +} + +function wrap(T $v): T +{ + return mid::($v); +} + +// Same-args mutual recursion: ping

forwards to pong::

, whose body forwards back +// to ping::

. Compile-time termination rides the specialization dedup (the second +// visit finds the mangled key already generated and only rewrites the call); runtime +// termination rides the counter. +function ping

(P $v, int $n): P +{ + return $n <= 0 ? $v : pong::

($v, $n - 1); +} + +function pong(Q $v, int $n): Q +{ + return $n <= 0 ? $v : ping::($v, $n - 1); +} + +$a = wrap::(7); +$b = ping::('x', 3); diff --git a/test/fixture/compile/generic_function_forward_chain/verify/runtime.php b/test/fixture/compile/generic_function_forward_chain/verify/runtime.php new file mode 100644 index 00000000..9e0bdbca --- /dev/null +++ b/test/fixture/compile/generic_function_forward_chain/verify/runtime.php @@ -0,0 +1,38 @@ +` → `mid` → `identity`) and the same-args mutual-recursion pair + * (`ping` ↔ `pong`) both execute against the emitted specializations. + */ + +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + $intHash = Registry::canonicalHash([new TypeRef('int', isScalar: true)]); + foreach (['wrap', 'mid', 'identity'] as $fn) { + Assert::assertTrue( + function_exists('App\\ForwardChain\\' . $fn . '_T_' . $intHash), + $fn . ' specialization is declared', + ); + } + $wrapInt = 'App\\ForwardChain\\wrap_T_' . $intHash; + Assert::assertSame(7, $wrapInt(7)); + + $stringHash = Registry::canonicalHash([new TypeRef('string', isScalar: true)]); + foreach (['ping', 'pong'] as $fn) { + Assert::assertTrue( + function_exists('App\\ForwardChain\\' . $fn . '_T_' . $stringHash), + $fn . ' specialization is declared', + ); + } + $pingString = 'App\\ForwardChain\\ping_T_' . $stringHash; + Assert::assertSame('x', $pingString('x', 3)); +}; diff --git a/test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp b/test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp new file mode 100644 index 00000000..8c44bace --- /dev/null +++ b/test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp @@ -0,0 +1,21 @@ + +{ + public function __construct(public readonly G $value) + { + } +} + +function grow(T $v): int +{ + return grow::>(new Box::($v)); +} + +grow::(1); diff --git a/test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp b/test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp new file mode 100644 index 00000000..fd3f9608 --- /dev/null +++ b/test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp @@ -0,0 +1,23 @@ + +{ + public function __construct(public readonly G $value) + { + } +} + +// A strictly-growing forward: every specialization of `grow` mints a deeper type +// argument (`grow` calls `grow::>`, which calls `grow::>>`, +// ...), so the specialization chain can never converge. Rejected loudly by the drain's +// hop cap instead of compiling forever. +function grow(T $v): int +{ + return grow::>(new Box::($v)); +} + +grow::(1); diff --git a/test/fixture/compile/generic_function_named_forward/source/Use.xphp b/test/fixture/compile/generic_function_named_forward/source/Use.xphp new file mode 100644 index 00000000..95ef2b4f --- /dev/null +++ b/test/fixture/compile/generic_function_named_forward/source/Use.xphp @@ -0,0 +1,23 @@ +($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity(U $x): U +{ + return $x; +} + +function wrap(T $v): T +{ + return identity::($v); +} + +$i = wrap::(3); +$s = wrap::('hi'); diff --git a/test/fixture/compile/generic_function_named_forward/verify/runtime.php b/test/fixture/compile/generic_function_named_forward/verify/runtime.php new file mode 100644 index 00000000..b4f5737c --- /dev/null +++ b/test/fixture/compile/generic_function_named_forward/verify/runtime.php @@ -0,0 +1,39 @@ +` + * dispatches its forwarded `identity::` call to a real `identity_T_` + * specialization — the emitted chain executes end to end and returns the value + * through both hops. + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. + * Free functions aren't autoloadable in PHP, so require the emitted Use.php explicitly + * to bring both specialized declarations into scope (its top-level driver statements + * run too — they exercise the same calls). + */ + +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + $intHash = Registry::canonicalHash([new TypeRef('int', isScalar: true)]); + $wrapInt = 'App\\NamedForward\\wrap_T_' . $intHash; + $identityInt = 'App\\NamedForward\\identity_T_' . $intHash; + Assert::assertTrue(function_exists($wrapInt), 'wrap specialization is declared'); + Assert::assertTrue(function_exists($identityInt), 'the forwarded identity specialization is declared'); + Assert::assertSame(3, $wrapInt(3)); + Assert::assertSame(41, $identityInt(41)); + + $stringHash = Registry::canonicalHash([new TypeRef('string', isScalar: true)]); + $wrapString = 'App\\NamedForward\\wrap_T_' . $stringHash; + $identityString = 'App\\NamedForward\\identity_T_' . $stringHash; + Assert::assertTrue(function_exists($wrapString), 'wrap specialization is declared'); + Assert::assertTrue(function_exists($identityString), 'the forwarded identity specialization is declared'); + Assert::assertSame('hi', $wrapString('hi')); +}; diff --git a/test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php b/test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php new file mode 100644 index 00000000..fa817f79 --- /dev/null +++ b/test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php @@ -0,0 +1,33 @@ +($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity_T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8(int $x): int +{ + return $x; +} +// A named generic free function forwarding a type argument grounded by the enclosing +// function's type parameter: `identity::($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity_T_473287f8298dba7163a897908958f7c0eae733e25d2e027992ea2edc9bed2fa8(string $x): string +{ + return $x; +} diff --git a/test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp b/test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp deleted file mode 100644 index c3b41a2b..00000000 --- a/test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp +++ /dev/null @@ -1,22 +0,0 @@ -($v)` depends on `wrap`'s `T`, which is not -// concrete here. The named-call turbofish is not specialized (its args are non-concrete), -// so the emitted `wrap_T_` calls `identity()` with the type parameter still leaked. -// Rejected by the emitted-marker backstop before that fatal-able code is written. -function identity(U $x): U -{ - return $x; -} - -function wrap(T $v): T -{ - return identity::($v); -} - -wrap::(3); From d8b0ff47c95e55aba694c16802491b3f47807af5 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 26 Jul 2026 00:48:40 +0000 Subject: [PATCH 3/9] feat(monomorphize): ground enclosing-param method turbofish per class specialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generic class calling a method-generic via turbofish whose type argument is its own type parameter (`self::gen::`, `Maker::wrap::` inside `Box`) was rejected with xphp.unspecialized_generic_leak: the method compiler runs before class specialization, where the enclosing T has no value, and nothing re-visited the marker once specialization made it concrete — while the equivalent constructor turbofish (`new Box::`) grounds through the post-specialization Name rewrite and works. Feed each fresh specialization back through the method compiler from inside the fixed-point loop (groundSpecializedClass): the Phase-1a template index and dedup map are retained past template stripping, the spec's identity (template FQN + concrete args) is threaded so `self::` resolves and the class substitution composes into member specialization, and the walk runs in the append-drain's markers-only mode with parse-time-resolved names. Own-template members land on the specialization itself, deduped per spec and dispatched via `self::` (the template class lowers to a marker interface); non-generic targets append onto the retained user AST, deduped globally across all forwarding classes. Members appended outside the spec are collected explicitly, so an instantiation that first appears inside a grounded body (`new Pair`) converges through the same fixed point. Grounding runs in check's resilient loop too: bound violations only provable after substitution, the unchanged static-context unprovable-bound rejection, and surviving markers are collected as diagnostics located at the template's real source file — closing the gap where `xphp check` silently passed shapes `compile` rejects. Deliberately still rejected (marker kept → emit backstop, now pinned by fixtures): a static target declared on a different generic template, and the `static::` / `parent::` spellings, whose current-class resolution would silently mis-dispatch rather than fail. The generic_class_method_turbofish fixture (formerly a reject pin) now compiles and runs end to end across two instantiations and two forwarding classes, with dedup invariants asserted at runtime. Co-Authored-By: Claude Fable 5 --- src/Transpiler/Monomorphize/Compiler.php | 71 +++- .../Monomorphize/GenericMethodCompiler.php | 331 +++++++++++++++++- .../Monomorphize/GroundingContext.php | 48 +++ .../Monomorphize/CheckPassIntegrationTest.php | 57 +++ .../GenericMarkerLeakIntegrationTest.php | 67 +++- .../GenericMethodIntegrationTest.php | 138 ++++++++ .../method_turbofish_bound/source/Use.xphp | 24 ++ .../method_turbofish_clean/source/Maker.xphp | 14 + .../method_turbofish_clean/source/Use.xphp | 60 ++++ .../source/Use.xphp | 28 ++ .../source/Use.xphp | 24 ++ .../source/Use.xphp | 28 ++ .../source/Maker.xphp | 14 + .../source/Use.xphp | 60 ++++ .../verify/runtime.php | 40 +++ .../BoxInt.expected.php | 34 ++ .../source/Use.xphp | 38 ++ .../verify/runtime.php | 27 ++ .../source/Use.xphp | 25 -- .../source/Use.xphp | 27 ++ .../source/Use.xphp | 25 ++ 21 files changed, 1117 insertions(+), 63 deletions(-) create mode 100644 src/Transpiler/Monomorphize/GroundingContext.php create mode 100644 test/fixture/check/method_turbofish_bound/source/Use.xphp create mode 100644 test/fixture/check/method_turbofish_clean/source/Maker.xphp create mode 100644 test/fixture/check/method_turbofish_clean/source/Use.xphp create mode 100644 test/fixture/check/method_turbofish_cross_template/source/Use.xphp create mode 100644 test/fixture/check/method_turbofish_unprovable/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_cross_template_turbofish_reject/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_method_turbofish/source/Maker.xphp create mode 100644 test/fixture/compile/generic_class_method_turbofish/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_method_turbofish/verify/runtime.php create mode 100644 test/fixture/compile/generic_class_method_turbofish/verify/testMethodTurbofishGroundedByEnclosingClassParamCompiles/BoxInt.expected.php create mode 100644 test/fixture/compile/generic_class_method_turbofish_discovery/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_method_turbofish_discovery/verify/runtime.php delete mode 100644 test/fixture/compile/generic_class_method_turbofish_leak_reject/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_parent_pseudo_turbofish_reject/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_static_pseudo_turbofish_reject/source/Use.xphp diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 3934ede5..1ea37ce7 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -135,8 +135,10 @@ public function compile( // Phase 2: fixed-point specialization loop. Fail-fast (an undefined template // or an exceeded depth throws) — the emit path must not proceed on a set it - // couldn't fully build. - $specializedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: false); + // couldn't fully build. The method compiler rides along: each fresh + // specialization is grounded (enclosing-param method-generic turbofish + // dispatched against the retained Phase-1a template index) before collection. + $specializedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: false, methodCompiler: $methodCompiler); // 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 @@ -202,12 +204,12 @@ public function compile( $specializedAsts[$generatedFqn] = $first; } - // Note for future-proofing (review F9): method-level specialization runs in Phase 1a - // against the raw user-file ASTs, NOT against the specialized cache classes. That's - // safe under the current MVP limit ("generic methods on non-generic classes only" — - // see GenericMethodCompiler's docblock). If that limit ever relaxes, the specialized - // class ASTs would need to be fed back through the method compiler with their - // enclosing namespace preserved so FQN keying still works. + // Method-level specialization runs twice-shaped: Phase 1a against the raw + // user-file ASTs, then per-specialization inside the Phase-2 loop + // (GenericMethodCompiler::groundSpecializedClass, fed the retained template + // index with the spec's identity threaded — the F9 wiring note this replaces). + // Anything neither pass could ground still carries its marker and is rejected + // by the backstop below. foreach ($specializedAsts as $generatedFqn => $classAst) { // Last-resort safety net: no generic marker may survive into emitted output. A // surviving turbofish/closure marker is a site the pipeline could not ground — @@ -305,6 +307,7 @@ private function specializeToFixedPoint( RegistryCollector $collector, TypeHierarchy $hierarchy, bool $resilient, + ?GenericMethodCompiler $methodCompiler = null, ): array { /** @var array $specializedAsts keyed by generated FQCN */ $specializedAsts = []; @@ -365,6 +368,44 @@ private function specializeToFixedPoint( } $specializedAsts[$generatedFqn] = $specialized; + + // Ground method-generic turbofish markers the class substitution just + // made concrete (`self::gen::` → `::`) BEFORE collecting: an + // own-template member appended onto the spec is then swept by the + // collect below, and externally-appended members (onto a non-generic + // user class or a function namespace — invisible to spec collection) + // are collected explicitly, so nested instantiation needs discovered by + // grounding converge through this same fixed point. + if ($methodCompiler !== null) { + if ($resilient) { + try { + $externalAppends = $methodCompiler->groundSpecializedClass( + $specialized, + $generatedFqn, + $instantiation->templateFqn, + $instantiation->concreteTypes, + emit: false, + ); + } catch (RuntimeException) { + // Grounding failures surface as collected diagnostics in + // check mode; a residual throw must not abort the resilient + // pass over the remaining instantiations. + $externalAppends = []; + } + } else { + $externalAppends = $methodCompiler->groundSpecializedClass( + $specialized, + $generatedFqn, + $instantiation->templateFqn, + $instantiation->concreteTypes, + emit: true, + ); + } + if ($externalAppends !== []) { + $collector->collect($externalAppends, ""); + } + } + $collector->collect([$specialized], ""); } @@ -381,6 +422,10 @@ private function specializeToFixedPoint( } if ($countAfter === $countBefore) { + // @infection-ignore-all Continue_ -- break vs continue reconverges: an + // unchanged count means this pass recorded no new instantiations, so the + // next iteration processes nothing new and exits via the !newlyProcessed + // break; the mutant merely skips that no-op pass. continue; } @@ -481,8 +526,12 @@ public function check(FilepathArray $sources): DiagnosticCollector // validation calls (which produce the diagnostics) run in BOTH modes; `emit` only governs // append/strip/finalize side-effects on `$astPerFile`, which is local and discarded. So // flipping it changes only wasted work, not the collected diagnostics. `emit: false` is the - // correct (no-wasted-work, no-mutation) choice. - (new GenericMethodCompiler($this->hashLength, $hierarchy, $diagnostics))->process($astPerFile, emit: false); + // correct (no-wasted-work, no-mutation) choice. The instance is held: the resilient + // specialization pass below feeds each spec back through it (groundSpecializedClass) so + // enclosing-param turbofish diagnostics only provable after substitution are collected — + // keeping check's verdicts aligned with compile's. + $methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy, $diagnostics); + $methodCompiler->process($astPerFile, emit: false); // Grounded closure-signature conformance. A `Closure(T $x)` target whose // type parameter is still abstract above is gradually accepted; grounding it @@ -497,7 +546,7 @@ public function check(FilepathArray $sources): DiagnosticCollector // by-ref) mismatches were already collected by the abstract pre-loop above, so // the grounded pass skips them to avoid a duplicate report at the specialized // location. - $groundedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: true); + $groundedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: true, methodCompiler: $methodCompiler); foreach ($groundedAsts as $generatedFqn => $classAst) { $closureValidator->validateFile([$classAst], "", $diagnostics, groundedTypesOnly: true); } diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 481ffb03..f54e3520 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -45,6 +45,7 @@ use PhpParser\Node\Stmt\While_; use PhpParser\Node\UseItem; use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor; use PhpParser\NodeVisitorAbstract; use RuntimeException; use XPHP\Diagnostics\Diagnostic; @@ -56,14 +57,22 @@ * Specializes method-scoped generics: `function NAME(...)` inside a class body, called via * `ClassFqn::NAME(...)`. * - * The pass runs after the class-level pipeline has settled. It walks the per-file AST set - * (the rewritten user code AND the specialized cache classes) twice: + * The pass runs in two stages. `process()` walks the raw per-file user ASTs (Phase 1a, + * before class specialization): * 1. Collect every generic-method template — keyed by "classFqn::methodName". * 2. Collect every StaticCall carrying ATTR_METHOD_GENERIC_ARGS — derive (classFqn, * methodName, args), generate a mangled method (cloning the template, substituting - * the type-param, renaming), append it to the owning class AST. + * the type-param, renaming), append it to the owning class AST — then drain the + * appends, re-walking each freshly specialized body so a forward that substitution + * just made concrete dispatches too. * 3. Strip the original generic-method ClassMethod from each class. * 4. Rewrite each StaticCall's Identifier name to the mangled form. + * Then `groundSpecializedClass()` runs per fresh class specialization inside the + * fixed-point loop, against the retained template index: a turbofish grounded by an + * ENCLOSING class type parameter (`self::gen::` inside `Box`) is abstract during + * Phase 1a and only becomes dispatchable once `Box`'s substitution rewrites the + * marker — own-template members land on the specialization itself, external targets on + * the retained user ASTs. * * Supported call shapes: static (`ClassFqn::method::(...)`), instance and nullsafe * (`$obj->method::(...)`, `$obj?->method::(...)`) via receiver-type analysis, and @@ -113,6 +122,21 @@ final class GenericMethodCompiler * (by `xphp check` with `process(..., emit: false)`), each is appended as a Diagnostic and the * pass continues, so all are reported in one run. */ + /** + * Phase-1a state retained for the post-specialization grounding pass + * ({@see groundSpecializedClass}). `process()` strips generic templates from the + * user ASTs at the end of its run, but the index keeps referencing the detached + * template nodes — retaining it is what lets a marker that only became concrete + * under class specialization still find its method/function template. The dedup + * map is shared too, so a member a Phase-1a call already appended (or another + * specialization already grounded) is never appended twice. + */ + private ?TemplateIndex $retainedIndex = null; + /** @var array shared specialization-dedup keys (see rewriteStaticCall) */ + private array $alreadyGenerated = []; + /** @var array class template FQN => source ast key (filepath), for grounding-time diagnostics */ + private array $classSourceByFqn = []; + public function __construct( private readonly int $hashLength = Registry::DEFAULT_HASH_HEX_LENGTH, private readonly ?TypeHierarchy $hierarchy = null, @@ -185,6 +209,7 @@ public function process(array &$astSet, bool $emit = true): void } foreach ($perFileClasses as $k => $v) { $classByFqn[$k] = $v; + $this->classSourceByFqn[$k] = (string) $astKey; } foreach ($perFileFns as $k => $v) { $functionTemplates[$k] = $v; @@ -226,9 +251,11 @@ public function process(array &$astSet, bool $emit = true): void $functionNamespaceByFqn, $allFunctionsByFqn, ); + // Retain for the post-specialization grounding pass; the template nodes stay + // reachable through the index even after the strip loops below detach them. + $this->retainedIndex = $index; - /** @var array $alreadyGenerated */ - $alreadyGenerated = []; + $alreadyGenerated = &$this->alreadyGenerated; foreach ($astSet as $astKey => &$ast) { // For top-level (null-namespace) functions: the visitor's pendingAppends // mechanism mutates a container's ->stmts; the top-level AST is a plain @@ -292,6 +319,93 @@ public function process(array &$astSet, bool $emit = true): void } } + /** + * Ground + dispatch the method-generic turbofish markers inside one freshly + * specialized class. + * + * Runs from the fixed-point specialization loop, right after the class substitution + * and BEFORE the spec is collected: a marker like `self::gen::` or + * `Maker::wrap::` is abstract at the Phase-1a walk (the enclosing `T` has no + * value in the template) and only becomes dispatchable here, once the substitution + * has rewritten it to `::`. Re-uses the Phase-1a rewrite machinery against the + * retained template index, in the markers-only mode the append-drain introduced, + * plus a {@see GroundingContext} that redirects own-template member appends onto the + * spec itself (the template class lowers to a marker interface in output) and + * records externally-appended members so the caller can collect their nested + * instantiation needs into the same fixed point. + * + * Shapes deliberately NOT grounded here keep their marker and fall to the emit + * backstop exactly as before: instance-call markers, static calls whose declaring + * class is a *different* generic template, `static::`/`parent::` spellings (their + * default resolution would silently mis-ground, not fail), and forwards to a bare + * top-level function template (no container to append to from a detached walk). + * + * In `check` mode (`$emit = false`) nothing is attached; diagnostics only provable + * after substitution (a violated bound, a surviving marker) are collected so check + * and compile agree. + * + * @param list $classArgs the instantiation's concrete type arguments, + * parallel to the template's declared parameters + * @return list members appended onto containers other than the spec + * (user classes / namespaces) — the caller must collect + * these for nested instantiation discovery + */ + public function groundSpecializedClass( + ClassLike $specialized, + string $generatedFqn, + string $templateFqn, + array $classArgs, + bool $emit, + ): array { + $index = $this->retainedIndex; + if ($index === null) { + // process() never built an index (no templates anywhere) — with no method + // or function templates in the program there is nothing a marker could + // dispatch to; any survivor is the emit backstop's to report. + return []; + } + // Cheap pre-scan: most specs carry no marker; skip the visitor entirely then. + if (GenericMarkerLeakGuard::findLeak($specialized, includeClosureTemplates: false) === null) { + return []; + } + + $currentFile = $this->classSourceByFqn[$templateFqn] ?? ""; + $grounding = new GroundingContext($specialized, $generatedFqn, $templateFqn, $classArgs); + /** @var list $topLevelAppends never grows in grounding mode (bare-template forwards keep their marker) */ + $topLevelAppends = []; + $this->rewriteCallSites( + [$specialized], + $index, + $this->alreadyGenerated, + $topLevelAppends, + $currentFile, + $emit, + $grounding, + ); + + // Check-mode parity backstop: compile rejects a still-marked spec at the emit + // loop's assertNoLeak; check has no emit loop, so collect the equivalent + // diagnostic here (call-marker arm only; sites the Phase-1a walk already + // reported — e.g. an unspecializable `$this` self-call — dedupe by position). + if (!$emit && $this->diagnostics !== null) { + // phpstan memoizes the identical pre-scan call above, but the grounding walk + // mutates the AST in between: a marker the pre-scan found is usually + // dispatched (nulled) by now, so this CAN be null. + $leak = GenericMarkerLeakGuard::findLeak($specialized, includeClosureTemplates: false); + // @phpstan-ignore-next-line notIdentical.alwaysTrue + if ($leak !== null && !$this->alreadyReportedAt($currentFile, $leak->getStartLine())) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMarkerLeakGuard::CODE, + GenericMarkerLeakGuard::leakMessage($leak, $generatedFqn), + new SourceLocation($currentFile, $leak->getStartLine()), + )); + } + } + + return $grounding->externalAppends; + } + /** * @param list $ast * @param array $methodTemplates out-param @@ -398,6 +512,7 @@ private function rewriteCallSites( array &$topLevelAppends, string $currentFile, bool $emit, + ?GroundingContext $grounding = null, ): void { $hashLength = $this->hashLength; $hierarchy = $this->hierarchy; @@ -446,6 +561,16 @@ private function rewriteCallSites( */ public bool $markersOnly = false; + /** + * Set (together with markersOnly) when this walk grounds a freshly + * specialized CLASS ({@see GenericMethodCompiler::groundSpecializedClass}). + * Widens the markers-only pass to static-call markers — their resolution + * is drain-safe here because names are attribute-resolved and the spec's + * own identity is threaded — and drives the append-target rule: an + * own-template member lands on the spec, deduped per specialization. + */ + public ?GroundingContext $grounding = null; + /** Receiver-type analysis state. Pushed on entering ClassLike, popped on leave. */ private ?string $currentClassFqn = null; /** @@ -643,8 +768,18 @@ public function primeDrainScope(?string $classFqn, string $namespace): void $this->callReturnCache = []; } - public function enterNode(Node $node): null + public function enterNode(Node $node): null|int { + // A markers-only walk skips generic declarations still carrying their + // template marker wholesale: in check mode nothing is stripped, so a + // spec clone contains the generic-method templates themselves, whose + // method-param-leaf markers the Phase-1a walk already validated — + // re-walking them would duplicate diagnostics (or false-flag closure + // templates the template walk already handled). leaveNode mirrors the + // test so it never pops a scope this skip never pushed. + if ($this->markersOnly && self::isUnspecializedTemplateDeclaration($node)) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } if ($node instanceof Namespace_) { $this->currentNamespace = $node->name?->toString() ?? ''; $this->currentNamespaceNode = $node; @@ -917,11 +1052,21 @@ public function enterNode(Node $node): null public function leaveNode(Node $node): ?Node { if ($node instanceof StaticCall) { - // Drain traversals leave static markers untouched: their class-name - // resolution is not drain-safe yet, and a kept marker falls to the - // leak guard exactly as it did before the drain existed. if ($this->markersOnly) { - return null; + // Phase-1a drain (no grounding context): static resolution is + // not drain-safe there; a kept marker falls to the leak guard + // exactly as it did before the drain existed. Grounding a + // specialized class DOES process static markers — but only + // marker-bearing ones, and never the `static::`/`parent::` + // spellings: `resolveClassName` maps both to the current class, + // which would silently mis-ground a late-bound or parent-side + // dispatch; keeping the marker fails loudly instead. + if ($this->grounding === null + || $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) === null + || $this->isLateBoundPseudoName($node->class) + ) { + return null; + } } return $this->rewriteStaticCall($node); } @@ -950,6 +1095,11 @@ public function leaveNode(Node $node): ?Node if ($node instanceof ClassLike) { $this->currentClassFqn = null; } + // Mirror of enterNode's markers-only template skip: the enter never + // pushed a scope for this declaration, so the pop below must not run. + if ($this->markersOnly && self::isUnspecializedTemplateDeclaration($node)) { + return null; + } if ($node instanceof Function_ || $node instanceof ClassMethod || $node instanceof Closure @@ -1263,7 +1413,15 @@ private function rewriteStaticCall(StaticCall $node): ?Node return null; } - $classFqn = $this->resolveClassName($node->class); + // A drained/grounding walk runs over a DETACHED body: lexical use-alias + // state is unavailable, so prefer the parse-time resolution attribute + // (`self` carries none and still routes through resolveClassName, whose + // currentClassFqn was primed per item). The normal Phase-1a walk keeps + // its lexical resolution byte-identical. + $resolvedAttr = $node->class->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + $classFqn = $this->markersOnly && is_string($resolvedAttr) + ? $resolvedAttr + : $this->resolveClassName($node->class); $methodName = $node->name->toString(); $key = $classFqn . '::' . $methodName; // Resolve through the inheritance chain (same as the instance path): @@ -1280,6 +1438,17 @@ private function rewriteStaticCall(StaticCall $node): ?Node return $this->reportUnresolvedTurbofishOrSkip($classFqn, $methodName, $node); } [$template, $declaringFqn] = $resolved; + // Grounding mode: only the spec's OWN template's members may land on the + // spec. A static target declared on a *different* generic template would + // need that other template's substitution mapping (and mutating a shared + // template mid-loop is order-dependent wrong code) — keep the marker and + // let the emit backstop reject it as a documented limitation. + if ($this->grounding !== null + && $declaringFqn !== $this->grounding->templateFqn + && $this->isGenericTemplateClass($declaringFqn) + ) { + return null; + } $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); if (!is_array($params)) { return null; @@ -1356,6 +1525,41 @@ private function rewriteStaticCall(StaticCall $node): ?Node } $mangled = self::mangleName($methodName, $args, $this->hashLength); + + // Grounding a spec whose OWN template declares the target: the member + // lands on the spec itself — the template class lowers to a marker + // interface in output, so an append there would vanish (and mutating a + // shared template mid-loop would be order-dependent). Dedup per + // specialization, but consult the global key first: a member a concrete + // Phase-1a call already appended onto the template was cloned INTO this + // spec, and appending again would redeclare the method (load-time fatal). + if ($this->grounding !== null && $declaringFqn === $this->grounding->templateFqn) { + $templateKey = $declaringFqn . '::' . $mangled; + $specKey = $this->grounding->generatedFqn . '::' . $mangled; + if (!isset($this->alreadyGenerated[$templateKey]) && !isset($this->alreadyGenerated[$specKey])) { + // Compose the enclosing class substitution under the method's + // own overlay: the detached template's body may reference class + // type parameters, which are concrete for THIS spec only. + $overlay = $this->groundingClassOverlay(); + foreach ($params as $i => $param) { + $overlay[$param->name] = $args[$i]; + } + $specialized = (new Specializer())->specializeMethod($template, Substitution::of($overlay), $mangled); + $this->pendingAppends[] = [$this->grounding->spec, $specialized, [ + 'classFqn' => $declaringFqn, + 'namespace' => self::namespaceOf($declaringFqn), + ]]; + $this->alreadyGenerated[$specKey] = true; + } + $node->name = new Identifier($mangled, $node->name->getAttributes()); + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); + // Dispatch through `self`: the member lives on the emitted spec, and + // the template FQN spelling (`Box::gen` / `\App\Box::gen`) would + // resolve to the stripped marker interface. + $node->class = new Name('self', $node->class->getAttributes()); + return $node; + } + // Emit onto the declaring class (see the instance path) so subclasses // inherit the single specialization; dedup by the declaring FQN. $generatedKey = $declaringFqn . '::' . $mangled; @@ -2447,6 +2651,14 @@ private function rewriteFuncCall(FuncCall $node): ?Node $generatedKey = 'fn::' . $mangledFqn; if (!isset($this->alreadyGenerated[$generatedKey])) { + // Grounding a specialized class: a BARE top-level function template + // has no container node, and the top-level bag routes into whatever + // file the walk was invoked for — which, for a detached spec, is no + // file at all (the append would be dropped silently). Keep the + // marker instead; the emit backstop rejects it loudly. + if ($this->grounding !== null && $this->index->functionNamespaceNode($fqn) === null) { + return null; + } $overlay = []; foreach ($params as $i => $param) { $overlay[$param->name] = $args[$i]; @@ -2836,15 +3048,95 @@ private static function namespaceOf(string $fqn): string $pos = strrpos($fqn, '\\'); return $pos === false ? '' : substr($fqn, 0, $pos); } + + /** + * A function/method/closure declaration still carrying its generic-template + * marker — never present in a compile-mode spec (templates are stripped or + * lowered before cloning), but present in check-mode clones; markers-only + * walks skip them wholesale (see enterNode). + */ + private static function isUnspecializedTemplateDeclaration(Node $node): bool + { + return ($node instanceof ClassMethod + || $node instanceof Function_ + || $node instanceof Closure + || $node instanceof ArrowFunction) + && is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)); + } + + /** + * A `static::` / `parent::` class spelling — late-bound (or parent-side) + * dispatch that `resolveClassName`'s currentClassFqn mapping would silently + * mis-ground in a detached grounding walk. + */ + private function isLateBoundPseudoName(Node $class): bool + { + if (!$class instanceof Name) { + return false; + } + $first = strtolower($class->getParts()[0]); + return $first === 'static' || $first === 'parent'; + } + + /** Whether the FQN names a generic class template (declares type parameters). */ + private function isGenericTemplateClass(string $fqn): bool + { + $params = $this->index->classLike($fqn)?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + return is_array($params) && $params !== []; + } + + /** + * The enclosing template's type-parameter → concrete-argument map for the + * spec being grounded, read off the RETAINED template node (the spec clone's + * generic attributes were nulled by the Specializer). + * + * @return array + */ + private function groundingClassOverlay(): array + { + if ($this->grounding === null) { + return []; + } + $classParams = $this->index->classLike($this->grounding->templateFqn) + ?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($classParams)) { + return []; + } + /** @var list $classParams — set as a list by XphpSourceParser::resolveAndAttach. */ + $overlay = []; + foreach ($classParams as $i => $classParam) { + if (isset($this->grounding->classArgs[$i])) { + $overlay[$classParam->name] = $this->grounding->classArgs[$i]; + } + } + return $overlay; + } }; + if ($grounding !== null) { + // Grounding a specialized class: markers-only walk, identity primed from + // the context (the spec clone is nameless and namespace-less — enterNode + // would never see a Namespace_/name to derive them from). + $visitor->markersOnly = true; + $visitor->grounding = $grounding; + $visitor->primeDrainScope( + $grounding->templateFqn, + self::namespacePrefixOf($grounding->templateFqn), + ); + } + $traverser = new NodeTraverser(); $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). - $this->rejectUnspecializedClosureTemplates($ast, $visitor->attemptedClosureTemplates, $currentFile); + // Skipped in grounding mode: a markers-only walk records no attempts, so an + // inner generic closure the template walk already handled would false-flag + // as an orphan on every specialization. + if ($grounding === null) { + $this->rejectUnspecializedClosureTemplates($ast, $visitor->attemptedClosureTemplates, $currentFile); + } // Pass 2 of the closure-dispatcher pipeline: materialize a dispatcher // closure per recorded template, replace the original Assign's RHS, @@ -2904,6 +3196,14 @@ private function drainSpecializedAppends(object $visitor, string $currentFile, b if ($emit) { $container->stmts[] = $stmt; } + // Grounding mode: a member appended onto anything but the spec + // itself (a non-generic user class, a function namespace) is + // invisible to the fixed-point loop's spec collection — record it + // so the caller can collect its nested instantiation needs. + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + if ($visitor->grounding !== null && $container !== $visitor->grounding->spec) { + $visitor->grounding->externalAppends[] = $stmt; + } // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. } elseif ($topLevelIdx < count($visitor->topLevelAppends)) { // Top-level (null-namespace) functions have no container node here; @@ -2999,6 +3299,13 @@ private function drainSpecializedAppends(object $visitor, string $currentFile, b } } + /** The namespace part of an FQN ('' for a global-namespace symbol). */ + private static function namespacePrefixOf(string $fqn): string + { + $pos = strrpos($fqn, '\\'); + return $pos === false ? '' : substr($fqn, 0, $pos); + } + /** * Whether the collector already holds a diagnostic at this exact source position. * Used by the drain's check-mode backstop to avoid re-reporting a site the source diff --git a/src/Transpiler/Monomorphize/GroundingContext.php b/src/Transpiler/Monomorphize/GroundingContext.php new file mode 100644 index 00000000..8b43bd16 --- /dev/null +++ b/src/Transpiler/Monomorphize/GroundingContext.php @@ -0,0 +1,48 @@ +` → `self::gen::`). Grounding them re-uses the Phase-1a + * rewrite machinery, but three decisions differ from a user-file walk and are driven by + * this context: + * + * - **identity**: the walked ClassLike is a nameless clone; `templateFqn` stands in as + * the current class FQN so `self::` resolution and method-template lookup key against + * the retained Phase-1a index, and `classArgs` are the instantiation's concrete type + * arguments (parallel to the template's declared parameters) for composing the class + * substitution into method specialization. + * - **append target**: a member specialized from the spec's own template lands on the + * spec itself (`spec`), deduped per specialization via `generatedFqn` — never on the + * template class, which lowers to a marker interface in emitted output. + * - **external collection**: members appended onto OTHER containers (a non-generic user + * class, a function namespace) are recorded in `externalAppends` so the fixed-point + * loop can collect their nested instantiation needs — user files were collected before + * these members existed. + */ +final class GroundingContext +{ + /** @var list members appended onto containers other than the spec */ + public array $externalAppends = []; + + /** + * @param list $classArgs concrete instantiation args, parallel to the + * template's declared type parameters + */ + public function __construct( + public readonly ClassLike $spec, + public readonly string $generatedFqn, + public readonly string $templateFqn, + public readonly array $classArgs, + ) { + } +} diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 3ce0316b..879649f4 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -54,6 +54,63 @@ public function testNamedForwardGroundedByEnclosingParamIsCleanInCheck(): void self::assertSame([], $diagnostics->all()); } + public function testMethodTurbofishGroundedByEnclosingClassParamIsCleanInCheck(): void + { + // `self::gen::` / `Maker::wrap::` inside `Box` ground per + // specialization; the validate-only pass must agree with compile and report + // nothing — across two instantiations and two forwarding classes. + $diagnostics = $this->check('method_turbofish_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testGroundedStaticBoundViolationIsCollectedByCheck(): void + { + // `gen` grounded with `T = int`: provable only after + // Box specializes, collected by the per-specialization grounding pass. + // The location must point at the template's real source file (the grounding + // pass resolves it through the retained class-source map), not a synthetic + // `` label. + $diagnostics = $this->check('method_turbofish_bound'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Registry::CODE_BOUND_VIOLATION, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('Use.xphp', $d->location->file); + } + + public function testCrossTemplateStaticTurbofishLeakIsCollectedByCheck(): void + { + // `Other::gen::` (a static method-generic on a DIFFERENT generic template) + // stays un-grounded by design; compile rejects at the emit backstop, and check + // must collect the same leak diagnostic from the grounding pass — located at + // the template's real source file. + $diagnostics = $this->check('method_turbofish_cross_template'); + + self::assertTrue($diagnostics->hasErrors()); + $leaks = array_values(array_filter( + $diagnostics->all(), + static fn ($d) => $d->code === GenericMarkerLeakGuard::CODE, + )); + self::assertCount(1, $leaks); + self::assertNotNull($leaks[0]->location); + self::assertStringEndsWith('Use.xphp', $leaks[0]->location->file); + } + + public function testClassParamBoundOnStaticIsStillUnprovableInCheck(): void + { + // `gen` on a static method-generic: genuinely unprovable in a static + // context — the pre-existing rejection survives the grounding pass in check + // exactly as in compile. + $diagnostics = $this->check('method_turbofish_unprovable'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(GenericMethodCompiler::CODE_BOUND_UNPROVABLE, $codes); + } + public function testGroundedForwardBoundViolationIsCollectedByCheck(): void { // `need` forwarded `T = int`: provable only after `wrap::` diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php index 100421c9..8795e602 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php @@ -12,19 +12,22 @@ /** * End-to-end coverage for the emitted-generic-marker backstop ({@see GenericMarkerLeakGuard}). * - * Two enclosing-parameter / generic-function-scope turbofish shapes slip past the source-level - * gates and reach the emit phase un-grounded: a *concrete* inner closure turbofish inside a - * generic function body, and a method turbofish grounded by an enclosing class type parameter. - * Left to emit, each produces PHP that references a non-existent type-parameter class (a runtime - * `TypeError`/`Error`) behind an otherwise clean compile. The backstop turns each into a loud - * compile failure carrying `xphp.unspecialized_generic_leak` before any output is written. + * The turbofish shapes that deliberately stay un-groundable reach the emit phase with their + * marker intact: a *concrete* inner closure turbofish inside a generic function body, a static + * method-generic declared on a *different* generic template, and the late-bound + * `static::`/`parent::` spellings. Left to emit, each produces PHP that references a + * non-existent type-parameter class or the wrong dispatch target behind an otherwise clean + * compile. The backstop turns each into a loud compile failure carrying + * `xphp.unspecialized_generic_leak` before any output is written. * - * (Two adjacent shapes are handled elsewhere: a generic closure grounded by an enclosing - * *function* parameter, `relay`, is a non-concrete variable turbofish caught earlier at the - * source seam in both modes — see {@see ClosureDispatcherIntegrationTest} and - * {@see CheckPassIntegrationTest}; and a NAMED free-function forward (`identity::` inside - * `wrap`) is grounded and dispatched by the append-drain rather than rejected — see the - * `generic_function_named_forward` runtime fixture in {@see GenericFunctionIntegrationTest}.) + * (Adjacent shapes are handled elsewhere: a generic closure grounded by an enclosing *function* + * parameter, `relay`, is a non-concrete variable turbofish caught earlier at the source seam in + * both modes — see {@see ClosureDispatcherIntegrationTest} and {@see CheckPassIntegrationTest}; + * a NAMED free-function forward (`identity::` inside `wrap`) is grounded by the + * append-drain — see `generic_function_named_forward` in {@see GenericFunctionIntegrationTest}; + * and an own-template / non-generic-target method turbofish grounded by the enclosing class + * parameter (`self::gen::`, `Maker::wrap::`) is grounded per specialization — see + * `generic_class_method_turbofish` in {@see GenericMethodIntegrationTest}.) * * The must-keep side — a working top-level `$g::` dispatcher and the `contains` * enclosing-bound forward — is proven zero-false-reject by the existing `closure_dispatcher_arrow` @@ -46,15 +49,49 @@ public function testConcreteInnerTurbofishInsideAGenericFunctionIsRejected(): vo } #[RunInSeparateProcess] - public function testMethodTurbofishGroundedByEnclosingClassParamIsRejected(): void + public function testCrossTemplateStaticTurbofishIsRejected(): void { + // A static method-generic declared on a DIFFERENT generic template + // (`Other::gen::` from inside `Holder`): the grounding pass deliberately + // leaves the marker (it would need Other's own substitution mapping, and + // appending onto a shared template mid-loop is order-dependent), so the + // backstop rejects. The own-template and non-generic-target forms of the same + // call shape ground and run — see the generic_class_method_turbofish fixture. $this->expectException(RuntimeException::class); $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); CompiledFixture::compile( - __DIR__ . '/../../fixture/compile/generic_class_method_turbofish_leak_reject/source', - 'generic-class-method-turbofish-leak', + __DIR__ . '/../../fixture/compile/generic_class_cross_template_turbofish_reject/source', + 'generic-class-cross-template-turbofish', ); } + #[RunInSeparateProcess] + public function testStaticPseudoNameTurbofishIsRejected(): void + { + // `static::gen::`: honoring late static binding is impossible for the + // grounding pass, and resolving `static` to the current class would silently + // re-route a subclass dispatch — keep-marker + loud reject is the contract. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_static_pseudo_turbofish_reject/source', + 'generic-class-static-pseudo-turbofish', + ); + } + + #[RunInSeparateProcess] + public function testParentPseudoNameTurbofishIsRejected(): void + { + // `parent::gen::`: same contract as `static::` — the current-class mapping + // would dispatch to the wrong side of the hierarchy, so the marker is kept. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_parent_pseudo_turbofish_reject/source', + 'generic-class-parent-pseudo-turbofish', + ); + } } diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 42aff925..88af0b95 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -1699,6 +1699,144 @@ class Box { public static function get(T $x): T { return $x; } } } } + #[RunInSeparateProcess] + public function testMethodTurbofishGroundedByEnclosingClassParamCompiles(): void + { + // `self::gen::` / `Maker::wrap::` inside `Box`: abstract in the + // template, grounded and dispatched per specialization. The spec carries its + // own `gen_T_` member (dispatched via `self::`, appended once despite + // two call sites) and the non-generic Maker gets one shared `wrap_T_` + // per unique argument tuple. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_method_turbofish/source', + 'genmethod-enclosing-turbofish', + ); + try { + $generated = self::globRecursive($fixture->cacheDir . '/Generated', '*.php'); + self::assertCount(3, $generated, 'Box, Box, CoBox'); + + $specs = array_combine($generated, array_map('file_get_contents', $generated)); + $boxIntSpec = null; + foreach ($specs as $path => $content) { + self::assertIsString($content); + if (str_contains($path, '/Box/') && str_contains($content, 'make(int $v): int')) { + $boxIntSpec = $content; + } + } + self::assertIsString($boxIntSpec, 'Box spec found'); + // One appended member, dispatched via self:: (never the marker interface). + self::assertSame(1, preg_match_all('/function gen_T_[0-9a-f]+\(/', $boxIntSpec)); + self::assertSame(2, preg_match_all('/self::gen_T_[0-9a-f]+\(/', $boxIntSpec)); + self::assertStringNotContainsString('Box::gen', $boxIntSpec); + SnapshotHash::assertMatches( + __DIR__ . '/../../fixture/compile/generic_class_method_turbofish/verify/testMethodTurbofishGroundedByEnclosingClassParamCompiles/BoxInt.expected.php', + $boxIntSpec, + ); + + // Maker (a plain user file) carries exactly two wrap specializations — + // int (shared by Box and CoBox) and string. + $maker = file_get_contents($fixture->targetDir . '/Maker.php'); + self::assertIsString($maker); + self::assertSame(2, preg_match_all('/function wrap_T_[0-9a-f]+\(/', $maker)); + + $fixture->registerAutoload('App\\MethodTurbofish'); + $runtime = require __DIR__ . '/../../fixture/compile/generic_class_method_turbofish/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testGroundingAppendedMemberInstantiationsAreCollected(): void + { + // `Pair` first exists inside the `twin_T_` member the grounding + // pass appends onto Maker — its instantiation must be collected into the + // fixed point or the emitted call references a missing generated class. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_method_turbofish_discovery/source', + 'genmethod-turbofish-discovery', + ); + try { + $pairSpecs = array_filter( + self::globRecursive($fixture->cacheDir . '/Generated', '*.php'), + static fn (string $p): bool => str_contains($p, '/Pair/'), + ); + self::assertCount(1, $pairSpecs, 'Pair was discovered and specialized'); + + $fixture->registerAutoload('App\\TurbofishDiscovery'); + $runtime = require __DIR__ . '/../../fixture/compile/generic_class_method_turbofish_discovery/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testGroundedStaticBoundViolationFailsCompilation(): void + { + // `gen` grounded with `T = int` once Box specializes: + // provable only after substitution, must fail at the grounding pass. + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated while instantiating App\Box::gen'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/check/method_turbofish_bound/source', + 'genmethod-turbofish-bound', + ); + } + + #[RunInSeparateProcess] + public function testClassParamBoundOnStaticStaysUnprovable(): void + { + // `gen` on a STATIC method: a class-param bound has no receiver to + // ground it in a static context — the pre-existing rejection must survive + // the grounding pass unchanged. + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot verify generic bound `U : T` for App\Box::gen'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/check/method_turbofish_unprovable/source', + 'genmethod-turbofish-unprovable', + ); + } + + #[RunInSeparateProcess] + public function testGroundedStaticBoundSatisfiedCompilesAndRuns(): void + { + // The bound twin that must keep compiling: `gen` grounded + // with a T that satisfies the bound. + $dir = sys_get_temp_dir() . '/xphp-genmethod-bound-ok-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + s; } + } + class Box { + public function m(T $v): T { return self::gen::($v); } + public static function gen(U $x): U { return $x; } + } + $b = new Box::

`, grounded to Pair): own-spec members are collected + // even though check attaches nothing, so check agrees with compile's reject. + $diagnostics = $this->check('method_turbofish_nested_bound'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(Registry::CODE_BOUND_VIOLATION, $codes); + } + + public function testDeferredTurbofishArityErrorIsReportedOncePerSite(): void + { + // An arity error on a deferred enclosing-param turbofish under TWO + // instantiations: one diagnostic at the source site — the per-spec grounding + // walks must not re-fire the same collector message per specialization. + $diagnostics = $this->check('method_turbofish_arity_once'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_TOO_MANY_TYPE_ARGUMENTS, $diagnostics->all()[0]->code); + } + + public function testTemplateTargetOutsideItsOwnSpecLeakIsCollectedByCheck(): void + { + // The compile-side keep-marker contract for a template-owned target named + // outside the template's own body (see the compile reject fixture) holds in + // check too: exactly one leak diagnostic, at the real source site. + $diagnostics = $this->check('template_target_outside_spec'); + + $leaks = array_values(array_filter( + $diagnostics->all(), + static fn ($d) => $d->code === GenericMarkerLeakGuard::CODE, + )); + self::assertCount(1, $leaks); + self::assertNotNull($leaks[0]->location); + self::assertStringEndsWith('Use.xphp', $leaks[0]->location->file); + } + public function testCrossTemplateStaticTurbofishLeakIsCollectedByCheck(): void { // `Other::gen::` (a static method-generic on a DIFFERENT generic template) diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php index ff6e68e0..3a19fa54 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php @@ -170,6 +170,34 @@ public function testFindLeakCanExcludeTheClosureTemplateArm(): void self::assertSame($call, GenericMarkerLeakGuard::findLeak(new Expression($call), includeClosureTemplates: false)); } + public function testFindLeakCanExcludeVariableTurbofishCalls(): void + { + // With $includeVariableTurbofish=false a FuncCall on a VARIABLE (`$f::`) + // is not a leak — check's class-spec backstop uses this because dispatchers + // are only materialized in compile mode. Named calls still count. + $varCall = new FuncCall(new Variable('f')); + $varCall->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + $body = new Expression($varCall); + + self::assertSame($varCall, GenericMarkerLeakGuard::findLeak($body)); + self::assertNull(GenericMarkerLeakGuard::findLeak($body, includeVariableTurbofish: false)); + + $namedCall = new FuncCall(new Name('identity')); + $namedCall->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + self::assertSame( + $namedCall, + GenericMarkerLeakGuard::findLeak(new Expression($namedCall), includeVariableTurbofish: false), + ); + + // Static/instance markers are unaffected by the toggle. + $static = new StaticCall(new Name('self'), new Identifier('gen')); + $static->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + self::assertSame( + $static, + GenericMarkerLeakGuard::findLeak(new Expression($static), includeVariableTurbofish: false), + ); + } + public function testLeakMessageNamesTheLabelTheLineAndTheCode(): void { $call = new FuncCall(new Variable('inner'), [], ['startLine' => 7]); diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php index 8795e602..d27bc459 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php @@ -66,6 +66,23 @@ public function testCrossTemplateStaticTurbofishIsRejected(): void ); } + #[RunInSeparateProcess] + public function testTemplateTargetOutsideItsOwnSpecIsRejected(): void + { + // `Holder::gen::` written inside Maker's body (a NON-member of Holder): + // grounding Holder drains Maker's freshly appended member, but the + // own-template arm must not fire there — a `self::` rewrite inside Maker + // would call a member Maker doesn't have (a runtime fatal). Keep-marker + + // backstop is the contract. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_template_target_outside_spec_reject/source', + 'generic-class-target-outside-spec', + ); + } + #[RunInSeparateProcess] public function testStaticPseudoNameTurbofishIsRejected(): void { diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index dfbd9f82..d9d8a294 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -1894,6 +1894,25 @@ public function testErasablePlainCallerForwardReusesLoweredMember(): void } } + #[RunInSeparateProcess] + public function testStaticInheritedTurbofishLandsOnTheCallingSpec(): void + { + // `self::gen::` where gen lives on generic `Base`: mirrors the + // instance-call rule — the member grounds through the extends chain onto the + // Holder spec, never the shared Base template. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_static_inherited_turbofish/source', + 'genmethod-static-inherited', + ); + try { + $fixture->registerAutoload('App\\StaticInherited'); + $runtime = require __DIR__ . '/../../fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + #[RunInSeparateProcess] public function testInstanceCrossTemplateTurbofishIsRejected(): void { diff --git a/test/fixture/check/class_closure_dispatcher_clean/source/Use.xphp b/test/fixture/check/class_closure_dispatcher_clean/source/Use.xphp new file mode 100644 index 00000000..f622c446 --- /dev/null +++ b/test/fixture/check/class_closure_dispatcher_clean/source/Use.xphp @@ -0,0 +1,21 @@ +` marker — the grounding +// pass's backstop must not flag it (that would reject code compile accepts). +final class Box +{ + public function m(T $seed): int + { + $f = fn(U $x): U => $x; + return $f::(41); + } +} + +$b = new Box::(); +$b->m(1); diff --git a/test/fixture/check/method_turbofish_arity_once/source/Use.xphp b/test/fixture/check/method_turbofish_arity_once/source/Use.xphp new file mode 100644 index 00000000..7030b416 --- /dev/null +++ b/test/fixture/check/method_turbofish_arity_once/source/Use.xphp @@ -0,0 +1,24 @@ + +{ + public function m(T $v): mixed + { + return self::gen::($v); + } + + public static function gen(U $x): U + { + return $x; + } +} + +$a = new Box::(); +$b = new Box::(); diff --git a/test/fixture/check/method_turbofish_nested_bound/source/Use.xphp b/test/fixture/check/method_turbofish_nested_bound/source/Use.xphp new file mode 100644 index 00000000..d45eae3a --- /dev/null +++ b/test/fixture/check/method_turbofish_nested_bound/source/Use.xphp @@ -0,0 +1,38 @@ +` instantiates +// `Pair` (bounded by Labeled), which only becomes `Pair` when Holder's +// grounding specializes gen. Check must collect it from the grounded member exactly +// as compile rejects it — own-spec members are collected even though check attaches +// nothing. +final class Pair

+{ + public function __construct(public readonly P $v) + { + } +} + +final class Holder +{ + public function go(T $v): mixed + { + return self::gen::($v); + } + + public static function gen(U $x): mixed + { + return new Pair::($x); + } +} + +$h = new Holder::(); +$h->go(1); diff --git a/test/fixture/check/template_target_outside_spec/source/Use.xphp b/test/fixture/check/template_target_outside_spec/source/Use.xphp new file mode 100644 index 00000000..d1490867 --- /dev/null +++ b/test/fixture/check/template_target_outside_spec/source/Use.xphp @@ -0,0 +1,37 @@ + drains Maker's freshly appended wrap member, whose body names +// Holder::gen::. Dispatching that through the spec's `self::` would emit a call to +// a member Maker doesn't have (a runtime fatal) — the marker is kept and the +// emitted-marker backstop rejects loudly instead. +final class Maker +{ + /** @return array */ + public static function wrap(X $v): array + { + return Holder::gen::($v); + } +} + +final class Holder +{ + /** @return array */ + public static function gen(U $x): array + { + return [$x, $x]; + } + + /** @return array */ + public function go(T $v): array + { + return Maker::wrap::($v); + } +} + +$h = new Holder::(); +$h->go(7); diff --git a/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp b/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp new file mode 100644 index 00000000..64747675 --- /dev/null +++ b/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp @@ -0,0 +1,37 @@ +` inside the +// Holder spec threads Holder's concrete arguments through the extends chain to Base's +// parameters and lands the member on the HOLDER specialization — mirroring the +// instance-call rule, never mutating the shared Base template. +class Base +{ + /** @return array */ + public static function gen(U $x): array + { + return [$x, $x]; + } + + public function idT(T $v): T + { + return $v; + } +} + +class Holder extends Base +{ + /** @return array */ + public function go(T $v): array + { + return self::gen::($v); + } +} + +$b = new Base::(); +$h = new Holder::(); +$r1 = $h->go(3); +$r2 = $b->idT('s'); diff --git a/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php b/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php new file mode 100644 index 00000000..bdebf578 --- /dev/null +++ b/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php @@ -0,0 +1,34 @@ + spec grew nothing. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame([3, 3], $r1); + Assert::assertSame('s', $r2); + + $genMembers = array_values(array_filter( + get_class_methods($h), + static fn (string $m): bool => str_starts_with($m, 'gen_T_'), + )); + Assert::assertCount(1, $genMembers); + Assert::assertSame( + get_class($h), + (new ReflectionMethod($h, $genMembers[0]))->getDeclaringClass()->getName(), + 'member declared on the Holder spec itself', + ); + Assert::assertSame([], array_values(array_filter( + get_class_methods($b), + static fn (string $m): bool => str_starts_with($m, 'gen_T_'), + )), 'the Base spec grew no grounded member'); +}; diff --git a/test/fixture/compile/generic_class_template_target_outside_spec_reject/source/Use.xphp b/test/fixture/compile/generic_class_template_target_outside_spec_reject/source/Use.xphp new file mode 100644 index 00000000..d1490867 --- /dev/null +++ b/test/fixture/compile/generic_class_template_target_outside_spec_reject/source/Use.xphp @@ -0,0 +1,37 @@ + drains Maker's freshly appended wrap member, whose body names +// Holder::gen::. Dispatching that through the spec's `self::` would emit a call to +// a member Maker doesn't have (a runtime fatal) — the marker is kept and the +// emitted-marker backstop rejects loudly instead. +final class Maker +{ + /** @return array */ + public static function wrap(X $v): array + { + return Holder::gen::($v); + } +} + +final class Holder +{ + /** @return array */ + public static function gen(U $x): array + { + return [$x, $x]; + } + + /** @return array */ + public function go(T $v): array + { + return Maker::wrap::($v); + } +} + +$h = new Holder::(); +$h->go(7); From 174a36ff579aa4af49a814109ea24f1ae44e3ab8 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 26 Jul 2026 10:11:17 +0000 Subject: [PATCH 7/9] fix(monomorphize): prune retained templates from the check backstop, severity-aware dedupe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-up fixes to the grounding pass's check-mode alignment, each pinned by a fixture that fails without it: The class-spec leak backstop now skips the SUBTREES of declarations still carrying their generic-template marker: check never strips templates, so a spec clone retains e.g. `a` whose body legitimately holds a `self::b::` marker — flagging that interior rejected a 2-hop own-template forward chain that compile grounds and runs. Compile-mode specs never contain such declarations, so the assert path is unchanged. The position dedupe (grounding-skip and leak-backstop sides) matches ERRORS only: a same-line warning previously suppressed grounding entirely, letting a bound violation nested in the grounded member pass check while compile rejects — the dangerous direction. Own-spec appended members now drain under the SPEC's identity rather than their declaring class's: the member lives on the spec, so `$this`/`self` in its body are the spec — an ancestor-declared member whose body forwards again (`self::genB::` declared on Base) now grounds hop by hop onto the calling spec instead of leaking spuriously after the first hop. Co-Authored-By: Claude Fable 5 --- .../Monomorphize/GenericMarkerLeakGuard.php | 62 ++++++++++++++++++- .../Monomorphize/GenericMethodCompiler.php | 37 ++++++++--- .../Monomorphize/CheckPassIntegrationTest.php | 29 +++++++++ .../GenericMarkerLeakGuardTest.php | 60 ++++++++++++++++++ .../GenericMethodIntegrationTest.php | 19 ++++++ .../source/Use.xphp | 34 ++++++++++ .../source/Use.xphp | 48 ++++++++++++++ .../source/Use.xphp | 34 ++++++++++ .../verify/runtime.php | 23 +++++++ .../source/Use.xphp | 9 +++ .../verify/runtime.php | 20 +++--- 11 files changed, 355 insertions(+), 20 deletions(-) create mode 100644 test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp create mode 100644 test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php b/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php index 5af128b8..f55b0197 100644 --- a/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php +++ b/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php @@ -12,7 +12,12 @@ use PhpParser\Node\Expr\NullsafeMethodCall; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Name; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Function_; use PhpParser\NodeFinder; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor; +use PhpParser\NodeVisitorAbstract; use RuntimeException; /** @@ -67,16 +72,25 @@ final class GenericMarkerLeakGuard * elsewhere (the source seam in both modes, or the append-drain backstop, whose * check side keeps this arm on because compile's drain rejects the same body). * + * `$skipUnspecializedTemplates` skips the SUBTREES of function/method/closure + * declarations still carrying their generic-template marker. Same check-mode + * class-spec backstop rationale: check never strips templates, so a spec clone + * retains e.g. `a` whose body legitimately holds a `self::b::` marker — the + * template as a whole is dispatch machinery, not emitted output, and flagging its + * interior would reject code compile accepts. Compile-mode specs never contain + * such declarations, so the assert path is unaffected. + * * @param Node|list $specialized the emitted specialized node(s) */ public static function findLeak( Node|array $specialized, bool $includeClosureTemplates = true, bool $includeVariableTurbofish = true, + bool $skipUnspecializedTemplates = false, ): ?Node { $nodes = is_array($specialized) ? $specialized : [$specialized]; - return (new NodeFinder())->findFirst($nodes, static function (Node $n) use ($includeClosureTemplates, $includeVariableTurbofish): bool { + $matcher = static function (Node $n) use ($includeClosureTemplates, $includeVariableTurbofish): bool { if ($n instanceof FuncCall || $n instanceof MethodCall || $n instanceof StaticCall @@ -92,7 +106,51 @@ public static function findLeak( } return false; - }); + }; + + if (!$skipUnspecializedTemplates) { + return (new NodeFinder())->findFirst($nodes, $matcher); + } + + // Subtree-skipping scan: NodeFinder can't prune, so walk with a traverser that + // refuses to descend into declarations still carrying the template marker. + // Preorder like findFirst, so both paths report the same first leak. + $visitor = new class($matcher) extends NodeVisitorAbstract { + public ?Node $leak = null; + + /** @param \Closure(Node): bool $matcher */ + public function __construct(private readonly \Closure $matcher) + { + } + + public function enterNode(Node $node): ?int + { + if ($this->leak !== null) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + if (($node instanceof ClassMethod || $node instanceof Function_ + || $node instanceof Closure || $node instanceof ArrowFunction) + && is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)) + ) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + if (($this->matcher)($node)) { + $this->leak = $node; + // @infection-ignore-all ReturnRemoval — descending into the found + // leak's children is a no-op: the leak-set early-exit above prunes + // every subsequent node before the matcher can overwrite. The + // prune is an optimization; first-leak-wins is pinned by + // GenericMarkerLeakGuardTest's document-order test. + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($nodes); + + return $visitor->leak; } /** diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 872af807..e4a83833 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -388,14 +388,17 @@ public function groundSpecializedClass( // diagnostic here (call-marker arm only; sites the Phase-1a walk already // reported — e.g. an unspecializable `$this` self-call — dedupe by position). if (!$emit && $this->diagnostics !== null) { - // Variable-turbofish markers are excluded: check never materializes closure - // dispatchers, so a class-spec clone legitimately carries the `$f::` - // marker compile's dispatcher pass grounds — flagging it would reject code - // compile accepts. + // Variable-turbofish markers and the interiors of retained generic-method + // templates are excluded: check never materializes closure dispatchers nor + // strips templates, so a class-spec clone legitimately carries a `$f::` + // marker (compile's dispatcher pass grounds it) and template bodies with + // method-param markers (compile clones stripped classes) — flagging either + // would reject code compile accepts. $leak = GenericMarkerLeakGuard::findLeak( $specialized, includeClosureTemplates: false, includeVariableTurbofish: false, + skipUnspecializedTemplates: true, ); if ($leak !== null && !$this->alreadyReportedAt($currentFile, $leak->getStartLine())) { $this->diagnostics->add(new Diagnostic( @@ -1577,9 +1580,14 @@ private function rewriteStaticCall(StaticCall $node): ?Node $groundingClassSubst->withOverrides(Substitution::of($overlay)), $mangled, ); + // Drain context = the SPEC's template, not the declaring class: + // the member now lives on the spec, so `$this`/`self` inside its + // drained body are the spec — an ancestor-declared member whose + // body forwards again (`self::genB::` on Base) must pass the + // in-spec site guard, or a groundable chain leaks spuriously. $this->pendingAppends[] = [$this->grounding->spec, $specialized, [ - 'classFqn' => $declaringFqn, - 'namespace' => self::namespaceOf($declaringFqn), + 'classFqn' => $this->grounding->templateFqn, + 'namespace' => self::namespaceOf($this->grounding->templateFqn), ]]; $this->alreadyGenerated[$specKey] = true; } @@ -1830,9 +1838,12 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): $classSubst->withOverrides(Substitution::of($overlay)), $mangled, ); + // Drain context = the SPEC's template (see the static arm): the + // member lives on the spec, so its drained body's `$this`/`self` + // are the spec and further own-chain forwards keep grounding. $this->pendingAppends[] = [$this->grounding->spec, $specialized, [ - 'classFqn' => $declaringFqn, - 'namespace' => self::namespaceOf($declaringFqn), + 'classFqn' => $this->grounding->templateFqn, + 'namespace' => self::namespaceOf($this->grounding->templateFqn), ]]; $this->alreadyGenerated[$specKey] = true; } @@ -3176,7 +3187,11 @@ private function siteAlreadyReported(int $line): bool return false; } foreach ($this->diagnostics->all() as $diagnostic) { - if ($diagnostic->location !== null + // Errors only: a same-line WARNING must not suppress grounding — + // that would mask a real error the grounded walk would surface + // (check-green on code compile rejects, the dangerous direction). + if ($diagnostic->severity === Severity::Error + && $diagnostic->location !== null && $diagnostic->location->file === $this->currentFile && $diagnostic->location->line === $line ) { @@ -3475,7 +3490,9 @@ private function alreadyReportedAt(string $file, int $line): bool return false; } foreach ($this->diagnostics->all() as $diagnostic) { - if ($diagnostic->location !== null + // Errors only — a same-line warning must not swallow the leak backstop. + if ($diagnostic->severity === Severity::Error + && $diagnostic->location !== null && $diagnostic->location->file === $file && $diagnostic->location->line === $line ) { diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index a4e57112..58460ada 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\Diagnostics\Severity; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FilepathArray; use XPHP\FileSystem\FileReader\NativeFileReader; @@ -116,6 +117,34 @@ public function testDeferredTurbofishArityErrorIsReportedOncePerSite(): void self::assertSame(Registry::CODE_TOO_MANY_TYPE_ARGUMENTS, $diagnostics->all()[0]->code); } + public function testTwoHopOwnTemplateForwardChainIsCleanInCheck(): void + { + // Compile grounds `go` → `self::a::` → `self::b::` and the program + // runs; check must stay silent — its un-stripped spec clone retains the + // `a`/`b` templates, whose interior method-param markers are dispatch + // machinery, not leaks. + $diagnostics = $this->check('method_turbofish_two_hop_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testSameLineWarningDoesNotMaskAGroundedError(): void + { + // A warning-producing construct shares the source line with the deferred + // turbofish: the position dedupe is severity-aware, so grounding still runs + // and the bound violation nested in the grounded member surfaces — check + // must not go green on code compile rejects. + $diagnostics = $this->check('method_turbofish_warning_same_line'); + + self::assertTrue($diagnostics->hasErrors()); + $errorCodes = array_map( + static fn ($d) => $d->code, + array_filter($diagnostics->all(), static fn ($d) => $d->severity === Severity::Error), + ); + self::assertContains(Registry::CODE_BOUND_VIOLATION, $errorCodes); + } + public function testTemplateTargetOutsideItsOwnSpecLeakIsCollectedByCheck(): void { // The compile-side keep-marker contract for a template-owned target named diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php index 3a19fa54..073ef43d 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php @@ -198,6 +198,66 @@ public function testFindLeakCanExcludeVariableTurbofishCalls(): void ); } + public function testFindLeakCanSkipUnspecializedTemplateInteriors(): void + { + // With $skipUnspecializedTemplates=true, the SUBTREE of a declaration still + // carrying its generic-template marker is not scanned: check-mode spec clones + // retain method templates whose interior markers are dispatch machinery, not + // leaks. Each declaration kind prunes independently. + $makeMarkedCall = static function (): StaticCall { + $call = new StaticCall(new Name('self'), new Identifier('gen')); + $call->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + return $call; + }; + + $method = new \PhpParser\Node\Stmt\ClassMethod('a', ['stmts' => [new Return_($makeMarkedCall())]]); + $method->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + $function = new \PhpParser\Node\Stmt\Function_('f', ['stmts' => [new Return_($makeMarkedCall())]]); + $function->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + $closure = new Closure(['stmts' => [new Return_($makeMarkedCall())]]); + $closure->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + $arrow = new ArrowFunction(['expr' => $makeMarkedCall()]); + $arrow->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + + foreach ([$method, $function, new Expression($closure), new Expression($arrow)] as $decl) { + self::assertNotNull( + GenericMarkerLeakGuard::findLeak($decl, includeClosureTemplates: false), + 'without the skip, the interior marker is a leak', + ); + self::assertNull( + GenericMarkerLeakGuard::findLeak($decl, includeClosureTemplates: false, skipUnspecializedTemplates: true), + 'with the skip, the template interior is pruned', + ); + } + + // A declaration WITHOUT the template marker is scanned normally... + $plainMethod = new \PhpParser\Node\Stmt\ClassMethod('b', ['stmts' => [new Return_($makeMarkedCall())]]); + self::assertNotNull( + GenericMarkerLeakGuard::findLeak($plainMethod, includeClosureTemplates: false, skipUnspecializedTemplates: true), + ); + // ...and the params attribute on a NON-declaration node never prunes (the skip + // is scoped to the four declaration kinds precisely). + $decoy = new Expression($makeMarkedCall()); + $decoy->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + self::assertNotNull( + GenericMarkerLeakGuard::findLeak($decoy, includeClosureTemplates: false, skipUnspecializedTemplates: true), + ); + } + + public function testFindLeakSkippingScanReturnsTheFirstLeakInDocumentOrder(): void + { + // The pruning scan must report the same FIRST leak the plain scan does — a + // later sibling must never overwrite it. + $first = new StaticCall(new Name('self'), new Identifier('one')); + $first->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + $second = new StaticCall(new Name('self'), new Identifier('two')); + $second->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + $body = [new Expression($first), new Expression($second)]; + + self::assertSame($first, GenericMarkerLeakGuard::findLeak($body, skipUnspecializedTemplates: true)); + self::assertSame($first, GenericMarkerLeakGuard::findLeak($body)); + } + public function testLeakMessageNamesTheLabelTheLineAndTheCode(): void { $call = new FuncCall(new Variable('inner'), [], ['startLine' => 7]); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index d9d8a294..5d329c8f 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -1894,6 +1894,25 @@ public function testErasablePlainCallerForwardReusesLoweredMember(): void } } + #[RunInSeparateProcess] + public function testTwoHopOwnTemplateForwardChainCompilesAndRuns(): void + { + // `go` → `self::a::` whose grounded body forwards `self::b::`: the + // drain re-grounds the appended member with the spec's own identity, so the + // chain bottoms out on the spec. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_method_forward_chain/source', + 'genmethod-forward-chain', + ); + try { + $fixture->registerAutoload('App\\MethodForwardChain'); + $runtime = require __DIR__ . '/../../fixture/compile/generic_class_method_forward_chain/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + #[RunInSeparateProcess] public function testStaticInheritedTurbofishLandsOnTheCallingSpec(): void { diff --git a/test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp b/test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp new file mode 100644 index 00000000..739c6852 --- /dev/null +++ b/test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp @@ -0,0 +1,34 @@ +` appends a member whose body +// holds a now-concrete `self::b::` — the drain must re-ground the appended member +// (with the spec's own identity, so the in-spec site guard passes) until the chain +// bottoms out. Check must stay silent: the retained `a`/`b` templates inside its +// un-stripped spec clone are dispatch machinery, not leaks. +class Holder +{ + /** @return array */ + public function go(T $v): array + { + return self::a::($v); + } + + /** @return array */ + public static function a(U $x): array + { + return self::b::($x); + } + + /** @return array */ + public static function b(V $x): array + { + return [$x, $x]; + } +} + +$h = new Holder::(); +$r = $h->go(9); diff --git a/test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp b/test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp new file mode 100644 index 00000000..c3f7e985 --- /dev/null +++ b/test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp @@ -0,0 +1,48 @@ + +{ + public function __construct(public readonly P $v) + { + } +} + +class Producer +{ + public function __construct(public readonly mixed $seed) + { + } +} + +final class Book +{ +} + +// A WARNING and a deferred turbofish share one source line: the position dedupe is +// severity-aware, so the warning must not suppress grounding — the bound violation +// nested in gen's grounded body still surfaces (check-green here while compile +// rejects would be the dangerous direction). +final class Holder +{ + public function go(T $v): mixed + { + $x = [new Producer::(1), self::gen::($v)]; return $x; + } + + public static function gen(U $x): mixed + { + return new Pair::($x); + } +} + +$h = new Holder::(); +$h->go(1); diff --git a/test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp b/test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp new file mode 100644 index 00000000..739c6852 --- /dev/null +++ b/test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp @@ -0,0 +1,34 @@ +` appends a member whose body +// holds a now-concrete `self::b::` — the drain must re-ground the appended member +// (with the spec's own identity, so the in-spec site guard passes) until the chain +// bottoms out. Check must stay silent: the retained `a`/`b` templates inside its +// un-stripped spec clone are dispatch machinery, not leaks. +class Holder +{ + /** @return array */ + public function go(T $v): array + { + return self::a::($v); + } + + /** @return array */ + public static function a(U $x): array + { + return self::b::($x); + } + + /** @return array */ + public static function b(V $x): array + { + return [$x, $x]; + } +} + +$h = new Holder::(); +$r = $h->go(9); diff --git a/test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php b/test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php new file mode 100644 index 00000000..68bc5b72 --- /dev/null +++ b/test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php @@ -0,0 +1,23 @@ +targetDir . '/Use.php'; + + Assert::assertSame([9, 9], $r); + + $grounded = array_values(array_filter( + get_class_methods($h), + static fn (string $m): bool => str_starts_with($m, 'a_T_') || str_starts_with($m, 'b_T_'), + )); + Assert::assertCount(2, $grounded, 'both hops grounded onto the spec'); +}; diff --git a/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp b/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp index 64747675..493bfbe0 100644 --- a/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp +++ b/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp @@ -12,6 +12,15 @@ class Base { /** @return array */ public static function gen(U $x): array + { + // A further hop DECLARED ON THE ANCESTOR: the grounded member lives on the + // Holder spec, so its drained body re-grounds `self::genB::` there too — + // the ancestor chain grounds hop by hop, not just one level. + return self::genB::($x); + } + + /** @return array */ + public static function genB(V $x): array { return [$x, $x]; } diff --git a/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php b/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php index bdebf578..f458201a 100644 --- a/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php +++ b/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php @@ -17,18 +17,22 @@ Assert::assertSame([3, 3], $r1); Assert::assertSame('s', $r2); + // Both hops of the ancestor-declared chain (`gen` → `genB`) ground onto the + // Holder spec itself. $genMembers = array_values(array_filter( get_class_methods($h), - static fn (string $m): bool => str_starts_with($m, 'gen_T_'), + static fn (string $m): bool => str_starts_with($m, 'gen_T_') || str_starts_with($m, 'genB_T_'), )); - Assert::assertCount(1, $genMembers); - Assert::assertSame( - get_class($h), - (new ReflectionMethod($h, $genMembers[0]))->getDeclaringClass()->getName(), - 'member declared on the Holder spec itself', - ); + Assert::assertCount(2, $genMembers); + foreach ($genMembers as $member) { + Assert::assertSame( + get_class($h), + (new ReflectionMethod($h, $member))->getDeclaringClass()->getName(), + 'member declared on the Holder spec itself', + ); + } Assert::assertSame([], array_values(array_filter( get_class_methods($b), - static fn (string $m): bool => str_starts_with($m, 'gen_T_'), + static fn (string $m): bool => str_starts_with($m, 'gen_T_') || str_starts_with($m, 'genB_T_'), )), 'the Base spec grew no grounded member'); }; From 956e8fe4dcb59675575618074aecb97d740b9d82 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 26 Jul 2026 21:47:23 +0000 Subject: [PATCH 8/9] refactor(monomorphize): reattach constructor docblock and skip templates in grounding pre-scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two no-behavior-change cleanups surfaced by review of the grounding pass: The constructor's `@param $diagnostics` docblock had been pushed away from `__construct` when the retained-state properties were inserted between them, leaving it orphaned (documenting nothing, and the constructor undocumented at its declaration). Move it back directly above the constructor. The cheap pre-scan in groundSpecializedClass called findLeak without `skipUnspecializedTemplates: true`, unlike the check-mode backstop 30 lines below. In check mode a spec clone keeps its unstripped generic-method templates, whose bodies carry markers the grounding walk already skips — so the pre-scan never took its "nothing to do" early exit for any spec declaring a generic method, running a redundant full walk. Aligning the flag restores the fast path; output is identical either way (verified: flipping the flag leaves the whole check/method suite green). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index e4a83833..7d7e9da4 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -116,12 +116,6 @@ final class GenericMethodCompiler */ private const MAX_METHOD_SPECIALIZATION_HOPS = 16; - /** - * @param ?DiagnosticCollector $diagnostics When null (the default — `xphp compile`), every - * method/function/closure-level generic error throws as before, byte-identical. When provided - * (by `xphp check` with `process(..., emit: false)`), each is appended as a Diagnostic and the - * pass continues, so all are reported in one run. - */ /** * Phase-1a state retained for the post-specialization grounding pass * ({@see groundSpecializedClass}). `process()` strips generic templates from the @@ -137,6 +131,12 @@ final class GenericMethodCompiler /** @var array class template FQN => source ast key (filepath), for grounding-time diagnostics */ private array $classSourceByFqn = []; + /** + * @param ?DiagnosticCollector $diagnostics When null (the default — `xphp compile`), every + * method/function/closure-level generic error throws as before, byte-identical. When provided + * (by `xphp check` with `process(..., emit: false)`), each is appended as a Diagnostic and the + * pass continues, so all are reported in one run. + */ public function __construct( private readonly int $hashLength = Registry::DEFAULT_HASH_HEX_LENGTH, private readonly ?TypeHierarchy $hierarchy = null, @@ -365,7 +365,16 @@ public function groundSpecializedClass( return []; } // Cheap pre-scan: most specs carry no marker; skip the visitor entirely then. - if (GenericMarkerLeakGuard::findLeak($specialized, includeClosureTemplates: false) === null) { + // skipUnspecializedTemplates matches the check-mode backstop below: in check + // mode a spec clone retains its (unstripped) generic-method templates, whose + // bodies carry call markers that the grounding walk deliberately skips — without + // this flag the pre-scan would see those and never take the cheap exit for any + // spec that declares a generic method. + if (GenericMarkerLeakGuard::findLeak( + $specialized, + includeClosureTemplates: false, + skipUnspecializedTemplates: true, + ) === null) { return []; } From 4252e9896e6361fae3d9607b12140ebb638e9dde Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 27 Jul 2026 06:36:34 +0000 Subject: [PATCH 9/9] docs: clarify that forwarding a method-level type parameter is unsupported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turbofish caveats described the method-level-parameter forward failure as happening "to a non-erasable target", which wrongly implied an erasable target would work. It doesn't: forwarding a method-level parameter (`$this->dup::` inside `probe`) fails regardless of the target, because a generic method is specialized before its class, so `W` has no concrete value where the forward would be grounded. A non-erasable target reports `xphp.unspecializable_self_call`; an erasable one leaks and reports `xphp.unspecialized_generic_leak` — neither is supported. State that plainly in both docs/syntax/methods-and-functions.md and docs/caveats.md, and note the underlying reason (class parameters become concrete at class specialization; method parameters don't). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/caveats.md | 9 ++++++--- docs/syntax/methods-and-functions.md | 14 +++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/caveats.md b/docs/caveats.md index dec305f9..b565be60 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -830,9 +830,12 @@ class Holder The late-bound `static::` / `parent::` spellings (resolving them statically could silently re-route a subclass or parent dispatch — rejecting loudly is the contract), a forward to a **bare top-level** (namespace-less) generic function from inside a -generic class, and a **method-level** parameter forwarded to a non-erasable target -(`$this->dup::` inside `probe` — reported precisely as -`xphp.unspecializable_self_call`, since no specialization ever grounds `W`). +generic class, and a **method-level** parameter forwarded to any generic method +(`$this->dup::` inside `probe`). A method-level parameter can't be forwarded +because a generic method is specialized before its class, so `W` has no concrete +value where the forward would be grounded — a non-erasable target reports +`xphp.unspecializable_self_call`, an erasable one `xphp.unspecialized_generic_leak`, +but neither is supported. A **strictly-growing** forward chain is rejected as non-convergent rather than compiled forever: diff --git a/docs/syntax/methods-and-functions.md b/docs/syntax/methods-and-functions.md index fa85b3a5..5da8b69b 100644 --- a/docs/syntax/methods-and-functions.md +++ b/docs/syntax/methods-and-functions.md @@ -129,11 +129,15 @@ build time instead of fataling at runtime with "Call to undefined method". - > ⚠️ Forwarding an **enclosing class type parameter** (`$this->dup::` / `self::gen::` / `Maker::wrap::` inside `Box`, or `identity::` inside a generic function `wrap`) - grounds per specialization and runs. Forwarding a **method-level** - parameter to a non-erasable target (`$this->dup::` inside - `probe`) stays a compile error (`xphp.unspecializable_self_call`), - as do targets on a *different* generic template and the - `static::`/`parent::` spellings. See + grounds per specialization and runs — the class parameter becomes + concrete when the class specializes. Forwarding a **method-level** + parameter (`$this->dup::` inside `probe`) does **not**: a generic + method is specialized before its class, so `W` has no concrete value + where the forward would be grounded. It is a compile error regardless + of the target — `xphp.unspecializable_self_call` for a non-erasable + target, `xphp.unspecialized_generic_leak` for an erasable one. + Targets on a *different* generic template and the `static::`/`parent::` + spellings are rejected too. See [caveats](../caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter). ## See also