fix(monomorphize): ground enclosing-param method-generic turbofish per specialization - #27
Conversation
…ll-node kinds 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::<T>`, `$this->m::<T>`) 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 <noreply@anthropic.com>
A generic function forwarding to a named generic (`identity::<T>($v)` inside `wrap<T>`) was rejected with xphp.unspecialized_generic_leak: specialization substituted the inner marker to a concrete `identity::<int>`, 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::<Box<T>>`) 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 <noreply@anthropic.com>
… specialization A generic class calling a method-generic via turbofish whose type argument is its own type parameter (`self::gen::<T>`, `Maker::wrap::<T>` inside `Box<T>`) 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::<T>`) 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<int>`) 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 <noreply@anthropic.com>
…ss specialization An instance method-generic turbofish grounded by the enclosing class type parameter was rejected on both receiver shapes: `$this->dup::<T>()` drew xphp.unspecializable_self_call at the Phase-1a walk (where T has no value), and `$obj->dup::<T>()` leaked to the emit backstop. Phase 1a now DEFERS the `$this`-rooted report when every abstract leaf in the turbofish names an enclosing CLASS parameter — the shape the per-specialization grounding pass dispatches once the class substitution makes it concrete. A METHOD-level parameter leaf keeps the precise rejection at the original site: nothing downstream ever grounds it, and a late leak error would be strictly worse. The grounding pass processes instance markers alongside static ones: `$this` resolves to the spec's own identity with the instantiation's concrete type arguments (so `<U : E>`-style bounds ground, and erasable targets route into the existing erasure lowering — reusing the E-mangled member rather than fatally redeclaring it); object receivers resolve through the spec's already-substituted declared types, with parse-time name resolution replacing the detached walk's missing alias state. A target declared on a generic BASE grounds through the inheritance chain and lands on the CALLING spec itself — never on the shared template, whose mid-loop mutation would be order-dependent. A receiver of a *different* generic template keeps its marker for the backstop (its member belongs on that template's own specs), and re-visited sites the template walk already diagnosed are never double-reported. A deferred marker in a never-instantiated generic class now compiles silently (the template lowers to a marker interface; the body is dropped) — pinned as a deliberate surface choice, matching check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The caveats section that listed every enclosing-parameter turbofish shape as rejected now documents the grounded-per-specialization behavior (named forwards, static and instance method calls, generic-base targets, erasable plain callers) and narrows the rejected list to what actually remains: closure turbofish inside generic function bodies, cross-template targets, the late-bound static::/parent:: spellings, bare top-level forwards from class scope, method-param leaves, and non-convergent growing chains. The check-vs-compile completeness-gap paragraph is gone — the validate-only pass now collects the same diagnostics. errors.md: the leak-backstop row names the remaining shapes, the unspecializable_self_call row is scoped to method-level parameter leaves, and the new xphp.unconverged_method_specialization code is listed. Turbofish and methods-and-functions caveats link the new behavior; CHANGELOG gains the Unreleased entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…align check diagnostics Four fixes to the per-specialization grounding pass, each pinned by a fixture that fails without it: The own-template arms (static and instance) now require the call site to lexically live inside the specialization being grounded. A drained body appended onto ANOTHER class could previously hit the arm — `Holder::gen::<X>` written inside Maker's forwarded member was rewritten to `self::gen_T_…()` inside Maker, a silent call to a member Maker doesn't have (runtime fatal; the same program was loudly rejected before the grounding pass existed). Outside the spec the marker is kept for the backstop; `$this` inside a foreign drained body also no longer borrows the spec's type arguments. The static arm gains generic-ANCESTOR support in the process (the declaring template's substitution threads through the extends chain, mirroring the instance rule) — `self::gen::<T>` with gen on `Base<T>` now grounds onto the calling spec. Check-side alignment: the class-spec leak backstop no longer flags a leftover variable-turbofish marker (`$f::<int>` in a generic-class method — compile materializes the dispatcher and the program runs; check simply never finalizes dispatchers), grounded own-spec members are collected in check mode even though nothing is attached (a bound violation nested in a grounded member's body — `new Pair::<U>` grounded to a bound-violating Pair<int> — was passing check while compile rejected), and markers-only re-walks skip call sites that already carry a diagnostic, so an arity error on a deferred turbofish is reported once at its source site instead of once more per specialization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…severity-aware dedupe 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<U>` whose body legitimately holds a `self::b::<U>` 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::<U>` 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 <noreply@anthropic.com>
|
| Filename | Overview |
|---|---|
| src/Transpiler/Monomorphize/GenericMethodCompiler.php | Core of the PR: adds groundSpecializedClass, drainSpecializedAppends, and a markersOnly/grounding mode to the rewrite visitor. The two-phase grounding, convergence cap, and dedup via alreadyGenerated are all sound. |
| src/Transpiler/Monomorphize/Compiler.php | Wires GenericMethodCompiler into specializeToFixedPoint for both compile and check paths; external appends from grounding are collected into the fixed-point loop. Logic is correct. |
| src/Transpiler/Monomorphize/GroundingContext.php | New lightweight value object threading spec identity, template FQN, concrete type args, and external-append accumulation into the grounding pass. Minimal surface, no issues. |
| src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php | Adds skipUnspecializedTemplates and includeVariableTurbofish parameters to findLeak, correctly preventing false positives in check-mode spec backstop. |
| src/Transpiler/Monomorphize/Specializer.php | Extends the substituting visitor to ground ATTR_METHOD_GENERIC_ARGS on all four call-node types during class specialization, so enclosing type-param leaves become concrete before the grounding pass reads them. |
Sequence Diagram
sequenceDiagram
participant C as Compiler
participant S as Specializer
participant GMC as GenericMethodCompiler
participant RC as RegistryCollector
Note over C: Phase 1a - raw user ASTs
C->>GMC: process(astPerFile, emit true)
GMC-->>C: markers deferred (T still abstract)
Note over C: Phase 2 - fixed-point loop
loop per instantiation e.g. Box_int
C->>S: specialize(Box_T, T to int)
S-->>C: Box_int spec with self::gen::int marker
C->>GMC: "groundSpecializedClass(spec, Box_int, classArgs=[int], emit true)"
Note over GMC: markers-only walk on spec
Note over GMC: self::gen::int appended onto spec
Note over GMC: Maker::wrap::int goes to externalAppends
Note over GMC: drain loop re-walks appended bodies
GMC-->>C: externalAppends list
C->>RC: collect(externalAppends)
C->>RC: collect([spec])
end
Note over C: Phase 3+ emit and leak-guard
Reviews (3): Last reviewed commit: "docs: clarify that forwarding a method-l..." | Re-trigger Greptile
…tes in grounding pre-scan 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) <noreply@anthropic.com>
…orted 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::<W>` inside `probe<W>`) 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) <noreply@anthropic.com>
A turbofish whose type argument comes from an enclosing generic scope was
rejected with
xphp.unspecialized_generic_leak, so a generic class could notdelegate to a shared static generic helper (
Maker::wrap::<T>insideBox<T>)and had to inline the helper body at every call site. Reported by a
downstream consumer.
These shapes now ground per specialization and run:
identity::<T>($v)inside wrapself::gen::<T>()andMaker::wrap::<T>()inside Box,including targets declared on a generic base class
$this->dup::<T>()and$m->dup::<T>()on a non-genericreceiver typed parameter
Freshly specialized bodies are re-grounded transitively: multi-hop chains
and mutual recursion converge, while a strictly growing chain fails loudly
with the new
xphp.unconverged_method_specialization. Grounded members arededuplicated per unique argument tuple, and bounds that only become
provable after specialization are checked per instantiation. xphp check
now collects the same diagnostics compile raises, closing the gap where
check reported green on code compile rejected.
Still rejected loudly, by design: closure turbofish inside generic function
bodies, targets on a different generic template, the static::/parent::
spellings, and method-level parameter forwards to non-erasable targets.
See docs/caveats.md for the updated support matrix.