0.4.0 - #34
Conversation
Each executable verify file under test/fixture/compile/*/verify/ relied on
an implicit, untyped $fixture variable being in the enclosing test method's
scope at the point of `require`. The dependency was invisible from the file,
untyped, and enforced only by a docblock convention across 26 driver methods.
Each verify file now returns a `function (CompiledFixture $fixture): void`
that runs its assertions when invoked, and each driver calls it explicitly:
$runtime = require __DIR__ . '/.../verify/runtime.php';
$runtime($fixture);
The fixture dependency is now an explicit typed parameter. Behavior is
unchanged: the inner `require $fixture->targetDir . '/Use.php'` still runs in
the closure scope (so locals like $result are produced as before), assertion
failures still propagate through the invocation, and the try/finally still
cleans up the temp dir.
Also drop the decorative `echo "OK\n"` lines from the 11 files that had them:
stdout is never inspected, so the `Assert::` calls already signal pass/fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ll-node kinds The specialization substitution visitor grounded ATTR_METHOD_GENERIC_ARGS type refs only on FuncCall nodes (the variable-turbofish shape), so a static, instance, or nullsafe method turbofish inside a generic template body (`Maker::wrap::<T>`, `$this->m::<T>`) kept an abstract type-param ref after the enclosing class specialized. Widen the arm to StaticCall, MethodCall, and NullsafeMethodCall. Behavior-inert on its own: nothing consumes the grounded marker on those node kinds yet, so the emit leak guard still rejects the shape — this is the enabling primitive for dispatching it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A generic function forwarding to a named generic (`identity::<T>($v)` inside `wrap<T>`) was rejected with xphp.unspecialized_generic_leak: specialization substituted the inner marker to a concrete `identity::<int>`, but the append flush attached the specialized body without re-walking it, so nothing dispatched the now-groundable call and the leak backstop tripped. Replace the flat flush with a worklist drain: each buffered specialized function/method is attached (compile mode), then re-traversed with the same rewrite visitor in a markers-only mode that touches ONLY named-call turbofish markers — plain-call sweeps, closure-dispatcher tracking, and static/instance markers (whose resolution is not drain-safe; they keep falling to the leak guard) are skipped. Freshly minted appends re-enter the queue: multi-hop chains ground to the bottom, same-args cycles terminate through the alreadyGenerated dedup, and a strictly-growing chain (`grow::<Box<T>>`) is cut off at a 16-hop cap with the new xphp.unconverged_method_specialization error. Appends now carry their declaring-class/namespace context so a detached body resolves against its own scope, not the last-walked file. The drain also runs in check mode (validate-only, nothing attached): bound violations only provable after substitution, non-convergence, and surviving call markers are collected as diagnostics — closing the gap where `xphp check` silently passed shapes `compile` rejects. Sites the source seam already reported are not double-reported (same-position dedupe, and the guard scan's closure-template arm is excluded in check mode via the new GenericMarkerLeakGuard::findLeak/leakMessage API). The generic_function_named_forward fixture (formerly a reject pin) now compiles and runs end to end; new fixtures pin the chain/cycle accepts, the growing rejects (namespaced + bare top-level), the grounded-bound reject, and check/compile parity for each. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… specialization A generic class calling a method-generic via turbofish whose type argument is its own type parameter (`self::gen::<T>`, `Maker::wrap::<T>` inside `Box<T>`) was rejected with xphp.unspecialized_generic_leak: the method compiler runs before class specialization, where the enclosing T has no value, and nothing re-visited the marker once specialization made it concrete — while the equivalent constructor turbofish (`new Box::<T>`) grounds through the post-specialization Name rewrite and works. Feed each fresh specialization back through the method compiler from inside the fixed-point loop (groundSpecializedClass): the Phase-1a template index and dedup map are retained past template stripping, the spec's identity (template FQN + concrete args) is threaded so `self::` resolves and the class substitution composes into member specialization, and the walk runs in the append-drain's markers-only mode with parse-time-resolved names. Own-template members land on the specialization itself, deduped per spec and dispatched via `self::` (the template class lowers to a marker interface); non-generic targets append onto the retained user AST, deduped globally across all forwarding classes. Members appended outside the spec are collected explicitly, so an instantiation that first appears inside a grounded body (`new Pair<int>`) converges through the same fixed point. Grounding runs in check's resilient loop too: bound violations only provable after substitution, the unchanged static-context unprovable-bound rejection, and surviving markers are collected as diagnostics located at the template's real source file — closing the gap where `xphp check` silently passed shapes `compile` rejects. Deliberately still rejected (marker kept → emit backstop, now pinned by fixtures): a static target declared on a different generic template, and the `static::` / `parent::` spellings, whose current-class resolution would silently mis-dispatch rather than fail. The generic_class_method_turbofish fixture (formerly a reject pin) now compiles and runs end to end across two instantiations and two forwarding classes, with dedup invariants asserted at runtime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ss specialization An instance method-generic turbofish grounded by the enclosing class type parameter was rejected on both receiver shapes: `$this->dup::<T>()` drew xphp.unspecializable_self_call at the Phase-1a walk (where T has no value), and `$obj->dup::<T>()` leaked to the emit backstop. Phase 1a now DEFERS the `$this`-rooted report when every abstract leaf in the turbofish names an enclosing CLASS parameter — the shape the per-specialization grounding pass dispatches once the class substitution makes it concrete. A METHOD-level parameter leaf keeps the precise rejection at the original site: nothing downstream ever grounds it, and a late leak error would be strictly worse. The grounding pass processes instance markers alongside static ones: `$this` resolves to the spec's own identity with the instantiation's concrete type arguments (so `<U : E>`-style bounds ground, and erasable targets route into the existing erasure lowering — reusing the E-mangled member rather than fatally redeclaring it); object receivers resolve through the spec's already-substituted declared types, with parse-time name resolution replacing the detached walk's missing alias state. A target declared on a generic BASE grounds through the inheritance chain and lands on the CALLING spec itself — never on the shared template, whose mid-loop mutation would be order-dependent. A receiver of a *different* generic template keeps its marker for the backstop (its member belongs on that template's own specs), and re-visited sites the template walk already diagnosed are never double-reported. A deferred marker in a never-instantiated generic class now compiles silently (the template lowers to a marker interface; the body is dropped) — pinned as a deliberate surface choice, matching check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The caveats section that listed every enclosing-parameter turbofish shape as rejected now documents the grounded-per-specialization behavior (named forwards, static and instance method calls, generic-base targets, erasable plain callers) and narrows the rejected list to what actually remains: closure turbofish inside generic function bodies, cross-template targets, the late-bound static::/parent:: spellings, bare top-level forwards from class scope, method-param leaves, and non-convergent growing chains. The check-vs-compile completeness-gap paragraph is gone — the validate-only pass now collects the same diagnostics. errors.md: the leak-backstop row names the remaining shapes, the unspecializable_self_call row is scoped to method-level parameter leaves, and the new xphp.unconverged_method_specialization code is listed. Turbofish and methods-and-functions caveats link the new behavior; CHANGELOG gains the Unreleased entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…align check diagnostics Four fixes to the per-specialization grounding pass, each pinned by a fixture that fails without it: The own-template arms (static and instance) now require the call site to lexically live inside the specialization being grounded. A drained body appended onto ANOTHER class could previously hit the arm — `Holder::gen::<X>` written inside Maker's forwarded member was rewritten to `self::gen_T_…()` inside Maker, a silent call to a member Maker doesn't have (runtime fatal; the same program was loudly rejected before the grounding pass existed). Outside the spec the marker is kept for the backstop; `$this` inside a foreign drained body also no longer borrows the spec's type arguments. The static arm gains generic-ANCESTOR support in the process (the declaring template's substitution threads through the extends chain, mirroring the instance rule) — `self::gen::<T>` with gen on `Base<T>` now grounds onto the calling spec. Check-side alignment: the class-spec leak backstop no longer flags a leftover variable-turbofish marker (`$f::<int>` in a generic-class method — compile materializes the dispatcher and the program runs; check simply never finalizes dispatchers), grounded own-spec members are collected in check mode even though nothing is attached (a bound violation nested in a grounded member's body — `new Pair::<U>` grounded to a bound-violating Pair<int> — was passing check while compile rejected), and markers-only re-walks skip call sites that already carry a diagnostic, so an arity error on a deferred turbofish is reported once at its source site instead of once more per specialization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…severity-aware dedupe Three follow-up fixes to the grounding pass's check-mode alignment, each pinned by a fixture that fails without it: The class-spec leak backstop now skips the SUBTREES of declarations still carrying their generic-template marker: check never strips templates, so a spec clone retains e.g. `a<U>` whose body legitimately holds a `self::b::<U>` marker — flagging that interior rejected a 2-hop own-template forward chain that compile grounds and runs. Compile-mode specs never contain such declarations, so the assert path is unchanged. The position dedupe (grounding-skip and leak-backstop sides) matches ERRORS only: a same-line warning previously suppressed grounding entirely, letting a bound violation nested in the grounded member pass check while compile rejects — the dangerous direction. Own-spec appended members now drain under the SPEC's identity rather than their declaring class's: the member lives on the spec, so `$this`/`self` in its body are the spec — an ancestor-declared member whose body forwards again (`self::genB::<U>` declared on Base) now grounds hop by hop onto the calling spec instead of leaking spuriously after the first hop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tes in grounding pre-scan Two no-behavior-change cleanups surfaced by review of the grounding pass: The constructor's `@param $diagnostics` docblock had been pushed away from `__construct` when the retained-state properties were inserted between them, leaving it orphaned (documenting nothing, and the constructor undocumented at its declaration). Move it back directly above the constructor. The cheap pre-scan in groundSpecializedClass called findLeak without `skipUnspecializedTemplates: true`, unlike the check-mode backstop 30 lines below. In check mode a spec clone keeps its unstripped generic-method templates, whose bodies carry markers the grounding walk already skips — so the pre-scan never took its "nothing to do" early exit for any spec declaring a generic method, running a redundant full walk. Aligning the flag restores the fast path; output is identical either way (verified: flipping the flag leaves the whole check/method suite green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…orted The turbofish caveats described the method-level-parameter forward failure as happening "to a non-erasable target", which wrongly implied an erasable target would work. It doesn't: forwarding a method-level parameter (`$this->dup::<W>` inside `probe<W>`) fails regardless of the target, because a generic method is specialized before its class, so `W` has no concrete value where the forward would be grounded. A non-erasable target reports `xphp.unspecializable_self_call`; an erasable one leaks and reports `xphp.unspecialized_generic_leak` — neither is supported. State that plainly in both docs/syntax/methods-and-functions.md and docs/caveats.md, and note the underlying reason (class parameters become concrete at class specialization; method parameters don't). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Infection is the slowest CI job (full coverage run + thousands of mutants), and it can only change outcome when the mutated code (src/) or the tests that kill mutants (test/) change. Gate it: a `changes` job (dorny/paths-filter) detects whether src/ or test/ was touched, and the `infection` job runs only when it was — so a docs- or config-only PR skips mutation entirely. The `needs: phpunit` ordering is preserved (mutation still waits for unit tests).
… tool locally and in CI Commit subjects had been following the Conventional Commits pattern (type(scope): lowercase subject) by convention only; nothing rejected a stray unprefixed message. Adopt commitlint as the single enforcement tool: rules live in commitlint.config.mjs (the stock config-conventional preset, which matches the repo history), versions are pinned by package-lock.json, and a new docker compose `node` service runs it — so contributors need docker but no host Node toolchain. The tracked .githooks/commit-msg hook pipes the message (comment lines stripped, worktree-safe) into commitlint via that service, and composer install/update wires the hook up automatically through core.hooksPath. CONTRIBUTING documents the format with real examples from the history. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Run the lockfile-pinned commitlint over every commit in the PR range (full-history checkout), with the same commitlint.config.mjs the local commit-msg hook uses — one tool, one version, one rule set in both places. The CI job is the authoritative check backing the local hook, which contributors can bypass with --no-verify. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… plugin Two robustness gaps in the commit-msg hook found in review: - The install guard only reinstalled when the commitlint binary was absent, so a version bump a collaborator pulls in (new package-lock.json, untouched node_modules) left the hook linting with the old version while CI used the pinned one -- defeating the "same version everywhere" goal. Reinstall when node_modules is missing OR the lockfile is newer than the installed binary; the already-fresh common case still skips npm ci. - The hook checked for `docker` but then runs `docker compose`, so a box with docker-engine but no Compose v2 plugin hit Docker's own cryptic error. Add a `docker compose version` preflight with a useful message.
The node service carried a `tail -f /dev/null` keepalive, so a bare `docker compose up` started and kept it running. That keepalive was also pointless here: the commit-msg hook (and CI) invoke it with `docker compose run`, which supplies its own command and never needs the service pre-started. Put it behind a `commitlint` profile and drop the keepalive -- `docker compose run node` still starts the profiled service on demand, and it no longer shows up in the default stack.
The feature grid compared declaration surface, bounds, variance, and runtime semantics, but never stated a call-site divergence: xphp has no type-argument inference — the ::<> turbofish is mandatory on every generic call, and omitting it is a compile error (needed to pick a specialization under monomorphization). TypeScript, Kotlin, and Rust all infer; Rust is the sharp parallel, borrowing the same ::<> spelling but using it only as the disambiguation fallback over default inference. The bound-erasure RFC has no inference either, but keeps the turbofish optional (omitting runs unvalidated) rather than required. Add a grid row (red cross for xphp and the RFC, both genuinely lacking inference) plus a short note capturing the mandatory-vs-optional-vs- inferred spread. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ully supported An honest pass over the feature grid: five rows were marked a plain green check but have real, documented caveats, so they now carry a warning sign with the caveat named in the cell and detailed in a new "Supported with caveats" section (each linking to the full write-up in caveats.md): - Generic closures / arrows: no $this capture, no `static function` closures, and reflection/serializers see the dispatcher rewrite. - Typed closure signatures: parameter/return/property positions only, not a generic argument or bound. - Generic functions / methods: no inference, and a turbofish can't forward a method-level parameter, target another generic template, or use static::/parent::. - Declaration-site variance: violations inside trait-used methods go unchecked; class-level only. - Real subtype edges: some covariant upcasts of erased method-generics are unschedulable, and self-reintroducing derivations may not converge — the celebratory subsection now points at this too. No cell moved between present and absent; the grid just stops overstating these five as caveat-free. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…compilers Honesty pass over the non-xphp columns, checked by compiling generic snippets in throwaway containers (TypeScript 7.0.2, Rust 1.97.1, Kotlin 1.4.10) rather than asserting from memory. The RFC column can't be run (no implementation exists) — its claims were checked against the RFC text. Corrections: - RFC "typed closure signatures" was a green check, but the bound-erased generics RFC has no structural closure/callable signature types (it lists them as future work). Now a red cross; PHP has only untyped callable / \Closure. (Verified against the RFC text.) - Kotlin reified T carried a plain check; `class Box<reified T>` fails with "only type parameters of inline functions can be reified", so it's now a caveat (inline fun only). (Compiler-verified.) - Generic enums: neither Kotlin `enum class` nor a TS `enum` can be generic (compiler-verified); both rows keep their check but note the real mechanism (sealed classes / discriminated unions). Everything else in the TS, Rust, and Kotlin columns was confirmed correct by the same compile checks — declaration-site variance, variadic tuples, union bounds, sum types, type aliases, associated types, use-site variance, reified/monomorphic T, and specialization-needs-nightly all behave as the grid claims. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Real subtype edges" bullet linked the caveat write-up for the array-backed-collection sub-problem but not for the self-reintroducing list-map derivation, even though that one also has a dedicated section in caveats.md (with a workaround). Add the missing link so a reader who hits the convergence error finds the fix. The unschedulable-upcast sub-problem stays unlinked by design -- it has no caveats section, only the inline error code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce TypeInference, the pure core that derives a generic call or instantiation's type arguments from its ordinary arguments' static types -- the structural inverse of Specializer::substituteTypeRef. It carries no pipeline state: the unifier walks a parameter type and a concrete argument type in lock-step binding each type-parameter leaf, subtype arguments are threaded up the supertype chain via TypeHierarchy::resolveInheritedArgs, and the driver pairs arguments to parameters (positional, named, variadic, spread- and first-class-callable-aware), unifies, and assembles the bindings into a declaration-ordered type-argument prefix. Inference resolves only to a complete, unambiguous, concrete tuple (or a concrete prefix whose omitted tail is entirely defaulted); a conflict, ambiguity, unknown argument type, or a hole yields null, so a later caller can leave the site bare and fall back to the explicit-turbofish path unchanged. Flow-dependent typing is delegated through the ExpressionTyper seam, keeping this logic outside the monomorphizer's anonymous NodeVisitor classes where it is directly unit- and mutation-testable. No wiring yet -- this class has no callers; the call and new sites adopt it in following commits. 44 unit tests; 100% MSI on the new code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add LiteralTyper, the context-free ExpressionTyper the inference driver uses for arguments whose static type is read directly off the expression: scalar literals (int/float/string/bool), array literals, and object construction (new X(...), carrying any turbofish the parser resolved onto the new). It returns null for anything flow-dependent -- a variable, a property, a call return -- and for a construction that is not fully concrete (an un-turbofished generic new, or an anonymous/dynamic class), never inventing a type it cannot read completely. An array literal is typed isScalar like the parser types an `array` turbofish argument, so an inferred tuple is byte-identical to the one an explicit ::<array> would produce. Still no callers -- the call and new sites wire this in with the flow tracker in following commits. 13 unit tests; 100% MSI on the new code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the turbofish optional on generic function, static-method, and instance-method calls: at each bare-call seam, before the missing-type- argument error, infer the type arguments from the call arguments and, on a complete concrete tuple, dispatch exactly as an explicit ::<> turbofish would. The monomorphizer's rewrite visitor now implements ExpressionTyper, answering argument types from LiteralTyper (literals, new) and its own receiver/scope tracking (class-typed parameters and locals, $this properties, class-returning calls); TypeInference turns those into the type-argument tuple. A miss leaves the site bare, so a call whose arguments don't determine the type parameter (e.g. one used only in the return type), a type conflict, or an unknown argument type still hits today's xphp.missing_type_argument, identically in check and compile. Because an inferred call is annotated to be indistinguishable from a turbofished one, everything downstream -- bounds, variance edges, mangling, specialization, check/compile parity -- is unchanged and cannot tell the two apart. First-class-callables are never inferred, and generic closure ($var) calls keep the explicit-turbofish requirement (deferred). The five enclosing-param bare-call tests that asserted the old "bare call always errors" behavior now use genuinely non-inferable shapes (the type parameter only in the return type), preserving what they guard -- that an unresolvable bare call still errors loudly rather than emitting a broken call. A new integration test compiles, executes, and check-verifies inference across all three call kinds and every supported argument shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the turbofish optional on generic class instantiation. A new front-end pass (NewInferencePass) infers a bare `new Box($x)`'s type arguments from its constructor arguments and annotates the node exactly as an explicit turbofish (or the all-defaults synthesis) would -- so the instantiation collector and, in compile, the call-site rewriter treat it identically. A `new` it can't resolve to a complete concrete tuple is left bare, falling back to today's all-defaults synthesis or missing-type- argument error. The pass runs after collectDefinitions (it needs the template registry) and before collectInstantiations, at the same position in both check and compile, so the two modes infer identically. In compile, collectDefinitions and the pass now run before the method compiler, so the pass sees the original ASTs -- not the stripped/appended ones the method compiler produces (which check never sees). The method compiler still runs before collectInstantiations, so its appended specializations are collected as before. Argument typing is conservative and sound: literals and `new` (via LiteralTyper), `$this` properties (from the declared type), and plain parameters -- but only a concretely-typed parameter that is never reassigned, so a rebound `$x` can't be typed from its stale declaration. A local, a reassigned parameter, a union type, or a type-parameter-typed argument yields no inference and the `new` falls back. The two bare-new check fixtures now use a genuinely non-inferable template (the type parameter only in a method return), preserving what they guard. Integration tests compile, execute, and check-verify `new` inference across literal/`new`/property/parameter arguments; NewInferencePass is at 100% MSI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Now that a bare generic call attempts inference, the free-function/closure diagnostic no longer claims a generic "takes no inference". It states the type arguments could not be inferred from the call arguments and to supply an explicit turbofish. The error code (xphp.missing_type_argument) and the collect-vs-throw behavior are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the comparison grid's type-argument-inference row from ❌ to⚠️ (inferred for calls and `new` where the arguments determine the type; explicit turbofish still required otherwise) and rewrite the accompanying prose — xphp now infers by default like Rust, reaching for the turbofish to disambiguate or where inference can't see the type. Drop the stale "no inference" note from the generic-functions/methods row. Add a "Type-argument inference is partial" caveat (what is and isn't inferred, why the argument-typing is conservative, and the turbofish workaround), a turbofish-syntax section describing inference and its sources, rewrite the mandatory-turbofish rule as optional-where-inferable, update the xphp.missing_type_argument error entry, and add a CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bare generic call whose argument was typed by an in-scope type parameter (the enclosing function/method/class parameter, e.g. `identity($x)` inside `outer<U>(U $x)`) inferred that parameter as a concrete class: the flow tracker stores a parameter's declared type as a resolved FQN (`App\U`), which typeOf() wrapped in a concrete TypeRef with no isTypeParam flag. The call then dispatched `identity::<App\U>` and emitted a specialization referencing the non-existent class `App\U` — `check` reported clean, `compile` succeeded, and the program fataled at runtime. typeOf() (and typeOfThisProperty) now treat such a value as abstract and return null, so the site falls back to the exact missing-type-argument error it produced before inference existed. In-scope type-parameter names are tracked through the scope stack (enclosing functions/methods/closures) and read from the enclosing class, mirroring NewInferencePass. The explicit turbofish grounded by the enclosing parameter (`identity::<U>($x)`) is unaffected and still grounds per specialization. Regression tests cover the argument-typed-by-a-function/method/class-type- parameter cases for the free-function, static, and instance seams, in both check and compile, and pin that the explicit-turbofish alternative still emits a concrete specialization with no reference to the abstract parameter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inference is skipped when an argument's simple type name coincides with an in-scope type parameter (e.g. a class imported `as U` inside `f<U>`); the name is treated as the shadowing type parameter and the call falls back to an explicit turbofish. It never mis-infers — document the conservative edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A nested generic `new` was inferred with the wrong type argument: because the pass annotated on enterNode (top-down), an outer `new Box(new Box(5))` read its inner argument before that inner node had been annotated, so LiteralTyper saw no turbofish and typed the inner as the raw `Box` template — the outer inferred `Box<Box>` and emitted a different specialization than the explicit `new Box::<Box<int>>(new Box::<int>(5))` would. Move the inference to leaveNode (bottom-up): inner nodes are fully annotated before their enclosing `new` reads them, so an inferred nested `new` now selects the byte-identical specialization the turbofish selects. The namespace context, class stack, and scope are still in place at a `New_` leave (they pop only when the enclosing class/function leaves, which is later). Regression tests: a nested `new Box(new Box(5))` now matches the turbofish's two specializations exactly; and — pinning the scope/class stack discipline the move relies on — property inference after a nested anonymous class and parameter inference after a nested closure both resolve against the correct outer scope. Docs: correct the inference-source list. A *call* additionally infers from a statically-tracked local (assigned from a `new` or a class-returning call) and from a call whose return type is a determinable class — it reuses the monomorphizer's receiver/flow tracking — while `new` inference stays limited to the conservative set. Adds a test for the class-returning-call source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Recognize a `type Name[<A, B>] = SingleHeadBody;` declaration in the scanner and blank it to equal-length whitespace, so the (otherwise invalid) statement never reaches the host PHP parser. The alias arm runs first — ahead of the bare `Name<…>` arm — so the `<A, B>` clauses on the alias head and its body are not half-stripped. This is the recognition + strip step only: the alias body is not yet captured or expanded (that follows). Statement-position gated so `type` used as a constant, function, or member name is never mistaken for a declaration. Single-head bodies only; a union / intersection / nullable body, or any other non-single-head shape, declines here and falls through (to become an explicit diagnostic in a later change). The separator must be `=`; the param list is parsed permissively. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture each `type Name[<…>] = SingleHead;` declaration and expand its uses into the alias body before specialization, so nothing downstream (registry, specializer, call-site rewriter) ever sees an alias and the emitted PHP contains no alias name. - Build a file-local alias table keyed by FQN, attributing each alias to its declaring namespace by byte span (a real class sharing an alias's short name in another namespace never collides). - Expand in the resolver's Name branch: the head AND, recursively, the arguments (an alias can appear as a generic argument, e.g. Bag<Elem>), substituting parameters via the resolved body. Nested and concrete-instantiation aliases (UserMap = Pair<int, User>) resolve fully; a non-alias name is left untouched. - Reject a self-referential (cyclic) or arity-mismatched alias loudly in both modes: compile throws, check collects the diagnostic. Aliases are a pure compile-time substitution with no runtime existence; v1 is file-local and single-head-bodied. A runtime fixture executes the compiled output and asserts every alias name is absent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Give each type-alias rejection a stable code and raise the two that were previously silent or unclear: - xphp.alias_class_collision — an alias whose FQN matches a class / interface / trait declared in the same file is now a loud error, not a silent shadow of that class. - xphp.alias_unsupported_body — a union / intersection / nullable / closure body is recognized and stripped (so `strip()` never emits a raw PHP parse error) and rejected with a clear message at parse time. - xphp.alias_duplicate — the same alias FQN declared twice in a file. - xphp.alias_cycle / xphp.alias_arity — promoted from the generic parse-error code to dedicated codes. XphpParseException carries an optional diagnostic code; check maps it onto the collected diagnostic (compile still throws). Every rejection is verified in both modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New syntax tour page (docs/syntax/type-aliases.md) + index row. - Caveat covering the v1 boundaries (file-local, single-head bodies, same-file collision detection) and their reasons. - Roadmap: move type aliases from Discovery to Shipped. - ADR-0023: the declaration-form syntax decision (`type Name<…> = Body`) and compile-time-substitution model, with the alternatives weighed. - CHANGELOG entry under [Unreleased]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lift the single-head-only restriction for union (`A|B|…`) and nullable (`?X`) alias bodies, which expand into a real PHP `UnionType` / `NullableType` where a slot can hold one. `?X` desugars to `X|null`; a three-member `A|B|null` stays a `UnionType`, while `?X` (one non-null, atomic) emits `?X` (`?(A&B)` would be a fatal parse error). A compound (union) alias is only representable as the WHOLE type of a param / property / return / class-const slot — threaded via a wholeSlot flag from markType. As a generic argument, in `new` / turbofish / `extends` / a bound, or nested inside another nullable/union at the use site, it is rejected loudly (`xphp.alias_compound_in_non_slot`) in both modes. Intersection, DNF, and closure-signature bodies remain `xphp.alias_unsupported_body` (a later change). A single-head alias that transitively resolves to a union expands as a union too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make an alias declared in one file usable in another. The Compiler runs a pre-pass over every source, merging each file's local alias table (XphpSourceParser::aliasTableOf) into one whole-program table, then injects it into the per-file parse so expansion resolves an alias no matter which file declares it. A file whose own aliases are malformed is skipped in the pre-pass; the same rejection re-surfaces (and is collected in check mode) when that file is parsed for real. A standalone parse (the LSP / tolerant path) keeps aliases file-local — the whole-program table is a compile/check concern. Same-file duplicate / collision / unsupported-body checks are unchanged; cross-file duplicate and collision are last-wins / undetected (a later refinement). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the type-alias docs to the delivered feature: union and nullable bodies and cross-file (whole-program) use. Rewrite the caveat (renamed to body/position limits — file-local and single-head no longer apply) and repoint the syntax/roadmap/ADR anchors; add the `xphp.alias_compound_in_non_slot` code; refresh the roadmap Shipped entry, the ADR consequences, and the CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A type alias's own parameters may declare defaults (`type P<A, B = A> = Dict<A, B>;`), which `parseTypeParamList` already parses but expansion discarded — using fewer args than params was a flat `xphp.alias_arity`. Retain the full per-param entries in the alias marker/table, resolve each default against the alias's params (so `B = A` and chained `C = B` fill from earlier arguments), and pad missing trailing arguments at expansion. The valid arity is now `required <= given <= total`; the message keeps the exact `expects N` form with no defaults and uses `between R and N` only when defaults make the count a range. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A type alias's parameters may declare bounds (`type B<T : Named> = Bag<T>;`), previously parsed and dropped. Enforce them: because alias expansion runs per file before the whole-program hierarchy exists, each used bounded alias records an AliasBoundObligation (its resolved parameters + concrete padded arguments + use-site location), collected across files and verified once the hierarchy is built by AliasBoundValidator via a new Registry::checkAliasBounds — the same check a class instantiation runs, so a violation surfaces as an identical xphp.bound_violation (thrown in compile, collected in check). An argument whose top level is a type parameter (`B<X>` inside `class C<X>`) is skipped (absent from the hierarchy, it would be spuriously rejected); a concrete head over a type-param inner (`Coll<X>`) is checked, since bounds erase generic arguments. Only file-local generic aliases reach enforcement — a cross-file generic-alias use is a separate unsupported case that hard-errors as an undefined template — so a captured bound always resolves in the context it was declared, with no cross-file misresolution. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the two new generic-alias parameter capabilities: defaults (`type P<A, B = A>`, trailing arguments may be omitted) and enforced bounds (`type B<T : Named>`, a violating argument is xphp.bound_violation). Update the syntax tour's Rules, the roadmap Shipped entry, and the CHANGELOG, and note in the caveat that a generic alias is file-local (a non-generic alias is cross-file). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se aborts In check mode, a file that aborts mid-parse (e.g. an arity error) has its AST dropped from the whole-program hierarchy, but any alias-bound obligation already captured during that file's traversal survived in the shared collector. A VALID bounded-alias use earlier in the same file was then verified against a hierarchy missing that file's types, producing a spurious xphp.bound_violation claiming a type that is declared right there "is not in the source set". Buffer each file's obligations in a per-file collector and absorb them into the shared one only after the file parses cleanly, so a failed file's obligations are discarded with its AST. compile() was unaffected (it aborts before the validator runs), but the fix keeps the two paths consistent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A parameter bound that names a type alias (`type Named = Face; type B<T : Named> = …`, and likewise `class Box<T : Named>` / a generic method) was resolved by name only, never expanded — so the alias became a phantom class `App\Named` and every argument was rejected as not extending it. Pre-existing for class/method bounds (an xphp.undeclared_type on the phantom); WI-05 exposed it for alias-parameter bounds by enforcing them. Expand an alias leaf in `buildBoundExprNode` exactly as a type position does: a single-head alias becomes that head, a union/nullable alias becomes a union bound (any-of). Reuses the existing expansion (generics, defaults, nested aliases). Guard the newly-reachable recursion — an alias whose own parameter bound refers back to itself — with an in-flight set in `resolveAliasParams`, turning what would be a stack overflow into a clean xphp.alias_cycle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ace assumption Record that a parameter bound may name an alias, and add a caveat that a generic alias's body / bound / default resolves in the using file's namespace — correct under one namespace per file (PSR), a mis-resolution risk only in multi-namespace files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…indexes Type aliases shipped but several cross-cutting docs still described them as unshipped or omitted their diagnostics: - comparison feature grid: xphp "Generic type aliases" ❌ →⚠️ (shipped, with the body-shape and file-local-generic caveats) - error catalog: add the six alias diagnostic codes (cycle, arity, class_collision, duplicate, unsupported_body, compound_in_non_slot) - docs/index and README: move type aliases from "under exploration" / "remaining" to shipped - syntax index: correct the one-line summary (non-generic cross-file, generic file-local; defaults + bounds) - type-bounds: note a bound may name an alias Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-pass) A type alias is a file-local declaration by design, like PHP's own `use` alias — there is no whole-program alias table. Remove WI-03's cross-file machinery: the Compiler's collectGlobalAliases, the parser's aliasTableOf, and the externalAliases parameter threaded through parse / parseWithMap / resolveAndAttach. Expansion now consults only the file's own alias table. This makes generic and non-generic aliases behave consistently (both file-local), and dissolves the two cross-file gaps entirely: a generic alias used in another file surfaces as an undefined template, a non-generic one is simply left unexpanded (flagged by the later PHP/PHPStan pass), and same-file duplicate/collision detection is unchanged. Share a vocabulary by declaring the alias in each file that uses it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Type aliases are now file-local for all cases (WI-07). Rewrite the docs to present file-locality as an intentional design choice — an alias is a local naming convenience like a `use` alias, not a whole-program symbol — rather than a "safe subset first" limitation: caveats, syntax tour + index, roadmap (timeline + shipped), the comparison grid caveat, CHANGELOG, and ADR-0023's delivered-scope note. Drop the cross-file duplicate/collision "not detected" notes (moot — per-file scoping has nothing to detect across files). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ycle Address review feedback on the type-alias feature: - The `xphp.alias_unsupported_body` message listed unions and nullables as unsupported, but both are supported — it now names only intersection, DNF, and closure-signature bodies. - A self-referential alias whose cycle runs through a generic argument of a non-alias class (`type A<T> = Bag<A<T>>`) bypassed the cycle guard and recursed without bound; argument expansion now carries the same visited chain as the body, so it is a clean `xphp.alias_cycle`. - Refresh two stale docblocks that said duplicate/cycle/arity diagnostics "land in a later change" — all are implemented. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(type-aliases): file-local generic type aliases via compile-time substitution
Rename the Unreleased section to [0.4.0] - 2026-08-06 with its compare link, and record type-argument inference and enclosing-parameter turbofish grounding under the roadmap's Shipped section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
| Filename | Overview |
|---|---|
| src/Transpiler/Monomorphize/XphpSourceParser.php | Adds the type-alias scanner, file-local resolution, recursive expansion, defaults, bounds, and alias diagnostics. |
| src/Transpiler/Monomorphize/TypeInference.php | Implements argument-to-parameter unification and inferred generic-prefix assembly. |
| src/Transpiler/Monomorphize/NewInferencePass.php | Adds constructor inference, but destructuring assignments can leave stale parameter types trusted as inference sources. |
| src/Transpiler/Monomorphize/GenericMethodCompiler.php | Adds inferred generic calls and per-specialization grounding; its assignment invalidation shares the destructuring-target gap. |
| src/Transpiler/Monomorphize/Compiler.php | Integrates alias validation, inference, and specialization grounding into compile and check pipelines. |
| src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php | Extends marker-leak detection for the new grounded-specialization paths. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
S[XPHP source] --> P[Parse aliases and generic markers]
P --> A[Expand aliases and validate bounds]
A --> I[Infer omitted type arguments]
I --> G[Ground enclosing generic parameters]
G --> F[Fixed-point specialization discovery]
F --> E[Emit vanilla PHP]
F --> C[Validate-only check diagnostics]
Reviews (1): Last reviewed commit: "docs(changelog): cut the 0.4.0 release" | Re-trigger Greptile
| $var = $target->var; | ||
| if ($var instanceof Variable && is_string($var->name)) { | ||
| // @infection-ignore-all TrueValue -- $names is a set; membership is tested with | ||
| // isset() in scopeForFunction, so the stored value is immaterial. | ||
| $names[$var->name] = true; |
There was a problem hiding this comment.
Destructuring preserves stale inference types
When a typed parameter is overwritten through destructuring such as [$value] = [1], reassignment tracking ignores the nested variable target and inference continues using its declared incoming type, causing the compiler to select the wrong generic specialization and potentially emit PHP that fails its generated type hints at runtime.
Cuts the v0.4.0 release. Full detail in the CHANGELOG.
Added
Fixed
check/compileparity on enclosing-parameter turbofish