diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 5115e794..9aef9380 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -66,6 +66,53 @@ jobs: - name: Run PHPStan run: make lint/phpstan + phpstan-pass: + # The `xphp check` PHPStan-integration tests (@group phpstan) shell out to a + # real phpstan binary, so they're excluded from `make test/unit` and run here. + # `composer install` provides vendor/bin/phpstan (a require-dev dependency). + name: PHPStan pass (xphp check) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, json, mbstring, tokenizer + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run the PHPStan-pass tests + run: make test/phpstan-pass + + check: + # End-to-end self-test of the `xphp check` gate: runs the real bin/xphp + # binary against the check fixtures and asserts the 0/1/2 exit contract. + # The in-process CheckCommandTest can't observe the shipped binary's + # process exit code or its rendered stdout, so this covers that gap. + name: xphp check (self-test) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, json, mbstring, tokenizer + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run xphp check self-test + run: make test/check + infection: name: Mutation testing runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03d8c72c..c8b15af8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,13 @@ jobs: # release fails BEFORE the asset is uploaded. run: php dist/xphp.phar list + - name: Smoke-test `check` on the PHAR + # Exercise the released artifact as a real gate: same 0/1/2 + # exit-contract assertions as CI, but against dist/xphp.phar + # instead of bin/xphp. Fails the release before upload if the + # packaged binary can't validate sources. + run: make test/check XPHP_BIN="php dist/xphp.phar" + - name: Compute SHA256 sum # `sha256sum` outputs ` `; running it from # the dist/ directory keeps the filename relative so users diff --git a/CHANGELOG.md b/CHANGELOG.md index 63820e0a..8cff6625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,480 @@ All notable changes to `xphp` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - Unreleased + +_In progress on this branch — content still accumulating; date set at tag time._ + +### Added + +- **`Closure(...)` signature types.** A type hint such as `Closure(int $x, string $y): + bool` may appear in any parameter, return, or property position; it documents the + callable a slot expects and **erases to a bare `\Closure`** in the emitted PHP. + Where a closure literal is returned against a `Closure(...)` return type (a + typed-closure factory), xphp checks conformance — parameters contravariant, return + covariant, by-reference exact, arity compatible — and fails the build on a + **provable** mismatch (`xphp.closure_conformance`), while accepting anything it can't + prove wrong (untyped ⇒ `mixed`, an unresolved or built-in supertype, a + still-abstract type parameter, a union/intersection). A signature that references an + enclosing type parameter is **grounded** per specialization, so `Registry` and + `Registry` check the same factory against different concrete targets. A flat + union / intersection / nullable inside a signature (`Closure(int|string $x): void`, + `Closure(): A&B`, `?int`) is variance-checked member by member, following PHP's own + union/intersection subtyping; an intersection in a parameter position stays gradual + (an intersection of unrelated types is uninhabited). Grounded conformance is checked + identically by `xphp compile` and `xphp check`, so the two never disagree on a + generic factory. Unsupported positions — a signature as a generic argument or bound, + the `Name[]` combination, and defaulted or untyped signature parameters — are + rejected with a clear, closure-specific compile error rather than a miscompile; see + the [known limitations](docs/syntax/closure-types.md#known-limitations). See + [closure types](docs/syntax/closure-types.md). +- **Multi-root builds via an `xphp.json` manifest.** A project declares its source + roots, output directory, and hash length in an `xphp.json` at the project root; + `xphp compile` and `xphp check` auto-detect it (or take an explicit `--config`). + Roots are merged into a single source set — a template in one root can reference a + type in another — and include patterns accept a `**` globstar for recursive + discovery. A consuming build compiles only the `.xphp` sources whose own + `xphp.json` opts in (so `"vendor/**"` picks up the dependencies that ship a + manifest, and skips those that don't). See + [getting started](docs/getting-started.md). +- **Element-typed methods on covariant collections.** A method-level type parameter + bounded by an enclosing class type parameter — `class Box { public function + contains(U $value): bool }` — has its bound **grounded** against the receiver's + concrete type argument: `Box::contains` is accepted when `Banana <: Fruit`, + and a genuine violation (`Box::contains`) is rejected with the bound shown as + the real type, not `E`. The receiver's argument is threaded up the `extends`/`implements` + chain, so a method declared on a generic interface/base and inherited by a concrete + collection is grounded too. The receiver's type is determined from a typed parameter or + `$this->prop`, a `new`-constructed local, a value whose type comes from a method return / + chained call / `self`/`static` factory, and a branch whose arms agree on the same + parameterised type. A bound that references a sibling parameter is grounded the same way, + at the class level (`class Pair`) and the method level (``). This is the + sound, element-typed alternative to a `mixed` parameter on a covariant `` collection + (`U` is invariant — not method-level variance). **Ground or fail:** where the receiver's + type argument genuinely can't be determined, the bound can't be proven, so it is a compile + error (`xphp.bound_unprovable`) with an actionable remedy — bind the receiver to a typed + local — rather than an unchecked call. Nothing knowable is skipped, and no check is deferred + to runtime. **Lowering:** a method whose bounded parameter is used only as a direct input + (`U $value`) is emitted as one `E`-typed member per class instantiation + (`contains_(Fruit)`) rather than one per call-site type — so a self-call that *forwards* + the parameter, `probe(U $v) { return $this->contains::($v); }`, compiles and runs (the + idiomatic way to call an element-consuming method from inside the class). A `$this`-rooted + forward to a *non-erasable* method (parameter used nested, in the return, or structurally), and + a direct concrete `$this->contains::()`, remain compile errors + (`xphp.unspecializable_self_call` / `xphp.bound_unprovable`) — never a runtime fault. **Covariant + interfaces:** the method may be declared on a covariant interface (`Collection`) and called + through an upcast (`ListColl` used as `Collection`) — the implementer specialization + is scheduled and inherited down the covariant chain automatically. When inheritance can't carry it + there — the implementing class has another `extends` parent, implements only a *parent* of the + interface, or reorders the `implements` clause — the member is instead emitted **directly** onto the + upcast source, with its bounded parameter widened to the supertype argument and its body read at the + source's own element type (sound because the source's element is a subtype of the supertype). When the + element type is itself a covariant generic (e.g. a `Tuple`), per-argument covariance makes the + source an instance of the interface at *several* supertype arguments at once — a diamond that single + inheritance can carry only one path of; the remaining obligations are supplied directly once the + inheritance chain is final, so a covariant container of covariant containers upcasts soundly. The + upcast remains a compile error (`xphp.unschedulable_covariant_upcast`) — never emitted load- or + runtime-fataling code — only where no emittable class body exists (a truly abstract or trait-only + method), where the method's return type names the element parameter (the widened argument would + escape through a narrower return), or where its parameters are bounded by different enclosing + parameters (no single member can be derived). See [type bounds](docs/syntax/type-bounds.md) and + [ADR-0018](docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md). +- **Generic methods resolved through inheritance.** A generic method declared on a + base or abstract class is now callable by turbofish on a *subclass* receiver — + instance (`$child->m::()`), static (`Child::m::()`), and nullsafe + (`$child?->m::()`). It is specialized once on its declaring class and + inherited, so the call dispatches to the real member. An **unresolved** turbofish — + a generic method that exists on neither the receiver nor any ancestor — is now a + compile error (`xphp.unresolved_generic_call`) instead of an emitted call to a + stripped method that fatals at runtime. +- **`xphp check`** — a validate-without-emitting CI gate. It runs every generic + validation `xphp compile` does (bounds, variance, defaults, missing/duplicate + generics, unsupported closures), but collects **all** problems in one run — + each as a structured diagnostic with a `file:line` — instead of aborting on the + first. Exit codes: `0` clean, `1` ≥1 error, `2` operational failure. + `--format=text|json|github` (the `github` format emits PR annotations). +- **PHPStan over the compiled output.** When the generic checks pass, `xphp check` + compiles to a throwaway directory, runs **your** PHPStan over the concrete + (monomorphized) output, and maps each finding back to the originating `.xphp` + template declaration — naming the concrete instantiation that surfaced it. One + config (your `phpstan.neon` drives level/rules), one gate, one exit code. + `phpstan/phpstan` stays optional and is never bundled in the PHAR; a missing + binary or a failed run is a non-failing Warning. Opt out with `--no-phpstan`; + override discovery with `--phpstan-bin` / `--phpstan-config`. +- **Type-parameter-typed constructors on variant classes.** A covariant / + contravariant class may take its type parameter in a (non-promoted) constructor + parameter — e.g. a covariant immutable `ImmutableList` built from + `T ...$items`. The parameter keeps its **real** element type on every + specialisation (`Book ...$items`, not `mixed`), so construction is + **runtime-type-checked** while `ImmutableList` still extends + `ImmutableList` — PHP exempts `__construct` from LSP, so the + specialisations' constructors may differ across the edge. A public/protected + promoted constructor param remains a visible property (strictly invariant — PHP + enforces property types across the edge for visible members), but a *private* + one is exempt (see below). See [variance](docs/syntax/variance.md). +- **Variance markers on private properties.** A `out T` / `in T` marker is now allowed + on a **private** property — declared or promoted, mutable or readonly — so the + natural covariant shape + `class Producer { public function __construct(private T $item) {} ... }` + compiles, keeps its **real** substituted slot type (nothing erased), and stays + runtime-type-checked. PHP does not type-check private property types across an + `extends` chain (a private slot is per-declaring-scope and never inherited) and + a private member is invisible to the variance surface, so it carries any variance + soundly. Public/protected properties (including public/protected promoted params, + and an externally-readable `public private(set)` property) stay strictly + invariant. A covariant single-value getter over a `private T` field is also + PHPStan-clean. See [variance](docs/syntax/variance.md). +- **By-reference parameters are an invariant variance position.** A `out T` / `in T` + type parameter used in a by-reference parameter (`&$x`) is now rejected: a + by-reference slot is both read and written through the caller's binding, so it is + invariant — the same rule already applied to a mutable property. See + [variance](docs/syntax/variance.md). +- **Variance composes through a nested generic type-argument.** A covariant slot whose + argument is itself a generic of a *different but related* template now emits its + `extends`/`implements` edge — so a covariant `Tuple` holding a covariant + container relates by that container's element type (`Tuple, Tag>` + is usable where a `Tuple, Tag>` is required, because + `ImmutableList ⊑ Collection`). The argument relationship is proven by + threading the subtype's element up its `implements`/`extends` chain to the supertype's + template and comparing under the inner template's variance; the edge is emitted only + when positively provable, so the covariance now holds at runtime (`instanceof`, type + hints) and not only at `check`. Previously such an upcast passed `check` but fatal'd at + load. See [variance](docs/syntax/variance.md). +- **A contravariant generic may be consumed by a covariant class.** A method parameter typed + by a contravariant generic of the class's covariant parameter — `class Box { pick( + Comparator $c): ?E }` where `Comparator` — is now accepted: `E` sits in a + contravariant slot inside a contravariant parameter position, which composes to a covariant + position a `out E` may occupy (sound under upcast — a `Comparator` compares the `Book` + elements of a `Box` viewed as `Box`). Variance validation now routes every + type-constructor-nested type-parameter through the composing check (which already knew the + inner slot's variance) instead of judging it by the bare outer position, so this sound, + `mixed`-free `sortedWith`/`minWith`/`pick` shape compiles instead of being wrongly rejected. + A bare `E` in a parameter position is still rejected — only the composed position is + covariant. See [variance](docs/syntax/variance.md). + +### Changed + +- **BREAKING — variance markers are now `out T` / `in T`.** Declaration-site variance + is written with the Kotlin-style keywords `out` (covariant) and `in` (contravariant) + instead of the previous `+T` / `-T`: `class Producer`, `class Consumer`. + The keywords are contextual — a leading `out`/`in` is a marker only when immediately + followed by the parameter name (`out T`, never `outT`), so ordinary parameters whose + names merely start with those letters are unaffected; the words are reserved and + cannot themselves name a parameter. Old `+T` / `-T` source now fails with a migration + error pointing at the new spelling. Variance semantics are unchanged — only the + surface syntax moves. See [Variance](docs/syntax/variance.md). +- **`xphp compile` runs the validation gate by default.** Compile now runs the same + gate as `xphp check` (the generic validators plus PHPStan over the compiled output) + *before* emitting, and fails the build — emitting nothing — when the gate reports an + error. So a type argument that names no real class (`new Box::()`) and + every other PHPStan-detectable error now fails at compile time instead of slipping + through to runtime. PHPStan's real autoloader visibility makes this sound — a genuine + plain-`.php` domain class used as a type argument is never false-rejected. For a fast + iteration build, `--no-check` skips the gate and compiles directly (the previous + behavior). `--no-phpstan` runs only the generic validators; a missing PHPStan degrades + to a non-failing warning. + See [ADR-0021](docs/adr/0021-compile-runs-the-check-gate-by-default.md). +- **BREAKING — a `final` variant class is now rejected.** A `final class Box` + previously compiled, with the generated specialization silently dropping `final` + so the `extends` subtype edge between specializations could land — which made + `ReflectionClass::isFinal()` disagree with the written source. A covariant / + contravariant class marked `final` is now a compile error (a `final` class can't + anchor the `extends` edge); omit `final` on a variant template. Non-variant + generic classes are unaffected. See [variance](docs/syntax/variance.md). + +### Fixed + +- **A turbofish on a dynamically-named call is rejected instead of silently dropped.** + A type-argument turbofish on a method or static call whose name is a runtime value — + `$o->$m::()`, the nullsafe `$o?->$m::()`, the variable-variable + `$$g::()`, or the static `Foo::$m::()` — used to be silently discarded (the + marker bound no AST node and the `::<…>` clause was stripped anyway), leaving a bare + dynamic call against a method that only exists in its specialized `_T_` form: a + runtime fatal behind a clean `compile` and `check`. Such a call cannot be + monomorphized — the method name is not known until runtime — so it now draws a clear + diagnostic at its real line (collected by `check`, thrown by `compile`). A turbofish on + a standalone variable holding a generic closure (`$f::()`) is unaffected. +- **A generic method may be named with a PHP keyword.** PHP permits every keyword + (`list`, `print`, …) as a method name, but a generic one could not be used: the + declaration `public function list(…)` reached php-parser as a raw parse error, and + the static call `Foo::list::()` did too (the instance call `$o->list::()` + happened to work, because PHP re-tokenizes the name after `->`). Keyword-named generic + methods now declare, specialize, and are callable through both the instance and static + turbofish. Keyword-named non-generic methods and `list(...)` destructuring are + unchanged. +- **A specialized generic body keeps calling the free functions and constants it + named.** When a generic class specializes, its body is relocated into an internal + `XPHP\Generated\…` namespace. An unqualified free-function call or constant read in + that body — `helper($x)`, `FACTOR`, whether resolved through the enclosing namespace + or a `use function` / `use const` import (single **or** grouped, e.g. + `use function Lib\{make, scale}`) — used to rebind against the generated namespace + (then PHP's global fallback), silently calling the wrong symbol or fatalling at + runtime with `Call to undefined function XPHP\Generated\…\helper()` behind a clean + `compile` and `check`. Each such reference the compilation unit can resolve is now + fully-qualified to the symbol the template meant; built-in functions, magic constants, + and any name the unit does not define keep PHP's normal global resolution (functions + match case-insensitively, constants case-sensitively). The re-qualification also + covers members supplied by the covariant-upcast gap-fill, which are appended after the + main relocation pass. +- **A specialized generic body keeps resolving the classes it group-imported.** The + relocation into `XPHP\Generated\…` also re-qualifies class references against the + unit's imports, but a class brought in through a grouped import — `use Vendor\{Tool};`, + or a mixed `use Vendor\{Tool, function make};` — was not recorded in the class + import map (only single `use Vendor\Tool;` imports were), so a reference to it in the + relocated body fell back to the generated namespace (`\App\Tool`) and fatalled at class + load with `Class "App\Tool" not found` behind a clean `compile` and `check`. A + group-imported class now re-qualifies to its import target exactly like its + single-import form; `use function` / `use const` group members keep resolving through + their own symbol namespaces. +- **A generic clause on a `use` import is rejected instead of misfiring.** Writing + a type argument on an import — `use App\Box;`, `use App\Box as B;`, + `use const App\BOX;`, or the grouped `use App\{Box, Bag};` — has no + meaning (imports name a symbol; they don't instantiate one). It used to either + blame a phantom double-qualified template (`Main\App\Box`) with no line, or — for + a grouped import whose name collided with a real template — **silently emit + unparseable PHP** (an absolute specialized name inside a `use N\{…}` prefix group) + that passed both `check` and `compile` and only failed when the file was loaded. + Every form now draws one clear diagnostic naming the real symbol at the import's + line, collected by `check` and thrown by `compile`. Import the template plainly + (`use App\Box;`) and apply the type arguments at the use site. A generic + **trait-use** (`class C { use Holder; }`) is unaffected — it still specializes + the trait. +- **A generic trait used with an `insteadof` / `as` adaptation loads instead of + fataling.** A generic trait combined with an adaptation block — `use A, B + { A::m insteadof B; B::m as bm; }` — specialized the `use`-**list** names to their + `\XPHP\Generated\…` form but left the `insteadof` / `as` **operand** names bare, so + they resolved to the now-removed template (`App\A`) and fataled at class load + (`Trait "App\A" not found`) behind a clean `compile` and `check`. Each adaptation + operand that names a generic trait the class uses is now rewritten to the **same** + specialization as its list entry, including operands that reference a trait brought + in by a different `use` statement of the same class. A non-generic trait operand (or + one the class does not use generically) stays untouched — it is a real trait. A bare + operand that matches two different specializations of one trait (`use A, + A { A::m insteadof B; }`) cannot be disambiguated in an adaptation clause and + now draws a clear diagnostic instead of silently picking one. +- **`check` reports parse-stage rejections at their real source line.** Syntax + the scanner rejects before the AST exists — a variance marker on a method or + closure (`out T` / `in T`), the legacy `+T` / `-T` glyphs, a malformed or + misordered generic default (`T = ?int`, `T = Foo | Bar`, a required parameter + after a defaulted one), or a call signature on a non-`Closure` name — was + reported by `check` at line 1 regardless of where it occurred, so an editor + could not navigate to it (the real location survived only in the message text). + These now carry the offending token's line. A few structural rejections raised + after parsing (a self-referential bound, a default referencing a later + parameter) still fall back to line 1, where no token position is available. + `compile` behaviour is unchanged. +- **A bare `new` of a generic without all-defaults is rejected instead of + silently emitting an uninstantiable marker.** `new Box(...)` where `Box` + has a required type parameter and no turbofish was skipped by the + instantiation collector, leaving the call site pointing at the stripped marker + `interface Box {}` — so the emitted code fatalled with "Cannot instantiate + interface" behind a clean compile and a clean `check`. It now fails with + `xphp.missing_type_argument` (throwing in `compile`, collected in `check`), + exactly as a turbofish-less generic call does. All-defaults generics still + instantiate from a bare `new`, and a bare `new B` still works when a plain + `class B` coexists with a generic `class B` (conditional same-name + declarations resolve to the plain class at runtime). +- **A first-class callable of a turbofish specialization emits valid PHP.** + `$g = $f::(...)` on a generic closure emitted `$f('T_…', ...)` — the + specialization tag prepended beside the `...` placeholder, which does not + parse, so the output failed to load. It now emits a forwarding closure that + routes through the dispatcher, preserving callable semantics (positional, + variadic, and named arguments and the closure's captures); empty-turbofish + all-defaults FCCs (`$f::<>(...)`) work the same way. +- **Parenthesised DNF types inside `Closure(...)` signatures are supported.** A + DNF group (`(A&B)|C`, `A|(B&C)`) anywhere in a signature previously broke the + type scanner: a leading group in a return position failed to compile, a + trailing group in a return hint silently mis-erased (emitting a wrong type and + a truncated recorded return), and a group in a parameter threw the signature's + arity off — wrongly rejecting a conforming factory literal. A DNF group now + scans as one **gradual** leaf: the signature erases correctly, arity and the + slots around the group are checked as usual, and the group itself is accepted + rather than variance-checked member by member. +- **Array-sugar types inside `Closure(...)` signatures are supported.** A + sugared leaf (`Closure(Item[] $items): int`, `Closure(): int[]`) previously + broke the signature scanner: in a return position the bracket pair survived + erasure as a raw parse error, and in a parameter position it mis-parsed as + three parameters — wrongly rejecting a conforming factory literal on arity. + The sugar now lowers to `array` inside signatures, the same lowering it gets + everywhere else, and is checked as a gradual `array` leaf. +- **Provable violations against built-in target types are now caught.** A factory + whose literal returns a class with fully-known, built-in-free ancestry checked + against a built-in target (`Closure(): \Throwable` returning a plain user + class) was silently accepted — the guard treated EVERY built-in target as + unprovable. When the candidate's whole ancestry is declared user code, the + non-relation is provable and now fails the build. Everything genuinely + satisfiable at runtime keeps compiling: subclasses of built-ins + (`extends \Exception` vs `\Throwable`), enums against interfaces they + implement (enum `implements` clauses and the implicit `UnitEnum`/`BackedEnum` + edges are now modeled — which also lets `T : UnitEnum` bounds accept enum + arguments), `__toString` classes against `\Stringable`, and candidates with + any unknown ancestor. +- **Expression-position `Closure(...)` calls are never mistaken for return types.** + The return-slot detector keyed on a bare `) :` pair — which ternaries, + `case expr():` labels, and alt-syntax blocks (`if/elseif/while/for/foreach/ + declare (…):`) also produce. A call to a user function named `Closure` in + those positions was silently rewritten into a `\Closure` constant fetch, and a + call to ANY function there (`$a ? b() : g(FOO);`) failed the compile with the + only-Closure error. The detector now requires an actual `function`/`fn` + declaration header (including by-ref, `use (…)` clauses, and tight spellings) + and ignores member calls of the semi-reserved names (`C::fn()`). +- **Relative `namespace\Foo` types resolve correctly everywhere names are read + from tokens.** A relative reference bound to the wrong name (`App\namespace\Foo`, + or a colliding `use` alias) wherever the resolver worked on raw token text — + most visibly a `Closure(): namespace\D` target type, which silently skipped + conformance checking. Relative names now bind to the current namespace, exactly + as PHP does, for signature targets, bounds, defaults, and generic arguments + alike; only the exact `namespace\` keyword segment is affected. +- **Fully-qualified types in closure literals participate in conformance.** A + factory literal spelling its type fully qualified (`fn(): \App\Fruit`) had the + leading `\` dropped during extraction, mis-resolving the name relative to the + current namespace — an undeclared class, so the check silently went gradual and + provable violations were missed. FQ names now resolve absolutely (relative and + imported names are unchanged). +- **Generic closures after array-sugar rewrites specialize correctly.** The + `Name[]` → `array` rewrite shortens the source, and the byte-keyed marker that + attaches type parameters to an anonymous `function` / `fn` was compared + against the shifted position — so a generic closure appearing after such a + rewrite silently lost its type parameters, compiled unspecialized, and the + emitted code fataled at runtime despite a clean validation pass. Marker + positions are now translated through the byte-offset map before matching. +- **Undeclared type parameters are now rejected** instead of silently compiling to + a reference to a non-existent class. A bare, single-segment, non-imported type + name used in a generic member, bound, or default that is neither a declared type + parameter nor a known type — e.g. `interface Foo { add(T $x); }` or + `class Box` — fails `xphp compile` and is reported by `xphp check` + as `xphp.undeclared_type`. Covers class/interface/trait members and method, + function, closure, and arrow generics. Imported (`use`) and fully-qualified names + are unaffected. +- **Too many type arguments are now rejected** instead of silently truncated: + `Box::` for a one-parameter `Box` reports `xphp.too_many_type_arguments`. +- **A turbofish call on an undeterminable receiver is now rejected** instead of + silently emitting a runtime fatal. A generic method call like `$x->m::()` is + specialized at compile time and the generic method is stripped from its class, so + when the receiver's type can't be determined — an untyped `foreach` variable, a + local whose type is ambiguous after a branch — the call previously compiled to + `$x->m(...)`, a call to a method that no longer exists (an "undefined method" fatal + at runtime). It now fails `xphp compile` and is reported by `xphp check` as + `xphp.undetermined_receiver`, with the fix: give the receiver a statically-known + type. Ground or fail — the compiler never emits a call it knows will fatal. +- **A class bound that references a sibling type parameter is now checked** + correctly. A bound such as `class Pair` grounds `T` against the + supplied argument instead of treating `T` as a phantom class — which previously + rejected valid code with a misleading "does not extend/implement T". +- **A scalar bound is no longer flagged as an undeclared type.** A bound naming a + scalar (`int`, `string`, …) is no longer reported as `xphp.undeclared_type`. +- **A turbofish-less call to a generic method, function, or closure is now rejected** + instead of silently skipped. A method generic takes no inference, so a forgotten + turbofish (`$x->pick('a')` instead of `$x->pick::('a')`) previously emitted a + call to the stripped mangled member and fataled at runtime — clean `check` and + `compile`. It now fails `xphp compile` and is collected by `xphp check` as + `xphp.missing_type_argument`, across instance, static, free-function, and closure + calls. A generic whose type parameters are all defaulted still resolves; a + first-class callable (`pick(...)`) and a non-generic call are unaffected. +- **A scalar type argument now satisfies a scalar generic bound.** A bound naming a + scalar or scalar union — `class Box` — previously namespace-qualified + its operands (`App\int | App\string`), so *every* valid scalar argument was rejected + with `"string" does not satisfy "App\int | App\string"`. The bound leaf was the one + type position that skipped the scalar-aware name resolution every other position uses. + Its reserved scalar/builtin keywords are now recognised and left unqualified, so + `Box::` satisfies `` and `` accepts `::`, + while a class argument is still rejected (`"App\Thing" does not satisfy "int | string"`) + and a class bound that merely *looks* like a legacy alias (``, where `Double` + is a real class — not a reserved keyword) keeps resolving to the class. +- **A class whose name aliases a scalar (`Integer`/`Boolean`/`Double`) now resolves to the + class in every type position.** The internal keyword list conflated the reserved PHP scalar + keywords with the legacy gettype-style aliases `integer`/`boolean`/`double`, which are + *legal class names*. So a class `Double` used as a generic type argument (`new + Box::(...)`) or as a member/signature type was mistaken for a scalar and emitted + as the bare type `double`, which PHP reads as a non-existent class — a `TypeError` at + runtime. The list now holds only the reserved keywords, so such a class resolves to + `\App\Double` in argument and signature positions (and, as before, in a bound). Genuine + scalars (`int`, `string`, `bool`, `float`, and case variants like `Int`) are unchanged; + an undeclared `Double` member is now reported as `xphp.undeclared_type` instead of being + silently absorbed as a scalar. +- **Generic declarations keep their type parameters regardless of header layout.** + Four marker-alignment defects silently de-generified declarations — clean compile, + clean `check`, raw `T` hints in the emitted code, `TypeError` at runtime: any + **multi-line** generic clause, turbofish argument list, or `T[]` sugar span + collapsed its newlines when stripped, shifting every later declaration off its + marker (`class Wide<\n T\n>` before `class Box` left `Box` raw); `static + fn(...)` anchored its marker at `fn` while the node starts at `static`; an + attribute before a generic closure (`#[A] function`, `#[A] static fn`) + moved the node start to `#[`; and an attribute or modifier on its own line before + a **named** generic declaration (`#[Override]` above `public function wrap`, + `final` above `class Pair`) lost the marker too — or false-rejected the class + as "instantiated but never defined". Stripping now preserves newlines + byte-for-byte (multibyte- and CRLF-safe; the `T[]` lowering re-appends its span's + newlines), anonymous markers anchor at the node's true start (walking back over + attribute groups and `static`), and named markers match on the declaration + name's line. A `static fn` now simply **specializes** like any arrow — it can + never bind `$this`; the rewritten dispatcher closure is technically non-static, + observable only via `Closure::bind`/reflection — while `static function` + keeps its loud not-yet-supported error, which now also fires when an attribute + precedes it. +- **A generic clause that fails to bind to its declaration is now a loud compile + error.** Every defect in the family above was silent for the same structural + reason: an unbound marker simply evaporated and the compiler carried on. The + strict compile path now reports it (as a transpiler bug to report), instead of + emitting silently de-generified code; the tolerant LSP path is exempt — + half-typed editor buffers legitimately strand markers. A `use function b;` + typo gets PHP's own syntax error on the `<` rather than being swallowed. +- **Fully-qualified and `namespace\`-relative spellings work at every generic + site.** Names were resolved from prefix-erased strings, losing the author's + qualification: `new \App\Box::` hard-failed as the undefined, doubled + `App\App\Box` — and an FQ generic **function** call (`\App\make::(...)`) + silently lost its dispatcher, an undefined-function fatal at runtime; + `new \App\Box("hi")` on an all-defaults generic silently emitted the stripped + marker interface plus the kept `new` — "Cannot instantiate interface" at + runtime; `new namespace\Box::` never bound its marker and silently emitted + the same fatal shape; on one line, a relative generic return type could steal + the body turbofish's marker, specializing the wrong site; `class Gen extends + namespace\Base` with a colliding `use Other\Base` in scope emitted the generated + class extending `\Other\Base` — a `use` alias never applies to a relative name — + and the same capture hit every marked type position, generic-method receiver + typing, and the conformance hierarchy (where a wrong parent edge could + false-reject a valid factory); and `namespace\Thing` colliding with a type + parameter was substituted like one, emitting `int $x` where the author wrote an + explicit class reference. Markers now record the raw source spelling + (case-folding only the `namespace` keyword), every resolver honors the node's + own qualification, fully-qualified call sites rewrite to their specializations, + and relative names bind to the current namespace — exactly as PHP does. +- **Generic markers bind byte-exact — two same-spelling sites on one line can no + longer steal each other's markers.** Marker matching was line-keyed + (first-traversed-wins), so `f(Box $a, Box $b)` emitted the specialization + on the **wrong parameter**; a plain return hint could steal a + `new Box::(...)` marker, leaving a raw `new` against the marker interface; + one-line same-name conditional classes handed the generic clause to the plain + class — rewriting it into an uninstantiable marker interface; and + `Plain::pick(5) + Util::pick::(4)` on one line **false-rejected both + calls** (the plain call's line-range claimed the generic call's marker). Every + marker now matches on the byte of the token it anchors at, translated through + the byte-offset map — exact across multi-line member chains, length-changing + `T[]` rewrites, interpolated-string turbofish, and multibyte identifiers. +- **A generic closure that nothing specializes is now rejected** + (`xphp.unspecialized_generic_closure`) instead of silently emitting raw + type-parameter hints. Specialization is call-site-driven, so a generic + closure/arrow with no in-scope grounding `$var::<...>(...)` call kept hints + naming the non-existent class `App\T` in the emitted output — a `TypeError` on + first invocation behind a clean compile and a clean `check`, including when the + value was only handed away as a callable (`array_map($f, ...)`, a returned + factory) — which cannot ground it. Both modes reject every such shape: + assigned-but-uncalled, return-position, argument-position, `use (...)`- + capturing, defaulted, conditional arms, and — uniformly — a clause whose + parameters are never referenced (dead syntax; delete it). A template whose + call sites drew their own rejection (static closure, `$this` capture, missing + turbofish) is not double-reported, and a turbofish with still-abstract type + arguments counts as a real call. + +## [0.2.1] - 2026-06-17 + +### Fixed + +- The Composer autoloader is located when xphp is installed as a project + dependency, not only when run from its own checkout. +- Bare imported (`use`) names are fully-qualified in specialized classes, so a + generated specialization in a mirrored namespace resolves them correctly. + ## [0.2.0] The first feature release on top of the core monomorphization pipeline. @@ -77,8 +551,9 @@ for a hands-on look at every feature below. These are documented in full in the [caveats](docs/caveats.md): -- Variance markers (`+T` / `-T`) are class-level only — not yet on - methods, free functions, closures, or arrows. +- Variance markers (`+T` / `-T`) are class-level only **by design** — not + supported on methods, free functions, closures, or arrows (a function or + closure specialization has no stable class identity for a subtype edge). - Generic closures and arrows that capture `$this`, and `static` closures, are rejected at the call site. - Reflection and closure serializers see the dispatcher shape, not the @@ -101,6 +576,7 @@ These are documented in full in the [caveats](docs/caveats.md): - Build-time hash-collision detection and a configurable `XPHP_HASH_LENGTH` (16–64). -[Unreleased]: https://github.com/xphp-lang/xphp/compare/v0.2.0...HEAD +[0.3.0]: https://github.com/xphp-lang/xphp/compare/v0.2.1...HEAD +[0.2.1]: https://github.com/xphp-lang/xphp/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/xphp-lang/xphp/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/xphp-lang/xphp/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84b97573..e5ced8be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,9 +3,10 @@ ## Test ```bash -make test/unit # PHPUnit on the default PHP 8.4 runtime -make test/unit/php85 # only tests tagged `@group php85`; needs a PHP 8.5 runtime -make test/mutation # Infection, MSI under a 95 % gate +make test/unit # PHPUnit on the default PHP 8.4 runtime +make test/unit/php85 # only tests tagged `@group php85`; needs a PHP 8.5 runtime +make test/phpstan-pass # only tests tagged `@group phpstan` (the `xphp check` PHPStan pass) +make test/mutation # Infection, MSI under a 95 % gate ``` The supported runtime is PHP `^8.4`. Tests that exercise newer-PHP syntax @@ -13,6 +14,13 @@ The supported runtime is PHP `^8.4`. Tests that exercise newer-PHP syntax excludes them and they self-skip via `#[RequiresPhp]` off an 8.5 host. CI runs them in a dedicated PHP 8.5 job. +The `xphp check` PHPStan-pass tests are tagged `@group phpstan` — they shell out +to a real phpstan subprocess (slow), so `make test/unit` excludes them and they +self-skip when `vendor/bin/phpstan` is absent (the same self-skip idea as +`@group php85`). CI runs them in a dedicated job; locally use `make +test/phpstan-pass`. (Not to be confused with `make lint/phpstan`, which runs +PHPStan over `src/`.) + No PHP 8.5 locally? Run them in the bundled 8.5 container: ```bash @@ -21,4 +29,11 @@ docker compose run --rm php85 make test/unit/php85 CI gates every PR on these targets. `infection.json5` carries a curated set of per-mutator `ignore` rules for genuinely-equivalent / defensive -mutations so the report only surfaces real test gaps. \ No newline at end of file +mutations so the report only surfaces real test gaps. + +## Architecture decisions + +Architecturally significant decisions — the ones that are expensive to reverse — +are recorded as [Architecture Decision Records](docs/adr/README.md). When you make +such a decision, add a new ADR under `docs/adr/` (copy +[`docs/adr/0000-adr-template.md`](docs/adr/0000-adr-template.md)). \ No newline at end of file diff --git a/Makefile b/Makefile index 3288b027..73a969f5 100644 --- a/Makefile +++ b/Makefile @@ -7,11 +7,19 @@ # targets here. .PHONY: test/unit -# Default runtime is PHP 8.4 (composer requires ^8.4). Tests exercising -# newer-PHP syntax are tagged `@group php85` and excluded here; they run -# on an 8.5 runtime via `make test/unit/php85`. +# Default runtime is PHP 8.4 (composer requires ^8.4). Two groups are excluded +# here: `php85` (newer-PHP syntax; runs on 8.5 via `make test/unit/php85`) and +# `phpstan` (the `xphp check` PHPStan pass, which shells out to a real phpstan +# subprocess and is slow; runs via `make test/phpstan-pass`). test/unit: - php vendor/bin/phpunit --exclude-group php85 + php vendor/bin/phpunit --exclude-group php85 --exclude-group phpstan + +.PHONY: test/phpstan-pass +# The `xphp check` PHPStan-integration tests (tagged `@group phpstan`). They +# shell out to the consumer's phpstan binary and self-skip when vendor/bin/phpstan +# is absent. NOTE: distinct from `lint/phpstan`, which runs PHPStan over src/. +test/phpstan-pass: + php vendor/bin/phpunit --group phpstan .PHONY: test/unit/php85 # Runs only the PHP 8.5-specific syntax tests (e.g. the pipe operator). @@ -35,6 +43,15 @@ lint/phpstan: test/mutation: php -d memory_limit=-1 vendor/bin/infection --show-mutations=max --threads=max --min-covered-msi=95 +.PHONY: test/check +# End-to-end self-test of the `check` gate: runs the real bin/xphp binary +# against the check fixtures and asserts the 0/1/2 exit contract plus that the +# text/json/github renderers all emit. Reused by release.yml against the built +# PHAR (override XPHP_BIN="php dist/xphp.phar"). Complements the in-process +# CheckCommandTest, which can't observe the shipped binary's process exit code. +test/check: + sh test/smoke/check.sh + # Humbug Box is the standard tool for compiling a Composer-managed # PHP project into a single self-contained PHAR. Pinned to a known- # good release (Box 4.6.6 supports PHP 8.4) so a new Box version diff --git a/README.md b/README.md index 1d4ee905..e778b135 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,47 @@ compile. `dist/` holds your rewritten code; `.xphp-cache/Generated/` holds the specialized classes. Both can be gitignored and rebuilt in CI. +For a real project — and **required** once you consume another package's +generics — drop an `xphp.json` manifest at the project root instead of +repeating the paths on every invocation: + +```json +{ + "sources": ["src"], + "include": ["vendor/**"], + "target": "dist", + "cache": ".xphp-cache" +} +``` + +Then `compile`/`check` take no positional source — they resolve the +manifest (auto-detected in the working directory, or via `--config`): + +```bash +vendor/bin/xphp compile # compiles this package + every included one +vendor/bin/xphp check +``` + +`include` globs auto-discover installed xphp packages (`vendor/**` finds +every one, at any depth, with no edit when you add another), so the +downstream build compiles the whole union into its own output. See +[Getting started](docs/getting-started.md#the-recommended-project-setup-an-xphpjson-manifest) +for the distribution model. The single-directory form above keeps working +unchanged. + +To validate generics without emitting anything — a CI gate that reports +every bound/variance/etc. problem with a `file:line`: + +```bash +vendor/bin/xphp check src # exit 1 if any error; --format=text|json|github +``` + +`check` runs all of xphp's generic validation (the specialization-loop guards +aside) and then, when those pass, runs **your** PHPStan over the compiled output +and maps the findings back to the `.xphp` template — one config, one gate (pass +`--no-phpstan` to skip it). You still run `compile` to emit the PHP. See +[Errors and diagnostics](docs/errors.md#xphp-check--validate-without-emitting). + ## See also - [Getting started](docs/getting-started.md) -- full walkthrough including PSR-4 details, runtime semantics, and what the generated PHP looks like diff --git a/composer.json b/composer.json index 255bce2a..553e08ae 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ "require": { "php": "^8.4.0", "nikic/php-parser": "^5.7", - "symfony/console": "^8.0" + "symfony/console": "^8.0", + "symfony/process": "^8.0" }, "require-dev": { "phpunit/phpunit": "^13.0", diff --git a/composer.lock b/composer.lock index f57c2638..53e38d9b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cb46e25ce55ce78ec96df2693e53d69a", + "content-hash": "540a07ad52596c518e7921c95c6f9d60", "packages": [ { "name": "nikic/php-parser", @@ -613,6 +613,71 @@ ], "time": "2026-04-10T17:25:58+00:00" }, + { + "name": "symfony/process", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.7.0", @@ -4076,71 +4141,6 @@ ], "time": "2026-04-26T13:10:57+00:00" }, - { - "name": "symfony/process", - "version": "v8.0.11", - "source": { - "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", - "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Process\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Executes commands in sub-processes", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/process/tree/v8.0.11" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-05-11T16:56:32+00:00" - }, { "name": "thecodingmachine/safe", "version": "v3.4.0", diff --git a/docs/adr/0000-adr-template.md b/docs/adr/0000-adr-template.md new file mode 100644 index 00000000..e1baee16 --- /dev/null +++ b/docs/adr/0000-adr-template.md @@ -0,0 +1,50 @@ +# NNNN. Short decision title + +- Status: Proposed | Accepted — YYYY-MM | Superseded by ADR-NNNN + +## Context and Problem Statement + +What forces are at play? What problem is being solved? One or two short +paragraphs. State it neutrally enough that the chosen option isn't a foregone +conclusion. + +## Decision Drivers + +- A property the solution must have, or a goal it serves. +- … + +## Considered Options + +- Option A +- Option B +- … + +## Decision Outcome + +Chosen: "Option A", because . + +### Consequences + +- Good, because <…>. +- Trade-off / bad, because <…>. + +### Confirmation + +How the decision is enforced or verified in the codebase (a test, a CI gate, a +fixture, an invariant). + +## Pros and Cons of the Options + +### Option A + +- Good: <…> +- Bad: <…> + +### Option B + +- Good: <…> +- Bad: <…> + +## More Information + +Links to the relevant source, docs, or external references (e.g. a PHP RFC). diff --git a/docs/adr/0001-monomorphization-over-type-erasure.md b/docs/adr/0001-monomorphization-over-type-erasure.md new file mode 100644 index 00000000..5c43a2be --- /dev/null +++ b/docs/adr/0001-monomorphization-over-type-erasure.md @@ -0,0 +1,96 @@ +# 1. Monomorphization over type erasure + +- Status: Accepted — 2026-05 + +## Context and Problem Statement + +PHP has no generics. xphp adds them as a source-level language feature, which +forces a foundational question: what should a generic *become* at runtime? Two +families of answer dominate the prior art. **Erasure** (Java, the PHP +bound-erased-generics RFC) keeps generic identity at compile time only — at +runtime a `Collection` is just a `Collection`, and the type argument is +gone. **Monomorphization** (C++ templates, Rust) stamps out a distinct concrete +type per instantiation — `Collection` becomes a real `Collection_User` +class with `User` baked into every signature. + +The choice is effectively irreversible: it defines what the generated code is, +what reflection sees, whether `instanceof T` can work, and how runtime type +errors surface. Everything else in xphp is built on top of it. + +## Decision Drivers + +- **Zero runtime penalty** — the result should run exactly like hand-written PHP, + with no dispatcher, reflection, or custom runtime in the hot path. +- **Runtime safety that doesn't lie** — passing the wrong type should raise a real + PHP `TypeError` at the boundary, not silently degrade to a bag of `mixed`. +- **Progressive enhancement** — output must be ordinary PHP a team can drop into + an existing project with no new runtime dependency. +- **Reflection fidelity** — tooling, serializers, and DI containers should see the + concrete type. + +## Considered Options + +- **Monomorphization** — one specialized class per instantiation. +- **Type erasure** — generic identity at compile time only; one runtime class. +- **Hybrid** — erase the class, carry type identity in a metadata sidecar. + +## Decision Outcome + +Chosen: **monomorphization**, because it is the only option that delivers all +four drivers at once. `class Box` plus `new Box::()` compiles to a +concrete `Box`-specialization whose members are typed with `Plastic`; PHP's own +type system then enforces them, reflection reports them, and `opcache` optimizes +the result like any other class. + +### Consequences + +- Good: the runtime sees only vanilla PHP — no dependency, no overhead, native + `TypeError` enforcement, accurate reflection. +- Good: reified types fall out for free — `new T(...)`, `T::class`, and + `instanceof` against a concrete arg work because `T` literally *is* the concrete + class in the generated code. +- Trade-off: each distinct instantiation emits a separate class file, so the + compiled output grows with the number of instantiations. +- Trade-off: compilation does real work (a fixed-point specialization loop), which + must be bounded against pathological inputs — see + [ADR-0006](0006-bounded-specialization-depth-cap.md). +- Trade-off: runtime semantics diverge from the erasure-based PHP RFC. The surface + syntax is kept compatible (see [ADR-0003](0003-rfc-aligned-turbofish-syntax.md)), + but the runtime models differ by design. + +### Confirmation + +The pipeline that performs specialization lives in +[`src/Transpiler/Monomorphize/`](../../src/Transpiler/Monomorphize/Compiler.php); +end-to-end fixtures compile real `.xphp` and snapshot the emitted PHP. See +[How it works](../guides/how-it-works.md) and +[Runtime semantics](../guides/runtime-semantics.md). + +## Pros and Cons of the Options + +### Monomorphization + +- Good: zero-overhead, reified, reflection-accurate, no runtime dependency. +- Bad: larger output; non-trivial compile-time work; runtime diverges from the RFC. + +### Type erasure + +- Good: tiny output, trivial compile, closest to a future native-PHP runtime. +- Bad: `instanceof T` / `T::class` impossible; reflection sees a raw `Collection`; + composition mistakes surface late. + +### Hybrid (metadata sidecar) + +- Good: smaller output than full monomorphization while keeping some identity. +- Bad: two models to keep in sync; a metadata registry to persist and invalidate; + runtime/metadata gaps to bridge. + +## More Information + +- [PHP RFC: bound-erased generic types](https://wiki.php.net/rfc/bound_erased_generic_types) + — the erasure model xphp deliberately diverges from at runtime. +- An earlier prototype implemented generics via runtime attributes and reflection; + it was fully superseded by the monomorphization compiler, which also let the + project drop its reflection-library dependencies. +- [ADR-0002](0002-build-time-transpiler.md) — the build-time transpiler model that + makes this practical. diff --git a/docs/adr/0002-build-time-transpiler.md b/docs/adr/0002-build-time-transpiler.md new file mode 100644 index 00000000..da25b50b --- /dev/null +++ b/docs/adr/0002-build-time-transpiler.md @@ -0,0 +1,79 @@ +# 2. A build-time transpiler that emits plain PHP + +- Status: Accepted — 2026-05 + +## Context and Problem Statement + +Given monomorphization ([ADR-0001](0001-monomorphization-over-type-erasure.md)), +*when* and *how* does the specialization happen? It could run at runtime (a +library that intercepts class loading and generates specializations on demand), +or ahead of time (a compiler that reads source and writes source). The answer +determines what a consuming project depends on, how the output interacts with +`opcache` and static analyzers, and whether xphp is something you *run* or +something you *build with*. + +## Decision Drivers + +- No runtime dependency on xphp in the shipped application. +- Output that existing PHP tooling (opcache, PHPStan, IDEs) understands natively. +- A clear, debuggable artifact — you can open and read the generated PHP. +- Deterministic, cacheable builds. + +## Considered Options + +- **Build-time transpiler** — `xphp compile ` reads `.xphp` + and writes plain `.php`. +- **Runtime library** — generate specializations lazily during autoloading. +- **A PHP extension** — implement generics in C in the engine. + +## Decision Outcome + +Chosen: a **build-time transpiler**. `.xphp` files are compiled offline into +ordinary PHP — rewritten user files under a `dist/` directory and generated +specialized classes under a cache directory — with the generic sugar fully +resolved away. The application ships and runs the plain PHP; xphp is a +development/CI-time tool, not a runtime. + +### Consequences + +- Good: the runtime has zero xphp dependency; the output is normal PHP that + `opcache`, PHPStan, and IDEs handle with no special support. +- Good: the generated code is inspectable and debuggable — no magic at runtime. +- Good: it makes the PHPStan-over-output layer + ([ADR-0009](0009-phpstan-over-compiled-output.md)) possible at all, since there + is concrete PHP to analyze. +- Trade-off: a build step is required before the code runs (and before stack + traces/line numbers map cleanly — source maps are roadmap work). +- Trade-off: analysis and tooling see only what was actually instantiated and + compiled, not the templates directly. + +### Confirmation + +The CLI entry point is [`bin/xphp`](../../bin/xphp); the compile command is +[`CompileCommand`](../../src/Console/Command/CompileCommand.php). See +[Getting started](../getting-started.md). + +## Pros and Cons of the Options + +### Build-time transpiler + +- Good: no runtime dep; tooling-native output; inspectable; cacheable. +- Bad: needs a build step; line/stack mapping back to `.xphp` is extra work. + +### Runtime library + +- Good: no separate build step. +- Bad: per-request generation overhead; fights opcache; static analyzers can't see + generated types; a hard runtime dependency. + +### PHP extension + +- Good: deepest integration, potentially best performance. +- Bad: enormous scope and maintenance; install friction; ties the project to engine + internals — out of proportion for a language experiment. + +## More Information + +- [How it works](../guides/how-it-works.md) — the parse → specialize → emit flow. +- `composer.json` declares the project a `library` of type *transpiler / + monomorphization*; the compiled output targets plain PHP. diff --git a/docs/adr/0003-rfc-aligned-turbofish-syntax.md b/docs/adr/0003-rfc-aligned-turbofish-syntax.md new file mode 100644 index 00000000..1f5f4cbd --- /dev/null +++ b/docs/adr/0003-rfc-aligned-turbofish-syntax.md @@ -0,0 +1,79 @@ +# 3. RFC-aligned turbofish surface syntax + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +xphp adds generic *syntax* to PHP, and there is an active PHP RFC for +bound-erased generic types. xphp's runtime model diverges from that RFC +(monomorphization vs erasure — [ADR-0001](0001-monomorphization-over-type-erasure.md)), +but the *surface syntax* is a separate choice. If xphp invents its own spelling +for instantiation and bounds, then `.xphp` source becomes a dead end the day PHP +ships native generics. If it tracks the RFC, today's source has a path to a +future native runtime. + +The specific tension is at call/`new` sites: `new Box()` is ambiguous +with the comparison/`<` grammar, which is exactly why the RFC uses *turbofish* +`Name::<...>`. + +## Decision Drivers + +- Forward compatibility: `.xphp` source should stay valid against a future native + PHP generics runtime. +- No grammar ambiguity at call sites. +- Familiarity: users who know the RFC should recognize xphp and vice versa. + +## Considered Options + +- **Track the RFC syntax** — turbofish `Name::<...>` at call/`new` sites, bare + `<...>` at declarations and type-hint positions, `:` for bounds. +- **Invent a bespoke syntax** optimized purely for the transpiler. +- **Accept both bare and turbofish at call sites** for convenience. + +## Decision Outcome + +Chosen: **track the RFC syntax**. Declarations and type-hints use bare angle +brackets (`class Box`, `public T $item`); call and `new` sites require +turbofish with byte-level adjacency (`Box::`, `identity::(…)`); a +parenless `new Box;` form is rejected to avoid silent specialization. +The whitespace-sensitive lookahead means `Foo:: ` is *not* a turbofish. + +### Consequences + +- Good: `.xphp` source is a subset of the prospective native syntax — migration is + mechanical, not a rewrite. +- Good: no ambiguity with the `<` operator at call sites; the parser's intent is + unmistakable. +- Trade-off: the turbofish `::<>` is more verbose at call sites than bare `<>`, and + is a hard requirement (no dual-accept), which was a one-time rewrite of existing + source. + +### Confirmation + +Surface syntax is handled in the parser +([`XphpSourceParser`](../../src/Transpiler/Monomorphize/XphpSourceParser.php)); the +[Syntax tour](../syntax/index.md) documents every position, and the divergence +note lives in [the docs index](../index.md) and [comparison](../guides/comparison.md). + +## Pros and Cons of the Options + +### Track the RFC syntax + +- Good: forward-compatible; unambiguous; familiar to RFC readers. +- Bad: more verbose call sites; a hard switch with no deprecation window. + +### Bespoke syntax + +- Good: could be terser or transpiler-convenient. +- Bad: guarantees a future migration; unfamiliar; no shared mental model with PHP. + +### Dual-accept bare + turbofish + +- Good: lenient for authors. +- Bad: undermines the forward-compat promise (bare call sites won't run on a future + native runtime); two idioms confuse tooling and learners. + +## More Information + +- [PHP RFC: bound-erased generic types](https://wiki.php.net/rfc/bound_erased_generic_types). +- [Syntax tour](../syntax/index.md), [Type bounds](../syntax/type-bounds.md). diff --git a/docs/adr/0004-marker-interfaces-for-instanceof.md b/docs/adr/0004-marker-interfaces-for-instanceof.md new file mode 100644 index 00000000..0be50080 --- /dev/null +++ b/docs/adr/0004-marker-interfaces-for-instanceof.md @@ -0,0 +1,76 @@ +# 4. Marker interfaces for `instanceof` across specializations + +- Status: Accepted — 2026-05 + +## Context and Problem Statement + +Monomorphization turns `Box` and `Box` into two unrelated +classes with mangled, instantiation-specific names. But user code naturally +wants to ask "is this *a Box*, of whatever element?" — `$x instanceof Box`. After +specialization there is no longer a class literally named `Box` for that check to +target, and the two specializations share no common supertype. Something has to +preserve the template's identity at runtime. + +## Decision Drivers + +- `$x instanceof App\Containers\Box` should be true for *any* `Box<…>` + specialization, without the call site knowing the concrete arguments. +- The mechanism must be ordinary PHP (no runtime support code). +- It must not break PHP's autoload/LSP rules for the generated classes. + +## Considered Options + +- **Marker interface** — replace the template with an empty interface at its + original FQN; every specialization implements/extends it. +- **A shared abstract base class** the specializations extend. +- **Drop cross-specialization `instanceof`** and offer only concrete checks. + +## Decision Outcome + +Chosen: a **marker interface**. Each generic class/interface template is replaced +in the output by an empty interface at the template's original fully-qualified +name; every specialization `implements` (for classes) or `extends` (for +interfaces) that marker. So `App\Containers\Box` survives as a real interface, +and `$x instanceof App\Containers\Box` is true for every specialization. Generic +*traits* are removed entirely, since PHP cannot `instanceof` a trait. + +### Consequences + +- Good: cross-specialization `instanceof` works with plain PHP and zero runtime + code; the template name stays meaningful. +- Good: an interface (not a base class) keeps specializations free to have their + own real parents and avoids single-inheritance conflicts. +- Trade-off: the original template name becomes an empty interface in the output, + which can surprise someone reading the generated code without context. +- Trade-off: the variance subtype edges between specializations are a separate, + related concern (covariant/contravariant `extends` links). + +### Confirmation + +The replacement happens in the call-site rewriter +([`CallSiteRewriter`](../../src/Transpiler/Monomorphize/CallSiteRewriter.php)); the +specializer adds the `implements`/`extends` back-link. See +[Runtime semantics](../guides/runtime-semantics.md). + +## Pros and Cons of the Options + +### Marker interface + +- Good: any-arg `instanceof`; interface composes cleanly with existing parents. +- Bad: template name is an empty interface in output; traits can't participate. + +### Shared abstract base class + +- Good: also enables `instanceof`. +- Bad: consumes the single allowed parent class, conflicting with specializations + that already extend something. + +### Concrete-only `instanceof` + +- Good: nothing to generate. +- Bad: loses a natural, expected capability — you couldn't ask "is this a Box?". + +## More Information + +- [Variance](../syntax/variance.md) — the subtype edges between specializations. +- [ADR-0001](0001-monomorphization-over-type-erasure.md). diff --git a/docs/adr/0005-nominal-erased-bound-checking.md b/docs/adr/0005-nominal-erased-bound-checking.md new file mode 100644 index 00000000..52e4445b --- /dev/null +++ b/docs/adr/0005-nominal-erased-bound-checking.md @@ -0,0 +1,120 @@ +# 5. Nominal, erased bound checking + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +Type-parameter bounds (`class Box`, intersections, unions, DNF, +F-bounded `T: Comparable`) need to be checked at compile time: does the +concrete argument satisfy the bound? Answering this requires a model of the type +hierarchy. But the compiler only sees the `.xphp` files it's given — a real +project also references vendor classes and hand-written `.php` classes the +compiler never parses. So for many concrete arguments the compiler genuinely +*cannot know* the answer. The design question is what to do with that ignorance. + +## Decision Drivers + +- Correctly accept satisfied bounds and reject violated ones for types the + compiler can see. +- Distinguish a *definite* violation from "can't tell", so the error message is + accurate and actionable rather than misleading. +- Keep the model simple and predictable. + +## Considered Options + +The mechanism for modelling the hierarchy, and the policy for the "can't tell" +case, are really one choice: + +- **A nominal ancestor map with a three-valued result, rejecting anything not + proven** — build a map of declared-class → direct ancestors; `isSubtype` + returns `true` / `false` / `null` (unknown); the bound check passes only on + `true` and rejects both `false` and `null`, using the distinction to emit a + different message for each. +- **A two-valued check** that collapses unknown into `false` — also rejects + unprovable types, but can't tell the user *why* (everything is "does not + implement"). +- **Accept the unknown case** (permissive) — anything unprovable passes silently. +- **Require every referenced type to be in the source set** (a closed world). + +## Decision Outcome + +Chosen: a **nominal ancestor map with a three-valued `isSubtype`**. A +`TypeHierarchy` records each declared class/interface/trait and its direct +ancestors (plus a small whitelist of common built-in interfaces); satisfaction is +a transitive walk over that map. Crucially `isSubtype` returns a *nullable* bool: +`true` (proven), `false` (disproven — the type is known and lacks the bound), or +`null` (the type isn't in the known set, so neither verdict is justified). Bound +combinators propagate the three values (intersection: any `false` → `false`, all +`true` → `true`, else unknown; union dually). Generic arguments on the bound +itself are erased for this check — comparison is by name. + +The bound check then passes **only on `true`**. A `false` is reported as a +definite violation (*"X does not extend/implement the bound"*); a `null` is also +reported, but with a distinct, actionable message (*"X is not in the analysed +source set, so the compiler cannot prove it satisfies the bound — add it to the +sources, or relax the bound"*). So the policy on "can't tell" is **conservative +rejection**, and the three-valued result exists precisely so that rejection can +be explained accurately instead of being conflated with a real violation. + +### Consequences + +- Good: bounds are enforced for everything the compiler can see; an unprovable + type produces a clear, distinct error at `check`/`compile` time — never a silent + pass and never a misleading "does not implement" for a type the compiler simply + couldn't see. +- Good: the three-valued result keeps the policy (what to do when unknown) at the + call site rather than baked into the hierarchy. +- Trade-off: the policy is conservative — a vendor or plain-`.php` type that *would* + satisfy the bound at runtime is still rejected when the compiler can't see it, + because it can't be proven. The remedy is to include that type in the analysed + sources or to widen/drop the bound. (Same closed-world limitation as the + undeclared-type check in [ADR-0010](0010-undeclared-type-and-arity-validation.md).) +- Trade-off: nominal erasure means bound-argument arity isn't checked — a deliberate + boundary, not an oversight. + +### Confirmation + +The three-valued subtype test is +[`TypeHierarchy::isSubtype`](../../src/Transpiler/Monomorphize/TypeHierarchy.php); +the three-valued folding over a compound bound is `Registry::evaluateBound`, and +the reject-unless-`true` policy (with the distinct messages) is in +`Registry::checkBounds` +([`Registry`](../../src/Transpiler/Monomorphize/Registry.php)). The bound AST node +types it folds over are +[`BoundLeaf`](../../src/Transpiler/Monomorphize/BoundLeaf.php), +[`BoundIntersection`](../../src/Transpiler/Monomorphize/BoundIntersection.php), and +[`BoundUnion`](../../src/Transpiler/Monomorphize/BoundUnion.php). See +[Type bounds](../syntax/type-bounds.md). + +## Pros and Cons of the Options + +### Nominal map, three-valued, reject-unless-proven + +- Good: precise where it can be; rejects the unknown case with an accurate, + actionable message; simple. +- Bad: conservative — rejects vendor / plain-`.php` types it can't see, even valid + ones (remedy: add them to the sources, or relax the bound). + +### Two-valued (collapse unknown into `false`) + +- Good: also rejects unprovable types; even simpler. +- Bad: can't distinguish "definitely violates" from "can't tell", so the user gets + a misleading "does not implement" for a type the compiler merely couldn't see. + +### Accept unknown + +- Good: never blocks valid code. +- Bad: silently misses real violations among unknown types — unsound. + +### Require all types in source + +- Good: a closed world makes every check decidable. +- Bad: incompatible with mixing `.xphp` and ordinary PHP — a non-starter. + +## More Information + +- [Type bounds](../syntax/type-bounds.md), [Caveats](../caveats.md). +- This check covers *bound satisfaction* only. Value-flow type errors *inside* + generic bodies are a separate concern handled by + [ADR-0009](0009-phpstan-over-compiled-output.md) (PHPStan over the compiled + output), not by this hierarchy. diff --git a/docs/adr/0006-bounded-specialization-depth-cap.md b/docs/adr/0006-bounded-specialization-depth-cap.md new file mode 100644 index 00000000..7b739191 --- /dev/null +++ b/docs/adr/0006-bounded-specialization-depth-cap.md @@ -0,0 +1,79 @@ +# 6. Bounded specialization with a hard depth cap + +- Status: Accepted — 2026-05 + +## Context and Problem Statement + +Specialization is a fixed-point process: specializing `wrap(): Box` for +`int` introduces a new `Box` instantiation, which must itself be +specialized, which may introduce more. For ordinary code this converges quickly. +But a self-referential or combinatorial template (e.g. one whose specialization +keeps producing a strictly more-nested instantiation) can drive the loop forever. +The compiler needs a guaranteed-terminating story. + +## Decision Drivers + +- The compiler must always terminate, even on pathological or malicious input. +- A real bug or runaway should fail loudly and quickly, not hang. +- Don't penalize legitimate (finite, possibly deep) generic code. + +## Considered Options + +- **A hard depth cap that aborts** the whole run when nesting exceeds a fixed + limit. +- **No cap**, trusting templates to converge. +- **Report the cap as a collectable diagnostic** and keep going. + +## Decision Outcome + +Chosen: a **hard depth cap that aborts**. The fixed-point specialization loop +tracks nesting depth and aborts the `compile` run with a clear message once it +exceeds a fixed limit (16 levels of nested specialization, +`Compiler::MAX_SPECIALIZATION_DEPTH`) set well above realistic generic nesting. +This is treated as a runaway-input guard — a distinct error class from +user-facing generic errors like a bound violation. The cap applies to `compile` +only: `check` validates without ever specializing, so it never enters the loop. + +### Consequences + +- Good: termination is guaranteed; a spiraling template fails fast with an + actionable message instead of hanging. +- Good: the limit sits well above realistic nesting, so ordinary deep generics + don't hit it. +- Trade-off: a genuinely legitimate but extremely deep instantiation would be + refused; the workaround is to break it into intermediate templates. +- Notable: unlike most checks, the depth cap is **not** routed through the + collect-or-throw diagnostic seam + ([ADR-0008](0008-collect-or-throw-diagnostic-seam.md)). If it were merely + collected and the loop continued, the next iteration would hit the cap again + forever — so it must abort. + +### Confirmation + +The cap is `Compiler::MAX_SPECIALIZATION_DEPTH` (16), enforced in the fixed-point +loop in [`Compiler::compile()`](../../src/Transpiler/Monomorphize/Compiler.php); a +fixture exercises a self-referential template that trips it. + +## Pros and Cons of the Options + +### Hard cap, abort + +- Good: guaranteed termination; fast, clear failure. +- Bad: a (rare) legitimate very-deep template is refused. + +### No cap + +- Good: never refuses anything. +- Bad: a single bad template can hang the compiler indefinitely. + +### Collect-and-continue + +- Good: consistent with the other checks' reporting model. +- Bad: doesn't actually stop the loop — it would re-trip endlessly; a contradiction + for a non-terminating condition. + +## More Information + +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — why a specialization + loop exists at all. +- [Caveats](../caveats.md). diff --git a/docs/adr/0007-xphp-check-gate.md b/docs/adr/0007-xphp-check-gate.md new file mode 100644 index 00000000..733097aa --- /dev/null +++ b/docs/adr/0007-xphp-check-gate.md @@ -0,0 +1,81 @@ +# 7. The `xphp check` validate-only gate + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +The only way to validate generic code used to be to `compile` it. Compilation +threw on the *first* generic error, as a bare exception with no `file:line`, and +produced output as a side effect. For CI and editors that's a poor fit: you fix +one error, recompile, find the next, repeat; there's no machine-readable result +and no way to "just check" without emitting `dist/`. xphp needs a first-class +validation entry point. + +## Decision Drivers + +- Report *all* problems in one run, each with a precise `file:line`. +- No side effects — a pure gate that writes nothing. +- Machine-readable, CI- and IDE-friendly output and exit codes. +- Resilience: one unparseable file shouldn't blind the check to the rest. + +## Considered Options + +- **A dedicated `xphp check` command** — validate-only, collect-all, structured + diagnostics, multiple renderers, structured exit codes. +- **Keep `compile` fail-fast, add a `--collect-errors` flag** to it. +- **Emit a diagnostics JSON file** from `compile` and let CI parse it. + +## Decision Outcome + +Chosen: a **dedicated `xphp check` command**. It runs the validation phases +without specializing or emitting anything, gathers every diagnostic in a single +run (each a structured record with a stable code, severity, and `file:line`), +renders them as `text`, `json`, or `github` (PR annotations), and returns exit +**0** (clean), **1** (≥1 error), or **2** (operational failure — bad source dir +or unknown format). Each file is parsed in isolation, so a syntax error in one is +reported and the rest are still checked. + +### Consequences + +- Good: one CI step surfaces every problem at once, with locations; the `github` + renderer puts findings inline on PRs; the `json` renderer feeds tooling/IDEs. +- Good: stable diagnostic codes (e.g. `xphp.bound_violation`) give a contract for + tooling and a searchable [errors reference](../errors.md). +- Good: it's the natural place to add more analyses behind one gate — see + [ADR-0009](0009-phpstan-over-compiled-output.md). +- Trade-off: there are now two entry points (`compile` and `check`) whose + validation must agree; that's exactly what the shared seam in + [ADR-0008](0008-collect-or-throw-diagnostic-seam.md) guarantees. + +### Confirmation + +[`CheckCommand`](../../src/Console/Command/CheckCommand.php) and the validate-only +path [`Compiler::check()`](../../src/Transpiler/Monomorphize/Compiler.php); the +renderers in [`src/Diagnostics/Renderer/`](../../src/Diagnostics/Renderer/). The +[errors reference](../errors.md) documents the codes, formats, and exit codes. + +## Pros and Cons of the Options + +### Dedicated `check` command + +- Good: collect-all; no side effects; structured codes/formats/exit codes; per-file + resilience; extensible. +- Bad: a second entry point to keep behaviorally consistent with `compile`. + +### `compile --collect-errors` + +- Good: one command. +- Bad: still emits output; conflates "validate" with "build"; risks forking the + validator logic. + +### Emit a JSON file + +- Good: machine-readable. +- Bad: pushes parsing/format burden to every CI; no inline PR annotations; awkward + for interactive use. + +## More Information + +- [Errors and diagnostics](../errors.md). +- [ADR-0008](0008-collect-or-throw-diagnostic-seam.md) — how `check` and `compile` + share one validator. diff --git a/docs/adr/0008-collect-or-throw-diagnostic-seam.md b/docs/adr/0008-collect-or-throw-diagnostic-seam.md new file mode 100644 index 00000000..0dd50476 --- /dev/null +++ b/docs/adr/0008-collect-or-throw-diagnostic-seam.md @@ -0,0 +1,86 @@ +# 8. The collect-or-throw diagnostic seam + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +`xphp check` ([ADR-0007](0007-xphp-check-gate.md)) needs to *collect* every +validation error and keep going; `xphp compile` needs to *throw* on the first +one and stay byte-identical to its long-standing behavior (a large body of tests +pins the exact exception messages, down to substrings like `(position 1)`). Both +must run the *same* validation logic — duplicating it would guarantee drift +between what `check` reports and what `compile` enforces. How should one set of +validators serve both modes? + +## Decision Drivers + +- A single source of truth for validation and for each error message. +- `compile` stays fail-fast and byte-identical (no message drift). +- Minimal, low-ceremony change — no heavyweight abstraction threaded everywhere. + +## Considered Options + +- **An optional trailing `?DiagnosticCollector` parameter** on the validation + methods: absent ⇒ throw (as before); present ⇒ append a diagnostic and continue. +- **A `DiagnosticSink` interface** with `Throwing` and `Collecting` implementations + injected through the stack. +- **Fork the validators** into separate compile and check code paths. + +## Decision Outcome + +Chosen: an **optional `?DiagnosticCollector` parameter**. Validation methods take +a nullable collector as their last argument. When it's absent (the `compile` +path), they throw exactly as before. When it's present (the `check` path), each +violation is appended as a structured diagnostic and validation continues — across +every validation phase — so all problems surface in one run. The user-facing text +comes from a single shared message builder used by both the `throw` and the +diagnostic, so the two can never diverge. The collector is mutable by design — +it's the one sink threaded through the validation phases. + +### Consequences + +- Good: one validator, one message string, two behaviors; `compile` output is + provably byte-identical and `check` collects — verified by tests that assert both + from the same input. +- Good: tiny surface area — a nullable parameter, no interface hierarchy or DI. +- Good: in `check` mode every validation phase runs unconditionally — there is no + early return between phases — so a single run yields effectively a flat list of + all diagnostics across phases, each with its location. Two deliberate exceptions: + the inner-variance pass skips templates the variance-position pass already flagged + (to avoid double-reporting the same issue), and a generated-name hash collision is + still thrown rather than collected (it's intentionally outside the seam). +- Trade-off: the no-collector default must be preserved at every call site, or that + site silently reverts to fail-fast; this is covered by tests. + +### Confirmation + +The seam threads through the [`Registry`](../../src/Transpiler/Monomorphize/Registry.php) +and the validators; the diagnostic model is in +[`src/Diagnostics/`](../../src/Diagnostics/DiagnosticCollector.php). Tests assert +both the byte-identical throw path and the collect path for the same fixtures. + +## Pros and Cons of the Options + +### Optional `?DiagnosticCollector` parameter + +- Good: one code path; shared message; minimal ceremony; byte-identical compile; + in `check` mode all phases run and collect into one flat report. +- Bad: a per-call-site convention to uphold — every call site must preserve the + no-collector default or it silently reverts to fail-fast. + +### `DiagnosticSink` interface + +- Good: clean polymorphism. +- Bad: two implementations + injection through the stack for only two call modes; + overkill. + +### Forked validators + +- Good: each path is self-contained. +- Bad: duplicated logic; near-certain message drift between check and compile. + +## More Information + +- [ADR-0007](0007-xphp-check-gate.md) — the gate this enables. +- [ADR-0009](0009-phpstan-over-compiled-output.md) and + [ADR-0010](0010-undeclared-type-and-arity-validation.md) reuse the same seam. diff --git a/docs/adr/0009-phpstan-over-compiled-output.md b/docs/adr/0009-phpstan-over-compiled-output.md new file mode 100644 index 00000000..e86fb267 --- /dev/null +++ b/docs/adr/0009-phpstan-over-compiled-output.md @@ -0,0 +1,107 @@ +# 9. PHPStan over the compiled output + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +xphp's own generic checks are nominal and erased +([ADR-0005](0005-nominal-erased-bound-checking.md)) — they validate that +instantiations are well-formed, but they don't analyze the *bodies* of generic +code for value-flow type errors. A method that returns the wrong type inside a +`Box` is invisible to xphp's checks. PHPStan already does exactly that kind of +analysis — but it can't read `.xphp`. Since monomorphization produces concrete +PHP ([ADR-0002](0002-build-time-transpiler.md)), there *is* something PHPStan can +analyze. The question is how to wire a real type-checker over the compiled output +without reinventing it or fighting the user's existing setup. + +## Decision Drivers + +- Reuse PHPStan's analysis; don't reimplement value-flow type checking. +- Respect the consumer's existing PHPStan configuration (their level, rules, + extensions, ignores) — one config, not a competing one. +- Map findings back to the `.xphp` the author wrote, not the generated files. +- Keep PHPStan optional and the xphp distribution lean. + +## Considered Options + +- **Run the consumer's PHPStan over compiled output, one representative + specialization per template, behind the same gate.** +- **Ship a fixed, xphp-owned PHPStan config.** +- **Bundle PHPStan as a hard dependency** so analysis always runs. +- **Analyze every specialization** of every template. + +## Decision Outcome + +Chosen: **run the consumer's own PHPStan over the compiled output**, integrated +into `xphp check`. When the generic checks pass, xphp compiles to a throwaway +directory and invokes PHPStan over **one representative specialization per +template** (chosen deterministically), driven by the **consumer's own config** +(auto-detected or pointed at with a flag) via an ephemeral config that `includes:` +it by absolute path. Findings are mapped from the generated file back to the +originating template's `.xphp` declaration line, naming the instantiation that +surfaced them. PHPStan stays a dev-only tool: never bundled in the PHAR, resolved +at runtime, and a **non-failing Warning** if absent — the generic gate still +delivers value on its own. + +One representative per template is sound because a body type error erases to +nominal types and therefore manifests identically across every specialization of +that template; analyzing one surfaces the bug once instead of N noisy times. This +also removed an earlier message-normalization de-duplication heuristic. + +### Consequences + +- Good: real value-flow analysis with zero reimplementation; one config and one CI + gate cover both generic correctness and body type safety. +- Good: findings point at the author's source; "one representative" keeps the + report free of duplicate findings and is deterministic for stable CI output. +- Good: lean distribution — PHPStan isn't shipped, and a missing/failed PHPStan + degrades to a Warning rather than breaking the build. +- Trade-off: a body error that only manifests for *specific* concrete arguments may + be missed — that's a value-flow bug PHPStan can't attribute to a template line + anyway. +- Trade-off: a consumer's path-based PHPStan baseline won't match the generated + paths (identifier/message ignores still work); a config that leans on `%rootDir%` + resolves it against the ephemeral config's location. Documented limitations. + +### Confirmation + +The pieces live in [`src/StaticAnalysis/`](../../src/StaticAnalysis/StaticAnalysisGate.php) +— workspace compile, representative selection, the PHPStan runner, and the result +mapper that re-anchors findings to `.xphp`. PHPStan is a dev-only dependency and is +stripped from the PHAR build. See the [errors reference](../errors.md) for the +`phpstan.*` diagnostic codes and the `--no-phpstan` / `--phpstan-bin` / +`--phpstan-config` options. + +## Pros and Cons of the Options + +### Consumer's PHPStan, one representative + +- Good: reuses PHPStan; respects the consumer's rules; one gate; deterministic, + de-duplicated findings mapped to source. +- Bad: per-argument-only errors can be missed; path-based baselines/`%rootDir%` + have caveats. + +### Fixed xphp-owned config + +- Good: simplest to package. +- Bad: two rule sets for one codebase; the consumer can't tune what runs over their + `.xphp`. + +### Bundle PHPStan as a hard dependency + +- Good: "always works". +- Bad: bloats the PHAR; couples xphp's release to a PHPStan version; users can't + upgrade independently. + +### Analyze every specialization + +- Good: maximal coverage. +- Bad: N duplicate findings per template; needs a fragile message-dedup heuristic; + slower. + +## More Information + +- [Errors and diagnostics](../errors.md) — `phpstan.*` codes and the CLI options. +- [ADR-0005](0005-nominal-erased-bound-checking.md) (the gap this closes), + [ADR-0007](0007-xphp-check-gate.md) and + [ADR-0008](0008-collect-or-throw-diagnostic-seam.md) (the gate and seam it builds on). diff --git a/docs/adr/0010-undeclared-type-and-arity-validation.md b/docs/adr/0010-undeclared-type-and-arity-validation.md new file mode 100644 index 00000000..22ce5608 --- /dev/null +++ b/docs/adr/0010-undeclared-type-and-arity-validation.md @@ -0,0 +1,91 @@ +# 10. Undeclared-type and arity validation + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +Two authoring mistakes used to pass silently. A generic member that names a type +parameter the template never declared — `interface Foo { add(T $x); }`, where +`T` is a typo for `Z` — compiled to a reference to a non-existent class (`\App\T`) +with no error. And instantiating with more type arguments than declared — +`Box::` for a one-parameter `Box` — silently dropped the extras. +Both produce wrong output from plainly-wrong input. The hard part of the first is +that, in erased nominal terms, a stray `T` is indistinguishable from a reference +to a real class named `T` — xphp has no whole-program symbol table to tell them +apart. + +## Decision Drivers + +- Catch these mistakes at `check`/`compile` time, not at runtime. +- Don't reject valid code, especially references to real types. +- Keep it inside xphp's erased model (no full type resolution — that's PHPStan's + job, [ADR-0009](0009-phpstan-over-compiled-output.md)). + +## Considered Options + +For undeclared-type detection: +- **A broad structural rule** — flag any bare, unqualified, single-segment type + name used inside a generic context that is neither a declared type parameter, + a built-in, nor imported, and that resolves to no type the compiler can see. +- **A narrow heuristic** — only flag single-letter names. +- **Defer entirely to PHPStan.** + +For arity: **report over-arity** vs **keep truncating silently**. + +## Decision Outcome + +Chosen: the **broad structural rule**, plus **reporting over-arity**. A bare, +unqualified, single-segment name inside a generic context that isn't a declared +parameter, a scalar/built-in, or brought in by a `use` import — and that resolves +to nothing the compiler knows — is reported as `xphp.undeclared_type`. Fully +qualified names and `use`-imported names are the escape hatch and are never +flagged. Supplying more type arguments than a template declares is reported as +`xphp.too_many_type_arguments` instead of being truncated. Both reuse the +collect-or-throw seam ([ADR-0008](0008-collect-or-throw-diagnostic-seam.md)), so +they fail `compile` and are collected by `check`. + +### Consequences + +- Good: the common typo and the over-arity mistake now fail fast with a clear + message and `file:line`, instead of emitting broken or silently-wrong code. +- Good: a dry-run of the broad rule over the entire existing test corpus produced + zero false positives on valid code. +- Trade-off (accepted): a real class in the *same namespace* that lives in a plain + `.php` file (which `check` doesn't scan) and is referenced **without** a `use` + will be flagged, because the compiler can't see it and it looks like a stray + parameter. The remedy is to `use`/fully-qualify it — both silence the check. The + alternative (a narrow single-letter heuristic) would miss real multi-letter + typos, so the broad rule with a documented escape hatch was preferred. + +### Confirmation + +The detection runs as a validation phase +([`UndeclaredTypeParameterValidator`](../../src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php)); +the arity check lives in the registry's argument padding. Both `xphp.undeclared_type` +and `xphp.too_many_type_arguments` are in the [errors reference](../errors.md). + +## Pros and Cons of the Options + +### Broad structural rule + +- Good: catches real typos in members, bounds, and defaults; zero false positives + on the existing corpus; cheap and resolution-free. +- Bad: the accepted false positive above (same-namespace plain-`.php` class, no + `use`). + +### Narrow single-letter heuristic + +- Good: even lower false-positive risk. +- Bad: misses multi-letter undeclared names; users have to learn the heuristic. + +### Defer to PHPStan + +- Good: full name resolution. +- Bad: PHPStan runs on generated PHP, can't see the generic context, and isn't + always installed; the mistake should fail the fast, resolution-free gate too. + +## More Information + +- [Errors and diagnostics](../errors.md). +- [ADR-0009](0009-phpstan-over-compiled-output.md) — the resolution-aware layer this + intentionally stops short of. diff --git a/docs/adr/0011-phar-distribution.md b/docs/adr/0011-phar-distribution.md new file mode 100644 index 00000000..09c43bc3 --- /dev/null +++ b/docs/adr/0011-phar-distribution.md @@ -0,0 +1,78 @@ +# 11. PHAR distribution via Humbug Box + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +xphp is a build-time CLI ([ADR-0002](0002-build-time-transpiler.md)). Composer +users get `vendor/bin/xphp` for free, but plenty of places that want to run it +aren't Composer projects: CI scripts, Docker images, ops tooling, a quick +one-off. They need a way to run the compiler with stock PHP and nothing else. And +when xphp *is* installed as a Composer dependency, `bin/xphp` must find the right +autoloader regardless of how it was installed. + +## Decision Drivers + +- A single self-contained artifact runnable with stock PHP, no Composer. +- Don't ship development-only dependencies (notably PHPStan) to end users. +- Verifiable, reproducible release artifacts. +- `vendor/bin/xphp` must work whether xphp is the root project or a dependency. + +## Considered Options + +- **A single PHAR built with Humbug Box**, published on every release tag with a + checksum; PHPStan stripped via a no-dev install. +- **Distribution only via Composer.** +- **A separate, hand-assembled binary repository.** + +## Decision Outcome + +Chosen: a **PHAR built with Humbug Box**, attached to every `v*` release tag +alongside a SHA-256 sum. The build does a `composer install --no-dev` before +packaging so development-only dependencies (PHPStan) are excluded, then restores +the dev install. The runtime dependency needed for the optional PHPStan pass +(`symfony/process`) *is* included, since the runner ships in the PHAR even though +PHPStan itself does not. Separately, `bin/xphp` probes for the Composer autoloader +in priority order — the Composer bin-proxy global, then the as-a-dependency path, +then the standalone path — so it works installed either way. + +### Consequences + +- Good: `curl` the PHAR and run it with stock PHP — no Composer, no install dance; + CI and Docker get a one-file tool. +- Good: PHPStan is never shipped to users; they install it themselves and upgrade it + independently of xphp's release cycle (see + [ADR-0009](0009-phpstan-over-compiled-output.md)). +- Good: the published checksum lets consumers verify the download. +- Trade-off: a second distribution channel (PHAR + Composer) to build and test; the + Box version is pinned so a new Box release can't silently change the artifact. + +### Confirmation + +[`box.json`](../../box.json) and the `build/phar` target in the +[`Makefile`](../../Makefile) (the `--no-dev` package-then-restore); the autoloader +probing in [`bin/xphp`](../../bin/xphp). The release workflow builds the PHAR and a +SHA-256 sum on every `v*` tag. + +## Pros and Cons of the Options + +### PHAR via Humbug Box + +- Good: self-contained; stock-PHP runnable; dev deps stripped; checksum-verifiable. +- Bad: a second channel to maintain; depends on a pinned external packaging tool. + +### Composer-only + +- Good: nothing extra to build. +- Bad: excludes every non-Composer consumer (CI/Docker/ops/one-offs). + +### Hand-assembled binary repo + +- Good: full control over contents. +- Bad: manual, error-prone, and duplicative of what Box automates. + +## More Information + +- [Getting started](../getting-started.md). +- [ADR-0009](0009-phpstan-over-compiled-output.md) — why PHPStan is dev-only and the + process dependency ships. diff --git a/docs/adr/0012-engineering-quality-bar.md b/docs/adr/0012-engineering-quality-bar.md new file mode 100644 index 00000000..0c6db243 --- /dev/null +++ b/docs/adr/0012-engineering-quality-bar.md @@ -0,0 +1,82 @@ +# 12. The engineering quality bar + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +A generics compiler is a correctness-critical tool: a bug doesn't just misbehave, +it emits wrong code into someone else's project. That argues for an unusually high +bar on the compiler's own codebase and its tests. But strictness has costs — +slower CI, more findings to triage, more test ceremony — so the bar has to be a +deliberate, documented choice rather than an accident. + +## Decision Drivers + +- High confidence that the compiler itself is correct. +- Tests that actually fail when behavior regresses (not just line coverage). +- Stable, modern target platform without blocking on bleeding-edge syntax. +- Fast, deterministic CI signal. + +## Considered Options + +- **A high, enforced bar**: PHPStan at the strictest level on `src/`, Infection + mutation testing gated on a high MSI, fixture+snapshot end-to-end tests, a fixed + minimum PHP with newer-syntax tests isolated into their own group/runtime. +- **A conventional bar**: a mid PHPStan level, line-coverage thresholds, ad-hoc + integration tests. + +## Decision Outcome + +Chosen: the **high, enforced bar**, adopted progressively. + +- **PHPStan at the strictest level** over `src/`, reached by stepping the level up + and clearing findings at each stop rather than baselining them away. +- **Mutation testing (Infection)** gated on a high MSI; every surviving mutant is + either killed with a new test or annotated inline as a genuinely-equivalent + mutant with a one-line reason, so the report only ever shows real gaps. +- **Fixture + snapshot tests**: a feature compiles a `.xphp` source tree and its + emitted PHP is snapshotted; deterministic generated-name hashes are normalized to + stable placeholders so a role-swap regression still shows up as a diff. +- **A fixed minimum PHP** (the supported runtime) with tests that exercise + newer-PHP syntax isolated into their own group, run on a dedicated runtime and + skipped elsewhere — so the default suite stays fast and the production parser + stays on the supported version. + +### Consequences + +- Good: high confidence in the compiler; tests bite on real regressions; CI signal + is fast (heavy/optional suites are split out) and the analysis target is stable. +- Good: the discipline is self-documenting — equivalent-mutant annotations and the + curated ignore set explain *why* something isn't tested, instead of hiding it. +- Trade-off: stricter analysis and mutation runs are slower and demand more effort + per change; newer-syntax features must wait for the dedicated runtime to cover + them. + +### Confirmation + +The PHPStan configuration, the Infection configuration with its curated +equivalent-mutant ignore set, the snapshot/fixture test support, and the CI +workflow split (default suite, newer-syntax group, the optional analysis-pass +tests, and the mutation job) collectively enforce this. See +[CONTRIBUTING](../../CONTRIBUTING.md) for how to run each locally. + +## Pros and Cons of the Options + +### High, enforced bar + +- Good: maximal confidence; regression-sensitive tests; fast, deterministic CI; + self-documenting discipline. +- Bad: slower analysis/mutation; more upfront effort; newer syntax gated on a + dedicated runtime. + +### Conventional bar + +- Good: cheaper and faster to satisfy. +- Bad: line coverage hides untested logic; a mid analysis level lets subtler bugs + through — unacceptable risk for a code generator. + +## More Information + +- [CONTRIBUTING](../../CONTRIBUTING.md) — the test/lint targets and the group split. +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — why correctness of the + generated code matters so much. diff --git a/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md new file mode 100644 index 00000000..d8b9dae0 --- /dev/null +++ b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md @@ -0,0 +1,123 @@ +# 13. Typed constructor parameters on variant classes + +- Status: Accepted — 2026-06 +- Amended by [ADR-0015](0015-variance-markers-on-private-properties.md) — 2026-06 (the property + rule below was later relaxed for *private* properties) + +## Context and Problem Statement + +Declaration-site variance (`out T` / `in T`) lowers to real `extends` edges between +specializations: `Producer` actually extends `Producer` when `Banana` +extends `Fruit` and `T` is covariant. A covariant immutable collection — Kotlin's +`List`, the backbone of an immutability-first collections library — wants to take +its element type as **construction input**: `class ImmutableList { public function +__construct(T ...$items) }`. The question is whether a variant class can accept its type +parameter in a constructor across the variance `extends` edge, and with what type. + +An earlier exploration assumed PHP enforces **invariant** constructor parameter types +across an `extends` chain — i.e. that `ImmutableList::__construct(Banana ...)` +extending `ImmutableList::__construct(Fruit ...)` would fatal at autoload — and so +proposed emitting the parameter *variance-erased* (to the bound, else `mixed`). **That +premise is false.** PHP exempts `__construct` from LSP signature-compatibility checks: a +child constructor may have a completely different parameter list from its parent's with no +error. (Verified empirically, both directions.) So no erasure is needed. + +## Decision Drivers + +- Let a covariant/contravariant class take `T`-typed construction input. +- Keep the type information real — a generic is worth most when the concrete type is + enforced. Avoid silently widening a declared `T` to `mixed`. +- Stay sound: a declared variance must not let an unsound subtype edge through. +- Don't fatal at autoload. + +## Considered Options + +- **Forbid `T` in a constructor** — status quo before this; a covariant collection can't + take typed construction input at all. +- **Emit the parameter variance-erased** (bound, else `mixed`) — chain-identical, but + based on the false fatal premise; throws away the real type and the runtime check. +- **Emit the parameter with its real substituted type** — relies on PHP's `__construct` + LSP exemption; keeps the real type and a runtime check. + +## Decision Outcome + +Chosen: **permit a non-promoted constructor parameter to carry `out T` / `in T`, emitted with +its real substituted type.** A constructor parameter is *variance-position-exempt* — a +constructor is never reached through an upcast reference, so it isn't part of the +externally-visible variance surface (the same reason Kotlin allows `out T` in a +constructor). And because PHP doesn't LSP-check `__construct` across the chain, the +specializations' constructors may legitimately differ (`Banana ...$items` on the child, +`Fruit ...$items` on the parent). The covariant edge holds, autoload is clean, **and +construction is runtime-type-checked** — building an `ImmutableList` from a +non-`Banana` throws a `TypeError`. + +A corollary about `final`: a `final` class can't be a parent in an `extends` edge, so a +variant class cannot be `final`. Rather than silently strip `final` from the generated +specializations (which would make `ReflectionClass::isFinal()` lie about them), xphp +**rejects** `final` on a `out T`/`in T` class at compile time. + +The relaxation is narrow. **Visible (public/protected) properties stay strictly +invariant** — mutable, `readonly`, and public/protected *promoted* constructor parameters +(which are visible properties). PHP makes property types invariant across an `extends` +chain *for visible members* (`Type of Child::$item must be …`), so a `T`-typed visible +property genuinely fatals — there is no way to carry a real `T` there. Such a property is +rejected at compile time (not erased). A **private** property is the exception — PHP does +not type-check private slots across the chain, so it carries a real `T` soundly. Non-bare +shapes in a constructor parameter (`?T`, `Box`, `T|X`) are not yet supported and stay +rejected. + +### Consequences + +- Good: a covariant immutable collection takes `T`-typed construction input that is + enforced at runtime, remains usable covariantly (`ImmutableList` where + `ImmutableList` is expected), and never fatals at autoload. +- Good: nothing is erased — the declared type survives into the emitted signature. +- Trade-off: a `T`-typed *visible* (public/protected) property is rejected, because PHP + property invariance across the edge is unavoidable for visible members. A `T`-typed + *private* property is allowed; a *multi-element* collection still hand-rolls a + `mixed`/`array` backing plus a covariant `get(): T`, since many elements can't live in one + `private T` slot. +- Trade-off: richer constructor-parameter shapes (`?T`, `Box`, `T|X`) aren't supported + yet, only a bare variance-marked type parameter. + +### Confirmation + +`VariancePositionValidator::checkMethod` allows a plain constructor parameter of a variant +class at any variance (a public/protected promoted one stays invariant; a private promoted +one is exempt); `InnerVarianceValidator` skips a bare variance-marked constructor parameter +(`isExemptVariantConstructorParam`) and still rejects the non-bare shapes. [`Specializer::specialize`](../../src/Transpiler/Monomorphize/Specializer.php) +substitutes the real type into the constructor parameter — no erasure step. Tests compile a +covariant `ImmutableList` and assert each specialization's constructor keeps its real +element type, that the chain autoloads with **no** fatal, that an `ImmutableList` +**throws** on a non-`Banana` element, and that the contravariant constructor chain +autoloads and constructs equally cleanly. + +## Pros and Cons of the Options + +### Real-typed constructor parameter (chosen) + +- Good: keeps the real type and a runtime check; no autoload fatal; localized to the + specializer (just normal substitution). +- Bad: visible (public/protected) properties still can't carry a real `T` (a *private* one + can); non-bare constructor shapes not yet supported. + +### Variance-erased constructor parameter + +- Good: chain-identical signatures. +- Bad: rests on a false fatal premise; discards the real type and the runtime check for no + benefit. + +### Forbid `T` in a constructor + +- Good: simplest. +- Bad: a covariant collection can't take typed construction input at all. + +## More Information + +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — why specializations (and their + `extends` edges) exist at all. +- [ADR-0014](0014-variance-markers-are-class-level-only.md) — why variance stays at the + class level (the related boundary). +- [ADR-0015](0015-variance-markers-on-private-properties.md) — the later refinement that + relaxed the property rule for *private* properties. +- [Variance](../syntax/variance.md) — the position rules and the typed-construction pattern. diff --git a/docs/adr/0014-variance-markers-are-class-level-only.md b/docs/adr/0014-variance-markers-are-class-level-only.md new file mode 100644 index 00000000..a267d748 --- /dev/null +++ b/docs/adr/0014-variance-markers-are-class-level-only.md @@ -0,0 +1,92 @@ +# 14. Variance markers are class-level only + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +Declaration-site variance (`out T` / `in T`) is realized as real `extends` edges between +*specialized classes*: `Producer` extends `Producer`, and PHP's native type +system carries the subtype relationship. That mechanism needs a stable, nominal class +identity at each end of the edge. + +Method-, function-, closure-, and arrow-scoped generics don't have one. Their +specializations are *functions* — mangled methods appended to a class, or top-level +functions, keyed by a call-site hash — not classes that can sit in an `extends` chain. So +the question is what to do when a type parameter on one of those carries a variance marker +(`function map(...)`, `$f = fn(...) => ...`): support it somehow, ignore it, or +reject it. + +## Decision Drivers + +- Variance must stay sound — a declared variance that isn't actually enforced is worse than + no variance. +- Don't advertise a feature the architecture can't honor without a disproportionate change. +- Give library authors a clear, stable answer so they don't design around a feature that + isn't coming. + +## Considered Options + +- **Implement method-level variance** — synthesize some stable identity for function + specializations so a subtype relationship can be expressed. Large, and there is no natural + PHP construct for "one function is a subtype of another." +- **Accept the markers and ignore them** — parse `out U` / `in U` on a function-scoped generic + but emit nothing. Silently unsound: the declared variance would have no effect. +- **Reject them at parse time as a permanent boundary** — and document the rationale. + +## Decision Outcome + +Chosen: **reject variance markers on method / function / closure / arrow type parameters at +parse time, as a permanent design boundary.** Variance is a class-level-only feature. The +error states plainly that this is by design (a function or closure specialization has no +stable class identity to anchor a subtype `extends` edge to), and points the author at the +class-level workaround. + +This is not a deferral. The cost of "implement it anyway" is high and the benefit is low: +the functional collection surface (`map`, `flatMap`, `groupBy`, …) works correctly +as *invariant* method generics, which matches Kotlin — whose `fun map(...)` is likewise +invariant. Method-level variance would be showcase richness, not a capability gap that +blocks real code. + +### Consequences + +- Good: the variance model stays simple and sound — every variance marker maps to a real, + enforced `extends` edge between classes. +- Good: the boundary is explicit and documented, so library authors know to keep variance at + the class level rather than waiting on a function-level feature. +- Trade-off: the interesting functional surface (method/closure generics) can never + participate in variance; it stays invariant. Acceptable — it matches Kotlin and doesn't + block correctness. + +### Confirmation + +The rejection is a single parse-time gate in +[`XphpSourceParser`](../../src/Transpiler/Monomorphize/XphpSourceParser.php) (the +`allowVariance` path), uniform across methods, free functions, closures, and arrow +functions, and pinned by tests for all four shapes. The boundary is documented in +[Variance](../syntax/variance.md) and [Caveats](../caveats.md). + +## Pros and Cons of the Options + +### Reject at parse time (permanent boundary) + +- Good: sound, simple, explicit; cheap; a clear answer for library authors. +- Bad: no method-level variance (matches Kotlin; not a real blocker). + +### Implement method-level variance + +- Good: maximal expressiveness. +- Bad: requires inventing a stable identity for function specializations with no natural PHP + analogue; large change for a "showcase" benefit. + +### Accept and ignore the markers + +- Good: no parse error. +- Bad: silently unsound — a declared variance that does nothing. + +## More Information + +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — specializations and their class + identities (or lack thereof for functions). +- [ADR-0013](0013-typed-constructor-parameters-on-variant-classes.md) — the class-level + variance surface this boundary sits alongside. +- [Variance](../syntax/variance.md), [Caveats](../caveats.md). diff --git a/docs/adr/0015-variance-markers-on-private-properties.md b/docs/adr/0015-variance-markers-on-private-properties.md new file mode 100644 index 00000000..44aa6b18 --- /dev/null +++ b/docs/adr/0015-variance-markers-on-private-properties.md @@ -0,0 +1,134 @@ +# 15. Variance markers on private properties + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +Declaration-site variance (`out T` / `in T`) lowers to real `extends` edges between +specializations: `Producer` extends `Producer` when `Banana` extends `Fruit` +and `T` is covariant ([ADR-0001](0001-monomorphization-over-type-erasure.md)). A variant +class therefore can't carry its type parameter in a position PHP would reject across that +edge. + +The original rule treated **every** property as such a position: a `T`-typed property — +mutable, `readonly`, or promoted — was rejected at compile time, justified by "PHP enforces +invariant property types across an `extends` chain." So the natural covariant shape + +```php +class Producer { + public function __construct(private T $item) {} + public function get(): T { return $this->item; } +} +``` + +was rejected, and authors had to hand-roll a `mixed` backing field plus a covariant +`get(): T` — which also trips the optional PHPStan-over-output pass +([ADR-0009](0009-phpstan-over-compiled-output.md)), since a `mixed` field reads as `mixed`. + +The premise turned out to be too broad. PHP enforces invariant property types across the +chain **only for visible (public/protected) members**. A *private* property is not +inherited or overridden — its slot is per-declaring-scope — so PHP does **not** type-check +it across the edge. The question: should a private property be exempt from the +property-invariance rule, like a non-promoted constructor parameter already is +([ADR-0013](0013-typed-constructor-parameters-on-variant-classes.md))? + +## Decision Drivers + +- Soundness first — an allowed position must not produce an autoload fatal or a wrong-typed + read at runtime. +- Don't over-restrict — rejecting a position PHP actually permits forces users into + workarounds (a `mixed` backing field) that are both noisier and less type-safe. +- Keep the variance surface honest — only positions invisible to the externally-visible + variance surface may differ across the edge. + +## Considered Options + +- **Keep rejecting all properties** — simplest rule, but over-restrictive: it bans a sound, + natural covariant shape and forces a `mixed`-backed workaround that defeats PHPStan. +- **Allow any property (drop the invariance rule)** — unsound: a public/protected `T` + property genuinely fatals at autoload when the variance edge lands. +- **Allow only `private` properties** — exempt a private property (declared or promoted; + mutable or readonly), keep public/protected strictly invariant. + +## Decision Outcome + +Chosen: **a `private` property is variance-exempt — it may carry any variance — while +public/protected properties stay strictly invariant.** A private property is treated like a +non-promoted constructor parameter: it is invisible to the externally-visible variance +surface (it can't be read through an upcast reference), and PHP doesn't type-check its slot +across the edge, so each specialization keeps its own real-typed field (`private Banana +$item` / `private Fruit $item`) with no fatal. The Specializer emits the real substituted +type there — nothing is erased. + +Soundness holds because monomorphization is total: each specialization re-emits its **own** +private field and its own accessor body, so no inherited parent method ever reads a +divergent-typed private slot of a child instance — the classic hazard is structurally +impossible. + +Detection is by the **`private` visibility bit**, not the mere absence of a bit. A +`readonly`-only promoted parameter has no visibility bit and is implicitly public; an +asymmetric `public private(set)` property (PHP 8.4) is externally readable. Both are on the +visible variance surface and correctly stay strictly invariant — only a truly private slot +is exempt. + +This refines, and does not supersede, [ADR-0013](0013-typed-constructor-parameters-on-variant-classes.md): +typed constructor *parameters* remain real-typed and LSP-exempt; this ADR adds that a +private promoted (or declared) *property* is likewise exempt. + +### Consequences + +- Good: the natural covariant single-value shape (`__construct(private T $item)` + `get(): + T`) compiles, keeps its real slot type (runtime-type-checked at construction), and is + **PHPStan-clean** — no `mixed` backing, so the getter's return type is provable. +- Good: the rule now matches what PHP actually enforces — no position is rejected that PHP + would have accepted. +- Trade-off: a *multi-element* collection still needs an `array` backing (many elements + can't live in one `private T` slot), which xphp emits without a value-type annotation, so + it still trips the optional PHPStan pass at level 6+ (the untyped `array` property has no + iterable value type). That case is documented, not "fixed." +- Trade-off: a public/protected `T` property is still rejected — unavoidable, PHP fatals on + it across the edge. + +### Confirmation + +The exemption lives in two validators — the property and promoted-parameter checks in +[`VariancePositionValidator`](../../src/Transpiler/Monomorphize/VariancePositionValidator.php) +and the inner-variance composition walk in +[`InnerVarianceValidator`](../../src/Transpiler/Monomorphize/InnerVarianceValidator.php) — +both keyed on the `private` visibility bit. It is pinned by tests: private declared/promoted +(mutable, `readonly`, inner-generic) properties compile; public/protected and the +externally-readable `public private(set)` shape stay rejected. A runtime-verify fixture +proves the covariant edge autoloads, a `Box` flows where a `Box` is expected, +and construction throws `TypeError` on a wrong element; a grouped test proves the private-`T` +getter is PHPStan-clean. The boundary is documented in [Variance](../syntax/variance.md) and +[Caveats](../caveats.md). + +## Pros and Cons of the Options + +### Allow only `private` properties (chosen) + +- Good: sound (PHP doesn't check private slots across the edge), matches PHP's real rule, + unlocks the natural covariant shape, PHPStan-clean for single-value containers. +- Bad: multi-element collections still need an untyped `array` backing (trips the PHPStan + pass at level 6+). + +### Keep rejecting all properties + +- Good: simplest rule. +- Bad: over-restrictive; forces a `mixed`-backed workaround that is noisier and defeats the + PHPStan pass even for the single-value case PHP permits. + +### Allow any property + +- Good: maximal expressiveness. +- Bad: unsound — a public/protected `T` property fatals at autoload when the edge lands. + +## More Information + +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — specializations and `extends` + edges. +- [ADR-0013](0013-typed-constructor-parameters-on-variant-classes.md) — typed constructor + parameters; this ADR refines its property-invariance corollary. +- [ADR-0009](0009-phpstan-over-compiled-output.md) — the PHPStan pass the `mixed` backing + tripped. +- [Variance](../syntax/variance.md), [Caveats](../caveats.md). diff --git a/docs/adr/0016-no-special-cased-value-equality-bound.md b/docs/adr/0016-no-special-cased-value-equality-bound.md new file mode 100644 index 00000000..b1dc84d4 --- /dev/null +++ b/docs/adr/0016-no-special-cased-value-equality-bound.md @@ -0,0 +1,100 @@ +# 16. No special-cased value-equality bound — use ordinary generics + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +A generic container that keys on or deduplicates **arbitrary objects** needs a value-equality +contract (a `hashCode()` / `equals()` pair), because PHP array keys are `int|string` only. xphp +recognized such a contract by **whitelisting** a `Hashable` name in +`TypeHierarchy::BUILTIN_TYPES` — first as the global `Hashable`, then (briefly) as a +namespaced `XPHP\Hashable` to dodge a global-namespace collision — so a bound like +`Set` would compile and be bound-checked without the interface being in +the scanned sources, and without xphp shipping a runtime type. This ADR replaces that whole +line of thinking. + +This left `Hashable` as the **only invented, non-PHP-native** entry in a whitelist otherwise made +of real PHP global interfaces (`Stringable`, `Countable`, …). Its direct analog — `Comparable`, +an ordering contract — is **not** whitelisted at all: a library declares `interface Comparable` +itself, and xphp's existing F-bounded generics handle `Sortable>` end-to-end +(`test/fixture/compile/bounds_f_bounded/`). The asymmetry had no principled basis. + +Two further facts undercut the whitelist: + +- It only satisfies the **static** bound check. At runtime a class still `implements` the + contract, so a real interface must exist regardless — the whitelist only spared xphp from + *seeing* it during the check. +- It can't express the **generic** `Hashable` form. A whitelisted name is a nominal leaf, but + the natural, type-safe shape is a generic interface whose `equals(T $other)` specializes to the + implementer's concrete type. That requires a real template, not a whitelist entry. + +Collections themselves (Set/Map) are a **separate** project; xphp is a transpiler whose job is to +make generics work, not to carry a domain-specific contract. + +## Decision Drivers + +- Consistency — value-equality should be expressed like every other contract (e.g. ordering), + not via a privileged name. +- Keep the transpiler free of domain types — contracts belong to the libraries built on xphp. +- Prefer the shape that gives natural, type-safe ergonomics over a static-only convenience. + +## Considered Options + +- **Keep the whitelisted `XPHP\Hashable`** (the prior approach) — zero-setup static recognition, + but a privileged invented name, static-only, and no generic form. +- **Ship a runtime `XPHP\Hashable` interface** — makes the name real, but fixes one contract shape + for everyone and reverses xphp's pure-transpiler stance. +- **Special-case nothing; value-equality is an ordinary library generic** — a library declares + `interface Hashable { hashCode(): int|string; equals(T $other): bool; }` and bounds on + `Set>`, exactly like `Comparable`. + +## Decision Outcome + +Chosen: **the transpiler special-cases nothing.** The `Hashable` whitelist entry is removed. +Value-equality, like ordering, is a contract a library defines as an ordinary generic interface +and bounds on with an F-bound: + +```php +interface Hashable { public function hashCode(): int|string; public function equals(T $other): bool; } +final class Money implements Hashable { /* hashCode(); equals(Money $o): bool */ } +class Set> { /* keys on $k->hashCode() */ } +``` + +This already works with no transpiler change. A generic interface lowers to an **empty marker** +interface (ADR-0004), so the implementing class declares `equals(Money $other)` with its concrete +type under no LSP obligation — identical to how a `Comparable` implementer writes +`compareTo(Money $other)`. The bound is checked nominally and erased (ADR-0005). + +Rather than make a privileged name collision-safe (an earlier iteration of this decision), we drop +the privilege entirely. The capability the original ask wanted — a compile-time-checked +value-equality bound — remains fully available, now uniform with the rest of the bound surface. + +### Consequences + +- Good: one consistent way to express any contract bound; no invented global/namespaced name to + collide with PHP or libraries; the transpiler carries no domain types. +- Good: the generic form gives implementers natural, concrete-typed `equals(T)` with no LSP + friction (empty marker), which the whitelist could never express. +- Trade-off: a library/app now declares its own value-equality interface (a few lines) and keeps + it in its compile set — the exact, trivial cost `Comparable` already pays. Bounding on the + name without declaring it is no longer possible. +- Trade-off: this is a static, compile-time contract only (bounds emit no runtime code); the + deduping/keying container is hand-written library code, as before. + +### Confirmation + +`TypeHierarchy::BUILTIN_TYPES` no longer contains any value-equality entry, and the +`resolveName` special-case is back to matching only the real PHP-native (no-namespace) built-ins. +The library-defined F-bounded-generic pattern is exercised by the existing +`test/fixture/compile/bounds_f_bounded/` (`Comparable` + `Sortable>` + a +class implementing the bare marker with a concrete same-type method); the transpiler is +indifferent to whether that method returns `int` (`compareTo`) or `bool` (`equals`), since the +marker is empty. Documented in [Caveats](../caveats.md); ordering/value-equality both fall under +[type bounds](../syntax/type-bounds.md) F-bounded recursion. + +## More Information + +- [ADR-0004](0004-marker-interfaces-for-instanceof.md) — generic templates lower to empty marker + interfaces (why a concrete `equals(T)` has no LSP obligation). +- [ADR-0005](0005-nominal-erased-bound-checking.md) — nominal, erased bound checking. +- [Caveats](../caveats.md), [Type bounds](../syntax/type-bounds.md). diff --git a/docs/adr/0017-config-manifest-source-resolution.md b/docs/adr/0017-config-manifest-source-resolution.md new file mode 100644 index 00000000..b5654408 --- /dev/null +++ b/docs/adr/0017-config-manifest-source-resolution.md @@ -0,0 +1,86 @@ +# 17. Config-manifest, multi-root source resolution (`xphp.json`) + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +`xphp compile`/`check` took a single source directory. Because specialization needs each generic +template's parsed AST in memory, a `new Foo::()` call site can only be specialized when +`Foo`'s `.xphp` template is in the *same* compiled source set ([ADR-0001](0001-monomorphization-over-type-erasure.md)). +So a package that ships `.xphp` templates, and any app consuming one, had to **stage** the +templates and the consumer tree into a single directory on every build (a copy step). Consuming +another package's generics is a first-class goal, and that friction defeats it. + +We need a way to compile several source roots together in one invocation, and to do so without the +developer hand-listing every dependency or re-editing config when a package is installed. + +## Decision Drivers + +- Let a package self-describe its `.xphp` sources, and pull in other packages, in one compile. +- Auto-discover dependencies — installing a new xphp package must not require a manifest edit. +- Keep xphp's minimal-dependency posture and its pure-transpiler runtime model. +- Preserve the existing single-directory CLI form unchanged. + +## Considered Options + +- **Repeatable `--source` flags** — multiple roots on the command line. Works, but the consumer must + enumerate every (transitive) dependency by hand and re-do it on every install. +- **Prebuilt/persisted registry** — ship a compiled, reloadable package and compile against it + without its sources. Larger change (serialize template ASTs + a load path); deferred (see below). +- **A config manifest (`xphp.json`)** that declares sources and transitively includes other + packages, with glob auto-discovery. + +## Decision Outcome + +Chosen: **an `xphp.json` manifest resolved into a multi-root source set.** A manifest declares +`sources` (its own `.xphp` roots) and `include` (other packages, transitively). `include` entries +may be globs (`*`/`?`/`[…]` within a segment, or `**` for recursive discovery); a glob-matched +directory with its own `xphp.json` is pulled in, others skipped, so `"include": ["vendor/**"]` +discovers every installed xphp package at any depth and is set once. Resolution +dedups by realpath (diamonds resolve once; cycles terminate). The CLI takes `--config ` +or auto-detects `xphp.json` in the working directory; the single-directory positional form is +unchanged (precedence: positional source → `--config` → auto-detect). + +Three sub-decisions: + +- **Plain JSON, no new dependency.** Parsed with the built-in `json_decode`. JSON5 (comments / + trailing commas) would need a runtime parser; not worth a dependency for v1. +- **Emit-all (consumer compiles the union).** Upstreams ship `.xphp` *sources*; the downstream + build pulls and compiles them, so it is the sole compiler and **emits everything it depends on** — + the upstream's marker interfaces ([ADR-0004](0004-marker-interfaces-for-instanceof.md)) and + non-generic classes, plus the consumer-driven specializations. A "resolve-only, don't re-emit + deps" policy would leave the generated specializations referencing a marker nobody emitted + (runtime fatal); it is only correct once upstreams ship *prebuilt* PHP, which is the deferred + Option B below. +- **Root-aware emit.** `Compiler::compile` gained a per-file source-root map so each emitted file + keeps its own PSR-4 layout instead of flattening a second root to `basename()`; a same-target + collision across roots is a hard error. + +The resolution logic lives in two small services (`ManifestParser`, `ManifestResolver`) plus a +`SourceResolver` that the commands share; the monomorphization core is otherwise untouched. + +### Consequences + +- Good: a library compiles its `src` + `tests` + `examples` together (no staging script), and an + app compiles against its dependencies' templates with a one-line, install-stable `include` glob. +- Good: no new runtime dependency; xphp stays a pure transpiler (it emits, it doesn't ship a runtime). +- Trade-off: the downstream recompiles included sources on each build ("compile when needed"); a + prebuilt/incremental path is future work (Option B). +- Trade-off: the manifest is plain JSON — no comments/trailing commas. + +### Confirmation + +`ManifestParser`/`ManifestResolver` are unit-tested (transitive resolution, realpath dedup, cycle +termination, glob discovery skipping non-xphp dirs, explicit-missing-manifest error). `CompileCommand` +is end-to-end tested incl. a cross-package consume whose compiled output is required in a subprocess +and runs with no fatal (the upstream marker is emitted), glob auto-discovery, auto-detect, `--config` +precedence, and target/cache precedence. The single-directory form's behaviour is pinned unchanged. +Documented in [getting started](../getting-started.md). + +## More Information + +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — specializations need the template AST. +- [ADR-0004](0004-marker-interfaces-for-instanceof.md) — the marker interfaces the consumer must emit. +- [ADR-0011](0011-phar-distribution.md) — why a new runtime dependency was avoided. +- Future: a prebuilt/persisted-registry distribution (serialize template ASTs + a load path), + enabling resolve-only deps and skip-unchanged incremental builds. diff --git a/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md new file mode 100644 index 00000000..29639fd3 --- /dev/null +++ b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md @@ -0,0 +1,144 @@ +# 18. Grounding a method-generic bound that references an enclosing class type parameter + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +A covariant collection `class Box` cannot take `E` in a parameter position, so an +element-consuming method (`contains`, `indexOf`, an immutable `withAdded`) classically falls back to +`mixed`. The *sound* spelling is a **method-level** type parameter bounded by the class parameter — +`public function contains(U $value): bool` — so the argument is constrained to a subtype of +the element type while the covariant `out E` never enters a parameter position (the same shape Hack +uses for the element-search methods on its covariant `ConstVector`). `U` is invariant, so this is **not** method-level +variance ([ADR-0014](0014-variance-markers-are-class-level-only.md)) — it only needs the enclosing +`E` to be resolved. + +But the bound `E` was evaluated against the literal type-parameter name: `isSubtype("Banana", "E")` +treats `"E"` as a phantom class, always returns false, and rejects *valid* code +(`Box::contains` with `Banana <: Fruit`) with a misleading +*"Banana does not extend/implement E"*. Bound checking is otherwise nominal and erased +([ADR-0005](0005-nominal-erased-bound-checking.md)); the missing piece is grounding `E` to the +receiver's concrete type argument before the check. + +## Decision Drivers + +- Let a covariant collection expose element-consuming methods with a *real* element-typed parameter + instead of `mixed`, soundly. +- Never false-reject *provably-valid* code — determine the receiver's element type wherever it's + statically knowable (the misleading `"does not extend/implement E"` rejection must go) — but never + silently accept an *unverifiable* bound either: prove it or fail the build, never defer to runtime. +- Keep the existing nominal/erased bound check; add grounding, don't replace it. +- Cover the shape real libraries use: methods declared on a generic **interface/base** and inherited + by concrete collections. + +## Decision Outcome + +Chosen: **at a method-generic call site, ground each bound that names an enclosing class type +parameter against the receiver's concrete type arguments, then run the existing bound check — and +when the receiver's argument genuinely can't be determined, fail the build rather than skip the +check.** This is *ground or fail*, driven by the project's non-negotiable principles +[#2 Maximum Runtime Safety](../../README.md#2-maximum-runtime-safety) (never let an unverified bound +through, and never defer the check to runtime) and +[#1 Zero Runtime Penalty](../../README.md#1-zero-runtime-penalty) (the check is a compile-time fact, +the emitted code carries nothing). + +- **Determination floor — maximise what's knowable first.** The receiver's type arguments are + recovered from flow typing and threaded up the parameterized `extends`/`implements` chain to the + method's **declaring** class (so a method inherited from `Collection` grounds against an + `ArrayList` receiver). The covered receiver shapes: + - a parameter or `$this->prop` of declared generic type (`Box $b`); + - a `new Box::()` local (and a closure-`use` capture of one); + - a value whose type comes from a **method return**, a **chained call**, or a `self`/`static` + factory (`$x = $repo->getBox(); $x->...`, `$repo->getBox()->...`); + - a **branch** whose every arm assigns the *same* parameterised type (the arms agree → the element + type survives the merge). + A bound that references a **sibling** parameter rather than the receiver's element type is grounded + against the supplied argument the same way, both at the class level (`class Pair`) and at + the method level (``, grounded against the call's own turbofish arguments). +- **The residual is a compile error.** A bound leaf that is still a bare type parameter after + grounding — the argument genuinely couldn't be determined (a raw/unparameterised receiver, a branch + whose arms construct different types, a static call with no instance) — is reported as + `xphp.bound_unprovable` with an actionable remedy ("bind the receiver to a typed local"). It is + **never** dropped to "unbounded" and **never** checked against the phantom name. In `xphp check` + the diagnostic is collected; in `xphp compile` it aborts the build. +- A bound that does **not** name an enclosing/sibling parameter — a real class, or an F-bounded + `Comparable` leaf — is untouched and checked exactly as before. + +### Consequences + +- Good: the one place a covariant collection degraded to `mixed` now has a sound, element-typed + parameter; `Box::contains` is accepted and `Box::contains` is + rejected with the bound shown **grounded** (`Fruit`), not `E`. +- **Ground or fail, never silently accept.** Where the receiver's argument can't be determined, an + unprovable bound is a *compile error*, not a silent accept and not a runtime check. This is the + whole point: a covariant generics library must not let an unverified element-type constraint reach + emitted PHP. The determination floor above keeps the error rare — it fires only on receivers that + carry no recoverable element type — and the message names the fix. +- **Compound bounds fail whole.** A method bound like `` whose `E` can't be + grounded fails the **entire** bound rather than checking half of it — the checkable `\Stringable` + operand isn't silently dropped, and the unprovable `E` operand isn't silently accepted. The user + grounds the receiver and the whole bound (both operands) is then checked. +- **Static methods fail.** A class type parameter is unbound in a static context — there is no + instance to ground `E` against — so a static method whose bound names a class parameter is + unprovable and fails. (The call's own method-parameter bounds, e.g. ``, still ground + against the turbofish arguments and are checked.) +- **Erasable methods are lowered, and a forwarded self-call works.** A method whose enclosing-bounded + parameter is used *only* as a direct top-level input (`U $value`) is lowered by **erasing `U` to its + bound `E`**: one concrete `E`-typed member per class instantiation (`contains_(Fruit)`), not + one per call-site turbofish (`contains_(Banana)`) — `` is, after all, the + variance-legal spelling of "an `E`-typed input". A `$this`-rooted self-call that *forwards* its + parameter to such a method — `probe(U $v) { return $this->contains::($v); }` — therefore + compiles and runs (the forward rewrites to the emitted member); it is the idiomatic way to call an + element-consuming method from inside the class. The bound is still checked at the call site before + erasure, so `Box::contains` is still rejected. +- **A covariant upcast to an interface schedules its implementer.** When the erasable method is declared + on a covariant *interface* (`Collection`) and a concrete `ListColl` is upcast to a supertype + specialization (`Collection`), that specialization declares a *distinct* abstract erased member + (`contains_`, separate from `contains_` — distinct names keep the covariant edge from + narrowing a parameter). The concrete implementation is carried down the covariant chain from the + declaring base specialized at the supertype's argument (`AbstractColl`), which the ordinary + fixed-point loop never discovers (an upcast is a usage relationship, not substitution). A specialization + closure step schedules it so the program loads and runs without an explicit instantiation of the + supertype. Where the implementation can't be carried down a single covariant chain — the declaring class + has another parent, a trait-only body, or a reordered `implements` clause — the upcast is a compile error + (`xphp.unschedulable_covariant_upcast`), never emitted load-fataling code. +- **The residual `$this` self-calls still fail — loudly, never at runtime.** A *direct concrete* + `$this->contains::()` self-call (its bound is checkable only on the abstract template) fails + with `xphp.bound_unprovable`; a forward to a *non-erasable* method (parameter used nested, in the + return, or structurally) fails with `xphp.unspecializable_self_call`. Both are compile errors, never + a runtime fault. (A future per-instantiation re-check could relax the direct-concrete case too, but + the common forwarding shape is already handled by erasure.) +- Boundary unchanged — grounding resolves the enclosing parameter to the receiver's argument; the + grounded bound is then checked nominally/erased as before. F-bounded and generic-argument bound + checking are unaffected. +- Variance — making a bare-type-parameter bound leaf a first-class type-parameter reference also + lets the variance phase see it: a covariant/contravariant class parameter used as the **bare** leaf + of a sibling class parameter's bound (`class Pair`) is now flagged, consistently with the + already-rejected inner-argument case (`Sortable>`) and the documented rule that bounds + are an invariant position. The supported method-level shape (`contains`, where `U` is a + *method* parameter) is unaffected. + +### Confirmation + +The grounding, the inheritance threading, the determination floor, and the hard-fail are covered +end-to-end: a direct and an **inherited** (`ArrayList extends Base`) accept, a +multi-argument (`Pair::containsValue`) accept that grounds the right parameter, a reject +whose message shows the grounded bound, the determined-receiver cases (parameter / property / +closure-`use` / method-return / chain / `self`-`static` / branch-arms-agree) accepting or rejecting on +the grounded type, and the unprovable cases (a raw generic parameter, a branch whose arms disagree, a +static class-parameter bound, and a *direct concrete* `$this` self-call) failing with +`xphp.bound_unprovable` — both thrown in `compile` and collected in `check`. The erasure lowering is +exercised by executing the compiled output (a forwarding self-call, an inherited member, a covariant +chain, a multi-class-param `Map`), and a forward to a *non-erasable* method fails with +`xphp.unspecializable_self_call`. A sibling-parameter bound (`class Pair`) is +unit-tested accept/reject with the grounded sibling shown, and a method-own sibling bound (``) +is grounded against the turbofish arguments. The receiver-argument threading is unit-tested for chains, +diamonds (agreeing → one grounding, conflicting → none), cycles, and arity gaps. The variance +consistency is pinned in the variance-position phase. See [type bounds](../syntax/type-bounds.md). + +## More Information + +- [ADR-0005](0005-nominal-erased-bound-checking.md) — the nominal, erased bound check this grounds into. +- [ADR-0014](0014-variance-markers-are-class-level-only.md) — why `U` is invariant (this is not method-level variance). +- [ADR-0004](0004-marker-interfaces-for-instanceof.md) — generic templates lower to empty markers. +- [Type bounds](../syntax/type-bounds.md), [variance](../syntax/variance.md). diff --git a/docs/adr/0019-trait-members-are-not-modeled-in-the-type-hierarchy.md b/docs/adr/0019-trait-members-are-not-modeled-in-the-type-hierarchy.md new file mode 100644 index 00000000..76b0276d --- /dev/null +++ b/docs/adr/0019-trait-members-are-not-modeled-in-the-type-hierarchy.md @@ -0,0 +1,129 @@ +# 19. Trait-imported members are not modeled in the type hierarchy + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +When a value is upcast to a covariant interface — `ListColl` used as +`Collection` — the element-consuming method (`contains`) must be supplied as a +concrete member at the supertype argument. The closer satisfies it one of two ways: **inherit** it +through the covariant `extends` chain (when the body sits on a parent-less covariant base), or, when +inheritance can't carry it, **emit** it directly onto the upcast source. Both paths first locate an +emittable **class** body for the method by walking the type hierarchy's ancestors. + +PHP also lets a class acquire a method body from a **trait** (`use SomeTrait;`). A trait is neither +a parent nor an interface; `TypeHierarchy` is built from `extends`/`implements` clauses and does not +record `use` edges. So a method whose only body is trait-supplied is **invisible** to the closer: +the hierarchy walk finds the method declared (abstractly, via the interface) but no class body to +inherit or copy. The question is what to do with that shape. + +Faithfully modelling traits is not a small addition. It would mean tracking `use` edges, then +honouring PHP's full trait semantics — method resolution order, `insteadof` conflict resolution, +`as` aliasing/visibility changes, abstract trait methods, and trait-on-trait composition — and +threading the imported, possibly-renamed members through the same parameterised-supertype machinery +the class hierarchy already uses. That is a self-contained feature, not a tweak to the upcast closer. + +## Decision Drivers + +- Soundness over coverage — a missing member must fail loudly, never silently emit load-fataling + output. +- Keep the upcast closer scoped — it reasons about the covariant `extends`/`implements` lattice; + trait composition is a separate concern. +- Don't ship a half-modelled trait system whose partial semantics mislead more than they help. + +## Considered Options + +- **Partially model traits** — record `use` edges and copy the matching trait method body, ignoring + conflict resolution / aliasing / abstract trait methods / trait composition. Cheap to start, and it + does handle the trivial case (a single trait, one unambiguous body, whose name matches the + interface). But it is silently wrong the moment a program uses any of the omitted semantics — and + because the closer *synthesizes* a new member (not just keeps a runtime `use`), getting it wrong + emits the wrong member rather than failing. + +- **Fully model traits** — implement PHP's trait resolution end-to-end (method resolution order, + `insteadof` conflict resolution, `as` aliasing and visibility changes, abstract trait methods, and + trait-on-trait composition), threaded through the same parameterised-supertype machinery the class + hierarchy already uses. This is what *correctly* supplying a trait body requires — partial modeling + is unsound the instant resolution decides **which** body lands or **under what name**: + + - **`as` aliasing — the body's name differs from the interface's.** A trait method imported under an + alias is what satisfies the interface, so the synthesized member must be found under the trait's + *original* name and emitted under the *alias*: + + ```php + trait SearchOps { public function locate(U $value): int { /* scan $this->items */ } } + interface OrderedCollection extends Collection { public function indexOf(U $value): int; } + class ListColl extends LinkedNode implements OrderedCollection { + use SearchOps { locate as indexOf; } // the alias is what satisfies indexOf + } + ``` + + The abstract member is `indexOf` at the supertype argument; its body is the trait's `locate`. A + name-keyed copy looks for `indexOf` in `SearchOps`, finds nothing, and leaves the member + unimplemented — a load fatal. Only the alias map resolves it. + + - **`insteadof` — two trait bodies, only one wins.** When two `use`d traits both supply the method, + PHP's `insteadof` picks the authoritative body; a copy with no conflict resolution emits the wrong + algorithm (a silent correctness bug) or a duplicate: + + ```php + trait LinearSearch { public function contains(U $v): bool { /* O(n) scan */ } } + trait HashSearch { public function contains(U $v): bool { /* hash lookup */ } } + class FastColl extends RingBuffer implements Collection { + use LinearSearch, HashSearch { HashSearch::contains insteadof LinearSearch; } + } + ``` + + Only honouring `insteadof` emits `HashSearch::contains` as the supertype-argument member; a partial + copy cannot tell which body is correct. + + Correct, but a large, self-contained feature unrelated to the upcast work, and unneeded until a real + program hits one of these shapes. + +- **Don't model traits; treat a trait-only body as a residual** — the hierarchy stays + `extends`/`implements`-only; a covariant-upcast member with no reachable *class* body fails loudly. + +## Decision Outcome + +Chosen: **traits are not modelled in the type hierarchy.** `TypeHierarchy` records only +`extends`/`implements` edges. When a covariant upcast needs a member whose only body would come from +a trait, no class body is found, so the upcast is a compile error +(`xphp.unschedulable_covariant_upcast`) — the same loud, actionable failure used for the other +shapes direct emission can't ground. The remedy is to move the element-consuming body onto the +covariant base **class** (where both the inheritance and direct-emission paths can reach it), which +is also the idiomatic place for it. + +This is deliberately consistent with the existing variance/bound caveat that bound and variance +rules are **not** recursively walked across trait `use` boundaries (see +[Caveats](../caveats.md#variance-validator-and-trait-use)): xphp does not currently follow generics +through traits in either direction. A trait-only covariant-upcast body falls under the same boundary +and fails the same way, rather than being a silent gap. + +### Consequences + +- Good: the upcast closer stays sound and small; an unsupported shape is a clear compile error with + a one-move remedy, never emitted code that fatals at load or run time. +- Good: no partially-correct trait semantics to mislead — the boundary is uniform with the existing + trait caveat. +- Trade-off: a library that puts an element-consuming method body in a trait and relies on it + through a covariant upcast must relocate that body to the covariant base class. Declaring the + method directly on the class is the supported shape. +- Reversible: if a real program needs it, modelling `use` edges (with full resolution semantics) is + an additive change behind the same diagnostic — the failure becomes a success without any + call-site change. + +### Confirmation + +Exercised by the covariant-upcast suite: a method whose body is supplied only through a trait +produces `xphp.unschedulable_covariant_upcast` rather than an emitted member, alongside the accepted +shapes where the body is on a parent-bearing class (direct emission) or a parent-less base +(inheritance). `TypeHierarchy` is built solely from `extends`/`implements` clauses; no `use` edge is +recorded. + +## More Information + +- [ADR-0018](0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md) — grounding + method-generic bounds on enclosing type parameters (the feature this boundary sits inside). +- [Type bounds](../syntax/type-bounds.md) — the covariant-upcast section and its residual cases. +- [Caveats](../caveats.md) — the variance/bound trait-`use` boundary this is consistent with. +- [Errors](../errors.md) — `xphp.unschedulable_covariant_upcast`. diff --git a/docs/adr/0020-diagnose-and-restructure-self-reintroducing-specialization.md b/docs/adr/0020-diagnose-and-restructure-self-reintroducing-specialization.md new file mode 100644 index 00000000..54df222d --- /dev/null +++ b/docs/adr/0020-diagnose-and-restructure-self-reintroducing-specialization.md @@ -0,0 +1,207 @@ +# 20. Diagnose and restructure self-reintroducing specialization (erased seam deferred) + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +Monomorphization ([ADR-0001](0001-monomorphization-over-type-erasure.md)) is a +fixed-point loop: each instantiation a member reaches becomes a new instantiation +to specialize. It does not converge when a member **re-introduces the receiver's +own type family in a strictly larger form**. The canonical shape is a covariant +collection with a grouping derivation: + +``` +class ImmutableList { + function groupBy(callable $keyOf): ImmutableMap> { … } +} +class ImmutableMap implements Map { + function values(): OrderedCollection { // re-exposes V — here, a list + return new ImmutableList::(...); + } +} +``` + +Reaching `values()` exposes `ImmutableList>`, whose own +`groupBy` produces a deeper map, and so on — an unbounded tower. Today this +terminates only because the depth cap +([ADR-0006](0006-bounded-specialization-depth-cap.md)) aborts the run, so the +group-then-iterate idiom does not compile. A related case — grouping at +subtype-related element types (`Book` <: `Media`) — compiles but fatals at PHP +class-load on an incompatible covariant override +([variance caveats](../caveats.md)). + +A controlled experiment settled how the cycle is actually driven. Three +restructurings of `values()` were compiled under a memory/time cap: + +| `values()` return type | `values()` body | Result | +|---|---|---| +| `iterable` (non-generic) | returns a plain `array` (no nested generic) | **compiles** | +| `iterable` (non-generic) | still `new ImmutableList::(...)` | **towers** | +| bare `OrderedCollection` (no arg) | still `new ImmutableList::(...)` | **towers** | + +The finding: **the member's body drives the tower, not its return type.** Erasing +only the declared type changes nothing; the cycle breaks only when the body stops +constructing the strictly-larger value. This is decisive for the design — it means +the author can already break the cycle today, and that any compiler-side "erase +the type at this position" feature would have to reach into the body, not just the +signature. + +## Decision Drivers + +- **Termination is already guaranteed** by the depth cap; the open question is the + author's experience when they hit it, not soundness. +- **Don't add language surface or machinery without strong evidence it is needed** + — the experiment shows the cycle is breakable with existing constructs. +- **The author needs an actionable path**, not just an abort. +- **Keep monomorphization's guarantees** ([ADR-0001](0001-monomorphization-over-type-erasure.md)) + intact for all code that does not opt out. + +## Considered Options + +- **A precise diagnostic plus author-side restructuring** — no new language + feature; tell the author exactly where the cycle is and let them break it. +- **An opt-in erased seam now** — a `dyn`-style marker that compiles a position to + its erased supertype and cuts the specialization edge. +- **Automatic erasure on cycle detection** — the compiler erases the offending + position itself. +- **Whole-program erasure** — already considered and rejected in + [ADR-0001](0001-monomorphization-over-type-erasure.md). + +## Decision Outcome + +Chosen: a **precise diagnostic plus author-side restructuring**. The cheapest +sound option, justified directly by the experiment: the author can break the cycle +**today, with no new feature**, by restructuring the member's body so it does not +construct the strictly-larger value — return the groups as a non-generic +`iterable`/`array`, or split the derivation. The runtime values are unchanged +(still concrete lists); only the static element type is given up past that +boundary, which is the unavoidable cost of stopping the expansion. + +The work this decision authorizes: + +1. **Keep the terminating depth cap** ([ADR-0006](0006-bounded-specialization-depth-cap.md)). +2. **Improve the diagnostic** to (a) fire in `check`, not only `compile` — today + `check` never specializes, so it passes green and `compile` then aborts — and + (b) name the offending member and source position, not just the runaway type + family. +3. **Document the restructuring** as the supported cure. + +The restructuring, concretely: + +```php +// BEFORE — towers: the body re-wraps V (a list) into a deeper list. +public function values(): OrderedCollection { + return new ImmutableList::(...\array_values($this->entries)); +} + +// AFTER — compiles: return the groups as a non-generic iterable; no deeper spec. +public function values(): iterable { + return \array_values($this->entries); // elements are still ImmutableList at runtime +} +``` + +Callers iterate the result (`foreach`) and read each group at runtime; the static +element type is `mixed` past the seam, re-narrowed with an `instanceof` where a +typed view is needed. + +**The erased seam is deferred, not rejected.** A first-class `dyn`-style seam +(emit the erased supertype *and* erase the body's constructed value) remains a +viable future ergonomic improvement — it would let the author keep writing the +natural generic signature instead of hand-erasing. It is deferred because the +experiment shows it is a deeper, costlier transformation than a type annotation +(it must reach into the body), and the manual restructuring already covers the +need. If demand for the ergonomics appears, this ADR's prior-art section is the +starting point. + +### Consequences + +- Good: no new language surface; the smallest change that resolves the trap; it is + how mature collection libraries already cope. +- Good: monomorphization's guarantees ([ADR-0001](0001-monomorphization-over-type-erasure.md)) + stay intact everywhere — nothing is silently erased. +- Good: the same restructuring that breaks the tower also removes the + covariant-override load fatal at that position, since the erased member returns a + uniform non-generic type. +- Trade-off: a legitimate idiom (group, then iterate via a *typed* view) does not + compile as written; the author must restructure and accept the loss of the + static element type past the boundary. +- Trade-off: the precision loss is manual and per-site rather than expressed by a + single marker — the ergonomic gap the deferred seam would close. + +### Confirmation + +The failure modes are pinned by characterization fixtures and tests so a +regression that turned the controlled abort into a hang, an OOM, or a +silently-wrong build is caught: `test/fixture/compile/reachable_groupby_then_values`, +`…_then_entries`, and `…_subtype_elements`, exercised by +[`SpecializationTowerBoundaryTest`](../../test/Transpiler/Monomorphize/SpecializationTowerBoundaryTest.php). +The terminating message lives in +[`Compiler`](../../src/Transpiler/Monomorphize/Compiler.php). The diagnostic +improvements (check-time detection, member naming) are the authorized follow-up. + +## Pros and Cons of the Options + +### Precise diagnostic + author restructuring + +- Good: cheapest; no new surface; restructuring works today; keeps every + monomorphization guarantee. +- Bad: the natural typed-view idiom must be hand-rewritten; precision loss is + expressed per-site, not declaratively. + +### Opt-in erased seam now + +- Good: the author keeps the natural generic signature; precision loss is one + explicit marker. +- Bad: not just a type annotation — must erase the body's constructed value too + (the experiment proves a signature-only change does not converge); new surface to + parse, validate (output-position only), and teach, for a need the restructuring + already meets. Deferred. + +### Automatic erasure on cycle detection + +- Good: nothing new to write. +- Bad: silent, non-local precision loss; output depends on detection order, so it + is non-deterministic; erases a position the author may have wanted concrete. + Rejected. + +### Whole-program erasure + +- Good: no tower can form; no override fatal. +- Bad: forfeits reified types, native `TypeError` enforcement, and reflection + fidelity — the reasons [ADR-0001](0001-monomorphization-over-type-erasure.md) + chose monomorphization. Rejected there. + +## More Information + +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — monomorphization is the + model; nothing here erases it. +- [ADR-0006](0006-bounded-specialization-depth-cap.md) — the depth cap that + guarantees termination; this decision improves the experience around it. +- [ADR-0014](0014-variance-markers-are-class-level-only.md) — variance is + class-level; relevant to any future output-position seam. +- [Caveats](../caveats.md) — the variance-edge-under-single-inheritance limit. + +### Prior art for the deferred seam + +If the erased seam is ever built, it is not novel — it is the established escape +hatch in monomorphizing languages, and the prior art also confirms the +body-erasure requirement found above. + +**Rust — `dyn Trait` / `Box` (trait objects).** Rust monomorphizes by +default; a trait object is the opt-in *erased* alternative. The docs call +`dyn Trait` an **"erased type"** — concrete type unknown at compile time, accessed +through a fat pointer to a vtable +([Rust Reference: Trait objects](https://doc.rust-lang.org/reference/types/trait-object.html), +[Rust Book §18.2](https://doc.rust-lang.org/book/ch18-02-trait-objects.html)). It +"enables polymorphism without monomorphization, which directly avoids the +monomorphization recursion limit." Crucially, erasure happens **at the value** — +the body boxes the concrete value (`Box::new(x) as Box`), exactly the +body-level step the experiment showed is required; `impl Trait` (opaque but still +monomorphized) is *not* the seam. The same indirection breaks infinitely-sized +recursive types +([Rust Book §15.1](https://doc.rust-lang.org/book/ch15-01-box.html)). + +**JVM-hosted and HHVM generics — whole-program erasure.** Kotlin/Java and Hack +erase generics outright — one runtime class per generic, the type argument dropped +([Hack & HHVM: Type Erasure](https://docs.hhvm.com/hack/generics/type-erasure/)). +A seam would do the same at a single chosen position rather than program-wide. diff --git a/docs/adr/0021-compile-runs-the-check-gate-by-default.md b/docs/adr/0021-compile-runs-the-check-gate-by-default.md new file mode 100644 index 00000000..1eb71c26 --- /dev/null +++ b/docs/adr/0021-compile-runs-the-check-gate-by-default.md @@ -0,0 +1,66 @@ +# 21. `xphp compile` runs the check gate by default + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +`xphp compile` and `xphp check` were independent commands. `compile` transpiles `.xphp` → `.php`; `check` +runs the generic validators **and** PHPStan over the compiled output ([ADR-0009](0009-phpstan-over-compiled-output.md)) +and is the authoritative "is this program sound?" gate. Because `compile` did not run that gate, a class of +errors slipped through it silently and surfaced only at runtime — most notably a **type argument that names +no real class** (`new Box::()`), which `compile` emitted as a reference to a phantom FQN +(`\App\Nonexistent`), fataling when the code ran. + +Earlier attempts to catch this *inside* the transpiler were rejected: a hard error on an unresolved type +argument, and a fallback that scanned the project's `.php` files, both **false-rejected valid code** — a +plain-`.php` domain class used as a type argument (`new Producer::()`, intentionally supported) is +indistinguishable from a typo when the transpiler only sees its own source roots and cannot read the autoload +map. The transpiler is the wrong place to answer "does this class exist?"; PHPStan, running with the project's +real autoloader, already answers it soundly. + +The question: how should `compile` stop emitting code that the gate would reject, without re-implementing +(unsoundly) the existence check PHPStan already does, and without taxing the fast edit/compile loop with a +multi-second PHPStan run on every build? + +## Decision + +**`compile` runs the same gate as `check` by default, before emitting anything**, and fails the build +(emitting nothing) when the gate reports an error. The shared gate is `CheckGate` (the generic validators +plus, on a clean pass, the PHPStan layer); `compile` and `check` both run it. One escape valve keeps it cheap: + +- **`--no-check`** skips the gate entirely and compiles directly (the previous behavior) — for a fast local + iteration build when the gate has already been run separately. + +A missing PHPStan degrades to a non-failing warning (the build proceeds), matching `check`. `--no-phpstan` +runs only the generic validators (still a real gate, just without the PHPStan layer). + +### A skip-when-unchanged marker was considered and rejected as unsound + +An earlier draft added a content-hash marker (`/.check-ok`) that skipped the gate when the inputs +hashed identically to the last green run, to spare no-op rebuilds the multi-second PHPStan floor. It was +dropped: the marker's key can only ever be a *proxy* for the gate's true dependency set, and the proxy is +strictly narrower than the real set. The gate's verdict also depends on the PHPStan config's transitive +`includes:` closure and on the whole autoload universe the emitted code can reference — neither of which the +key can cheaply capture. So the marker could report "current" for an input whose gate result had actually +changed, **skip a gate that would now fail, and re-emit rejected output at exit 0** — silently re-opening the +exact runtime-fatal hole the gate exists to close. A slower-but-sound default beats a fast one that +occasionally lies. Fast warm rebuilds are deferred to a sound mechanism (reusing PHPStan's own result cache, +which tracks its config and analyzed-file set) rather than a home-grown skip. + +## Consequences + +- **Good:** a typo'd or undeclared type argument (and every other PHPStan-detectable error) now fails at + `compile`, not at runtime — "compile error over runtime error" holds for the default build. The soundness + comes from PHPStan's real autoloader visibility, so no valid plain-`.php` domain class is ever + false-rejected, and the transpiler gains no unsound `.php`-scanning machinery. +- **Good:** `--no-check` is always available for a guaranteed-fast build when the gate has already been run. +- **Trade-off:** the default `compile` now pays the PHPStan floor on every build (≈10× a bare compile on a + small project), since the unsound skip marker was dropped. This is the deliberate safety/speed trade; + `--no-check` bounds its reach for local iteration. +- **Trade-off:** `compile` now depends on PHPStan being resolvable for the full gate (it degrades to a + warning when absent), where before it had no such dependency. +- **Deferred:** fast warm rebuilds via a *sound* cache — reusing PHPStan's own result cache (stabilize the + representative emission paths and persist its cache directory) so it re-analyzes only what changed, without + a home-grown skip that could false-hit. A single-emit optimization (run PHPStan over the real emitted output + instead of a separately compiled representative set) and a `--require-phpstan` strict mode are further + follow-ups. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..3140591b --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,41 @@ +# Architecture Decision Records + +This directory records the **architecturally significant** decisions behind +xphp — the choices that are expensive to reverse and that shape everything +built on top of them: how generics are implemented, what the compiler emits, +how the `check` gate and the PHPStan layer work, and the quality bar the +codebase holds itself to. + +Each record states the problem, the options that were on the table, the option +chosen and why, and the consequences (the good and the trade-offs). They are +written after the fact, from the project's history, so they read as a map of +*why xphp is shaped the way it is* — useful whether you're evaluating xphp, +contributing to it, or just curious. + +We use the [MADR](https://adr.github.io/madr/) format. New significant decisions +should be added here as a new numbered file; copy +[`0000-adr-template.md`](0000-adr-template.md) to start. + +| # | Decision | Status | +|---|----------|--------| +| [0001](0001-monomorphization-over-type-erasure.md) | Monomorphization over type erasure | Accepted | +| [0002](0002-build-time-transpiler.md) | A build-time transpiler that emits plain PHP | Accepted | +| [0003](0003-rfc-aligned-turbofish-syntax.md) | RFC-aligned turbofish surface syntax | Accepted | +| [0004](0004-marker-interfaces-for-instanceof.md) | Marker interfaces for `instanceof` across specializations | Accepted | +| [0005](0005-nominal-erased-bound-checking.md) | Nominal, erased bound checking | Accepted | +| [0006](0006-bounded-specialization-depth-cap.md) | Bounded specialization with a hard depth cap | Accepted | +| [0007](0007-xphp-check-gate.md) | The `xphp check` validate-only gate | Accepted | +| [0008](0008-collect-or-throw-diagnostic-seam.md) | The collect-or-throw diagnostic seam | Accepted | +| [0009](0009-phpstan-over-compiled-output.md) | PHPStan over the compiled output | Accepted | +| [0010](0010-undeclared-type-and-arity-validation.md) | Undeclared-type and arity validation | Accepted | +| [0011](0011-phar-distribution.md) | PHAR distribution via Humbug Box | Accepted | +| [0012](0012-engineering-quality-bar.md) | The engineering quality bar | Accepted | +| [0013](0013-typed-constructor-parameters-on-variant-classes.md) | Typed constructor parameters on variant classes | Accepted | +| [0014](0014-variance-markers-are-class-level-only.md) | Variance markers are class-level only | Accepted | +| [0015](0015-variance-markers-on-private-properties.md) | Variance markers on private properties | Accepted | +| [0016](0016-no-special-cased-value-equality-bound.md) | No special-cased value-equality bound (use ordinary generics) | Accepted | +| [0017](0017-config-manifest-source-resolution.md) | Config-manifest, multi-root source resolution (`xphp.json`) | Accepted | +| [0018](0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md) | Grounding a method-generic bound that references an enclosing class type parameter | Accepted | +| [0019](0019-trait-members-are-not-modeled-in-the-type-hierarchy.md) | Trait-imported members are not modeled in the type hierarchy | Accepted | +| [0020](0020-diagnose-and-restructure-self-reintroducing-specialization.md) | Diagnose and restructure self-reintroducing specialization (erased seam deferred) | Accepted | +| [0021](0021-compile-runs-the-check-gate-by-default.md) | `xphp compile` runs the check gate by default | Accepted | diff --git a/docs/caveats.md b/docs/caveats.md index 4799252b..4eb09207 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -73,6 +73,12 @@ class Holder { ## `static` closures not supported +`static` **arrows** work: `static fn(T $x): T => $x` specializes +exactly like a plain arrow (an arrow can never bind `$this`, so the +`static` is inert; note the rewritten dispatcher closure is technically +non-static — observable only through `Closure::bind` or reflection). +The gap below is specific to the `static function` (closure) syntax. + ### ❌ What doesn't work ```php @@ -100,16 +106,23 @@ to a named function. ### Why -The dispatcher closure that xphp emits to route specialized calls -needs a `$this`-binding target for one of the planned future -extensions. `static` closures explicitly block `$this` binding, -which removes that hook. +Specializing an anonymous template at its call site landed in stages. +Plain generic closures and arrows are rewritten through the dispatcher +today; `static` closures (alongside explicit `use (...)` closures) are +a still-unimplemented branch of that rewrite. It's a capability gap, +not a binding one — a `static` closure has no `$this` to begin with, so +this is unrelated to the [`$this`-capture +rejection](#this-capturing-arrows-and-closures-rejected) above. The +named-function path is already complete, so lifting the body to a +file-scope generic function side-steps it. ### ✅ Workaround -Drop the `static` modifier, or lift the body to a named function: +Use an arrow, drop the `static` modifier, or lift the body to a named +function: ```php +$f = static fn(T $x): T => $x; // works $f = function(T $x): T { return $x; }; // works // or function id(T $x): T { return $x; } @@ -118,39 +131,87 @@ id::(42); // works --- +## Closure signature types only in parameter, return, and property slots + +A `Closure(int $x): bool` signature type is accepted anywhere a plain +type hint goes — a parameter, a return, a property, or nested inside +another signature. Two positions are **not** supported: a generic type +argument (`Box`) and a generic bound +(`class C`). Each is a clear compile error: + +``` +A Closure(...) signature type is not supported as a generic type argument +(closure signatures are allowed only in parameter, return, and property +types). Use a bare \Closure, or introduce a named type alias. +``` + +A signature parameter must also carry a type and cannot have a default +value — a signature describes the callable's shape, not call-time +values. + +### Why + +A signature erases to a bare `\Closure` before specialization, but a +generic argument or bound participates in specialization *itself* +(naming, hashing, subtype edges), where a structural type has no +identity to anchor to. Rejecting loudly keeps the cardinal rule: no +silent miscompile. Lifting these positions is on the +[roadmap](roadmap.md) as a discovery item. + +### ✅ Workaround + +Use a bare `\Closure` in the generic position — you lose the +compile-time conformance check but keep a working type — or wrap the +callable in a named class: + +```php +class C {} // works: bare Closure bound +$b = new Box::<\Closure>(fn() => 1); // works: bare Closure argument +``` + +See [closure types → known limitations](syntax/closure-types.md#known-limitations) +for the full list. + +--- + ## Variance markers are class-level only ### ❌ What doesn't work ```php -function process<+T>(T $x): T { /* ... */ } // free function +function process(T $x): T { /* ... */ } // free function class Box { - public function map<+U>(callable $f): Box { /* ... */ } // method + public function map(callable $f): Box { /* ... */ } // method } -$producer = function<+T>(): T { /* ... */ }; // closure -$arrow = fn<+T>(T $x): T => $x; // arrow +$producer = function(): T { /* ... */ }; // closure +$arrow = fn(T $x): T => $x; // arrow ``` ``` -Variance markers `+T` / `-T` are not yet supported on methods, -functions, closures, or arrow functions; move the generic to a -class-level type parameter. +Variance markers `out T` / `in T` are not supported on methods, functions, +closures, or arrow functions — variance is a class-level-only feature by +design: a function or closure specialization has no stable class identity +to anchor a subtype `extends` edge to. Move the generic to a class-level +type parameter. ``` ### Why -Variance turns into real `extends` chains between specialized classes -(see [variance](syntax/variance.md)). Methods, functions, closures, -and arrows don't have a stable identity to anchor an `extends` chain -to — their specializations are functions, not classes, so there's -nothing for the subtype edge to attach to. +This is a **permanent design boundary**, not a pending feature. Variance turns +into real `extends` chains between specialized classes (see +[variance](syntax/variance.md)). Methods, functions, closures, and arrows +don't have a stable class identity to anchor an `extends` chain to — their +specializations are functions, not classes, so there's nothing for the subtype +edge to attach to. (This matches Kotlin, whose `fun map(...)` is likewise +invariant.) Keep variance at the class level and let method-level type +parameters stay invariant. ### ✅ Workaround Put the template on a named class and use its method: ```php -class Producer<+T> { +class Producer { public function __invoke(): T { /* ... */ } } ``` @@ -267,22 +328,25 @@ $x = new Foo(); if ($cond) { $x = new Bar(); } -$x->m::($arg); // de-specializes -- not a Foo or Bar method call +$x->m::($arg); // compile error: xphp.undetermined_receiver ``` -The post-branch call drops to a non-specialized path because the -analysis can't prove a single class for `$x`. +The post-branch call **fails to compile**: the analysis can't prove a +single class for `$x`, and a turbofish call can only be specialized +against a known receiver type. -> This is a **precision** issue, not a soundness one. xphp will NOT -> pick the wrong class — it just gives up on the specialization. +> This is a **conservatism** issue, not a soundness one. xphp will NOT +> pick the wrong class, and it will NOT emit a runtime-broken call — it +> refuses at compile time and tells you to give `$x` a known type. ### Why -Receiver-type analysis is conservative: when `$x` is reassigned -inside a branch and the arms don't agree on a class, post-branch -calls fall back to a non-specialized path. Otherwise the compiler -could pick a class that doesn't match what the variable actually -holds at runtime. +Receiver-type analysis is conservative: when `$x` is reassigned inside a +branch and the arms don't agree on a class, the receiver's type is +undetermined. The generic method is stripped from its class at compile +time, so a non-specialized `$x->m(...)` would call a method that no +longer exists and fatal at runtime — so the compiler reports +`xphp.undetermined_receiver` instead of emitting it (ground or fail). The same-arms-agree shape IS supported: @@ -330,7 +394,7 @@ trait HasItem { public function set(T $item): void { /* ... */ } } -class Container<+T> { // covariant +class Container { // covariant use HasItem; // The `set(T $item)` from the trait places T in a contravariant // position. The validator should reject -- but it doesn't, because @@ -356,6 +420,72 @@ can see it. --- +## Covariant `array`-backed collections trip the `xphp check` PHPStan pass + +> **Scope:** this affects only a *multi-element* covariant collection backed by an +> `array` field, at **PHPStan level 6 and above**. A covariant **single-value** +> container no longer hits this — store the element in a `private T` property (PHP +> doesn't type-check private slots across the `extends` edge), and the emitted +> `get(): T` over a real-typed `private T` field is PHPStan-clean at every level. +> See the [`Producer`](syntax/variance.md#example) example. + +### ❌ What gets flagged + +```php +class ImmutableList { + private array $items; // many elements → `array` backing, not `T` + public function __construct(T ...$items) { $this->items = $items; } + public function get(int $i): T { return $this->items[$i]; } +} + +$list = new ImmutableList::(new Banana()); // compiles + runs fine +``` + +`xphp compile` is happy and the runtime is correct, but `xphp check`'s +optional [PHPStan-over-the-compiled-output pass](errors.md#phpstan-over-the-compiled-output), +**at level 6 or higher**, reports on the `ImmutableList` specialization: + +``` +Property ...\ImmutableList\T_::$items type has no value type specified +in iterable type array. +[missingType.iterableValue] +``` + +(At level ≤5 it is clean — the missing-iterable-value-type rule only switches on at +level 6.) + +### Why + +A collection holds *many* elements in one field, so the backing must be an `array` +(you can't fit them in a single `private T` slot). xphp substitutes type parameters +in **signatures** (the emitted `get(): Banana` is correct) but emits the backing as +a plain `private array $items` with **no value-type annotation** — PHP has no native +typed array, and xphp doesn't synthesise a `@var Banana[]` docblock for the +specialization. From level 6 PHPStan requires a value type on every iterable, so it +flags the untyped `array` property. This is the PHPStan pass being stricter than +xphp's own generic checks, not a generics error. (The element read +`return $this->items[$i]` is `mixed`, but PHPStan reports the *property*'s missing +value type rather than the return.) + +A single-value container avoids this entirely because its backing field can be a +real-typed `private T` (a private property is variance-exempt — see the +[variance](syntax/variance.md) rules), so there is no untyped `array` at all. The +limitation is specific to the `array`-backed collection shape. + +### ✅ Workaround + +- Run the generic checks without the PHPStan pass: `xphp check src --no-phpstan` + (the generics themselves still validate). +- Or analyse at level ≤5 (the missing-iterable-value-type rule is off there). +- Or scope a PHPStan ignore to the generated property in your `phpstan.neon` + (e.g. `ignoreErrors` on + `#Property .*::\$items type has no value type specified in iterable type array#`). + +The construction side is unaffected — the constructor parameter keeps its real +type and is runtime-type-checked. + +--- + ## `T[]` is xphp-only ### ❌ What doesn't work @@ -387,17 +517,42 @@ class Map { } ``` -If you need a typed key/value container, build it from a generic -class: +PHP array keys are `int|string` only, so `$this->items[$k] = $v` works +only when `K` is a string/int — it **fatals for object keys**. For a +container keyed on (or deduplicating) arbitrary objects, define your own +value-equality contract as a generic interface and bound on it, keying on +`hashCode()` internally. xphp special-cases nothing here — this is the same +pattern as a `Comparable` ordering bound (see [type bounds](syntax/type-bounds.md)): ```php -class Map { - private array $items = []; - public function set(K $k, V $v): void { $this->items[$k] = $v; } - public function get(K $k): V { return $this->items[$k]; } +// Your library/app declares the contract — xphp ships no `Hashable`. +interface Hashable { + public function hashCode(): int|string; + public function equals(T $other): bool; +} + +final class Money implements Hashable { // bare marker — no LSP friction + public function __construct(private int $cents) {} + public function hashCode(): int|string { return $this->cents; } + public function equals(Money $other): bool { return $other->cents === $this->cents; } +} + +// F-bounded: K must be hashable to its own kind. +class Map, V> { + private array $buckets = []; + public function set(K $k, V $v): void { $this->buckets[$k->hashCode()] = [$k, $v]; } + public function get(K $k): V { return $this->buckets[$k->hashCode()][1]; } } ``` +Because a generic interface lowers to an **empty marker** (see ADR-0004), the +implementing class declares `equals(Money $other)` with its **concrete** type and +PHP imposes no signature constraint — exactly how a `Comparable` implementer +writes `compareTo(Money $other)`. The deduping/keying logic itself is ordinary +runtime code in your container; the bound just gives it a compile-time-checked +contract. (The container above is illustrative — xphp is a transpiler and ships +no collection types.) + --- ## Duplicate generic template declaration @@ -525,3 +680,79 @@ PHP 8.5 for the pipe operator. Plain (non-generic) code, including newer-PHP syntax, passes straight through untouched. Note the emitted PHP still requires a runtime that supports those features to *execute*; the supported floor for xphp itself is PHP 8.4 (`composer.json`). + +--- + +## Self-reintroducing specialization (list ↔ map derivations) + +### ❌ What doesn't work + +A derivation whose result type re-wraps the receiver's own type family in a +*growing* form does not compile once that result is instantiated: + +```php +class ImmutableList { + // Seeds a map of sub-lists: the result type reintroduces the receiver's own + // family (ImmutableList) one level deeper. + public function groupBy(callable $keyOf): ImmutableMap> { /* ... */ } +} +class ImmutableMap { + public function values(): OrderedCollection { /* ... */ } // re-exposes the value as a list +} + +// Aborts as soon as the result map is instantiated: specializing it walks its view +// bodies (values()/entries() construct deeper lists) even if you never call them. +$byKey = $list->groupBy::($keyOf); // ❌ re-seeds List → Map → List → … without bound +$bucket = $byKey->get('a'); +``` + +``` +Generic specialization did not converge (exceeded depth 16): a self-reintroducing +cycle grows without bound through App\ImmutableMap (…/ImmutableMap.xphp) and +App\ImmutableList (…/ImmutableList.xphp) — e.g. "…". Break the cycle: return a +non-self-reintroducing type from the re-exposing member (for example a +non-generic iterable), or split the derivation … +``` + +### Why + +This is a **by-design boundary**, not a pending feature +([ADR-0020](adr/0020-diagnose-and-restructure-self-reintroducing-specialization.md)). +xphp monomorphizes — one class per instantiation +([ADR-0001](adr/0001-monomorphization-over-type-erasure.md)) — so a member that +keeps producing a strictly-deeper instantiation of its own family has no fixed +point. Specialization discovers new instantiations **structurally**, from every +member of a specialized class — return types *and* the `new` expressions in method +bodies — including members you never call. So the result map's family-re-exposing +views (`values()`/`entries()`) re-seed the cycle on their own, and retyping a +signature without also changing the body that *constructs* the deeper value does not +stop it. Termination is guaranteed by the depth cap +([ADR-0006](adr/0006-bounded-specialization-depth-cap.md)), which aborts with a +localized diagnostic naming every concrete class in the cycle and its source file. +(It surfaces at `xphp compile`; `xphp check` does not specialize, so it passes +green — compile to see the diagnostic.) Auto-erasing the cycle (a +`dyn`-style seam) is deferred, not built: every monomorphizing language provides +such an escape hatch (Rust's `dyn Trait`, JVM/HHVM erasure), but it must erase the +*constructed value*, not merely the type. + +### ✅ Workaround + +Give the grouped result a **view-less type** — one with no `values()`/`entries()`/ +`keys()` member (and no body) that returns or constructs the receiver's family. A +result exposing only `get(key)`/`count` cannot re-seed the cycle, so the derivation +converges. In practice, host `groupBy`/`associateBy` as static generics on a plain, +non-variant helper returning that view-less result, rather than as members of the +collection — which also keeps the list template free of any map-returning member. + +If you do want bucket iteration, expose it past a **non-generic seam**: a view typed +`iterable`/`array` whose body returns a plain array (no `new ImmutableList::<…>`), so +neither the signature nor the body re-introduces the family: + +```php +public function valuesList(): iterable { return array_values($this->entries); } +// foreach ($byKey->valuesList() as $bucket) { /* a real list at runtime; element type is mixed */ } +``` + +The element type is `mixed` past that seam (re-narrow with `instanceof` where a typed +bucket is needed) — the same trade a `dyn` boundary makes. Or **split the derivation** +so the growing type is never reached through an unbounded chain. diff --git a/docs/errors.md b/docs/errors.md index 1e14d3c4..db1836b2 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -4,13 +4,115 @@ Every compile-time rejection from xphp lists the error message verbatim below, paired with the docs section that explains the constraint. Search this page for the text your compile output shows. +## `xphp check` — validate without emitting + +`vendor/bin/xphp check ` validates your generic `.xphp` +code and reports **every** problem below as a structured diagnostic — +each with a `file:line` location — instead of aborting on the first. +It writes no output (no `dist/`, no cache); it's a pure gate, ideal +for CI. + +```bash +vendor/bin/xphp check src +``` + +Output formats via `--format`: + +| Format | Use | +|--------|-----| +| `text` (default) | human-readable, one block per problem | +| `json` | machine-readable; stable `{ "diagnostics": [ … ] }` shape for tooling | +| `github` | GitHub Actions annotations (`::error file=…,line=…::…`) so problems show inline on a PR | + +Exit codes: **0** (clean), **1** (at least one error), **2** (bad +source directory or unknown `--format`). A file that fails to parse is +reported and skipped, so the rest of the tree is still checked in the +same run. + +### Diagnostic codes + +The `json` and `github` formats tag each diagnostic with a stable code: + +| Code | Meaning | +|------|---------| +| `xphp.bound_violation` | a concrete type argument doesn't satisfy its parameter's bound | +| `xphp.default_bound_violation` | a parameter's default doesn't satisfy its own bound | +| `xphp.missing_type_argument` | a required type argument was omitted and has no default — including a **turbofish-less call** to a generic method, function, or closure (`$x->pick('a')` instead of `$x->pick::('a')`), and a **bare `new` of a generic without all-defaults** (`new Box(...)` where `Box` has a required parameter, instead of `new Box::(...)`): the type argument takes no inference, so it must be supplied explicitly | +| `xphp.too_many_type_arguments` | more type arguments were supplied than the template declares (e.g. `Box::` for a one-parameter `Box`) | +| `xphp.variance_position` | an `out T` / `in T` parameter appears in a position its variance forbids | +| `xphp.inner_variance` | variance is violated through another generic's slot (composition) | +| `xphp.undefined_template` | a generic was instantiated but never declared | +| `xphp.undeclared_type` | a generic member, bound, or default names a type that is neither a declared type parameter nor a known type — e.g. `interface Foo { add(T $x); }` or `class Box` where the name is a stray/typo'd parameter (would otherwise compile to a reference to a non-existent class). Imported (`use`) and fully-qualified names are never flagged. A real class referenced bare must be imported or fully-qualified — including a class in the *same namespace* that lives in a plain `.php` file (which `xphp check` doesn't scan), even though PHP itself wouldn't require the `use` | +| `xphp.duplicate_generic_function` | the same generic function is declared in two files | +| `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) | +| `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) | +| `xphp.unspecialized_generic_closure` | a generic closure/arrow is declared but no in-scope `$var::<...>(...)` call grounds its type parameters — the emitted value would keep raw hints naming non-existent classes (`App\T`) and fatal on first invocation, even when only handed away as a callable (which cannot ground it). Call it with a turbofish in the scope that declares it, or remove the `<...>` clause (also flagged when the parameters are never referenced — the clause is dead syntax) | +| `xphp.unresolved_generic_call` | a turbofish method call (`$obj->m::<…>()` / `Foo::m::<…>()`) names a generic method that can't be resolved on the receiver's type — a typo or wrong receiver type, caught at compile time instead of fataling at runtime | +| `xphp.bound_unprovable` | a method-generic bound that references an enclosing class type parameter (`contains`) can't be proven because the receiver's type argument isn't determinable here — a raw `Box` with no argument, a branch whose arms disagree, a static call, or a `$this` self-call. Ground the receiver (bind it to a typed local) or the build fails | +| `xphp.undetermined_receiver` | a turbofish method call's receiver has no statically-known type (an untyped `foreach` variable, a local whose type is ambiguous after a branch), so the call can't be specialized — it would emit a call to a stripped method that fatals at runtime. Give the receiver a declared type | +| `xphp.unspecializable_self_call` | a `$this`-rooted self-call forwards a type parameter to a **non-erasable** generic method (one whose parameter is used nested, in the return, or structurally). Forwarding to an *erasable* method — parameter used only as a direct input — compiles and runs; otherwise move the call to a typed-receiver context | +| `xphp.unschedulable_covariant_upcast` | a value is upcast to a covariant *interface* whose element-consuming method (`contains`) needs a concrete implementation at the supertype argument that can neither be inherited through the covariant chain nor emitted directly onto the upcast source. Direct emission already covers the cases where inheritance can't carry it (the implementing class has another `extends` parent, implements only a parent of the interface, or reorders the clause); the upcast fails only when **no** emittable class body exists (a truly abstract or trait-only method), the method's **return type** names the element parameter (the widened argument would escape through a narrower return), or its parameters are bounded by **different** enclosing parameters (no single member can be derived). Provide a concrete implementation on a class — move a trait body onto the covariant base, or give the method a non-element return type | +| `xphp.closure_conformance` | a closure literal returned against a `Closure(...)` type doesn't conform to it — its parameters aren't wide enough, its return isn't narrow enough, its by-reference-ness differs, or its arity is incompatible | +| `xphp.parse_error` | the source can't be parsed — either a PHP syntax error after the generic strip pass, or a parse-time xphp rejection (a variance marker on a method/closure, a malformed generic default, a generic clause on a `use` import, a `Closure(...)` signature with a defaulted or untyped parameter, or a `Closure(...)` signature type in an unsupported position such as a generic argument or bound), reported at the offending line | +| `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`; a finding that carries no identifier falls back to the literal `phpstan.error`) — present only when the PHPStan pass runs | +| `phpstan.unavailable` | (Warning) no phpstan binary was found, so the PHPStan pass was skipped | +| `phpstan.run_failed` | (Warning) phpstan was found but couldn't complete (e.g. a config error) | + +> **Scope.** `xphp check` runs every generic *validation* check `xphp compile` +> does — class/interface/trait-level **and** method/function/closure-level +> (bounds, variance, defaults, missing/duplicate generics, unsupported closures). +> By design it does not run the specialization loop or emit code, so the two +> runaway/config guards — the nested-specialization **depth cap** and the +> **hash-collision** check — surface only at `xphp compile` (they aren't type +> errors). You still run `xphp compile` to produce the PHP; `check` is the fast +> validation gate in front of it. + +### PHPStan over the compiled output + +PHPStan never sees `.xphp` generic sugar, so it can't analyse a generic body. When +the generic checks above pass, `xphp check` closes that gap: it compiles your +sources to a throwaway directory, runs **your** PHPStan over the concrete +(monomorphized) output, and maps any finding back to the originating `.xphp` +template declaration — the diagnostic names the concrete instantiation that +surfaced it (e.g. *triggered by `App\Box`*). The findings merge into the same +report and exit code as the generic checks: **one PHPStan, one config, one gate.** + +- **One config.** Your own config drives the level, rules, and extensions — + auto-detected at the project root (`phpstan.neon`, then `phpstan.neon.dist`, + then `phpstan.dist.neon`), or pass `--phpstan-config=PATH`. xphp adds only what's + needed to resolve the generated code's symbols; it picks no level of its own. +- **Opt-out + graceful.** `phpstan/phpstan` is an optional, `require-dev`-style + dependency and is never bundled in the PHAR. The binary is resolved as + `--phpstan-bin=PATH` → `vendor/bin/phpstan` → `$PATH`; if none is found (or an + explicit `--phpstan-bin` doesn't exist), or phpstan can't complete, `check` + emits a **Warning** and carries on — a missing optional tool never fails the + gate. Pass `--no-phpstan` to skip the pass entirely. +- **One representative per template.** A body type error is identical across every + specialization of a template, so xphp analyses a single representative + specialization per template — surfacing the bug once, not once per instantiation. + (Trade-off: a body error that only manifests for *specific* concrete arguments may + be missed; that's the value-flow class PHPStan can't attribute to a template line + anyway.) + +```bash +vendor/bin/xphp check src # generic checks + PHPStan, one gate +vendor/bin/xphp check src --no-phpstan # generic checks only +vendor/bin/xphp check src --phpstan-config=phpstan.neon.dist +``` + +In CI (GitHub Actions), one step gates the build and annotates the diff: + +```yaml +- run: vendor/bin/xphp check src --format=github +``` + ## Quick index | If the message contains... | Read | |----------------------------|------| | `captures \`$this\`` | [Caveats — `$this`-capturing arrows and closures](caveats.md#this-capturing-arrows-and-closures-rejected) | | `static closures cannot yet be specialized` | [Caveats — `static` closures not supported](caveats.md#static-closures-not-supported) | -| `Variance markers \`+T\` / \`-T\` are not yet supported` | [Caveats — variance markers are class-level only](caveats.md#variance-markers-are-class-level-only) | +| `Variance markers \`out T\` / \`in T\` are not supported` | [Caveats — variance markers are class-level only](caveats.md#variance-markers-are-class-level-only) | | `Variance violation in template` | [Variance](syntax/variance.md) | | `Generic bound violated` | [Type bounds](syntax/type-bounds.md) | | `Default for generic parameter \`...\` violates the parameter's bound` | [Type bounds](syntax/type-bounds.md) + [Defaults](syntax/defaults.md) | @@ -19,7 +121,15 @@ constraint. Search this page for the text your compile output shows. | `cannot use itself as a bound` | [Type bounds — F-bounded](syntax/type-bounds.md) | | `already declared` ... `duplicate declaration` | [Caveats — duplicate generic template declaration](caveats.md#duplicate-generic-template-declaration) | | `was instantiated but never defined` | The template was used but no source file declared it — typo or missing import | +| `could not be resolved to a declared generic method` | A turbofish method call names a generic method that can't be resolved on the receiver's type — check the method name or the receiver's type. | +| `Cannot verify generic bound` | [Type bounds — ground or fail](syntax/type-bounds.md#ground-or-fail) — the receiver's type argument isn't determinable; bind it to a typed local. | +| `Cannot determine the receiver's type` | [Turbofish — receiver-type analysis](syntax/turbofish.md#receiver-type-analysis-instance-methods) — give the receiver a declared type. | +| `Cannot specialize the self-call` | [Type bounds — ground or fail](syntax/type-bounds.md#ground-or-fail) — the forward targets a non-erasable method; forward to an erasable one or move the call to a typed-receiver context. | | `was instantiated with N type argument(s) but parameter ... has no default` | [Defaults](syntax/defaults.md) — supply all required args or add defaults | +| `signature type is not supported as a generic` | [Closure types — known limitations](syntax/closure-types.md#known-limitations) — a `Closure(...)` signature can't be a generic type argument or bound; use a bare `\Closure` there. | +| `signature parameter must have a type` | [Closure types — known limitations](syntax/closure-types.md#known-limitations) — every `Closure(...)` signature parameter needs a type; use `mixed` for an unconstrained slot. | +| `signature type cannot give parameter` ... `a default value` | [Closure types](syntax/closure-types.md) — a signature describes the callable's shape; drop the `= ...` default. | +| `Closure literal does not conform` | [Closure types — conformance checking](syntax/closure-types.md#conformance-checking) | | `Nested generic specialization exceeded depth` | A generic refers to itself transitively too deeply (compiler aborts at depth 16) — usually a recursive instantiation cycle. Refactor to break the cycle. | | `Parser returned null AST` | The source file isn't valid PHP after the generic strip pass. Run `php -l .xphp` mentally on the cleaned source — most often a syntax error in the user code that's unrelated to generics. | @@ -48,9 +158,11 @@ generic function at file scope. ### Variance markers on non-class templates ``` -Variance markers `+T` / `-T` are not yet supported on methods, -functions, closures, or arrow functions; move the generic to a -class-level type parameter. +Variance markers `out T` / `in T` are not supported on methods, +functions, closures, or arrow functions — variance is a +class-level-only feature by design: a function or closure +specialization has no stable class identity to anchor a subtype +`extends` edge to. Move the generic to a class-level type parameter. ``` ### Variance composition violation @@ -61,6 +173,31 @@ appears in -only position (via slot N of ). ``` +### Variance position violation + +``` +Generic parameter `<+|->T` appears in position, which is not +allowed for variance. +``` + +`` is the forbidding position — e.g. `method parameter`, +`method return`, `mutable property`, `readonly property`, or +`by-reference parameter`. A property only forbids variance when it is +**public or protected**; a *private* property (declared or promoted) is exempt, +because PHP doesn't type-check private slots across the `extends` chain. A +**by-reference parameter** (`T &$x`) is invariant (it is read and written back), +so neither `out T` nor `in T` is allowed there. + +### Variant class declared `final` + +``` +A variant class cannot be declared `final`: its specializations participate +in `extends` subtype edges that a `final` class cannot anchor. Remove `final`. +``` + +A `out T` / `in T` class is specialized into a chain of `extends`-linked classes; a +`final` class can't be a parent in that chain. Drop `final` from the declaration. + ### Bound violations ``` @@ -79,6 +216,112 @@ parameter's bound. reason: ``` +### Unprovable enclosing-parameter bound + +A method-generic bound that references the enclosing class parameter +(`contains`) is checked by grounding `E` to the receiver's element +type. When that can't be determined, the bound can't be proven and the +build fails — ground or fail, never an unchecked call. See +[type bounds — ground or fail](syntax/type-bounds.md#ground-or-fail). + +```php +class Box { + public function contains(U $value): bool { /* ... */ } +} + +function pick(Box $b): bool { // raw Box — no element type to ground E + return $b->contains::(new Banana()); +} +``` + +``` +Cannot verify generic bound `U : E` for App\Box::contains: the receiver's type +argument is not determinable at this call site, so the bound cannot be proven. +Bind the receiver to a typed local (e.g. `Box $x = ...;`) or pass it as a +typed parameter so its type arguments are known here. +``` + +A `$this`-rooted self-call gets a variant of the message (the receiver is +`$this`, so "bind to a typed local" doesn't apply), and a static method whose +bound names a class parameter fails the same way (no instance to ground `E`): + +```php +class Box { + public function contains(U $value): bool { /* ... */ } + public function probe(): bool { + return $this->contains::(new Banana()); // E is abstract here + } +} +``` + +``` +Cannot verify generic bound `U : E` for App\Box::contains in a `$this`-rooted +self-call: the bound references the enclosing class's own type parameter, which +is abstract in the class template, so it can only be checked once the class is +instantiated. Move this call to a context where the receiver has a concrete +element type (e.g. a function taking `Box $b` then `$b->contains::<...>(...)`), +or don't turbofish an enclosing-parameter-bounded method on `$this`. +``` + +This is the **direct, concrete** self-call (`$this->contains::()`). A +self-call that **forwards a method parameter** to an *erasable* method — +`probe(U $v) { return $this->contains::($v); }` — compiles and runs (see +[type bounds](syntax/type-bounds.md)); only a forward to a *non-erasable* target +fails (next). + +### Unspecializable forwarded self-call + +A `$this`-rooted self-call that forwards a type parameter compiles when the +target is *erasable* (its parameter is used only as a direct input) — both +methods lower to one `E`-typed member per instantiation and the forward resolves +to it. When the target is **not** erasable (the parameter appears nested, in the +return, or structurally), the forward can't be specialized: + +```php +class Box { + public function nested(Box $items): bool { /* ... */ } // not erasable (U nested) + public function relay(Box $items): bool { + return $this->nested::($items); // forwards to a non-erasable target + } +} +``` + +``` +Cannot specialize the self-call `$this->nested::<...>()`: it forwards a type +parameter to a generic method, which has no concrete value in the class template. +Move the call to a context where the receiver has a concrete element type (e.g. a +function taking a typed `Box`), or call the method on a directly-constructed +value. +``` + +### Undeterminable turbofish receiver + +A turbofish call is specialized at compile time, and the generic method is +stripped from its class, so the receiver must have a statically-known type. An +untyped `foreach` variable or a local whose type is ambiguous after a branch +can't be specialized — leaving the call would emit a non-existent method that +fatals at runtime, so it fails at compile time instead. + +```php +function pick(array $boxes): void { + foreach ($boxes as $box) { // $box has no static type + $box->contains::(new Banana()); + } +} + +// Ambiguous after a branch: +$x = new Foo(); +if (mt_rand(0, 1)) { $x = new Bar(); } +$r = $x->fooId::(7); // Foo|Bar — undeterminable +``` + +``` +Cannot determine the receiver's type for the generic call `contains::<...>()`. A +turbofish call is specialized at compile time, so the receiver must have a +statically-known type. Give it a declared type — a typed parameter or property, +or a local assigned from `new ...::<...>()` or a typed return. +``` + ### Defaults ``` @@ -140,6 +383,52 @@ Nested generic specialization exceeded depth 16. Latest registry: ``` +### Closure-signature conformance + +``` +Closure literal does not conform to the declared `Closure(...)` type: + +``` + +Emitted when a closure literal is returned against a `Closure(...)` return +type it doesn't satisfy. The `` names the exact mismatch, e.g. +`parameter 1: string is not wider than int` (a parameter must be the same +as or **wider** than the target's — contravariance), `return type: A is not +a subtype of B` (the return must be the same as or **narrower** — covariance), +`by-reference-ness must match exactly`, or an arity message. See +[closure types](syntax/closure-types.md). The check only ever reports a +*provable* mismatch: an unresolved class, a still-abstract type parameter, an +untyped (⇒ `mixed`) slot, a union/intersection, or a built-in supertype is +accepted rather than falsely rejected. + +### Closure-signature parse rejects + +Each of these is a parse-time rejection (`xphp check` reports it as +`xphp.parse_error`); see +[closure types → known limitations](syntax/closure-types.md#known-limitations). + +``` +A Closure(...) signature type is not supported as a generic type argument +(closure signatures are allowed only in parameter, return, and property +types). Use a bare \Closure, or introduce a named type alias. +``` + +``` +A Closure(...) signature type is not supported as a generic bound (closure +signatures are allowed only in parameter, return, and property types). Use +a bare \Closure, or introduce a named type alias. +``` + +``` +A Closure(...) signature parameter must have a type (untyped signature +parameters are not supported). Add a type, e.g. `Closure(int $x): int`. +``` + +``` +A Closure(...) signature type cannot give parameter <$name|N> a default +value: a signature describes the callable's shape, not call-time values. +``` + ### Parse / AST ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 377c1b05..5e4ee35a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,7 +18,7 @@ composer require --dev xphp-lang/xphp ``` This puts the compiler at `vendor/bin/xphp` and pulls in the runtime -dependencies (`nikic/php-parser`, `symfony/console`). +dependencies (`nikic/php-parser`, `symfony/console`, `symfony/process`). ## 2. Set up the PSR-4 autoload @@ -113,6 +113,63 @@ After the compile completes you'll have: Both `dist/` and `.xphp-cache/` can be gitignored — they're generated artifacts your CI/CD pipeline rebuilds on every deploy. +**Safe by default:** `compile` runs the same validation gate as +[`check`](#) — the generic validators plus PHPStan over the compiled +output — *before* emitting, and fails the build (writing nothing) if it +finds an error, so a typo'd or undeclared type never reaches runtime. For a +fast iteration build — when you've already run `check` and just want to +re-emit — skip the gate with `--no-check`: + +```bash +vendor/bin/xphp compile src dist .xphp-cache --no-check # transpile only, no gate +``` + +### The recommended project setup: an `xphp.json` manifest + +The single-directory form above is the quickest way to compile one +self-contained tree (and it keeps working unchanged). But for any **real +project** — and **required** the moment you *consume* another package's +templates or *ship* your own — the recommended setup is an **`xphp.json`** +manifest at the project root. It's the project config (like `composer.json`): +it records your source roots and dependencies once, so `compile`/`check` take +no positional arguments, and it's what lets the compiler pull several source +roots together instead of staging them into one tree: + +```json +{ + "sources": ["src"], + "include": ["vendor/**"], + "target": "dist", + "cache": ".xphp-cache" +} +``` + +- `sources` — this package's own `.xphp` roots (relative to the manifest). + Omitted ⇒ `["."]`. +- `include` — other packages to pull in, transitively. Each entry is a directory + or a **glob**: `*`/`?`/`[…]` match within one path segment, and `**` (globstar) + matches recursively. A glob auto-discovers: any matched directory that has its + own `xphp.json` is compiled in, others are skipped — so `"vendor/**"` picks up + every installed xphp package at any depth and needs no edit when you add another. + (`"vendor/*/*"` also works for Composer's flat `vendor//` layout.) An + explicit (non-glob) entry without an `xphp.json` is an error. +- `target`/`cache` — optional output dirs (CLI `--target`/`--cache` override). + +Then compile (or check) against the manifest — `--config`, or just run where the +`xphp.json` is auto-detected: + +```bash +vendor/bin/xphp compile --config xphp.json # or: vendor/bin/xphp compile (auto-detect) +vendor/bin/xphp check --config xphp.json +``` + +**Distribution model.** A library ships its `.xphp` *sources* plus its `xphp.json` +(via Composer). A downstream build pulls those sources and compiles the whole +union into its own output — so the upstream's marker interfaces and the +specializations your call sites need all get emitted, and everything runs without +the library being pre-compiled. The single-directory `compile src dist cache` form +above keeps working unchanged. + ## 5. Run it Any normal PHP runtime that loads Composer's autoload will pick up @@ -158,4 +215,4 @@ ever has to know the generated class names. message. - [How it works](guides/how-it-works.md) — the compile pipeline, end to end. -- [Roadmap](roadmap.md) — what's coming next. +- [Roadmap](roadmap.md) — what's shipped, what's in discovery. diff --git a/docs/guides/comparison.md b/docs/guides/comparison.md index 6eb1ec6b..668ad6f0 100644 --- a/docs/guides/comparison.md +++ b/docs/guides/comparison.md @@ -27,12 +27,13 @@ than erasure can. | Generic classes / interfaces / traits | ✅ | ✅ | ✅ | ✅ | ✅ | | Generic functions / methods | ✅ | ✅ | ✅ | ✅ | ✅ | | Generic closures + arrow functions | ✅ | ✅ | ✅ | ✅ | ✅ | +| Typed closure signatures (`Closure(int): bool`) | ✅ (erases to `\Closure`; literal conformance checked at compile time) | ✅ (runtime-lenient) | ✅ (function types) | ✅ (`(Int) -> Bool`) | ✅ (`Fn(i32) -> bool`) | | Upper bounds | ✅ | ✅ | ✅ | ✅ | ✅ | | Multiple bounds (intersection) | ✅ | ✅ | ✅ | ✅ | ✅ | | Union bounds + DNF | ✅ | ✅ | ✅ | ❌ (intersection only via `where`) | n/a | | F-bounded recursion (`T : Box`) | ✅ | ✅ | ✅ | ✅ | ✅ | | Default type parameters | ✅ | ✅ | ✅ | ✅ | ✅ | -| Declaration-site variance (`+T` / `-T`) | ✅ | ✅ | ✅ | ✅ | ⚠️ inferred (lifetime-driven; PhantomData for unused type params) | +| Declaration-site variance (`out T` / `in T`) | ✅ | ✅ | ✅ | ✅ | ⚠️ inferred (lifetime-driven; PhantomData for unused type params) | | Inner-template variance composition | ✅ | ✅ | ✅ | ✅ | ✅ | | Reified T at runtime | ✅ (via AOT) | ❌ (erased) | ❌ | ✅ (inline) | ✅ (monomorphic) | | `instanceof OriginalFqn` works | ✅ | ✅ (trivially: only one class exists at runtime) | n/a | n/a | n/a | @@ -81,7 +82,7 @@ runtime — just the concrete class name substituted in. ### Real subtype edges between specializations ```php -class Producer<+T> { +class Producer { public function get(): T { /* ... */ } } ``` diff --git a/docs/guides/how-it-works.md b/docs/guides/how-it-works.md index 3452aee0..8d8b9ccb 100644 --- a/docs/guides/how-it-works.md +++ b/docs/guides/how-it-works.md @@ -2,9 +2,9 @@ Narrative walkthrough of the compile pipeline -- what `bin/xphp compile` does between reading `.xphp` source and writing vanilla `.php` files -that any stock PHP 8.4 runtime can execute. Each stage is paired with -a Mermaid diagram so the same information is available both -visually and in prose. +that any stock PHP 8.4 runtime can execute. Each stage has its own page +below, paired with a Mermaid diagram so the same information is +available both visually and in prose. For the feature inventory ("what does xphp support today?") see the [syntax tour](../syntax/index.md). For the strategic comparison against @@ -26,11 +26,14 @@ directory while specialized classes land in a cache directory at `/Generated/