(!) Closure signature types, out/in variance markers, and a validating compile gate#23
Open
math3usmartins wants to merge 76 commits into
Open
(!) Closure signature types, out/in variance markers, and a validating compile gate#23math3usmartins wants to merge 76 commits into
math3usmartins wants to merge 76 commits into
Conversation
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three headline capabilities land together:
Closure(...)signature types with conformance checkingout/indeclaration-site variance markersvalidate-before-emitcompile gateAdditionally, a broad monomorphizer correctness sweep that closes a family of silent-compile -> runtime-fatal defects
Highlights
Closure signature types
Closure(int $x, string $y): booltype hints in any parameter, return, or propertyposition, erasing to a bare
\Closurein the emitted PHP.Closure(...)return type is checked forconformance -- 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.
specialization.
Variance
out T/in T(contextual keywords).invariant position; variance markers are allowed on private properties;
type-parameter-typed constructors on variant classes are supported.
Generics & inheritance
collections; multi-root builds via an
xphp.jsonmanifest.Workflow / tooling
xphp check-- a validate-without-emitting CI gate running every generic validatorplus PHPStan over the compiled output.
xphp compileruns that same gate by default and emits nothing on error(
--no-check/--no-phpstanopt out).Robustness -- silent-fatal sweep
A batch of defects where clean
compile+check+php -lmasked a runtime fatalor a wrong-symbol bind are now caught or fixed, including: generic-trait
insteadof/asadaptation operands, group-imported and free-function/const re-qualification inrelocated specialized bodies, dynamically-named and keyword-named turbofish forms,
bare
newof a non-defaults generic, undeclared type parameters, too-many-type-arguments, generic clauses on
useimports, and byte-exact marker binding. Parse-stagerejections now report their real source line for editor navigation.
(!) Breaking changes
out T/in T(was+T/-T). Semantics areunchanged; old
+T/-Tsource fails with a migration error pointing at the newspelling. Rewrite
class Box<+T>->class Box<out T>,<-T>-><in T>.finalon a variant class is now rejected. Afinal class Box<out T>can'tanchor the
extendsedge between specializations (and previously disagreed withReflectionClass::isFinal()); omitfinalon a variant template. Non-variantgenerics are unaffected.
compilevalidates before emitting. Errors that previously slipped through toruntime (e.g. a type argument naming no real class) now fail the build. Use
--no-checkfor the previous fast-emit behavior.