(!) Closure signature types, out/in variance markers, and a validating compile gate#23
Merged
Merged
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>
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>
Across the diagnostic, parser, closure-conformance, variance, registry, renderer, and static-analysis suites, ~90 `assertStringContainsString` substring checks are upgraded to exact assertions: full-message `assertSame` on diagnostic/exception text (sourced from the production message builders), whole-string `assertSame` on deterministic renderer/config output and on the byte-length-preserving closure-signature erasure, and structured field assertions (violation `->kind`, generated-FQN prefixes) where a typed accessor exists. Several tests that fished multiple substrings (plus a companion `assertStringNotContainsString`) out of one value collapse into a single exact assertion that subsumes both the positive and negative coverage. Genuinely variable haystacks — PHPStan's own output, CommandTester display chrome, and hash-bearing emitted PHP already pinned by snapshots — are left as substring/anchored checks, where an exact match would couple to third-party or non-deterministic formatting. No production code changes. Full suite green (1276), PHPStan L9 clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…grams Split the single 600-line guide into an index (intro, pipeline-overview diagrams, a stage table-of-contents, cross-cutting concerns, and the class roster) plus one page per stage under docs/guides/how-it-works/: source parsing, hierarchy/registry, method-function specialization, the fixed-point loop, variance edges, rewriting/emission, bound validation, and the validation gate. Each stage page keeps its diagram and gains prev/index/next navigation. The index keeps its path, so existing inbound links are unaffected; relative links inside the moved content were re-based for the deeper directory. Add Mermaid diagrams for the validation gate (how check and a safe compile share one gate), closure-signature checking (erase to a bare Closure, check only the statically-decidable return-position literal), and variance extends-edge emission (the real subtype links wired between specializations of a variant template). Reword the phase-labels note for the split layout: drop the stale "six narrative stages below" phrasing, complete the internal phase list (2.3, 2.4, 3.5), call out variance-edge emission as Phase 2.5 / Stage 4.5, and note the validation gate as command-level orchestration rather than a numbered phase. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…turbofish Two byte-offset edge cases were exercised only indirectly. Add direct regression tests: - A genuine multibyte identifier (`Café`, bytes >= 0x80) sitting against its `<T>` marker, with a second generic class after it. Asserts the blanked clause stays byte-exact (multibyte name bytes are the identifier, outside the span) so the later class's byte-keyed marker does not shift off its node. - A byte-keyed turbofish (`Box::<int>`) that follows a length-changing `T[]` -> `array` sugar span on the same scope. Asserts the turbofish still attaches its `<int>` argument, i.e. the stripped-source shrink does not desync markers matched in stripped space. Both pass; no source changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The conformance section stated "parameters are contravariant, the return
is covariant" but only demonstrated it with int/string -- unrelated
scalars, so neither is "wider" and the variance never shows. Add a
concrete section over a LivingThing > Animal > {Dog, Cat} hierarchy with
a target of Closure(Dog): Animal:
- a six-row accept/reject table (wider param accepted, narrower return
accepted, sibling param and wider return rejected), each verdict
matching the compiler's actual xphp.closure_conformance output;
- the intuition spelled out from the slot's point of view -- it only
ever passes a Dog and only promises an Animal back -- including why the
"I expect a Dog, not a Cat" instinct is what makes a wider Animal
handler safe rather than unsafe;
- a note tying this per-signature structural check to PHP's own method
override rules, and cross-linking declaration-site out T / in T class
variance as the same principle by a different mechanism.
Also add a See-also footer linking variance, closures-and-arrows, the
error catalogue, and the conformance fixtures. Docs only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e type A default inside a `Closure(...)` signature type (`Closure(int $x = 0): int`) was silently mis-parsed: the parameter reader never consumed the `= <expr>`, so the caller's loop re-scanned it as two phantom parameters, inflating the target's required arity. A perfectly valid returned closure literal was then false-rejected with a nonsensical "expects at least 3 parameter(s)" message. A signature type describes the callable's shape, not call-time values, so a default has no meaning there. Reject it at parse time with a clear message that names the parameter (or its 1-based position when unnamed), pointing at the `=` line. The guard sits after the name consumption, so it also supersedes the "variadic must be last" path for a defaulted variadic. `compile` throws; `check` collects it as a parse-error diagnostic at the real line. Behavioral accept/reject pairs pin the new logic in both modes (the parser reader carries an infection-ignore, so MSI there is vacuous): named, unnamed, and variadic defaults reject with the exact message; single / multi-param / variadic default-free signatures still parse, and a nested closure signature stays accepted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…a helper The Phase 2 loop that specializes every instantiation to a fixed point (and closes the set under the covariant-upcast requirement) was inline in compile(). Extract it into a private specializeToFixedPoint() so a second caller can reuse it. The helper takes a `resilient` flag: false reproduces compile's fail-fast behavior exactly (undefined template / exceeded depth throw), true (for a forthcoming validate-only caller) skips an unspecializable instantiation and stops at the depth cap instead of throwing. Pure refactor — compile's behavior is unchanged (the loop body is moved verbatim; the stateless closer is reconstructed for the later gap-fill phase). Full suite and PHPStan L9 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pile A closure signature that names an enclosing type parameter (`Closure(T $x)`) is gradually accepted while T is abstract and only becomes a provable mismatch once T grounds (e.g. `Registry<string>` ⇒ `Closure(string $x)`). compile catches this per specialization (Phase 2.4); check did not — it never specialized. So `xphp check --no-phpstan` silently passed code that compile rejects, and the default PHPStan-backed check reached the same reject through the compile-driven pass and surfaced it as a raw exception box instead of a diagnostic. check now specializes to a fixed point in resilient mode — reusing the extracted Phase-2 loop, so it discovers transitively-instantiated generics (a `Closure(T)` factory reached only through another generic), not just source-visible ones — then runs the type-relation half of conformance over every specialization in collector mode. Arity / by-reference mismatches are grounding-independent and already collected by the abstract pre-loop, so the grounded pass skips them (new checkTypesOnly / groundedTypesOnly seam) to avoid a duplicate report at the synthetic specialized location. Both check modes now report the same clean `xphp.closure_conformance` diagnostic compile does, and the exception box is gone (the gate short-circuits before the PHPStan pass once check has an error). Resilient mode skips an undefined template (already reported), an arity-mismatched instantiation (already reported; would otherwise ValueError in array_combine), and a specialization that throws, and stops at the depth cap instead of aborting — a conformance pass over a partial set can only miss a provable violation, never invent one. Grounded reject / accept, the transitive case, single-report-of-structural, and a non-generic structural mismatch are pinned; 100% MSI on the diff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ure positions A `Closure(...)` signature type in a position xphp doesn't support used to fail only via a misleading nikic parse error (`unexpected '<'`) that never mentioned closures. Each unsupported position now reports a clear, closure-specific error at the real source line — still always before any emission, so the cardinal safety rule holds: - Generic bound (`class C<T : Closure(int): int>`) — rejected at the bound reader, a declaration-header seam that is never the speculative `<`-comparison path, so the reject is safe. A bare `\Closure` bound stays valid. - Untyped signature parameter (`Closure($x): int`) — the pre-filter now lets a `$var` first-inner through to the position gate, which only a genuine type slot satisfies; a real `Closure($x)` expression is never followed by a `$var` / return slot, so it is untouched. - Generic type argument (`Box<Closure(int): int>`), the same nested in an F-bound, and the `Name<Args>[]` combination — these share the speculative generic-arg reader, so intercepting them there would false-reject valid code like `$a < Closure(5)`. Instead the already-failed nikic parse is enriched: only a parse that has ALREADY failed is inspected, so valid code (which parses cleanly) can never be reached or rejected — safe by construction. Pinned by reject tests in both modes (compile throws, check collects at the real line) plus no-misfire tests: a `<` comparison against a user function named `Closure` stays valid, a bare `\Closure` bound/argument is untouched, and an unrelated syntax error keeps nikic's original message. 100% MSI on the diff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…imitations List the positions where a Closure(...) signature type is not yet accepted — generic type argument, generic bound, the nested-in-a-bound and Name<Args>[] combinations, and an untyped signature parameter — each noted as a clear compile error today and possible future scope, with the bare \Closure workaround. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… rejects Record in the changelog that grounded closure conformance is checked identically by compile and check, and that unsupported signature positions (generic argument/bound, Name<Args>[], defaulted/untyped parameters) are clear compile errors. Extend the xphp.parse_error catalogue entry to list the new closure parse-time rejections. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion Add Closure(...) signature types everywhere the public docs enumerate features: the roadmap (Shipped section + Discovery items for the unsupported positions), the comparison feature grid, the syntax quick-reference card, a caveats section for the unsupported positions, and the errors catalogue (verbatim parse-reject texts plus quick-index rows, including the missing closure-conformance row). Restructure the roadmap to two sections — Shipped and Discovery — moving the former Next items (stream-wrapper live transpilation, source maps) under Discovery → Ecosystem, and align the docs-index and getting-started cross-references with the new structure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…neric enricher PhpParser's getAttributes() returns array<string, mixed>, and the closure-in-generic error enricher used startFilePos as an int after only an isset() check, leaving the scan cursor and substr offset mixed at PHPStan's eyes. Narrow with is_int() instead — behaviorally identical (nikic always sets the attribute as an int when file positions are on), but the offsets are now proven integers. Co-Authored-By: Claude Fable 5 <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.