0.2.0#17
Merged
Merged
Conversation
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>
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>
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.
No description provided.