feat(monomorphize): richer type-alias bodies (intersection, DNF, closure signatures) - #35
Open
math3usmartins wants to merge 17 commits into
Open
feat(monomorphize): richer type-alias bodies (intersection, DNF, closure signatures)#35math3usmartins wants to merge 17 commits into
math3usmartins wants to merge 17 commits into
Conversation
Migrate the type-alias body from a flat union list<TypeRef> to an AliasBody value object wrapping a DNF list<list<TypeRef>> (union of intersection-clauses): a single head is [[X]], a union [[A],[B]], and ?X desugars to [[X],[null]]. The two-condition single-head predicate (one clause, one leaf) lives on the VO so it stays in one place and carries mutation coverage; every expander consumer — the slot path, the generic-argument path, and the bound path — routes through it. Behavior-preserving: only union / nullable / single-head bodies are read, so every clause is single-leaf and the emitted PHP is unchanged. This is the seam that makes intersection / DNF bodies an easy addition. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A type-alias body may now be an intersection (`type Both = A & B;`) or a DNF — a union of intersections (`type Dnf = (A & B) | C;`) — expanded, as any compound body, only as the whole type of a param / property / return / class-constant slot, and as all-of / any-of when named as a bound. The body is read through the shared bound-expression reader and normalized to DNF; a shape that would need distribution (a union nested in an intersection, `(A|B)&C`, whether written directly or reached by expanding a union alias inside an intersection) is rejected with `xphp.alias_compound_needs_distribution` rather than distributed. A scalar or built-in member in an intersection — including one revealed only after a type-parameter substitution (`type Pair<T> = T & Countable; Pair<int>`) — is rejected at emit time with `xphp.alias_scalar_in_intersection`, since PHP forbids it. A nested intersection alias flattens by `&`-associativity, and a cycle through an intersection member is still a clean `xphp.alias_cycle`. Bounds that name an intersection/DNF alias expand to BoundIntersection / BoundUnion, so `class Box<T : A & B>` is all-of (an argument implementing only one member is rejected). Single-head / union / nullable bodies are unchanged. Closure-signature bodies remain the one unsupported shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lift the caveats, syntax tour, error catalog, and changelog to reflect that intersection and DNF alias bodies are supported (whole-slot, all-of / any-of as a bound); only closure-signature bodies remain unsupported. Add the xphp.alias_compound_needs_distribution and xphp.alias_scalar_in_intersection codes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A duplicate member in an emitted intersection or union — `A&A`, or a composed `type Outer = Inner & B` where `Inner = A & B` emitting `A&B&B`, or `A|A` — is a PHP "Duplicate type is redundant" PARSE fatal that takes down the whole generated file. Dedupe members by canonical name at emit (identity-preserving: `A&A` ≡ `A`, `A&B&B` ≡ `A&B`, `A|A` ≡ `A`), so a composed alias that reintroduces a member collapses cleanly instead of emitting an unparseable file. The scalar-in-intersection guard already covered the analogous scalar fatal; this closes the duplicate case. Found in code review. Subtype-redundancy (`A&B` with `B extends A`) is left as-is — PHP accepts it, only exact-name duplicates fatal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A type-alias body may now be a closure signature — `type Handler = Closure(int $x): bool;` — and, generic, `type Mapper<T, R> = Closure(T): R;`. Used as the whole type of a param / property / return slot it erases to a bare `\Closure`, carrying the parsed signature on ATTR_CLOSURE_SIG so the existing conformance validator checks call sites and factory returns against it — identical to a directly-written `Closure(...)` in that slot. A generic closure-sig alias, or one whose signature references an enclosing class type parameter, grounds per specialization through the Specializer's existing ATTR_CLOSURE_SIG handling; compile and check agree. The body is recognized via the ungated closure-signature core (findClosureSigEnd + buildClosureSignature) — the gated scanner requires a following `$var`/return slot, which a `;`-terminated alias body is not — and stored on the AliasBody value object as a signature variant. Used anywhere other than a whole slot (a generic argument, `new`, a bound) it is `xphp.alias_compound_in_non_slot`, never an un-`new`-able `new \Closure()`. A closure signature combined with a union (`A | Closure(...)`) or nullable (`?Closure(...)`) stays unsupported. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lift the caveats, syntax tour, error catalog, and changelog: a closure signature is now a supported alias body (erased to `\Closure`, conformance- checked, grounded per specialization for a generic one). Only a closure combined with a union/nullable (`A | Closure(...)`, `?Closure(...)`) stays unsupported. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pin the closure-signature body recognition: a fully-qualified `\Closure(...)` body, a bare `\Closure` combined in a union (which is NOT a signature body and stays a union), and a complete signature followed by a trailing token (which must decline rather than silently drop it). Justify the two equivalent mutants in the recognizer (the `<`/`<=` boundary that only differs on a bare `type X = Closure;`, and the unobservable distribution flag on a valid body). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A closure-signature alias reached through another alias's clause leaf
(`type A = Closure(int): bool; type B = A;`) was silently dropped: the
clause loop in expandAliasToDnf read only `->clauses`, which is empty for a
signature body, so the `\Closure` type and its conformance check vanished —
emitting un-loadable / untyped PHP (a whole-slot `type B = A` wrote
`function make(): {`), accepting a non-conforming closure, and dropping a
union arm.
Now a signature reached as the SOLE single-head clause propagates (a
single-head alias to a closure-sig alias IS that signature — it erases to
`\Closure` and its conformance rides through); a signature combined in a
union or intersection is `xphp.alias_unsupported_body`; and a closure-sig
alias used as a bound is `xphp.alias_compound_in_non_slot`.
Found in code review. The runtime fixture now executes a transitive alias
slot, proving the emitted PHP loads.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… full body set The roadmap Shipped section + timeline, the syntax index row, the comparison grid + prose, and ADR-0023's scope note still described type-alias bodies as single-head / union / nullable only (and listed intersection / DNF / closure as unsupported / roadmapped). Update them to the shipped set: intersection, DNF, and closure-signature bodies, with the new distribution / scalar codes; the ADR keeps its historical decision and gains a forward-note that the richer bodies landed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y member A closure signature may now be a MEMBER of a compound alias body — nullable (`type N = ?Closure(int): bool;`), a union (`type U = Foo | Closure(...);`), or an intersection (`type X = Foo & Closure(...);`) — erasing to a bare `\Closure` inside the `?`/`|`/`&` node, byte-identical to the directly-written slot types. This gives alias bodies parity with direct `Closure(...)` slots. The AliasBody DNF leaf widens from `TypeRef` to `TypeRef|ClosureSignature` (the separate whole-body `signature` field is gone — a signature is now just a leaf). The alias-body reader threads an `allowClosureSig` flag through the shared bound-expression reader (a defaulted flag, so real generic bounds — where `Closure(...)` stays rejected — are untouched); the `?`-reader handles `?Closure(...)`. Resolve / substitute / expand / dedupe / emit all branch on the leaf kind. Conformance parity, not more: the validator unwraps a NullableType (so `?Closure` is enforced) but does not descend into a union/intersection (so a member is gradual) — exactly matching the direct forms. Because every closure erases to the same `\Closure`, a body with two `\Closure`-erasing leaves would emit a `\Closure|\Closure` PHP duplicate-type fatal and is rejected. A scalar next to a closure in an intersection still rejects on the scalar; a closure-in- compound alias as a bound / generic-argument / `new` stays compound-in-non-slot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the caveats, syntax tour, error catalog, roadmap, comparison grid, and changelog: a closure signature is now usable as a nullable / union / intersection member of an alias body (erasing to `\Closure` inside the `?`/`|`/`&`), with parity to the directly-written slot types. The only remaining body-shape limit is at most one closure per body — every closure erases to the same `\Closure`, and PHP forbids a duplicate `\Closure|\Closure`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n coverage Fold the whole-body / nullable closure recognition into a shared readAliasDnf helper (the `?`-reader now reuses parseBoundExpr, dropping tryWholeBodyClosureSig and its edge-guard mutants). Make the bound-expression reader's allowClosureSig / source parameters required (they are always passed explicitly, so the defaults were dead). Mark the two dead-value mutants (the signature `nullable` flag, unread by conformance; the defensive `\Closure` ltrim) as equivalent, and make isClosureErasing private. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the closure_sig_alias fixture source Handlers.xphp -> Registry.xphp so the file name matches its primary class, and point the runtime verify at the emitted Registry.php. The output file is named by source basename, so the mismatch broke the require. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
| Filename | Overview |
|---|---|
| src/Transpiler/Monomorphize/AliasBody.php | Adds the DNF representation and deduplication helpers, but duplicate class-like members are compared case-sensitively. |
| src/Transpiler/Monomorphize/XphpSourceParser.php | Extends alias parsing, recursive expansion, validation, and AST emission for intersections, DNF, and closure signatures. |
| test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php | Adds broad compile/check and runtime coverage for richer alias bodies, though mixed-case duplicate members are not covered. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[Type alias declaration] --> B[Parse body expression]
B --> C[Normalize to AliasBody DNF]
C --> D[Resolve and substitute aliases]
D --> E[Deduplicate clauses and leaves]
E --> F[Construct PHP union/intersection/Closure AST]
F --> G[Compile or check emitted program]
Reviews (1): Last reviewed commit: "test(monomorphize): name the closure fix..." | Re-trigger Greptile
PHP class-like names are case-insensitive, so a compound alias body that repeats one class in different casing (`type X = Foo & foo`) named the same class in both leaves — yet the dedup key used the case-preserving canonical name, so both survived and the slot emitted `\App\Foo&\App\foo`, which PHP rejects at load as a duplicate-type fatal. The same held for a union arm (`Foo | foo` → `\App\Foo|\App\foo`). Lowercase the leaf key so it matches PHP's own case-insensitive class resolution; both the intersection (`dedupeLeaves`) and union (`dedupeClauses`) paths route through it, so one change covers both. Genuinely distinct members (`A & B`) are untouched. Aligns the key with `isClosureErasing`, which already lowercases the class name. 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.
Additions that bring type-alias bodies to parity with directly-written slot types:
A & B,(A & B) | CClosure(int): bool, genericMapper<T, R>?Closure(...),Foo | Closure(...),Foo & Closure(...)