Skip to content

(!) Closure signature types, out/in variance markers, and a validating compile gate#23

Open
math3usmartins wants to merge 76 commits into
0.3.xfrom
feat/closure-signature-types
Open

(!) Closure signature types, out/in variance markers, and a validating compile gate#23
math3usmartins wants to merge 76 commits into
0.3.xfrom
feat/closure-signature-types

Conversation

@math3usmartins

Copy link
Copy Markdown
Member

Summary

Three headline capabilities land together:

  1. first-class Closure(...) signature types with conformance checking
  2. Kotlin-style out/in declaration-site variance markers
  3. validate-before-emit compile gate

Additionally, a broad monomorphizer correctness sweep that closes a family of silent-compile -> runtime-fatal defects

Highlights

Closure signature types

  • Closure(int $x, string $y): bool type hints in any parameter, return, or property
    position, erasing to a bare \Closure in the emitted PHP.
  • A closure literal returned against a Closure(...) return type is checked for
    conformance -- parameters contravariant, return covariant, by-ref exact, arity
    compatible -- failing the build only on a provable mismatch and staying gradual
    otherwise. Union / intersection / nullable / parenthesised-DNF members and
    fully-qualified & built-in target types all participate.
  • Signatures that reference an enclosing type parameter are grounded per
    specialization.

Variance

  • Declaration-site variance is now written out T / in T (contextual keywords).
  • Variance composes through nested generic arguments; by-reference parameters are an
    invariant position; variance markers are allowed on private properties;
    type-parameter-typed constructors on variant classes are supported.

Generics & inheritance

  • Generic methods resolved through inheritance; element-typed methods on covariant
    collections; multi-root builds via an xphp.json manifest.

Workflow / tooling

  • xphp check -- a validate-without-emitting CI gate running every generic validator
    plus PHPStan over the compiled output.
  • xphp compile runs that same gate by default and emits nothing on error
    (--no-check / --no-phpstan opt out).

Robustness -- silent-fatal sweep

A batch of defects where clean compile + check + php -l masked a runtime fatal
or a wrong-symbol bind are now caught or fixed, including: generic-trait insteadof/
as adaptation operands, group-imported and free-function/const re-qualification in
relocated specialized bodies, dynamically-named and keyword-named turbofish forms,
bare new of a non-defaults generic, undeclared type parameters, too-many-type-
arguments, generic clauses on use imports, and byte-exact marker binding. Parse-stage
rejections now report their real source line for editor navigation.

(!) Breaking changes

  1. Variance syntax moved to out T / in T (was +T / -T). Semantics are
    unchanged; old +T / -T source fails with a migration error pointing at the new
    spelling. Rewrite class Box<+T> -> class Box<out T>, <-T> -> <in T>.
  2. final on a variant class is now rejected. A final class Box<out T> can't
    anchor the extends edge between specializations (and previously disagreed with
    ReflectionClass::isFinal()); omit final on a variant template. Non-variant
    generics are unaffected.
  3. compile validates before emitting. Errors that previously slipped through to
    runtime (e.g. a type argument naming no real class) now fail the build. Use
    --no-check for the previous fast-emit behavior.

math3usmartins and others added 30 commits July 6, 2026 02:18
Introduce the value classes for a parsed closure signature type
(`Closure(int $x): bool`): a recursive `SigType` sum (`SigTypeRef` |
`SigClosure`) for signature leaves — so a nested `Closure(...)` in a
parameter or return position is representable — plus `ClosureSignature`
and `ClosureSignatureParam`. Leaves carry a `TypeRef` so generic
substitution and the type hierarchy are reused. Mirrors the separate
`BoundExpr` hierarchy rather than widening `TypeRef`. No wiring yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Recognize a `Closure(params): return` production in a type slot
(parameter, property, or return position) and erase it to a bare
`\Closure` in the emitted PHP, carrying the parsed shape as an AST
attribute for the later conformance validator.

- A position gate distinguishes a genuine type slot (the signature is
  followed by a `$var`, or sits in a `) :` return slot) from an
  expression-context call to a user function named `Closure`, which is
  left untouched.
- `Closure(int)` — where PHP lexes `(int)` as a single cast token
  rather than `(` `int` `)` — is accepted as the one-scalar parameter
  list; the cast-token spelling maps back to its scalar name.
- Erasure preserves newlines so line-keyed markers on later lines stay
  aligned, and always emits the fully-qualified `\Closure` so a bare
  `Closure` hint does not fatal inside a namespace.
- Nested `Closure(...)` parameter/return types recurse; enclosing
  type parameters are carried through so a signature can reference them.
  Union / intersection / nullable leaf types are erased and retained as
  raw text pending their structured form.
- Two structural errors are raised at parse time: a call signature on a
  non-`Closure` name, and a variadic parameter that is not last.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…yte-key markers

Address three defects found reviewing closure signature parsing:

- `array` and `callable` lex as their own tokens (T_ARRAY / T_CALLABLE),
  not T_STRING, so a signature that used them as a parameter or return
  type — `Closure(array $a): callable`, `Closure(): int|array` — was not
  recognized and failed to erase (or, for a union, silently re-parsed into
  a bogus `\Closure|array` return type). Recognize the reserved-word type
  keywords `array` / `callable` / `static` everywhere a type name is
  expected, and represent them as structured leaves rather than raw text.

- Erasure emitted an 8-byte `\Closure` over a 7-byte `Closure`, shifting
  every byte position after the signature and desyncing later byte-keyed
  markers (an anonymous closure's generic parameters would silently fail
  to attach). Reclaim the extra byte from the blanked tail so the span is
  erased to exactly its original length.

- The parsed signature was matched to its `\Closure` Name by line, so a
  plain `Closure` hint sharing a line with a real signature stole the
  marker. Match by byte position (mapped back through the offset map)
  instead, so the signature lands on the exact Name it belongs to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pure rule engine that decides whether a candidate closure signature
conforms to a target `Closure(...)` type, using the engine's inherited-
method variance: parameters contravariant, return covariant, by-reference
exact, arity compatible.

The check is one-directional — it reports only a *provable* mismatch, and
gradually accepts everything unproven (unresolved class, still-abstract
type parameter, untyped/`mixed` leaf, raw union/intersection, and the
pseudo-type leaves `object`/`never`/`iterable`/`callable`/`self`/`static`/
`parent`, which are decided by an explicit table rather than nominal
subtyping so they never false-reject). Only class-vs-class and true-scalar
pairs consult the type hierarchy.

Structural rules (arity + by-reference) are exposed separately so they can
run once at an abstract generic template, with the type rules run per
grounded specialization. A parameter gains an `optional` flag so a
defaulted candidate parameter lowers the required arity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turn a `fn`/`function` literal into a candidate ClosureSignature the
conformance engine can check against a `Closure(...)` target. Candidate
types resolve against the enclosing file's namespace context, into the
same FQN/scalar space the target's types were resolved into.

Gradual by construction: an untyped parameter becomes `mixed` (the top
type — always conforms), an absent return stays absent, and a
nullable/union/intersection leaf is carried raw — so extraction never
manufactures a false mismatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ure(...) return type

Wire the closure-signature conformance engine to the one statically
decidable literal site: a function/method/closure/arrow that declares a
`Closure(...)` return type and hands back a closure literal. The returned
literal's signature is extracted and checked against the target (parameters
contravariant, return covariant, by-reference exact, arity compatible); a
provable mismatch fails `compile` fast and is collected by `check`.

A parameter/property default is not a site: a default must be a constant
expression and a closure literal is not one, so such a slot can never hold a
literal candidate. Every other value flow (assignment, call argument) leaves
the candidate non-literal at the target and stays gradual.

The `Closure(...)` type is still erased to a bare `\Closure`; this adds only a
compile-time gate, verified end to end by a fixture whose erased output runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…generic return type

A `Closure(...)` return target that references an enclosing type parameter is
gradual at the abstract template (the parameter could ground to anything), so
the pre-specialization pass accepts it. Once specialized, substitute the
target's type-parameter leaves alongside the returned literal's own types and
re-run conformance over each specialized class: a mismatch that only becomes
provable after grounding — e.g. a `string`-parameter literal against a
`Closure(T $x)` target resolved to `Closure(int $x)` — now fails the build.

Structural mismatches (arity, by-reference) do not depend on grounding and are
caught at the template, which fails fast before specialization; the grounded
pass therefore only ever adds type-relation violations, never duplicates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ilt-in supertype

The conformance engine read `TypeHierarchy::isSubtype(...) === false` as a proof
of non-subtyping for any two declared classes. But the hierarchy seeds ancestor
edges only from scanned source, not PHP's built-in class graph, so a real
relation like `Exception <: Throwable` (or any user class whose ancestry passes
through a built-in) returns a hard `false` — turning valid, idiomatic factories
(exception, iterator, enum) into hard compile failures by default.

A `false` verdict is trustworthy only when the target is a user-declared class:
reaching it is possible solely over user edges, all of which are modeled.
Reaching a built-in target may traverse unmodeled built-in ancestry, so treat a
built-in target as unprovable and accept it gradually — consistent with the
one-directional "never a false reject" rule the rest of the engine follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a syntax page for the `Closure(int $x): bool` signature type — its
positions, erasure to a bare `\Closure`, return-position conformance rules
(contravariant parameters, covariant return, exact by-ref, arity), the
provable-only/gradual policy, and per-specialization grounding. Register the
`xphp.closure_conformance` diagnostic (code table + verbatim error text) and
add a CHANGELOG entry for the unreleased 0.3.0 line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…closure signatures

Add structured SigUnion / SigIntersection leaves and teach the conformance
engine their subtyping. The four decomposition arms fold into the existing
binary predicate, decomposing the SUB side first (super-first would false-reject
`int|string` against `string|int`, since the resulting AND-of-ORs strictly
over-approximates the sound OR-of-ANDs):

  union sub      A|B <: Y   provable iff SOME member is    (OR)
  union super    X <: A|B   provable iff vs EVERY member   (AND)
  intersection super  X <: A&B  provable iff vs SOME member (OR)
  intersection sub    A&B <: Y  GRADUAL

The intersection-sub case stays gradual on purpose: an intersection of
incompatible members is uninhabited (`never`, a subtype of everything), and with
no inhabitation check decomposing it would false-reject a vacuously-wide `never`
parameter. Diagnostics render `A|B` / `A&B`.

This is the engine only; the extractor and parser still emit SigRaw, so nothing
reaches these arms yet — pinned by direct engine unit tests including the
uninhabited-intersection accept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nullable types

The candidate extractor now builds SigUnion / SigIntersection from a closure
literal's nikic type nodes instead of an opaque SigRaw: `?A` becomes `A|null`, a
union maps its members (including a DNF `(A&B)` member, which nikic nests as an
intersection inside the union — structured recursively for free), an intersection
maps its members. A member that fails to resolve, or a scalar member in an
intersection (only reachable from already-invalid PHP), falls the whole leaf back
to a gradual SigRaw so no partially-structured leaf can false-decide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…get signatures

The target-side token scanner now splits a flat `A|B` / `A&B` / `?A` in a
`Closure(...)` type into a SigUnion / SigIntersection (parseFlatCompound walks
members after the first leaf), and the signature resolver recurses into their
members so each resolves to the same FQN / scalar / type-parameter space as any
other target leaf. A shape the flat splitter doesn't own — a DNF group `(A&B)|C`,
a mixed `|`/`&` at top level, a `?`-prefixed member, or a scalar in an
intersection — falls back to a gradual SigRaw with a correct span.

With both the target scanner and the candidate extractor structuring compounds,
a union/intersection member mismatch is now caught end to end, e.g.
`Closure(): int|string` handed `fn(): float` fails the build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…signature members

The specialization walk now recurses into SigUnion / SigIntersection members, so a
`Closure(): T|int` return target grounds its `T` member alongside the concrete
`int` when the class specializes. A member mismatch that was gradual at the
abstract template (the `T` member accepts anything) becomes provable once
grounded — `Box<string>` turns `T|int` into `string|int`, and a class-returning
factory literal now fails the build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…inst compound supers

An intersection on the sub side of a signature subtyping check stays gradual
(uninhabited intersections are `never`, a subtype of everything). Add behavioral
accept tests covering that sub-intersection against a union super, an intersection
super, and the covariant return analog, and sharpen the comment explaining why the
early gradual return is equivalent to letting the compound-super arms recurse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…..) signatures

Describe member-by-member variance checking for flat unions, intersections, and
nullable types, and note the two shapes that stay gradual (a DNF, and an
intersection in a parameter position). Flag the current limitation that a
parenthesised DNF in a signature's return position is not yet erased.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correct the sub-intersection subtyping comment to describe the closure-super
path accurately, and rewrite the DNF caveat in the closure-types guide: a
parenthesised DNF is unsupported in both positions — it fails to compile in a
return and can throw off arity checking in a parameter — so steer users away
from it entirely rather than implying a parameter DNF is always safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fset map

The anonymous closure/arrow marker match compared the marker's
ORIGINAL-source byte position against getStartFilePos() on the STRIPPED
source. Any length-changing rewrite earlier in the file — the Name[] ->
array sugar rewrite shortens by |name|-3 bytes — shifted the two apart,
so a generic closure following such a rewrite silently lost its markers:
type parameters never attached, the closure compiled unspecialized, and
the emitted code fataled at runtime (TypeError on the un-erased T hint)
despite a clean validation pass.

Translate the node position back through the byte-offset map before
comparing, the same mapping attachClosureSig already applies. Pinned by
a fixture that places long-name array sugar ahead of a generic closure
and executes the compiled output (fails pre-fix with the runtime
TypeError).

Named class/name markers match by line, not byte, and are NOT covered
by this fix: any strip that replaces a newline with a space (a
multi-line type-param list, turbofish arg list, or sugar span) still
shifts every later line-keyed marker — the same silent-miscompile
family, tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…type leaves

The signature type scanner could not consume a `(`-group, neither leading
a leaf nor continuing one after `|`/`&`. Four failure modes, one root
cause:

- `Closure(): (A&B)|C` in a return: the scan bailed on the `(`, the
  signature was never recognised, and the raw superset syntax reached
  PHP as a parse error.
- `Closure(): A|(B&C)` in a function's return hint: the scan stopped
  MID-TYPE after `A`, erasing only through it — emitting the valid but
  wrong hint `\Closure|(B&C)` and recording a truncated `return = A`
  that could provably (and wrongly) reject a conforming literal.
- `Closure((A&B)|C $x): int` at a checked site: the leaf fell back to a
  single-token raw `(`, the param loop read `A&B` as a second parameter
  (one-param target seen as two — false-rejecting a correct literal and
  false-accepting a wrong-arity one), and the declared return was
  dropped entirely.
- `Closure(A|(B&C) $x)`: same family, mis-read as four parameters.

scanTypeExprEnd now consumes a balanced group where a LEAF may start
(scan start or right after a separator), validating the group interior
with the same leaf machinery (names, generics, nested signatures,
nested groups) and requiring it to end exactly at the matching `)` — so
an expression-context paren (`... : ($x)`) still terminates the scan.
The separator lookahead accepts `(` so trailing groups continue a
compound. The existing SigRaw fallbacks already delegate span-finding
here, so a DNF leaf now covers its full span and stays gradual
(accepted, rendered verbatim in diagnostics); structuring DNF members
for variance checking remains future work.

The blanket infection ignore on scanTypeExprEnd is removed — the walk
is now mutation-tested, with narrowly-scoped equivalence annotations
where a mutant is provably unobservable. Pinned by parse-shape tests
for all four modes, accept/reject conformance pairs in both modes, and
a runtime fixture whose DNF-signature factories compile, erase, and
execute.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the closure-types guide's DNF limitation callout — both former
failure modes are fixed — with the actual semantics: a DNF group is one
gradual leaf, checked for arity around it but never member-variance.
Record the fix in the changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n conformance

The candidate extractor read a closure literal's param/return type via
Name::toString(), which drops the leading backslash of a fully-qualified
name. `fn(): \App\Fruit` inside `namespace App` therefore resolved as
the relative `App\App\Fruit` — an undeclared class — and the leaf went
gradual, silently missing provable violations that spell their types
fully qualified. (Never a false reject: the flattening only ever
over-accepted.)

Branch on Name::isFullyQualified() and use toCodeString() there, which
keeps the `\` for the resolver's absolute path. A relative
`namespace\Foo` stays on toString(), which already resolves it
correctly, and Identifier nodes (scalars) have no toCodeString() at all
— so the branch is the precise seam, not a blanket swap. Pinned by
extractor-level resolution tests and an accept/reject conformance pair
in both modes (the reject fails pre-fix: the violation was invisible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…amespace, not a use alias

PHP resolves `namespace\Foo` against the current namespace uncondition-
ally — a `use Other\Foo` import never captures it. The candidate
extractor flattened the relative name to bare `Foo` (Relative::toString
erases the prefix) and sent it through the alias-aware resolver, so a
colliding import resolved it to `Other\Foo`; when that class was
declared and provably unrelated to the target, a CONFORMING factory
literal was falsely rejected.

Resolve Name\Relative against the current namespace directly, bypassing
the alias map. Pinned at the extractor level (alias-collision and
global-namespace forms, both failing pre-fix) and by a conformance-
level accept for the multi-namespace repro.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… wrong

Review of the DNF-group scanning found two mutation-ignore justifica-
tions refuted by construction and one escaped mutant:

- Resuming the walk one token BEFORE a consumed nested signature is not
  confluent: `Closure(): Closure(A)` re-consumes the nested signature
  (or corrupts the span with a stray `)`). Pin the nested-no-return
  shape and narrow the ignore to the genuinely equivalent `+ 0` resume.
- Negating the separator-continuation predicate is not span-neutral:
  `A&|B` would be silently swallowed instead of left as loud residue.
  Drop the annotation and pin the residue.
- A group leaf opening right after a completed name leaf (`A (B)`) must
  keep hitting the only-Closure structural throw, not be absorbed as a
  fresh group — pin the throw.

Infection on the file: 100% covered MSI, zero escaped, zero timeouts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… to array

A `Name[]` leaf inside a `Closure(...)` signature broke the scanner in
three ways: in a return position the bracket pair survived erasure as
raw superset syntax (PHP parse error); in a parameter position at a
checked site it mis-parsed as THREE parameters (`U`, `[`, `]`) — falsely
rejecting a correct one-param literal on arity and false-accepting a
three-param one; and where the pair sat between the signature and its
recognition gate the signature went unrecognised entirely, letting the
global T[] rewrite fire mid-signature (parse error).

Consume the empty bracket pair into the leaf (whitespace tolerated, the
same spelling the global sugar accepts) and lower it to `array` — the
lowering T[] gets everywhere else — at the first leaf, at union /
intersection members, and under a nullable prefix. As a gradual leaf it
can only widen acceptance; the arity around it is checked as before.
The `Name<Args>[]` combination stays the pre-existing loud gap, in
signatures as elsewhere; a non-empty `[expr]` is never treated as sugar.

Pinned by parse-shape tests for all three modes plus the resume-offset
and junk-residue edges, accept/reject conformance pairs in both modes
(the accept fails pre-fix on the arity mis-parse), and a runtime fixture
whose sugared factories compile, erase, and execute. Infection on the
file: 100% covered MSI, zero escaped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ignatures

Review caught the sugar consumption stopping after ONE bracket pair
where the global sugar consumes chains: `Closure(U[][] $x)` left the
second pair as phantom parameters — falsely rejecting a conforming
one-param `fn(array $x)` literal on arity — and `Closure(): int[][]`
left `[]` residue that failed to re-parse. The suffix scan now loops
over consecutive empty pairs (the lowering already collapses any depth
to one `array`), a partial chain (`A[][0]`) consumes the sugar and
leaves the junk loud, and the misplaced docblock between the group and
suffix helpers is restored to its function.

Pinned by chained param+return shapes (parse and conformance, both
modes) and the partial-chain residue. Infection on the file: 100%
covered MSI, zero escaped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… in string resolution

The string-side resolver had no branch for PHP's relative-name form: a
`namespace\D` target signature type fell through to the bare-name path
and resolved as `App\namespace\D` — an impossible class (`namespace` is
reserved), so the leaf silently went gradual and provable violations
spelled with a relative target type were missed. The AST-side extractor
gained its relative branch earlier; this is the same rule on the token
path, placed in NamespaceContext::resolveAgainstContext so every string
consumer (signature targets, bounds, defaults, generic args) inherits
the correct binding at once.

Only the exact case-insensitive `namespace\` segment is treated as
relative — a class path merely starting with the word (Namespaced\Utils)
keeps the bare-name route, and the alias map is never consulted for a
relative reference. Pinned at the resolver level (binding, alias
collision, global namespace, case-insensitivity, prefix-lookalike) and
by conformance accept/reject pairs with relative-named TARGET types
(the reject fails pre-fix: the violation was invisible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… as a return slot

The return-slot heuristic accepted ANY `)` followed by `:` — a pattern
ternaries, `case expr():` labels, and every alt-syntax construct
(`if/elseif/while/for/foreach/declare (…):`) also produce. Two failure
classes, both on valid PHP:

- a call to a user function named `Closure` with a type-ish argument in
  those positions was SILENTLY ERASED to a bare `\Closure` constant
  fetch (`$a ? b() : Closure(...)`, `if ($x): Closure(A); endif;`,
  `case f(): Closure(A);`);
- a call to ANY function there hard-failed the compile with the
  only-Closure structural error (`$a ? b() : g(FOO);`) — ordinary,
  Closure-free code.

The walk now matches the `)` back to its `(` (whole-token compare, so
parens inside string/cast tokens never skew the depth), hops one
optional closure `use (…)` layer, skips an optional function name and
by-ref `&`, and requires a T_FUNCTION/T_FN head that is NOT preceded by
`::`/`->`/`?->` — `fn` and `function` are semi-reserved member names, so
`$c ? C::fn() : Closure(A)` must stay a call. All previously recognized
return-slot spellings (by-ref, use clauses, tight colons, nullable,
modifiers, abstract/interface bodies, comments before the colon,
paren-ish defaults) are pinned as still recognizing.

The blanket mutation ignore on the old heuristic is removed — the new
branch logic is fully mutation-tested (100% covered MSI, zero escaped),
with narrowly-scoped ignores only for token-0 boundary guards, each
justified in place. Pinned by an expression-context provider (17
shapes, byte-identical output), a declaration-slot provider (13
spellings), and a runtime fixture in which the compiled calls to the
user's own `Closure` function execute.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
math3usmartins and others added 30 commits July 7, 2026 11:26
…rrows specialize

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… marker steals

Named class/method markers and every name-marker branch (bare hints,
turbofish, member calls, function calls, variable turbofish) matched by
line — or line range — plus spelling, first-traversed-wins. Two
same-spelling sites sharing a line could therefore cross-claim:
`f(Box $a, Box<int> $b)` emitted the specialization on the WRONG
parameter; a return hint stole a `new Box::<int>` marker, leaving a raw
`new` against the marker interface; one-line same-name conditional
classes handed the generic clause to the PLAIN class, rewriting it into
an uninstantiable marker interface; and — a live false reject —
`Plain::pick(5) + Util::pick::<int>(4)` on one line drew bogus
unresolved-call and missing-type-argument errors because the plain
call's line range claimed the generic call's marker.

Every marker already records the byte of the token it anchors at; match
that byte (translated through the byte-offset map, so length-changing
sugar rewrites stay exact) against the matching node's own anchor — the
name Identifier for named declarations, the method Identifier for
member calls, the callee Name/Variable for function and variable
turbofish, the Name node itself for hints. Line-range matching is gone;
multi-line member chains keep working because the identifier byte is
position-exact regardless of layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Specialization is call-site-driven: a generic closure or arrow that no
`$var::<...>(...)` call grounds kept its raw type-parameter hints in the
emitted output — references to the non-existent class `App\T` that fatal
the moment the value is invoked, behind a clean compile and a clean
check. Handing the closure away as a plain callable (a return value, an
array_map argument, a stored callback) cannot ground it either, and the
direct untyped call was already loudly rejected — so the silent gap was
exactly the never-called / handed-away template.

Every generic closure/arrow template that no call site attempted to
specialize is now rejected in both modes as
`xphp.unspecialized_generic_closure` (compile fails, check collects one
diagnostic per orphan) — across assigned, return-position,
argument-position, use-capturing, defaulted, and conditional shapes,
and uniformly when the type parameters are referenced nowhere (the
clause is dead syntax; the remedy is deleting it). A template whose
call sites drew their own rejects (static closure, `$this` capture,
missing turbofish) is not double-reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nging

`Rec[]` lowers to `array` at the same byte length, so the fixture ran on
the identity offset map and never exercised the shift path it claims to
pin; `R[]` grows by one byte and does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`$f::<T>($v)` inside a still-abstract enclosing template bails out of
the rewrite before the template lookup, so the unspecialized-closure
check misread the template as never-called and rejected it with advice
the author had already followed. The bail now marks the template as
attempted; how such a call grounds when the enclosing template
specializes remains that pipeline's own (tracked) concern.

Also restores the static-analysis gate to green: reattach the
applyReplacements docblock that the blanking helpers were inserted
above (its parameter type annotated the wrong function), prove the
extracted signature params list-shaped where it is built, and drop a
redundant ignore and assert. Aligns the error-table wording with the
actual rule (no IN-SCOPE grounding call, not merely "never called").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two Fixed entries for the current section: generic markers now bind
byte-exact so same-spelling sites on one line no longer steal each
other's markers, and a generic closure that nothing specializes is
rejected (xphp.unspecialized_generic_closure) instead of silently
emitting raw type-parameter hints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bare `new Box(...)` where `Box<T>` has a required (non-defaulted) type
parameter and no turbofish was silently skipped by the instantiation
collector: the call site was left pointing at the stripped marker
`interface Box {}`, so the emitted code fatalled with "Cannot
instantiate interface" behind a clean compile and a clean check.

Route the non-all-defaults bare-new through the canonical
padArgsWithDefaults reporter, so it fails with the same
xphp.missing_type_argument code and message as the function/method call
path — throwing in compile, collecting in check. All-defaults generics
still synthesize and run.

Guard against a false reject: when a plain `class B` coexists with a
generic `class B<T>` (conditional same-name declarations), the bare
`new B` resolves to the instantiable plain class at runtime, so the
collector now records non-generic class names and skips the reject for
them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A first-class callable of a turbofish specialization
(`$g = $f::<int>(...)`) emitted `$f('T_…', ...)` — the specialization
tag prepended beside the `...` placeholder, which is not valid PHP: the
output failed to parse. The finalize pass blindly array_unshift-ed the
tag onto every recorded call site's args, and the FCC's args are just
the placeholder.

Detect the first-class-callable form in the variable-turbofish rewriter
and, instead of recording a direct call site, return a forwarding
closure `fn(...$p) => $f('<tag>', ...$p)` that routes through the
dispatcher. It preserves callable semantics — positional, variadic, and
named arguments, plus the captures the dispatcher already carries — and
the argSet is still registered so the dispatcher and specialization are
generated. The variadic parameter name is kept distinct from the
dispatcher variable so it cannot shadow it. Empty-turbofish all-defaults
FCCs (`$f::<>(...)`) go through the same path; an FCC of a
`$this`-capturing closure still draws the loud capture error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…guard

Exercise the branch that suffixes the forwarding closure's variadic
parameter when the dispatcher variable is itself named like the reserved
param: a closure assigned to `$__xphp_fcc_args` and turbofish-FCC'd would
otherwise shadow the captured dispatcher and fatal at runtime. Confirmed
to fail when the suffixing loop is disabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sure

- errors.md: xphp.missing_type_argument now also covers a bare `new` of a
  generic without all-defaults.
- closures-and-arrows.md: a first-class callable of a specialization emits a
  forwarding closure through the dispatcher.
- CHANGELOG: two Fixed entries for the current section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a generic template specializes, an inner variable-turbofish call
(`$inner::<S>(...)`) in its body carries the enclosing type parameter in
its recorded type arguments. The substituting visitor grounded closure
signatures, bare type-param names, and generic-args trees, but never the
turbofish's ATTR_METHOD_GENERIC_ARGS — so `$inner::<S>` stayed abstract
after `S -> int` and the inner closure could not be dispatched to a
concrete specialization.

Substitute those recorded type arguments too, so a later per-specialization
closure-grounding pass sees `$inner::<int>`. This is inert end-to-end on its
own (nothing consumes the grounded args until that pass lands); it is a
prerequisite. Pinned by a unit test confirmed to fail beforehand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ions in check mode

The scanner's xphp-specific rejections (variance markers on methods/closures,
legacy `+T`/`-T` glyphs, malformed or misordered generic defaults, non-Closure
call signatures, variadic-position errors) were thrown as bare RuntimeExceptions
carrying no position. `check` caught them and reported every one at line 1,
which is useless for an editor jumping to the offending syntax.

Introduce XphpParseException (extends RuntimeException) carrying the offending
token's original-source line, and throw it with `$tokens[$i]->line` at the nine
token-aware scanner throw sites. `check` catches it specifically and reports the
real line, with the line-1 fallback preserved for the remaining position-less
structural rejections (self-bound, later-referencing default) raised over parsed
entries. Compile mode is unchanged: XphpParseException is a RuntimeException, so
the existing catch keeps the same message and throw behavior.

Pinned by check-mode line assertions across three distinct throw sites, a
fallback-path test for a position-less rejection, and an unchanged compile-mode
throw pin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The invalid-default (`T = ?int`) and union-default (`T = Foo | Bar`) rejections
report their line from `$tokens[$afterBound]` and `$tokens[$afterDefault]`
respectively — index expressions distinct from the variance sites' `$tokens[$i]`
and the name sites' captured line. They were hand-verified but unpinned; a future
edit to either index could silently regress the reported line with no failing
test. Add a check-mode line assertion for each.

Also tighten the line-fallback comment to describe actual usage: every current
throw site supplies a real token line, so the `> 0` guard is a defensive floor
for the exception's documented "no position" (0) contract rather than a branch
any site currently exercises.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A generic clause on a namespace-import `use` was scanned as a type
instantiation. Depending on the import form this either mis-blamed a
double-qualified phantom template at a null line, or — for a grouped
import whose (mis)resolved member collided with a real template — drove
a specialization whose absolute name was emitted inside a `use N\{…}`
prefix group. That last case is unparseable PHP produced silently: it
passed both `check` and `compile` and only failed when the emitted file
was loaded.

Reject the clause at resolve time, before the blanket Name branch
consumes its marker. The import forms are precisely typed at the AST
(`Stmt\Use_` / `Stmt\GroupUse`, matching each member name and the group
prefix), so the reject can never touch a generic trait-use
(`Stmt\TraitUse`), which stays a supported, specialized construct. The
rejection throws `XphpParseException`, so it carries the offending
import's real line and is collected in `check` and thrown in `compile`
alike, naming the source-spelled symbol rather than the requalified
phantom. Matching is byte-exact, so two imports of the same qualified
name (one clause-less) never cross-fire.

`use function …<…>` is unaffected: its clause is left unstripped, so
php-parser reports its own syntax error before this stage.

Pinned by check-mode rejects across all five import forms (right symbol,
right line), a same-spelling non-cross-fire case, an exact-message
assertion, compile-mode throw, clean ordinary/grouped imports, and an
execute test proving generic trait-use still specializes and runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CHANGELOG: Fixed entry for rejecting a generic clause on a `use` import
  (single/aliased/const/grouped), eliminating the grouped silent unparseable emit.
- errors.md: broaden the xphp.parse_error row to cover parse-time xphp rejections
  (variance markers, malformed defaults, generic clause on a use import) reported
  at the offending line, not only post-strip PHP syntax errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Context

PHP resolves a free-function name against `use function` imports (and a const
against `use const`), independently of class imports — a class `use App\Helper;`
never binds a `helper()` call. NamespaceContext lumped all three import kinds into
one alias map, so it could not resolve a function or const name correctly.

Partition `use function` / `use const` imports into their own maps (the class map
still keeps every import, so class resolution and isImported() are unchanged), and
add resolveFunctionName / resolveConstName: an unqualified name binds its own
import map then the current namespace (no leading backslash added — the caller's
in-set guard decides whether to fully-qualify, preserving PHP's global fallback);
a qualified name's leading segment resolves via the class/namespace map; FQ and
relative names bind as PHP does. This is the resolution half of re-qualifying
free-function and const references in relocated generic template bodies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the FQNs of every free function and free constant declared in the
compilation unit while the definitions pass walks the ASTs. Function names are
keyed case-insensitively (function and namespace names are), const names with a
case-insensitive namespace but a case-sensitive short name. Class methods and
class constants are different node types and are not collected.

This is the membership half of re-qualifying unqualified free-function/const
references in relocated generic template bodies: the Specializer will fully
qualify such a reference only when its resolved target is known here, so builtins
and any name the unit does not define keep PHP's global fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d bodies

A generic class body is relocated out of its origin namespace into
XPHP\Generated\… when it specializes, so an unqualified free-function call
(`helper()`) or const fetch (`FACTOR`) in it rebinds against the generated
namespace — then PHP's global fallback — instead of the origin. When the origin
defined that symbol, the specialized copy called a phantom that does not exist
and fatalled at load/run (silently, under --no-check).

The resolver now records each free-function callee's and const fetch's resolved
FQN (honoring the file's `use function` / `use const` imports, relative and
qualified spellings), and after the fixed-point loop every finalized
specialization is swept: an unqualified reference is fully-qualified to that FQN
only when the compilation unit defines the symbol. So an in-unit `helper()`
becomes `\App\helper()`, a `use function Vendor\make` call becomes
`\Vendor\make()`, and a relative or qualified spelling is pinned to the same
target the template resolved — while builtins (`strlen`), magic constants
(`true`/`false`/`null`), and any name the unit does not define keep the global
fallback untouched. Generic functions/methods are unaffected: their
specializations are appended into the origin namespace, not relocated.

Pinned by execute fixtures that compile and RUN the output: a body mixing bare,
qualified, const, builtin, and magic-constant references (asserting the computed
result), and a cross-namespace `use function`/`use const` template.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ported bodies

Two gaps in the free-function/const re-qualification of relocated template
bodies, both surfacing as `Call to undefined function XPHP\Generated\…()` at
runtime behind a clean compile:

- Covariant-upcast gap-fill appends erasable members onto concrete specs AFTER
  the main re-qualification sweep, so a gap-filled body that called an in-unit
  free function or read an in-unit const kept the bare name and fatalled when
  that upcast member was invoked. The sweep now also runs over each spec the
  gap-fill touches, before the call-site rewrite. Idempotent on members that
  were already fully qualified.

- Group-form imports (`use function N\{a, b}`, `use N\{function a, const B}`)
  were never indexed into the function/const symbol maps — only single `use`
  statements were — so a body referencing a group-imported symbol resolved to
  the wrong namespace and fatalled. GroupUse is now indexed alongside Use_ in
  both collector visitors, routing only its function/const members and leaving
  class resolution untouched.

Both are pinned by execute fixtures confirmed to fatal without the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Note in the runtime-semantics guide that a relocated specialization still binds
the free functions and constants of its template's namespace, and add the
matching changelog entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ad of dropping it

A turbofish on a dynamically-named method or static call — `$o->$m::<int>()`, its
nullsafe `$o?->$m::<int>()` and variable-variable `$$g::<int>()` and static-dynamic
`Foo::$m::<int>()` siblings — recorded a `variableTurbofish` marker that no AST node
could ever bind (those parse to a MethodCall/StaticCall or a dynamic-name FuncCall,
never the `FuncCall(name: Variable)` the marker matches). The marker was silently
discarded while the `::<…>` clause had already been stripped, leaving a bare dynamic
call against a method that now exists only in its specialized `_T_<hash>` form — a
runtime fatal behind a clean compile and check.

Such a call is unmonomorphizable: the method name is a runtime value. The scanner now
rejects it with a clear parse-stage diagnostic at the receiver's real line (collected
by check, thrown by compile) rather than dropping the marker. The reject fires only
when the variable is immediately preceded by `->`/`?->`/`::`/`$` — a standalone
`$f::<…>()` (the generic-closure call shape) is untouched, so that keeps working.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PHP permits every semi-reserved keyword (`list`, `print`, …) as a method name, but a
generic method whose name was a keyword could neither be declared nor statically
called. The declaration scanner required a T_STRING name, so `public function list<T>`
left its `<T>` clause to reach php-parser as a raw parse error; and the static
call-site scanner rejected a keyword name after `::`, so `Foo::list::<int>()`
parse-errored too. (The instance call `$o->list::<int>()` already worked, because PHP
re-tokenizes `list` as T_STRING after `->`.)

Both gates now accept a keyword whose text is a valid PHP label: the declaration gate
records the generic-method marker, and a dedicated static-call branch strips the
`Recv::keyword::<…>` turbofish into a `named` marker the resolver's StaticCall arm
already binds. The static branch is kept separate from the name-token gate so keyword
tokens never reach that gate's bare-`<`, closure-signature, or array-sugar
sub-branches — `list($a, $b) = …` destructuring and keyword-named non-generic methods
pass through untouched. A keyword-named generic method now declares, specializes, and
runs through both call sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tation-visible

The dynamically-named-turbofish reject carried a defensive `$prevIdx >= 0` bounds
guard annotated `@infection-ignore-all`. skipWsBack can never actually walk off the
front here (index 0 is always the open tag, never whitespace, and never a reject
token), so the guard is dead — but a block-scoped ignore risked masking mutants on the
reject discriminator itself. Fold the bounds check into a `$tokens[$idx] ?? null`
lookup and drop the annotation, so every clause of the `->`/`?->`/`::`/`$` discriminator
stays exposed to mutation testing (each is killed by its own reject fixture). Behaviour
is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ic methods

Add changelog entries and a turbofish rule: a turbofish on a dynamically-named method
or static call is rejected (the specialized name can't come from a runtime value),
while a generic method may be named with a PHP keyword and called through both the
instance and static turbofish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A generic class body is relocated out of its origin namespace into
XPHP\Generated\…, so every class reference inside it is re-qualified
against the enclosing use-map. `indexUse` stored each single import there,
but `indexGroupUse` never did — so a group-imported class (`use Vendor\{Tool};`)
referenced in a relocated body fell back to the current namespace
(`\App\Tool`), compiled and linted clean, then fataled at runtime with
"Class App\Tool not found".

`indexGroupUse` now populates the class/namespace use-map for every group
member, mirroring `indexUse`, so a group-imported class re-qualifies to its
import target exactly like its single-import form. `use function` / `use const`
members continue to route additionally into their own symbol maps.

Covered by a compile-and-execute fixture (a group import and a single import
side by side in one relocated Box<int> body) pinned against the pre-fix fatal,
plus NamespaceContext unit tests asserting the group class import lands in the
class map (and honours an alias) without polluting the function/const maps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A generic trait used with an `insteadof` / `as` adaptation block specialized
its `use`-list names (`use A<int>, B<int>`) to their `\XPHP\Generated\…` form
but left the adaptation operand names bare (`A::m insteadof B; B::m as bm;`).
The bare operands resolved to the now-removed template (`App\A`) and fataled at
class load with "Trait App\A not found" behind a clean compile and check.

The resolver visitor now, on entering each class-like body, builds a class-scoped
map of every generic trait-use list entry (keyed on resolved template FQN) and
rewrites each adaptation operand that names a generic trait to the same
specialization as its list entry — so the adapted class loads and runs. The map
is class-scoped, not per-`use`-statement, because an adaptation may reference a
trait brought in by a different `use` of the same class (`use A<int>; use B<int>
{ A::m insteadof B; }`). Operands are matched by resolved FQN, so a qualified or
aliased spelling still binds its bare list entry, and the two records dedupe to
one generated trait.

A non-generic trait operand (or one the class does not use generically) is left
exactly as written — it is a real, still-existing trait. A bare operand that
matches two different specializations of one trait (`use A<int>, A<string> {
A::m insteadof B; }`) cannot be disambiguated in an adaptation clause and draws a
clear diagnostic (collected by check, thrown by compile) instead of silently
picking one.

Covered by compile-and-execute fixtures — a cross-`use`-statement insteadof/as
pair and a mixed generic/plain block with a two-trait insteadof — both pinned
against the pre-fix class-load fatal, plus a collected/thrown ambiguity reject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nable

The rejection pointed users at "give the conflicting traits distinct names",
but an `insteadof` / `as` clause renames methods, not traits — there is no
trait-level rename to make. Reword the remedy to the real recourse (use a single
specialization of the trait in an adapted class) and pin the corrected phrasing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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