Skip to content

0.2.0#17

Merged
math3usmartins merged 36 commits into
mainfrom
0.2.x
Jun 11, 2026
Merged

0.2.0#17
math3usmartins merged 36 commits into
mainfrom
0.2.x

Conversation

@math3usmartins

Copy link
Copy Markdown
Member

No description provided.

math3usmartins and others added 29 commits June 4, 2026 19:49
Track php-rfc/bound_erased_generic_types so .xphp sources written today
stay valid against a future RFC-PHP runtime. At call/`new` sites the
parser now requires the turbofish `Name::<…>` (with byte-level adjacency
between `::` and `<`); bare `Name<…>(...)` is left un-stripped so PHP
surfaces the syntax error rather than xphp silently specializing a form
the future runtime would refuse. Declaration sites and type-hint
positions keep bare `<…>` -- the RFC accepts both there.

Variance docs flip to RFC-style `+T` / `-T` (and the multi-bound example
to `\Stringable & \Countable`) so future xphp variance work doesn't have
to revisit. All 16 compile-fixture call sites + 5 integration-test
inline sources rewritten to turbofish; 9 new parser tests lock the
recognition rules. Unit suite 194/194 green, Infection MSI 100%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix: the bare-`<…>` call-site rejection only triggered on trailing `(`,
so `new Box<Plastic>;` (PHP-legal parenless `new`) silently specialized
-- a form the RFC turbofish requirement would refuse. Widen `$isCallSite`
with an `isPrecededByNew()` lookback (same flat-walk shape as the existing
member-access helpers); lock the fix with
`testBareNewWithoutParensIsRejectedAndLeftUnstripped`.

Doc / roadmap follow-ups: section-level "Status: not yet parsed" notice on
`## Tier 1 gaps` in comparison.md so readers don't mistake the `+T`/`-T`
variance and `A & B` intersection-bound snippets for live syntax; roadmap
bullets refined to spell the same RFC tokens. Inline `@todo` on the
turbofish marker append noting that `MethodCall` resolver coverage for
instance-method generic calls is pending. Review items 5 (commit-message
test count cosmetic) and 6 (migration diagnostic implementation) left as
roadmap-only per the original hard-switch decision.

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

Every prose / list mention of the RFC is now a proper `[text](url)` link
(`comparison.md` Tier-1 status notice + multiple-bounds bullet). Mermaid
timeline items in `roadmap.md` can't carry markdown links, so an
anchoring line under `## Overview` establishes the link once and notes
that subsequent "per RFC" mentions in the timeline resolve there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A clear up-front note that xphp's surface syntax tracks
php-rfc/bound_erased_generic_types while the runtime model
(monomorphization vs erasure) is intentionally different. Readers see
both the alignment and the honest tradeoff before they hit the rest of
the README.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RFC bound-erased generic types forbids `class A<T : T>` -- a type
parameter cannot use itself as a bound at the top level. xphp used to
parse the declaration and surface a confusing "compiler cannot prove
satisfaction" error at instantiation time; the new
`assertNoTopLevelSelfReference` guard fires inside `parseTypeParamList`
so the failure mode points at the real problem.

F-bounded recursion (`class A<T : Box<T>>`) is intentionally allowed and
covered by item #5; the guard only rejects bare-self bounds, and
`boundIsFq` filters out `\T` (a global class named T) so users can still
bound a type-param by a same-named global class.

Locked by three tests:
  - testTopLevelSelfReferenceBoundIsRejectedAtDeclarationTime (negative)
  - testFullyQualifiedBoundWithSameNameAsTypeParamIsAllowed (regression)
  - testForwardReferenceToEarlierTypeParamAsBoundIsAllowed (regression)

Infection: ignore Concat / ConcatOperandRemoval on the new method's
error-message sprintf (rationale matches the existing
Registry::validateBounds entries -- the test asserts on substring, not
exact ordering).

P1.1 of the RFC alignment sprint (see
.claude/plans/rfc-bound-erased-generics-alignment/18-...md).

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

`self` and `parent` are T_STRING tokens and already fell through the
Name-token recognizer. `static`, though, is a PHP keyword (T_STATIC),
so `static<T>` slipped past the scanner and survived into the cleaned
source -- nikic then errored on the leftover `<`.

The fix is one disjunction on the Name branch's entry guard
(`isNameToken($tok) || $tok->id === T_STATIC`) -- the rest of the
recognition path (turbofish prefix check, trailing-`(` rejection,
strip-and-record) handles the keyword identically to a regular name.
The trailing-`(` heuristic plus `isPrecededByNew` together still
correctly reject `new static<T>()` and `static::<T>()` at expression
context.

Three new tests lock the three pseudo-types:
  - testSelfWithTypeArgsInReturnPositionIsAccepted
  - testStaticWithTypeArgsInReturnPositionIsAccepted
  - testParentWithTypeArgsInReturnPositionIsAccepted

P1.2 of the RFC alignment sprint (see
.claude/plans/rfc-bound-erased-generics-alignment/15-...md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RFC bound-erased generic types forbids `new class<T> { ... }`. xphp's
T_CLASS branch already requires a T_STRING name after the keyword, so
the anon-class form falls through without recognition -- a property of
the scanner shape, not an explicit guard.

This test pins that alignment so a future refactor of the T_CLASS
branch (e.g. dropping the T_STRING requirement to support some other
shape) can't quietly start accepting anonymous-class type parameters.
Contract: `parser->strip()` must leave the `<T>` clause intact.

P1.3 of the RFC alignment sprint (see
.claude/plans/rfc-bound-erased-generics-alignment/17-...md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`function identity<T>(T $x): T { ... }` declared OUTSIDE any
`namespace { }` block now compiles end-to-end, producing the same
mangled specializations as the namespaced shape. Before this change
the parser parsed the declaration but `GenericMethodCompiler::process`
silently dropped any Function_ whose enclosing Namespace_ was null --
the rewritten output kept literal `T` in the function signature,
producing broken PHP.

Pipeline changes in `GenericMethodCompiler`:

  - `process()` now takes `array &$astSet` by reference so it can
    mutate the top-level statement list when no Namespace_ wraps the
    template. Compiler::compile passes `$astPerFile` as before; PHP's
    signature-level `&` makes the call pass-by-reference implicitly.
  - The indexTemplates Function_ branch no longer requires a
    non-null `currentNamespaceNode`. Top-level templates are
    registered with `functionNamespaceByFqn[$fqn] = null` and an
    accompanying `functionAstKeyByFqn[$fqn]` so the strip step knows
    which AST array to mutate.
  - `rewriteCallSites` takes a new `&$topLevelAppends` out-param.
    When a generated specialization belongs to a null-namespace
    template, the visitor pushes it there instead of the existing
    `pendingAppends` (which expects a container with `->stmts`).
    The outer `process()` loop flushes top-level appends by direct
    array mutation.
  - New `stripTopLevelFunction` helper mirrors `stripFunction` but
    operates on a `list<Node\Stmt>` rather than a `Namespace_->stmts`.

Three integration tests lock the contract:
  - testBareTopLevelFreeFunctionSpecializesEndToEnd (happy path)
  - testBareTopLevelStripPreservesAllNonTemplateStatements
    (mutation regression on the strip loop's per-statement guard +
    the ArrayOneItem mutant on the return value)
  - testMixedTopLevelAndNamespacedTemplatesBothGetStripped
    (mutation regression on the Continue_ between strip branches --
    uses FilepathArray for explicit ordering so the namespaced
    template isn't last and the Continue_/Break_ distinction is
    observable)

P1.4 of the RFC alignment sprint (see
.claude/plans/rfc-bound-erased-generics-alignment/02-...md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review of `beb4955` (P1.2) flagged that the scanner half landed but the
resolve/compile half didn't. Stripping `<T>` so PHP can parse the source
is necessary but not sufficient -- the bare `self` Name then routes
through the generic-args resolver and the Registry tries to specialize
a non-existent `App\…\self` template, aborting with:

  COMPILE FAILED: Generic template "App\SelfProbe\self" was instantiated
  but never defined.

Fix: in the bare-`<…>` (type-hint) branch of `scanAndStrip`, when the
name token is `self` / `static` / `parent`, still strip the `<…>` clause
(so PHP can parse) but skip the marker record entirely. The Name node
never gets ATTR_GENERIC_ARGS attached, the Registry never sees a
phantom template, and monomorphization on the enclosing class -- which
already specialized it with concrete type args -- carries the bare
`self` reference through unchanged; PHP's runtime resolves it to the
right specialized class.

Follow-up to beb4955 (the original P1.2 commit) rather than an amend
because beb4955 is a middle commit on phase-1-polish, not HEAD, and
the project SDLC forbids interactive rebase. "1 phase can have multiple
commits" carries the slack.

Three existing P1.2 parser tests (`testSelfWithTypeArgsInReturnPositionIsAccepted`
and siblings) now ALSO assert that the pseudo-type Name carries no
ATTR_GENERIC_ARGS -- they used to stop at `strip()` + `parse() must not
throw`, which is precisely the gap the review caught. New integration
test `testSelfWithTypeArgsCompilesEndToEnd` exercises the full
`Compiler::compile` pipeline against the reviewer's reproduction
fixture and runs the rewritten output at runtime to confirm `self`
resolves to the specialized class.

Infection: one `UnwrapStrToLower` ignore added to `infection.json5`
on `scanAndStrip` -- the mutant only differs for mixed-case spellings
(`Self<T>`, `STATIC<T>`) that no real PHP code uses; adding a test
would lock a stylistic choice rather than a real contract.

Test count 205 -> 206; MSI 100%.

Resolves Issue A from `.claude/review.md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`$obj->method::<T>(...)` and `$obj?->method::<T>(...)` now specialize
end-to-end alongside the existing static-call shape. Both Stages A
(strict-typing receiver resolution) and B (local flow typing) ship
together as one milestone -- Stage A alone left too many real-world
patterns silently un-specialized to be worth announcing.

Resolver: `XphpSourceParser::resolveAndAttach` now attaches
`ATTR_METHOD_GENERIC_ARGS` to MethodCall and NullsafeMethodCall in
addition to StaticCall, reusing the same line-range matching that
already handles multi-line `Foo::\n  method::<int>(...)` constructs.

GenericMethodCompiler receiver-type analysis (handles, in order):
  1. `$this`            -> enclosing class FQN
  2. `$paramName`       -> typed parameter declaration
  3. `$this->prop`      -> typed property declaration on the enclosing class
  4. `$localVar`        -> last lexical `$x = new ClassName(...)` assignment
                           in the same scope (local flow typing)
  5. `$importedVar`     -> closure `use ($x)` import OR arrow function
                           implicit capture, copied from parent scope

Scope isolation across nested function-likes: each Function_,
ClassMethod, Closure, and ArrowFunction pushes a snapshot of
`(currentScopeParamTypes, currentScopeLocalTypes, branchSnapshots)` on
enter and pops on leave. Branches inside a closure don't leak to the
enclosing function's branches; outer-scope variables aren't visible
inside the closure body except through explicit `use ($x)` import or
arrow-function lexical capture.

Conservative branching analysis: If_/Switch_/Match_/While_/Do_/For_/
Foreach_/TryCatch push a branch frame on enter. Sibling branches
(Else_/ElseIf_/Case_/MatchArm/Catch_/Finally_) reset the local-types
map to the pre-branch snapshot so each sibling starts from the same
state. On leave of the branching parent, the frame is popped, the
snapshot restored, and every variable assigned anywhere in the branch
body is invalidated -- the post-branch state can't tell whether the
branch ran. Intra-branch specialization still works because the
analysis runs LIVE during the branch walk; only post-branch and
sibling-branch states are conservative.

`resolveClassName` short-circuits pseudo-types (`self`, `static`,
`parent`) to `$currentClassFqn` instead of routing through namespace /
use-map resolution -- closes the same gap as the scanner's pseudo-type
filter (a parameter typed `self` was resolving to `App\…\self`, a
phantom class).

Tests (13 new):
  - testTurbofishOnInstanceMethodCallIsRecognized (renamed from
    StripsButHasNoResolverYet; now asserts the marker IS attached)
  - testTurbofishOnNullsafeInstanceMethodCallIsRecognized
  - testInstanceMethodGenericThisReceiverSpecializes (`$this`)
  - testInstanceMethodGenericParamReceiverSpecializes (typed param +
    nullable-param regression)
  - testInstanceMethodGenericLocalVariableReceiverSpecializes (flow
    typing on `$x = new Foo()`)
  - testInstanceMethodGenericPropertyReceiverSpecializes (`$this->prop`)
  - testReceiverTypeAnalysisDoesNotLeakAcrossClosureScopes (Issue B)
  - testReceiverTypeAnalysisDoesNotLeakAcrossArrowFunction (symmetry)
  - testBranchingReassignmentInvalidatesPostBranchSpecialization (the
    "actual bug" the second review caught -- if/else reassignment now
    correctly invalidates post-branch tracking)
  - testBranchingIntraBranchSpecializationStillWorks (the analysis
    runs LIVE during the branch walk so intra-branch calls keep
    specializing correctly)
  - testBranchingElseBranchSeesPreBranchState (sibling-branch reset
    means else sees pre-if state, not the if body's mutations)
  - testClosureUseImportPreservesReceiverType (closure `use ($x)`
    imports the type from the parent scope)
  - testArrowFunctionImplicitCapturePreservesReceiverType (arrow
    functions copy all parent params + locals)

Docs: `comparison.md` Tier-1-gaps "Instance-method generic calls"
section deleted -- the gaps framing was awkward for shipped work; the
roadmap and item-11 per-item doc already capture the status. The
"Known unsoundness" subsection in `11-instance-method-call.md` is
replaced with "Branching control flow (now handled)" and
"Closure / arrow-function lexical capture (now handled)" sections that
document the live analysis.

P2 of the RFC alignment sprint (see
.claude/plans/rfc-bound-erased-generics-alignment/11-...md).

Resolves Issue B from `.claude/review.md` plus both deferred items
(branching unsoundness, closure `use (...)` imports) from the same
review cycle's follow-up triage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Widen the bound representation from a single FQN string to a dedicated
`BoundExpr` tree on `TypeParam.bound`, then ship the scanner combinators
for `&` / `|` / DNF parens and F-bounded recursion (`T : Box<T>`).

The bound tree lives separately from `TypeRef` so the
`name + isScalar + isTypeParam` invariant the rest of the pipeline
depends on stays clean. BoundExpr only appears at bound positions,
never at instantiation args or method/return type slots.

New value objects:
  - `BoundExpr` abstract base (readonly).
  - `BoundLeaf(TypeRef)` -- a single class / interface reference; carries
    a TypeRef so F-bounded `Comparable<T>` works without a second
    representation. Inner generic args are resolved against the enclosing
    type-param scope (T marks `isTypeParam: true`).
  - `BoundIntersection(...BoundExpr)` -- `A & B`; all-must-satisfy.
  - `BoundUnion(...BoundExpr)` -- `A | B`; any-suffices. DNF builds as
    `BoundUnion(BoundIntersection(A, B), C)` -- natural outer-OR-of-
    inner-ANDs nesting.

Scanner combinators (`XphpSourceParser`):
  - `parseBoundExpr` -- recursive descent. Grammar:
        bound      := orBound
        orBound    := andBound ('|' andBound)*
        andBound   := primary ('&' primary)*
        primary    := '(' bound ')' | leaf
        leaf       := Name typeArgList?        // typeArgList for F-bounded
  - `parseOrBound` / `parseAndBound` / `parsePrimaryBound` / `parseLeafBound`
    return `[bound_array, newIdx]` or null. Single-operand `or` and `and`
    collapse to their inner operand so a plain `T : Foo` stays a leaf in
    the resulting tree.
  - F-bounded shapes consume an inner `parseTypeArgList` (the same
    machinery used at instantiation sites) so nested generic args
    resolve correctly.

Resolver (`buildBoundExpr` in the inner visitor): walks the parsed bound
tree and produces a `BoundExpr`. Leaf names route through
`resolveNameOnly` for namespace + use-map resolution; leaf args route
through `resolveTypeRefList`. The enclosing class/method's type-params
are now pushed onto `$typeParamStack` BEFORE the bound is built (two-pass
within the ClassLike / ClassMethod / Function_ branch), so F-bounded
`T : Box<T>` sees its own T on the resolution stack.

Self-reference guard (`assertNoTopLevelSelfReference`): recursively
walks the bound tree. Any leaf whose name matches the param name AND
isn't fully qualified AND has no generic args is rejected. F-bounded
forms (`T : Box<T>`) explicitly survive because the inner T is inside
`Box`'s args, not a top-level leaf.

Registry combinator (`Registry::evaluateBound`):
  - Leaf:        delegates to `TypeHierarchy::isSubtype`.
  - Intersection: any false -> false; all true -> true; any null + no
                  false -> null. (Conservative under unknown operands.)
  - Union:       any true -> true; all false -> false; any null + no
                  true -> null.

Error message renders the bound in source form (`A & B`, `A | B`, or
`(A & B) | C` for DNF). Single-leaf bounds keep the original
"does not extend/implement" wording so the existing tests still match;
compound bounds get "does not satisfy" instead.

F-bounded termination is safe under the existing fixed-point depth cap:
the bound check is shallow (one substitution into `Comparable<T>`,
erased subtype check against `Comparable` -- the args are not consulted
by `TypeHierarchy::isSubtype`). No recursive `recordInstantiation` runs.

Fixtures (`test/fixture/compile/`):
  - `bounds_intersection/` -- `Stringable & Countable` satisfied by Tag.
  - `bounds_union/` -- `Stringable | Countable` exercised with both arms
    via StringableOnly / CountableOnly, generates two specializations.
  - `bounds_dnf/` -- `(Stringable & Countable) | Iterator` exercised with
    StringableCountable (left arm) and IteratorOnly (right arm).
  - `bounds_f_bounded/` -- `Sortable<T: Comparable<T>>` with Tag
    implementing `Comparable<Tag>`.

Tests: parser + integration coverage for intersection, union, DNF, and
F-bounded; symmetric parens rendering in both intersection-of-union and
union-of-intersection error messages; three-way verdict (true/false/null)
for unknown operands on both combinators; recursive self-reference guard.

Infection: new bound sub-parsers added to existing `LessThan` /
`LogicalAnd` / `LogicalOr` / `GreaterThanOrEqualTo` / `IncrementInteger`
ignore lists with rationales matching the existing parser-helper
entries (boundary-check mutants are absorbed by inner-function
defensive guards; reaching the OOB case requires malformed input that
fails further downstream).

Test count 217 -> 236; MSI 100%.

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

XphpSourceParser's inner visitor and the RegistryCollector both need
the same name-resolution policy: turn a bare `Box` into an FQN given
the enclosing namespace + use map. Extracting it into a new
`NamespaceContext` class lets both consumers share one implementation
instead of duplicating ~30 lines of namespace + use-map plumbing.

No behavior change. The early return on leading-`\\` in `resolveTypeRef`
is removed because the context's `resolveAgainstContext` already
handles the leading-`\\` case at its first branch -- both paths
produced the same FQN, so the early return was redundant.

New value object:
  - `NamespaceContext` -- holds `currentNamespace` (string) and `useMap`
    (alias -> FQN). Three methods: `enterNamespace(?string)` (resets the
    use map), `indexUse(Use_)` (parses one Use_ node into the map), and
    `resolveAgainstContext(string)` (returns the FQN with no leading
    backslash). A `currentNamespace()` accessor exposes the prefix for
    the ATTR_TEMPLATE_FQN computation.

Tests (9 new): bare name resolution, leading-backslash strip, use-map
alias rewrite, alias overrides short name, tail-segment preservation
on use-map hits, top-level (no namespace) keeps bare names, namespace
re-entry resets the use map, alias + tail combine, multi-`UseItem`
Use_ node indexes all items.

Test count 236 -> 245; MSI 100%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `class Box<T = string>` and `class Box<T : Bound = Default>` -- defaulted
type parameters fill in trailing args at call sites that omit them, including
the fully-omitted `new Cache;` form. Bound + default + earlier-param-ref
combinations interoperate cleanly.

Parser (`parseTypeParamList`): accepts `= TypeRef` after the optional bound.
A new `$allowDefaults: bool` argument routes class headers (true) and method/
function headers (false) through the same parser; method/function defaults
get a clear "not yet supported" rejection. Trailing-default rule fires when
a required param follows a defaulted one. A separate forward-ref guard
(`assertDefaultsReferenceOnlyEarlierParams`) rejects `class Bad<T = U, U>`,
`class Bad<T = T>`, and nested-arg references to later params like
`class Bad<A = Box<U>, U = string>`. The bound's self-reference walker is
intentionally NOT reused -- the default's policy (strictly-earlier indices)
differs from the bound's (no bare self at any depth). Nullable / union /
intersection default shapes get a clean "no nullable or union shapes" error.

Schema: `TypeParam` grows `?TypeRef $default`. The parser entry shape
carries it through to the resolver, which routes the raw TypeRef through
the existing `resolveTypeRef` (namespace + use-map + scalar + type-param
marking) before storing on the final `TypeParam`.

Registry (`recordInstantiation`): pads `$args` from defaults when supplied
args are short, substituting earlier already-positional concretes into any
type-param references in the default. After padding, the existing
`validateBounds` + `generatedFqn` paths see the full arg tuple, so
`Cache::<>` and `Cache::<string, mixed>` produce the same specialization
FQN. Padding throws a clear error when a non-defaulted param is missing
(naming both the param and its 1-based position).

Registry (`validateDefaultsAgainstBounds`): declaration-time check on
fully-concrete defaults. Type-param-referencing defaults defer to the
instantiation-time `validateBounds` path (their concreteness depends on
the call-site substitution). Runs once after defs-only collect, before
instantiations are recorded -- so a bad declaration fails the compile at
the source level before any padded instantiation amplifies the error.

`RegistryCollector`: split into `collectDefinitions` and
`collectInstantiations` (two methods, not a stateful flag). The fixed-point
loop calls the unified `collect()`. The instantiations pass synthesizes
bare-`new Cache;` -- when the resolved FQN matches an all-defaults template,
it attaches `ATTR_GENERIC_ARGS = []` + `ATTR_TEMPLATE_FQN` and records the
zero-arg instantiation (which the registry pads to the all-defaults tuple).
Uses the `NamespaceContext` helper from the prior refactor commit so the
file's namespace + use map are tracked the same way the parser tracks them.

Empty turbofish: `parseTypeArgList` accepts empty `<>` (returning an empty
arg list). The scanner additionally recognizes T_IS_NOT_EQUAL immediately
after `::` as the empty-turbofish shape -- PHP's tokenizer keeps `<>` as a
single legacy not-equal token, and splitting it unconditionally would break
real `$x <> $y` comparisons elsewhere.

`CallSiteRewriter`: the `args !== []` gate is lifted. Both `Cache::<>` and
the synthesized `new Cache;` route through the same recordInstantiation +
fqn-rewrite path. The one VisitorGuards test that pinned the old gate is
updated to assert the new contract.

Compiler: Phase 1b is split into 1b.i (defs across all files) ->
`validateDefaultsAgainstBounds` -> 1b.ii (insts + bare-new synthesis).
Phase 2 fixed-point loop unchanged.

Fixtures (`test/fixture/compile/`):
  - `defaults_full/` -- `Cache<K = string, V = mixed>` with all four
    call-site shapes (`new Cache;`, `new Cache::<>`, `new Cache::<int>`,
    `new Cache::<int, Tag>`).
  - `defaults_forward_ref/` -- `Pair<A, B = A>` with `new Pair::<int>`
    (padding substitutes A) and explicit `Pair::<int, string>`.
  - `defaults_cross_file_bare_new/` -- the template and the bare-new call
    site live in different source files. Exercises the defs-then-insts
    pipeline ordering.

Tests: 14 new parser tests, 10 new Registry tests (positional-first bound
failure, null-verdict on unknown default, continue-past-skip cases, position
number, etc.), 4 new collector guard tests (bare-new synthesis gating),
8 new integration tests. Test count 245 -> 285; covered-code MSI 100% on
item-06 files (infection.json5 ignores follow existing patterns for
sprintf-friendly error messages and defensive ltrim/boundary mutants).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`class Producer<+T>` and `class Consumer<-T>` parse, validate position
constraints at declaration time, and emit subtype edges between
specializations -- the payoff of variance: `Producer_Banana extends
Producer_Fruit` when `Banana <: Fruit` lifts to a real PHP `extends` edge.

Parser (`parseTypeParamList`): a leading `+` / `-` token on a type-param
name is recorded as a `Variance` enum value (Invariant by default). Class
headers accept variance; method/function headers reject it with the same
"not yet supported on methods or functions" message family as defaults.

Schema: `TypeParam` grows `Variance $variance = Variance::Invariant`.

Position validator (`VariancePositionValidator::assertPositions`): runs
once per generic class definition during the resolver's `leaveNode` hook
(so the body's nested `xphp:genericArgs` attributes are populated by the
time the walker reaches them). Rejects:

  - `+T` in method parameter, mutable / readonly property, constructor
    parameter, or bound / default position.
  - `-T` in method return, mutable / readonly property, constructor
    parameter, or bound / default position.
  - Either marker on a method-level type parameter (same message family
    as method-level defaults).

Properties are strict-invariant even when readonly: PHP enforces
invariant property types across `extends` chains regardless of the
readonly modifier, so a covariant +T on a property would PHP-fatal at
autoload when the variance edge lands. Users who need a "covariant
field" use a bound-typed backing field + a method `get(): T`.

Constructor parameters are strict-invariant (deviation from RFC's
"constructors exempt"): PHP applies LSP signature compatibility to
`__construct` at autoload time on `extends` chains, so a covariant
constructor param would PHP-fatal.

F-bounded variance (`class Sortable<+T : Comparable<T>>`) is rejected
because `+T` appears inside its own bound (an invariant position).

Phase 2.5 emitter (`VarianceEdgeEmitter`): runs once after specialization
(Phase 2), before CallSiteRewriter. For each pair of specializations of
the same template, checks every arg pair against the param's variance.
Three rules:

  - Invariant:    `arg1.canonical() == arg2.canonical()`
  - Covariant:    `isNestedSubtype(arg1, arg2)`
  - Contravariant: `isNestedSubtype(arg2, arg1)`

Nested generic args route through a recursive `isNestedSubtype`: leaves
delegate to `TypeHierarchy::isSubtype`; same-template generics recurse
through that inner template's variance. Different templates or mixed
shapes return false conservatively -- a wrong edge would PHP-fatal at
autoload while a missed edge only loses an `instanceof` relationship.

Edge filtering: only DIRECT supers emit. `Banana <: Apple <: Fruit`
produces `Banana extends Apple`; the Fruit edge is reached transitively
via Apple. Scalar type-args skip variance edges entirely (no PHP-level
subtype relationship between scalars).

Edge shape:

  - Class_ specialization gets a single `extends` (PHP single
    inheritance). Lexicographically-first direct super wins
    deterministically when multiple unrelated directs exist.
  - Interface_ specialization gets multi-target `extends`.

Compiler pipeline wires `VarianceEdgeEmitter` between Phase 2 (fixed-point
specialization) and Phase 3 (CallSiteRewriter). The edges are added to
cloned specialized ASTs; CallSiteRewriter only rewrites template Class_/
Interface_ nodes, so the edges survive untouched.

Fixtures (`test/fixture/compile/`):

  - `variance_covariant_happy/` -- `Producer<+T>` with `Banana <: Fruit`;
    Producer_Banana extends Producer_Fruit.
  - `variance_contravariant_happy/` -- `Consumer<-T>` with `Dog <: Animal`;
    Consumer_Animal extends Consumer_Dog (flipped).
  - `variance_with_defaults_and_bounds/` -- `Cache<+K : Stringable &
    Countable, V = mixed>` (all three Phase-3 features composed).

Tests (26 new): 17 parser tests covering parsing, variance enum storage,
mixed-variance lists, method-level rejection, and rejection in every
invalid position (including nested generic args in input/output);
9 integration tests including the autoload-time signature-compat check
that spawns a subprocess to require every generated file via an
autoloader and instantiate the chain (catches the PHP fatal class that
pure AST-shape assertions miss).

Test count 282 -> 308; covered-code MSI 100% on item-13 files.
infection.json5 ignores follow existing patterns for sprintf error
messages, defensive defensive `varianceByName`-outer-filter masking, and
structural non-semantic mutations on the edge emitter's iteration order
(the integration tests assert the SHAPE of emitted edges via autoload
chain reachability).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The scanner produces three marker streams (class, name, method) that the
resolver later matches against AST nodes by (line, name). Anonymous
template forms -- closures and arrow functions, coming next -- have no
name, so the (line, name) anchor breaks. Two new fields on every marker
make the shape forward-compatible:

  - `bytePosition: int` -- the source byte offset of the anchor token
    (the class / method name for named templates; the `function` / `fn`
    keyword for anonymous ones). Stable across the scanner's stripping
    passes because every byte-range substitution is equal-length, so the
    offset round-trips through `applyReplacements` unchanged.

  - `kind: string` -- the marker's shape family. `'named'` for now; the
    closure / arrow patch will add `'closure'`, `'staticClosure'`,
    `'arrow'`, and `'variableTurbofish'`.

No behavior change: today's consumers still match by (line, name); the
new fields are populated but unused. The next commit adds anonymous-
template recognition and starts reading bytePosition + kind on the
matcher side.

The two `ArrayItem` mutants surfaced by Infection (dropping either new
entry produces an observationally-identical marker today) get an
`@ignore` in infection.json5 that will be removed once the anonymous-
recognition consumers land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Methods and free functions now accept `<T = string>` defaults at parse
time. Bare calls (no `::<...>`) on all-defaulted method or static
generics pad their args from the defaults at call-site rewrite time,
so `$m->id('hello')` on a method declared `id<T = string>(T): T` works
without an explicit turbofish.

Parser (`parseTypeParamList`): split the single `$allowDefaults` flag
into `$allowDefaults` + `$allowVariance`. Class headers pass both true;
method/function headers pass `(defaults=true, variance=false)`. The
variance rejection message broadens to "methods, functions, closures,
or arrow functions" so future closure/arrow support won't need another
message reshuffling. The defaults rejection message moves to "closures
or arrow functions" since the method/function path no longer rejects.

Registry: extract `padWithDefaults` into a public static
`padArgsWithDefaults(list<TypeParam>, list<TypeRef>, string)`. Both the
existing class-level instantiation path and the new method-level
bare-call path call the static utility -- padding semantics are
identical regardless of call-site shape (substitute earlier-positional
concretes into any type-param refs in the default; throw with a clear
"parameter `X` (position N) has no default" if a non-defaulted param
is missing).

GenericMethodCompiler: `rewriteStaticCall` and `rewriteInstanceMethodCall`
no longer require ATTR_METHOD_GENERIC_ARGS to be non-empty. When the
attribute is null (bare call, no turbofish) and the resolved method
template has all-defaulted typeParams, the rewriter synthesizes an
empty arg list and runs it through `padArgsWithDefaults`. Same shape as
the bare-`new Cache;` synthesis in RegistryCollector for class
instantiations -- empty args route through the padding utility, which
fills them from defaults.

`hasAllDefaults` helper on the inner visitor returns true only when
every param has a default (and the param list is non-empty). Bare calls
on a partially-defaulted generic method continue to bail out --
specializing without an arg shape isn't well-defined when some params
are required.

Tests: parser tests pinning the old method/function default rejection
are flipped to "accepted + stored" assertions; the variance-rejection
message split is also pinned. New integration test
`testMethodLevelBareCallPadsFromDefaults` exercises the bare-call
padding end-to-end on a method declared `id<T = string>`. The bound +
default decl-time check for method/function templates is deferred to
the existing per-instantiation `Registry::checkBounds` path -- a real
violation surfaces at the first specialization rather than at the
source-level (acceptable scope cut; class-level decl-time check stays).

Test count 308 -> 309; MSI 100% on touched files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The variance position validator now walks INSIDE method bodies, looking
for nested Closure / ArrowFunction nodes whose parameter or return types
reference an outer-variance-marked T. Without the recursion the inner
closure's signature slips through the per-class validator, and PHP
fatals at autoload with a "Declaration of X::emit must be compatible
with Y::emit" when the variance edge lands.

Implementation: a small recursive walker (`walkBodyForNestedClosures`)
traverses statements + expressions via nikic's `getSubNodeNames()` (no
NodeTraverser bootstrap inside the per-class hot path). On encountering
a Closure or ArrowFunction, its params get checked against
`[Invariant, Contravariant]` (the contra-allowed input position) and
its return type against `[Invariant, Covariant]` (the co-allowed output
position) -- both against the OUTER class's `varianceByName` map.

The closure's own type-params (when item 16 ships) will shadow same-
named outer T's; the recursive walk has the right shape for that
extension because `varianceByName` is passed by value to each recursion
step -- the future shadow-filter just clones + unsets the relevant
entries before recursing into the closure.

Tests: `testCovariantInNestedClosureParameterIsRejected` exercises +T
captured by a nested closure's param; `testContravariantInNestedArrowReturnIsRejected`
exercises -T in a nested arrow's return. Test count 309 -> 311; MSI
100% on the touched method.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`function<T>(...) { ... }`, `static function<T>(...) { ... }`, and
`fn<T>(...) => ...` are parsed as anonymous generic templates. The call
shape `$var::<T>(...)` is recognized at the scanner and routed to the
resolver, which attaches `ATTR_METHOD_GENERIC_ARGS` to the resulting
`FuncCall(name: Variable, ...)`.

Specialization is intentionally out of scope for this commit -- the
parser surface (declaration + call recognition) lands first; the
follow-up commit wires GenericMethodCompiler to specialize anonymous
templates against the variable's tracked closure-typed binding.

Scanner (`scanAndStrip`): three new arms cover the anonymous-template
recognition:
  - `T_FUNCTION` / `T_FN` immediately followed by `<` (no T_STRING
    between) -- records a marker with kind `'closure'` or `'arrow'`,
    anchored at the keyword's byte position.
  - `T_STATIC T_FUNCTION` followed by `<` -- records kind
    `'staticClosure'` anchored at the T_STATIC byte position.
  - `T_VARIABLE T_DOUBLE_COLON <` -- records a marker with kind
    `'variableTurbofish'`, name = variable identifier (no `$`),
    anchored at the T_VARIABLE byte position.

`parseTypeParamList` is called with `(allowDefaults: false,
allowVariance: false)` for closure/arrow headers -- defaults on closures
get the "not yet supported on closures or arrow functions" rejection;
variance gets the shared "methods, functions, closures, or arrow
functions" rejection.

Resolver (`resolveAndAttach`): the method-template matching branch now
also fires for `Closure` and `ArrowFunction` nodes. Anonymous matches
use `(kind != 'named', bytePosition == node->getStartFilePos())` as
the anchor; named matches keep the legacy `(line, name)` semantics. The
push/pop typeParamStack pattern extends symmetrically so bare `T`
inside a closure body resolves to an `isTypeParam: true` TypeRef.

A new FuncCall arm matches `FuncCall(name: Variable($name), ...)`
against `variableTurbofish` markers, attaching ATTR_METHOD_GENERIC_ARGS
the same way the StaticCall / MethodCall arms do. The existing
FuncCall-on-Name arm gets a `kind != 'variableTurbofish'` clause so
the two arms don't fight over a shared marker.

Tests (7 new): generic closure parsing (named, static, arrow); closure
+ arrow default rejection; closure variance rejection; variable
turbofish recognition with verification of the resolved scalar arg.

Test count 311 -> 318; MSI 100% on the changed surface. infection.json5
ignores follow existing patterns -- the new scanner arms share the
"observationally-equivalent under the outer `$i++` advance" rationale
that the named-function arms have used since item 02.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture-free generic closures (`function<T>(T $x): T { ... }` with no
`use` clause and no `static` modifier) specialize end-to-end. The
variable-turbofish call site `$pair::<string, int>('age', 42)` rewrites
to a hoisted top-level Function_ with the mangled name, matching the
existing free-function specialization shape.

Arrow functions, static closures, and closures with `use (...)`
clauses parse cleanly but reject at GMC time with a clear compile-time
error pointing the user to the named-function workaround. Their
specialization breaks PHP's capture-evaluation semantics in ways that
can't be preserved by a top-level hoist:

  - Arrow functions implicitly capture every outer variable by value at
    expression-evaluation time; the hoist evaluates them never.
  - Closures with `use ($x)` evaluate captures at the closure
    construction site, also lost by the hoist.
  - Static closures have different `$this` semantics; rewriting changes
    observable behavior.

Tracking `$var = <closure-template>` assignments: GMC's inner visitor
records the variable-name → AST mapping on enterNode for any Assign
whose RHS is a Closure or ArrowFunction carrying
ATTR_METHOD_GENERIC_PARAMS. At `$var::<T>(...)` call-site rewrite time,
the binding is looked up to find the template body for substitution.

Hoist mechanics: build a synthetic `Function_` from the closure's
params, return type, byRef, and stmts. Route through the existing
`Specializer::specializeFunction(template, substitution, mangled)`
path -- no new specialization machinery; only a new synthesizer. The
mangled name shape is `closure_<varname>_T_<hash>`; the
`alreadyGenerated` cache dedups same-shape calls.

`GenericMethodCompiler::process()` previously early-returned when no
named templates existed. Closures don't get indexed up-front, so the
early-return was masking the closure path. A new
`hasAnonymousGenericCallSite` pre-scan keeps the optimization for the
common "no generics anywhere" case while letting the closure-only path
through.

Tests (3 new integration): closure-without-use hoists end-to-end
producing the mangled top-level function; arrow function rejected with
the documented error; closure with `use` rejected with its own
documented error. Fixture `test/fixture/compile/closure_generic/`
exercises the round-trip and verifies the de-duplication on repeated
calls. Test count 318 -> 321; MSI 100% on the touched surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Branching analysis in GenericMethodCompiler picks up a precision pass:
when every reachable arm of an `if/else`, `if/elseif/else`, `switch`
(with `default`), or `match` (with default arm) assigns `$x` to the
same class FQN, `$x` keeps that type post-branch instead of
de-specializing.

Mechanics: each branchSnapshots frame grows `perBranchTypes` (a list of
per-arm end-of-arm `{name -> ?FQN}` maps) and `armIndex` (current arm
counter, starting at 0 for `If_` and -1 for `Switch_` / `Match_` whose
parent body has no arm). At every sibling-enter, the prior arm's
end-state is captured before locals reset. At branching-parent leave,
the final arm is captured, then `computeMergedTypes` walks the per-arm
maps: when `canMergeOnLeave` permits (else / default present) AND the
captured arm count matches the structural arm count AND every arm
agrees on the same FQN, the merge keeps the type.

Mismatched arms, missing `default` / `else`, untracked RHS in any arm,
and loops still de-specialize. The change is purely additive
precision: every pattern that de-specialized before continues to do
so. Loops + TryCatch never merge (implicit zero-iterations /
exception-not-thrown paths). `If_` without `else` doesn't merge even
when both reachable paths agree, because the structural-arm-count
guard trips on the implicit empty arm -- conservative against refactors
that add the missing else later.

10 new tests pin the contract:
  - all-arms-agree (5): if/else, three-arm if/elseif/else, switch with
    default, match with default, nested merge.
  - sibling / RHS disagreement (2): middle elseif differs, untracked
    RHS in one arm.
  - reachability guards (3): if without else, switch without default,
    match without default.

Test count 321 -> 331; MSI 100% on the changed surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`new self::<T>(...)`, `new parent::<T>(...)`, and `new static::<T>(...)`
now compile cleanly inside generic classes. Previously the scanner
recorded a marker with `name='self'`, the resolver attached
`ATTR_TEMPLATE_FQN = App\…\self`, and the Registry failed looking up
the non-existent template.

The turbofish branch in `scanAndStrip` now shares the `isPseudoType`
filter that already guards the bare-`<>` type-hint branch. The `::<T>`
clause strips unconditionally so the cleaned source parses, but the
marker append is skipped for `self` / `parent` / `static`. Monomorph-
ization preserves the bare pseudo-type literal and PHP's runtime
resolves it against the specialized class.

Extracted the pseudo-type check into a static `isPseudoType` helper
to dedupe the bare-`<>` and turbofish branches.

Pinned by four new parser tests (`testTurbofishOnSelfConstructor`,
`Parent`, `Static`, plus a mixed-case `SELF` regression and a
`self::method::<T>()` static-method regression), three new
end-to-end integration tests (`testNewSelfTurbofishCompilesEndToEnd`
and the `Static`/`Parent` counterparts), and 100% Infection MSI on
`XphpSourceParser.php`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The parse-time variance validator walks `xphp:genericArgs` recursively
but propagates the OUTER allowed-list at every depth. That misses the
composition: an outer `+T` at a return position should be rejected if
the inner slot it lands in is invariant, even though `+T` is fine for
the outer position. The parse-time validator can't catch this because
templates are collected later, in Phase 1b.i.

New `Registry::validateInnerVariance()` runs between
`validateDefaultsAgainstBounds` (Phase 1b.bound-default-check) and
`collectInstantiations` (Phase 1b.ii). By then every template's
variance markers are known, so the composition rule fires:

  compose(V_pos, V_inner):
    V_inner == Invariant     -> Invariant
    V_inner == Covariant     -> V_pos
    V_inner == Contravariant -> flip(V_pos)

Walked positions: method return (Covariant), method param (Contravariant;
ctor params are Invariant per PHP class-compat), property (Invariant),
F-bound expressions (Invariant), type-param defaults (Invariant). Vendor
templates not in the registry are treated as Invariant (sound).

Closes task #32 + caveat C2. Pinned by 28 new tests in
`RegistryInnerVarianceTest.php` covering each composition cell, both
walker classes (PhpType + TypeRef), NullableType / UnionType branches,
BoundUnion / BoundIntersection arms, F-bounds, defaults, leading-`\\`
FQN normalization, conservative-unknown fallback, error-message
inner-template naming, static methods, and constructor-promoted
properties.

Infection MSI = 100% on Registry.php under filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-this commit: generic closures specialized via a streaming hoist --
each `$pair::<...>(...)` site rewrote to a fully-qualified call to a
synthesized top-level function. Arrows and `use (...)` closures were
rejected because their capture semantics aren't preservable by a
function hoist.

This commit ships the dispatcher infrastructure both flavors will need.
A new `ClosureDispatcher` helper materializes one specialized top-level
`Function_` per unique arg tuple plus a dispatcher closure that routes
runtime calls via:

    $pair = function (string $__xphp_tag, mixed ...$__xphp_args): mixed {
        return match ($__xphp_tag) {
            'T_<hash-A>' => \App\closure_pair_T_<hash-A>(...$__xphp_args),
            'T_<hash-B>' => \App\closure_pair_T_<hash-B>(...$__xphp_args),
            default      => throw new RuntimeException(...),
        };
    };

Tags reuse `Registry::canonicalHash` so tag <-> specialized-FQN stays
bidirectional. Call sites get the tag prepended as the first arg.
Reflection on the rewritten variable now sees a 2-arg variadic shape
rather than the original body's arity -- documented as caveat C8.

The migration is load-bearing: the existing capture-free hoist becomes
the first consumer of the dispatcher. `GenericMethodCompiler` now
collects every call-site arg-tuple per (varName, startFilePos) during
the visitor pass, then a post-traversal `finalizeClosureDispatchers`
phase emits dispatchers, swaps Assign RHSs in place, and prepends tag
args to each recorded call site. Arrow / `use` / static rejections fire
eagerly at collection time, identical to pre-P5.4 behavior.

19 new tests: 10 unit (AST shape, dedup, tag derivation, runtime
routing through eval) and 9 integration (end-to-end compile, runtime
exec, scope isolation, rejection preservation, empty-argSets safety).
Existing `testGenericClosureWithoutUseHoistsAndSpecializes` and the
two rejection tests pass unchanged.

Infection MSI = 100% on both `ClosureDispatcher.php` and
`GenericMethodCompiler.php` under filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-this commit: generic arrow functions rejected at GMC with a clear
"capture-by-value semantics aren't preserved" error.

This commit lifts the rejection by routing the arrow's implicit
captures through the P5.4 dispatcher. The dispatcher Closure (already
constructed at the arrow's lexical site) carries a synthesized
`use (...)` clause for every variable the arrow body references from
its outer scope; each specialized top-level function takes those
captures as trailing `mixed` params; the match-arm body forwards them
via named-after-unpack args (legal since PHP 8.1).

The capture-time-evaluation contract is preserved by construction: the
dispatcher's `use` clause snapshots outer values at the original
assign site, identical to PHP's semantics for the unrewritten arrow.

New analyzer `ClosureDispatcher::implicitCapturesOf` walks the arrow
body collecting free variables. Discipline: skip the arrow's own
params, skip `$this`, don't descend into nested regular `Closure`
bodies but DO harvest their `use ($w)` clauses (so the outer scope
can supply `$w` at runtime), recurse into nested `ArrowFunction`s.
Captures are returned in deterministic first-occurrence order.

`$this`-capturing arrows are rejected eagerly via a separate
`usesThis()` detector -- PHP doesn't allow `use ($this)`. Captures
named `__xphp_tag` / `__xphp_args` trigger an automatic rename of the
dispatcher's own param names to a collision-free alternative derived
from the template's start file pos.

15 new tests in `ArrowSpecializationTest.php` cover the analyzer
discipline (empty / single / multi / param-shadowing / nested-arrow /
nested-closure-use-harvest / this-skip), end-to-end specialization
(single-capture / multi-capture / no-capture / param-shadowing /
multi-tuple), `$this` rejection, and both reserved-name auto-rename
paths. Existing `testGenericArrowFunctionRejectedAtCallSite` flipped
to assert successful specialization.

Closes caveat C5 and deferred work item D2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-this commit: generic closures carrying an explicit `use (...)`
clause rejected at GMC with "captures aren't preserved by the top-level
hoist".

P5.4 + P5.5 built the dispatcher infrastructure to handle implicit
captures (arrows) and lifted trailing params. P5.6 is the smallest
additive step: the closure already names its captures via
`$template->uses`, so no analyzer is needed. The rejection block is
removed; the dispatcher forwards `$template->uses` onto its own `use`
clause; each capture's `byRef` flag propagates through both the
dispatcher's `use (&$y)` AND the lifted trailing param `mixed &$y`,
preserving PHP reference semantics so the body's mutations still
write through to the outer scope.

`ClosureDispatcher::usesThis()` widened to `Closure|ArrowFunction`:
walks `$template->stmts` for closures, `$template->expr` for arrows.
GMC's `$this` rejection now applies uniformly to both flavors with a
flavor-aware error message. The `static` closure rejection stays
unchanged (no `$this` binding target).

8 new tests in `UseClosureSpecializationTest.php` cover by-value and
by-ref captures (with runtime mutation verification), mixed ref/value
mixes, multiple arg tuples, the static-closure rejection regression,
the new `$this`-capturing closure rejection, and the auto-rename
collision path for `$__xphp_args` captures. Existing
`testGenericClosureWithUseClauseRejectedAtCallSite` flipped to assert
successful specialization.

Closes caveat C7 and deferred work item D3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-this commit: generic anonymous closures and arrow functions
rejected `T = Default` at parse time with "not yet supported on
closures or arrow functions".

P5.5 + P5.6 shipped the specialization paths for both forms via the
P5.4 dispatcher. P5.7 lifts the per-form rejection: anonymous closures
(`function<T = int>(...)`) and arrows (`fn<T = string>(...)`) now allow
defaults at parse time. `static function<T = ...>` still rejects per
the per-form gating rule from the sprint plan (its specialization
path didn't ship).

The call-site rewrite path in `GenericMethodCompiler::rewriteFuncCall`
distinguishes variable-turbofish (`$f::<>()`) from named-call: the
former allows empty `$args` through to the recorder, which pads via
`Registry::padArgsWithDefaults` before the arity check. Each call site
in the dispatch-plan bag is paired with its post-padding tag so the
empty-turbofish all-defaults shape routes to the right specialization
arm.

The error message updates to point users at the still-rejected
`static` closure case.

10 new tests in `ClosureArrowDefaultsTest.php` cover single defaults,
trailing defaults that pad at the call site, defaults referring to
earlier params, missing-required-param error, the per-form-gated
static-closure rejection, and defaults composed with `use (...)` and
arrow implicit-capture features.

Closes caveat C4 and deferred work item D4. After this commit, all
seven Phase 5 work items (P5.1 - P5.7) are complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 5 P5.4 - P5.7 features are well-covered by inline-source
tests under `test/Transpiler/Monomorphize/`, but `test/fixture/compile/`
had only the `closure_generic/` directory whose comment described the
pre-P5.4 streaming-hoist mechanic that no longer matches the emitted
code.

This commit:

- Refreshes the `closure_generic/source/Use.xphp` header comment to
  describe the dispatcher-routed shape (sentinel-arg-prefix `match`
  on `T_<hash>`, top-level `closure_pair_T_<hash>` specializations).
- Adds three new fixtures under `test/fixture/compile/` mirroring the
  remaining three dispatcher consumers:
    closure_dispatcher_arrow         (P5.5: implicit captures)
    closure_dispatcher_use_clause    (P5.6: explicit use(), incl. &-ref)
    closure_dispatcher_defaults      (P5.7: defaults + empty turbofish)
- Wires a `DispatcherFixtureIntegrationTest` that compiles each fixture
  through `Compiler::compile` and asserts on both the emitted PHP
  shape AND runtime behavior (running the compiled output through
  `exec`). The use-clause and defaults fixtures' runtime checks pin
  the by-ref mutation contract and the empty-turbofish routing.

418 -> 422 tests (4 new fixture tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`test/mutation` was OOM-ing under PHP's default `memory_limit` while
running the full Infection sweep -- the per-mutant test process can
peak above the default cap once enough source files are mutated in
parallel via `--threads=max`. Setting `-d memory_limit=-1` removes
the cap for the Infection driver so local mutation runs complete
end-to-end.

CI has its own memory ceiling and is exempted; this change targets the dev-env run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-refresh docs/ was a mix of a 438-line compiler deep-dive, a
stale roadmap listing already-shipped features as "next", and a
single sprawling generics reference page. Restructure into a layered
tree that lets a new PHP developer go from install to specific syntax
feature in a few clicks, with all rejections collected in one place.

New layout:

  docs/index.md            -- landing + nav + heads-up banner
  docs/getting-started.md  -- install + first compiled generic class
  docs/syntax/             -- 9 syntax pages, one per feature, all
                              following the same Example / What gets
                              emitted / Rules / Caveats / See also
                              template
  docs/caveats.md          -- centralized "what's not supported" page
                              with x/y/why structure per item
  docs/errors.md           -- searchable reference of every compile-
                              time error string with section pointers
  docs/guides/             -- how-it-works (the old compiler.md, with
                              pipeline phases renamed to stages),
                              runtime-semantics (extracted), and a
                              refreshed comparison vs RFC/TS/Kotlin/Rust
  docs/roadmap.md          -- shipped section expanded, next slimmed,
                              long-term renamed to discovery; Mermaid
                              timeline simplified so it actually
                              renders, with plain-Markdown detail
                              below as the source of truth

Test fixture comments under closure_generic/ and the three
closure_dispatcher_* fixtures get cleaned of internal sprint
identifiers so the fixtures referenced from the new syntax pages
read as user-facing examples rather than implementation notes.

Subsequent edits in this commit address an expert review pass
calibrated to the actual readership (PHP internals, the RFC author,
phpstan/psalm authors). The substantive changes:

- comparison.md gains an RFC column and is reframed against
  bound-erasure as the runtime-semantics dividing line, with cell-
  level corrections for the cross-language claims (TS unknown is
  not a wildcard; Kotlin where-clauses give intersection only, not
  DNF; Rust variance is inferred, not declared via PhantomData)
- runtime-semantics.md renames "marker interfaces as existential
  types" to "wildcard-shaped positions" -- the marker carries no
  runtime witness for T, so calling it an existential is too strong;
  the Kotlin Box<*> and Java Box<?> analogies are now correctly
  bounded with that caveat
- type-bounds.md error-message example is replaced with the verbatim
  string Registry::checkBounds actually emits
- caveats.md self-contradicting reflection example ("returns 2, not
  the original 2") is replaced with a 3-arg closure that actually
  demonstrates the point
- errors.md F-bounded recursion message replaces the unparseable
  placeholder form Box<<T>> with the literal Box<T>
- variance.md drops the misleading "tighter than the RFC text"
  framing (the RFC doesn't define variance) and reframes the strict-
  invariance rule as a consequence of emitting real extends edges
- how-it-works.md adds a one-paragraph reconciliation between the
  six narrative stages and the source's finer-grained Phase 0..5
  labels
- README PSR-4 setup gets a clarifier about which paths in the App\\
  array mapping carry weight and which are dead weight, plus a note
  that dump-autoload only needs to run once after editing
  composer.json
- README drops the Wikipedia link on monomorphization in favor of a
  one-clause inline definition
- README is condensed from a 5-step Getting started walkthrough to a
  ~50-line Quick start, with the full walkthrough now living
  exclusively under docs/getting-started.md; the See also section is
  reorganized to surface the docs in reading order
- docs/index.md tightens the "zero awareness" framing
- Anchor names propagate after the existential -> wildcard-shaped
  rename so cross-doc links keep resolving
- The wildcard story gets a Discovery roadmap entry (under "Type
  system breadth") signalling the work that would lift the marker
  from a wildcard-shaped position to a real existential
- getting-started.md is rewritten to match the README's canonical
  PSR-4 flow rather than telling a parallel (and previously wrong)
  clone-and-handroll-spl-autoload story
- Three broken links from the README pointing at the deleted
  docs/type-system/ tree are fixed

Verification: every cross-link inside docs/ and README.md resolves;
every fixture reference under test/fixture/compile/ points at a real
directory; 422-test suite continues to pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@math3usmartins math3usmartins requested a review from a team June 7, 2026 07:11
math3usmartins and others added 7 commits June 10, 2026 20:24
Replaces the integration-test layer with two reusable helpers and
reorganizes the fixture tree so snapshots live next to whatever
produces them.

New test harness

- SnapshotHash::assertMatches normalizes the deterministic `T_<sha256>`
  segments xphp emits into stable per-file `T_<HASH_N>` placeholders
  before byte-compare. Identical hashes collapse to the same N;
  distinct hashes get distinct Ns, so a role-swap regression still
  surfaces as a diff. XPHP_UPDATE_SNAPSHOTS=1 refreshes expected files
  in place; CI=1 vetoes the refresh so stale snapshots can't slip
  through.
- CompiledFixture::compile spins up a throwaway work dir, runs the
  compile pipeline on a tracked source/ directory, optionally registers
  a Composer PSR-4 loader for the user prefix and the generated
  namespace, and rms the temp dir on cleanup.

Test rewrites

Sixteen integration tests under test/Transpiler/Monomorphize/ moved
off two brittle patterns:

- Inline `file_put_contents($runScript, ...) + exec('php ...')`
  runtime checks became tracked verify/runtime.php files driven by
  CompiledFixture and `require`d from the test method under
  #[RunInSeparateProcess]. Process isolation is required because PHP
  has no class_unload — autoloading a generated FQN in one test
  poisons that FQN for siblings that compile a different temp dir
  under the same name.
- Ad-hoc `assertContains` / `assertStringContainsString` content
  checks became SnapshotHash assertions, with structural-invariant
  pins (preg_match_all counts, negative `\App\…\self` / `::<` checks,
  exact FQN substring matches) preserved alongside each snapshot to
  catch regressions that hash normalization would mask.

Fixture layout

- xphp input: test/fixture/compile/<feature>/source/
- runtime driver: test/fixture/compile/<feature>/verify/runtime.php
- snapshot oracle: test/fixture/compile/<feature>/verify/<testMethod>/*.expected.php

Tests that compile inline source strings (single-bug regressions too
narrow to warrant a tracked fixture) keep their snapshots co-located
with the test class itself, at test/Transpiler/Monomorphize/<TestClass>/<testMethod>/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add phpstan/phpstan ^2.2 as a dev dep, ship phpstan.neon at level 5
scoped to src/, and triage the 35 baseline findings to zero across
six clusters.

Real fixes:

- XphpSourceParser stale PHPDocs (10 findings): scanAndStrip return
  type, resolveAndAttach + inner-visitor params, and buildDefault
  $entry now declare the marker/entry shapes the runtime actually
  emits (kind, bytePosition, default, variance) rather than the
  narrower shape that was annotated when the keys were first added.
- Redundant null guards (5): drop ?-> and ?? [] in Registry,
  VarianceEdgeEmitter, RegistryCollector, XphpSourceParser where
  PHPStan proves the left side is non-null. Two sites rewritten as
  `!== null ? a : b` so the defensive intent stays explicit.
- nikic boundary narrowing (3): assert($x instanceof Stmt|ClassLike)
  immediately after NodeTraverser::traverse() / Specializer::deepClone
  in CallSiteRewriter, Compiler, Specializer so PHPStan sees the
  narrowing without widening the public API to nikic's Node[].
- Minor cleanups (4): @var → @param on the ByteOffsetMap ctor
  promoted property; drop array_values() over an already-list result
  in ClosureDispatcher; tighten the by-ref @param on the inner
  collectFreeVarsFromExpr visitor; per-line ignore for the usesThis
  visitor's by-ref $found property (read via reference in the
  outer scope).
- Logic findings (4): Registry::assertLeaf's sigil match → ternary
  (Variance::Invariant is unreachable per the early-return above);
  narrow BoundExpr → BoundIntersection|BoundUnion before accessing
  $operands in VariancePositionValidator; widen $scopeSnapshots
  PHPDoc to match $branchSnapshots' actual shape in
  GenericMethodCompiler; narrow rewriteVariableTurbofishCall return
  type from ?Node to null (every return path returns null today).

Suppressions (9): inline @phpstan-ignore-next-line with rationale on
defensive instanceof guards against nikic/php-parser's PHPDoc-narrowed
AST collections (ClosureUse::\$var, Use_::\$uses, method params).
These are runtime checks against polymorphism the AST library juggles
across parser versions, not dead code; an inline ignore documents
intent at the call site rather than globally relaxing
treatPhpDocTypesAsCertain.

phpunit: 422 / 1131 green. phpstan: [OK] No errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bump phpstan.neon level 5 → 6 and clear the 17 missingType.iterableValue
findings without lowering strictness.

Sixteen of the findings are in XphpSourceParser, all stemming from the
`bound:?array` annotation used on parsed type-parameter entries and the
parse* helpers that build them. The bound shape is polymorphic and
recursive:

  leaf:         array{kind: 'leaf', name: string, isFq: bool, args: list<TypeRef>}
  intersection: array{kind: 'and',  operands: list<bound>}
  union:        array{kind: 'or',   operands: list<bound>}

Tried a precise @phpstan-type alias first (both as
BoundDictLeaf|BoundDictComposite and as a single self-referential
shape) — PHPStan rejects either form as a circular definition; recursive
type aliases aren't supported. Landed on a named loose alias instead:

  @phpstan-type BoundDict array<string, mixed>

so the iterable-value rule is satisfied, every site that handled
?array now reads ?BoundDict, and the runtime contract stays documented
in plain text in parseTypeParamList's @return docblock. Imported the
alias into the anonymous visitor class via @phpstan-import-type so
buildBoundExpr / buildDefault resolve it.

Seventeenth finding: ClosureDispatcher::syntheticFunctionFromTemplate
was missing @param list<ClosureUse> $useClauses; added it inline with
the existing @param list<TypeParam> $params.

phpunit: 422 / 1131 green. phpstan level 6: [OK] No errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bump phpstan.neon level 6 → 7. Level 7 enables union-discrimination
and list-vs-array narrowing, which surfaces 55 findings across eight
files. Cleared without lowering strictness.

Bigger interventions

- Tightened the BoundDict type alias from `array<string, mixed>` to a
  discriminated union by `kind`:

    @phpstan-type BoundDict
        array{kind: 'leaf', name: string, isFq: bool, args: list<TypeRef>}
        | array{kind: 'and'|'or', operands: list<array<string, mixed>>}

  Tried a fully-recursive alias first (Bound = BoundLeaf|BoundComposite
  where BoundComposite carries list<Bound> operands) — phpstan rejects
  recursive aliases as circular, so the recursion bottoms out at
  array<string, mixed> and the visitor narrows operand-by-operand at the
  recursion boundary with an inline @var cast. Imported the alias into
  the anonymous visitor via @phpstan-import-type so buildBoundExprNode /
  boundContainsSelfReference resolve it.

- Marker properties on the anonymous visitor (classMarkers, methodMarkers,
  nameMarkers) typed `array<int, ...>` rather than `list<...>` — the
  visitor `unset(...[$i])`s consumed markers as it traverses, which makes
  the array sparse but never re-keyed.

Mechanical fixes (recurring patterns)

- Inline /** @var list<TypeRef> $args */ / list<TypeParam> $params casts
  immediately after `is_array(...)` guards on `getAttribute(...)` reads,
  documenting that XphpSourceParser::resolveAndAttach sets these as
  lists. Restructured a few sites so the cast sits between the guard and
  the use site — @var doesn't apply to a variable being reassigned on
  the next line, so e.g. `padArgsWithDefaults` got split into
  `$padded = Registry::padArgsWithDefaults(...); $args = $padded;` so
  the cast actually narrows what reaches the call.

- /** @var list<Node\Stmt> $ast */ after nikic's parse() / ditto after
  PhpToken::tokenize() — both return array<X> per their phpdoc, but
  runtime keys are always 0..N-1.

- RegistryCollector::runPass now takes `@param self::MODE_* \$mode` to
  match the property's literal union (was plain `string`).

One-offs

- NativeFileReader::read throws RuntimeException when file_get_contents
  returns false instead of returning string|false.
- BoundIntersection / BoundUnion ctors wrap variadic `\$operands` in
  array_values() — `...\$operands` collects as array<int<0,max>, X>, not
  list<X>, even though it's a list at runtime.
- rewriteVariableTurbofishCall asserts `\$node->name instanceof Variable
  && is_string(...)` instead of relying on a "validated by caller"
  comment.

Acknowledged ignores

- Three @phpstan-ignore-next-line property.notFound on
  finalizeClosureDispatchers(object \$visitor): \$visitor is an
  anonymous class declared above; phpstan has no way to name its
  shape. The accessed properties (\$closureDispatchPlan, \$pendingAppends,
  \$topLevelAppends) are documented on the visitor itself.

phpunit: 422 / 1131 green. phpstan level 7: [OK] No errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a `lint/phpstan` Makefile target running phpstan at the existing
level 7 config, and a parallel `phpstan` job in ci-core.yml that
shells out to `make lint/phpstan` — matching the `make test/unit`
→ `phpunit` job pattern. No dependency on PHPUnit (independent
gate for fastest feedback); infection still gates on phpunit only.

Memory limit lifted to 2G because deep generic array shapes
(BoundDict's recursive operand chains, marker shape stacks) push the
default 256M ceiling.

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

Drive the escaped-mutant count to zero (Covered Code MSI 100%).

Catch:
- Add InnerVarianceIntegrationTest exercising the full Compiler::compile
  pipeline on `class P<+T> { f(): Container<T> }` (invariant inner slot).
  RegistryInnerVarianceTest only calls validateInnerVariance() directly, so
  the MethodCallRemoval mutant that drops the call from compile() escaped;
  the integration test fails when the call is removed.

Justify (equivalent mutants):
- UnwrapArrayValues on Bound{Intersection,Union}::__construct -- array_values()
  on a variadic is a no-op (same rationale as the FilepathArray entries).
- LogicalOr on ClosureDispatcher::buildDispatcherClosure -- use-clause $var is
  always a string-named Variable; the guard never fires either way.
- LogicalOrAllSubExprNegation on VariancePositionValidator::checkBoundExpr --
  mutated `!A || !B` assert is always true (bound is exactly one operand type).
- NotIdentical on GenericMethodCompiler::finalizeClosureDispatchers -- under the
  unbraced `namespace X;` emission model both append targets serialize
  identically; all 82 closure tests pass with the flip.
- Foreach_ on CallSiteRewriter::rewrite -- assert-only type-narrowing loop;
  suppressed inline because the method holds an anonymous class, so the
  per-method ignore matcher can't bind to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep a Changelog / SemVer format documenting the 0.2.0 feature surface
(bounds, defaults, variance, function-level generics, generic closures
and arrows, pseudo-types, T[] sugar) and the 0.1.0 baseline. Version
headings carry no dates -- a version is dated when its tag is published,
so a pre-tag date would be misleading. Linked from the README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@math3usmartins math3usmartins changed the title 0.2.x 0.2.0 Jun 11, 2026
@math3usmartins math3usmartins merged commit 85ff090 into main Jun 11, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant