Skip to content

fix(monomorphize): ground enclosing-param method-generic turbofish per specialization - #27

Merged
math3usmartins merged 9 commits into
0.3.xfrom
fix/method-turbofish-enclosing-param-grounding
Jul 27, 2026
Merged

fix(monomorphize): ground enclosing-param method-generic turbofish per specialization#27
math3usmartins merged 9 commits into
0.3.xfrom
fix/method-turbofish-enclosing-param-grounding

Conversation

@math3usmartins

@math3usmartins math3usmartins commented Jul 26, 2026

Copy link
Copy Markdown
Member

A turbofish whose type argument comes from an enclosing generic scope was
rejected with xphp.unspecialized_generic_leak, so a generic class could not
delegate to a shared static generic helper (Maker::wrap::<T> inside Box<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:

  • named free-function forwards: identity::<T>($v) inside wrap
  • static calls: self::gen::<T>() and Maker::wrap::<T>() inside Box,
    including targets declared on a generic base class
  • instance calls: $this->dup::<T>() and $m->dup::<T>() on a non-generic
    receiver 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 are
deduplicated 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.

math3usmartins and others added 7 commits July 25, 2026 23:44
…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>
@greptile-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a long-standing limitation: a turbofish whose type argument is an enclosing generic class parameter (e.g. self::gen::<T>() inside Box<T>) was previously rejected with xphp.unspecialized_generic_leak. The fix introduces a second grounding phase — groundSpecializedClass — that runs inside the fixed-point specialization loop, after class substitution has made those abstract type arguments concrete.

  • A new GroundingContext value object threads the spec's identity, template FQN, and concrete type args into the existing rewrite machinery, which is re-invoked in markers-only mode per fresh specialization. Static calls, instance calls, and named free-function forwards are all handled; static::/parent:: spellings, cross-template targets, and closure turbofish deliberately keep their markers and fall to the existing assertNoLeak backstop.
  • Freshly specialized bodies are drained transitively via a worklist: multi-hop chains and mutual recursion terminate through the shared alreadyGenerated dedup; strictly-growing chains halt at MAX_METHOD_SPECIALIZATION_HOPS = 16 with the new xphp.unconverged_method_specialization error code.
  • xphp check is brought into full parity: groundSpecializedClass runs with emit: false inside the resilient fixed-point loop, collecting bound violations and surviving markers that were previously only reported by compile.

Confidence Score: 5/5

Safe to merge. The two-phase grounding and drain worklist are correctly implemented, convergence is capped, and check/compile parity is maintained.

The new grounding pass correctly defers to the retained Phase-1a template index, deduplicates appends via the shared alreadyGenerated map, threads concrete class args through the inheritance chain for both static and instance call shapes, and terminates growing chains at the same depth cap as the class-level loop. The check path runs the same machinery with emit: false, collects the same diagnostics, and feeds external appends into the resilient fixed-point loop so nested instantiation needs discovered by grounding converge correctly. No bugs were found in the changed paths.

Files Needing Attention: No files require special attention.

Important Files Changed

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
Loading

Reviews (3): Last reviewed commit: "docs: clarify that forwarding a method-l..." | Re-trigger Greptile

Comment thread src/Transpiler/Monomorphize/GenericMethodCompiler.php Outdated
Comment thread src/Transpiler/Monomorphize/GenericMethodCompiler.php
Comment thread src/Transpiler/Monomorphize/GenericMethodCompiler.php
@math3usmartins math3usmartins changed the title Ground enclosing-param method-generic turbofish per specialization fix(monomorphize): ground enclosing-param method-generic turbofish per specialization Jul 26, 2026
math3usmartins and others added 2 commits July 26, 2026 21:47
…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>
@math3usmartins
math3usmartins merged commit 3450221 into 0.3.x Jul 27, 2026
7 checks passed
@math3usmartins
math3usmartins deleted the fix/method-turbofish-enclosing-param-grounding branch July 27, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant