diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 5115e79..9aef938 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 03d8c72..c8b15af 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 63820e0..b523351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,187 @@ 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 + +- **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<+E> { 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 `<+E>` 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<+E>`) 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). 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<+T>` 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 `+T` / `-T` marker is now allowed + on a **private** property — declared or promoted, mutable or readonly — so the + natural covariant shape + `class Producer<+T> { 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 `+T` / `-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<+A, +B>` 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<+E> { pick( + Comparator $c): ?E }` where `Comparator<-T>` — is now accepted: `E` sits in a + contravariant slot inside a contravariant parameter position, which composes to a covariant + position a `+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 — a `final` variant class is now rejected.** A `final class Box<+T>` + 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 + +- **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. + +## [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 +258,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 +283,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 84b9757..e5ced8b 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 3288b02..73a969f 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 1d4ee90..e778b13 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 255bce2..553e08a 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 f57c263..53e38d9 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 0000000..e1baee1 --- /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 0000000..5c43a2b --- /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 0000000..da25b50 --- /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 0000000..1f5f4cb --- /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 0000000..0be5008 --- /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 0000000..52e4445 --- /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 0000000..7b73919 --- /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 0000000..733097a --- /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 0000000..0dd5047 --- /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 0000000..e86fb26 --- /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 0000000..22ce560 --- /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 0000000..09c43bc --- /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 0000000..0c6db24 --- /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 0000000..5129a82 --- /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 (`+T` / `-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<+T> { 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 `+T` / `-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 `+T`/`-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<+T>` 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 0000000..f1fce93 --- /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 (`+T` / `-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<+U>(...)`, `$f = fn<-T>(...) => ...`): 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 `+U` / `-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 0000000..e3c7d3c --- /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 (`+T` / `-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<+T> { + 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 0000000..b1dc84d --- /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 0000000..b565440 --- /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 0000000..0045495 --- /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<+E>` 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 `+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<+E>` 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<+E>`) 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<+T, U : T>`) is now flagged, consistently with the + already-rejected inner-argument case (`Sortable<+T : Box>`) 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<+E>`) 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 0000000..cca2b19 --- /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<+E> { public function locate(U $value): int { /* scan $this->items */ } } + interface OrderedCollection<+E> extends Collection { public function indexOf(U $value): int; } + class ListColl<+E> 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<+E> { public function contains(U $v): bool { /* O(n) scan */ } } + trait HashSearch<+E> { public function contains(U $v): bool { /* hash lookup */ } } + class FastColl<+E> 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/README.md b/docs/adr/README.md new file mode 100644 index 0000000..27cba74 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,39 @@ +# 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 | diff --git a/docs/caveats.md b/docs/caveats.md index 4799252..cec0d6f 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -100,10 +100,15 @@ 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 @@ -132,18 +137,23 @@ $arrow = fn<+T>(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 `+T` / `-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 @@ -267,22 +277,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: @@ -356,6 +369,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<+T>`](syntax/variance.md#example) example. + +### ❌ What gets flagged + +```php +class ImmutableList<+T> { + 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 +466,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 diff --git a/docs/errors.md b/docs/errors.md index 1e14d3c..3a1f51a 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -4,6 +4,106 @@ 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')`): a method generic takes no inference, so the type argument 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` | a `+T`/`-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.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.parse_error` | the file isn't valid PHP after the generic strip pass | +| `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 | @@ -19,6 +119,10 @@ 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 | | `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. | @@ -61,6 +165,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 `+T` nor `-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 `+T` / `-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 +208,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<+E> { + 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<+E> { + 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<+E> { + 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 ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 377c1b0..9cd7350 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,52 @@ 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. +### 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 diff --git a/docs/guides/how-it-works.md b/docs/guides/how-it-works.md index 3452aee..f0b99ca 100644 --- a/docs/guides/how-it-works.md +++ b/docs/guides/how-it-works.md @@ -102,8 +102,9 @@ and reattach** trick implemented in 2. **Scan** for generic clauses -- `Name<...>` patterns -- with depth tracking so arbitrarily nested `Box>` constructs are handled. -3. **Strip** every `<...>` clause from the source text, producing - plain valid PHP, AND remember the original byte spans. +3. **Blank** every `<...>` clause by overwriting it with spaces of + equal length, producing plain valid PHP while keeping every byte + offset and line number identical to the original source. 4. **Parse** the stripped source with nikic. 5. **Reattach** the generic metadata to the resulting AST as node attributes (`ATTR_GENERIC_PARAMS` on ClassLike declarations, @@ -111,24 +112,31 @@ and reattach** trick implemented in `ATTR_METHOD_GENERIC_PARAMS` / `ATTR_METHOD_GENERIC_ARGS` on method-scope generics). -The strip step would lose original-source positions, which matters -for editor diagnostics ("the offending `` is at line 12, column -5"). That's what +Because the `<...>` clauses are blanked with equal-length spaces, +generic-clause stripping needs no position bookkeeping at all -- AST +offsets round-trip to the original source for free. The one +length-changing rewrite is the `T[]` array-suffix sugar: `T[]` (3 +bytes) becomes `array` (5 bytes), which shifts every offset to its +right. That's what [`ByteOffsetMap`](../../src/Transpiler/Monomorphize/ByteOffsetMap.php) -solves: it records each removal so any later byte offset in the -stripped source can be translated back into the original. The pair -of (AST, ByteOffsetMap) is returned together as +solves: it records each length-changing segment so any later byte +offset in the stripped source can be translated back into the +original -- which matters for editor diagnostics ("the offending +`` is at line 12, column 5"). When no length-changing +replacement happened the map is the identity and returns the offset +unchanged. The pair of (AST, ByteOffsetMap) is returned together as [`ParseWithMapResult`](../../src/Transpiler/Monomorphize/ParseWithMapResult.php). -Two parser entry points exist: +The parser exposes strict and tolerant modes, each in a with-map and +without-map variant: -- `parse()` -- strict mode, throws on parse errors. Used by - `bin/xphp compile` because compilation must fail on broken - source. -- `parseTolerantWithMap()` -- recovers from trailing parse errors - by feeding the stripped source through nikic's error-handler- - collecting mode. Used when callers need partial results from - incomplete source. +- `parse()` / `parseWithMap()` -- strict mode, throws on parse + errors. `bin/xphp compile` uses the strict path because + compilation must fail on broken source. +- `parseTolerant()` / `parseTolerantWithMap()` -- recover from + trailing parse errors by feeding the stripped source through + nikic's error-handler-collecting mode. Used when callers need + partial results from incomplete source. --- @@ -211,8 +219,9 @@ walks the AST tracking each variable's class from typed parameters, typed properties, `$this`, and local `$x = new Foo()` assignments; when the receiver class is unambiguous, the call binds to the right specialization. When the analysis can't prove a single class (e.g., -after a branching reassignment whose arms disagree), the call falls -back to the non-specialized path rather than risking a wrong dispatch +after a branching reassignment whose arms disagree), the turbofish call +is a compile error (`xphp.undetermined_receiver`) rather than a silently +de-specialized call that would fatal at runtime — see the [branching narrowing caveat](../caveats.md#branching-narrowing-precision-loss). Method-scoped generics work on non-generic AND generic enclosing @@ -266,6 +275,25 @@ the runaway. --- +## Stage 4.5 -- Variance-edge emission + +Once the fixed-point loop has recorded *every* specialization (and not +before -- the comparison is pairwise across the full set), a single +pass over the specialized ASTs wires up the real subtype edges that +declaration-site variance promises. +[`VarianceEdgeEmitter::emitEdges()`](../../src/Transpiler/Monomorphize/VarianceEdgeEmitter.php) +walks each pair of specializations of the same variant template and, +where the type arguments are related the right way, adds the +`extends` / `implements` link between them: `Producer` +actually `extends Producer` when `Banana extends Fruit` and +`T` is covariant (`+T`), dually for contravariant (`-T`). The edges +are appended to the cloned specialization's `implements` / `extends` +list and survive the next stage untouched -- the rewriter only +rewrites *template* `Class_` / `Interface_` nodes, not specialized +ones. + +--- + ## Stage 5 -- Rewriting and emission Two transformations happen during rewrite, both implemented in @@ -383,17 +411,19 @@ birthday collisions are impossible at any practical project size. ### Position fidelity -`ByteOffsetMap` carries the bytes-stripped-and-where info so any -diagnostic span computed after the strip can be translated back to -the original `.xphp` source. +Generic-clause blanking preserves positions on its own, so the only +length-changing rewrite is the `T[]` → `array` sugar. `ByteOffsetMap` +records those segments so any diagnostic span computed after the strip +can be translated back to the original `.xphp` source; with no such +rewrite it's the identity map. --- ## Class roster -Every class under +The core classes under [`src/Transpiler/Monomorphize/`](../../src/Transpiler/Monomorphize/), -grouped by role. +grouped by role (validators and bound-AST nodes omitted for clarity). ```mermaid mindmap @@ -415,6 +445,7 @@ mindmap Specialize Specializer GenericMethodCompiler + VarianceEdgeEmitter Rewrite CallSiteRewriter Emit @@ -438,7 +469,9 @@ and runs bound checks; `RegistryCollector` walks ASTs to populate it; **Specialize** -- `Specializer` substitutes type-params in a cloned template AST to produce a concrete specialization; `GenericMethodCompiler` -does the analogous job for method-scope and free-function generics. +does the analogous job for method-scope and free-function generics; and +`VarianceEdgeEmitter` wires the subtype edges between specializations of +a variant template once they all exist. **Rewrite** -- `CallSiteRewriter` swaps generic Name references for the specialization's generated FQN and replaces generic ClassLike diff --git a/docs/index.md b/docs/index.md index 9c2847f..367f4c3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,6 +35,7 @@ echo $intContainer->get(), $strContainer->get(); // 42hello | [Runtime semantics](guides/runtime-semantics.md) | What the generated code looks like and why reflection/serializers behave the way they do. | | [Comparison](guides/comparison.md) | TS / Kotlin / Rust experience — what carries over and what's different. | | [Roadmap](roadmap.md) | What's shipped, what's queued. Generics are the start. | +| [Architecture decisions](adr/README.md) | Why xphp is shaped the way it is — the significant design choices and their trade-offs. | ## Heads up — divergence from the RFC diff --git a/docs/roadmap.md b/docs/roadmap.md index 519d195..23becd2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,6 +21,7 @@ timeline Generic templates : classes and interfaces and traits : methods on static and instance receivers + : methods inherited from base classes : free functions at any scope : closures and arrow functions Bounds @@ -47,6 +48,12 @@ timeline Developer experience : RFC-aligned call-site syntax : empty turbofish for all-defaults templates + Validation and diagnostics + : xphp check validate-only gate + : collect-all diagnostics with text json github renderers + : undeclared-type and arity validation + : unresolved-generic-call detection + : PHPStan over the compiled output section Next Editor and tooling : Live transpilation via stream wrapper @@ -57,6 +64,10 @@ timeline : Generic type aliases : Variance edges on trait-owned templates : Branching narrowing precision + Generic completeness + : this-capturing and static generic closures + : generic methods inherited via traits + : trait composition for variance and bounds Module surface : internal visibility modifier : composer-package boundary @@ -71,7 +82,6 @@ timeline : Variadic type parameters : Per-arg specialization Ecosystem - : phpstan bridge : REPL and playground : Migration tooling from PHPDoc Explorations @@ -108,14 +118,27 @@ upcoming one. ### Function-level generics - Generic methods on static and instance receivers. +- Generic methods resolved through inheritance: a method declared on a + base (or abstract) class is callable via turbofish on a subclass + receiver (instance, static, nullsafe), emitted once on the declaring + class and inherited. - Generic free functions at namespace scope and bare top-level. - Nullsafe instance turbofish (`$obj?->m::()`). - Receiver-type analysis for `$this`, typed parameters, typed - properties, and local `$x = new Foo()` assignments. -- Conservative branching: post-branch calls de-specialize on - reassignment rather than risking a wrong dispatch. + properties, local `new` assignments, and a value returned by a + method / chained call / `self`-`static` factory. +- Conservative branching: when a post-branch receiver can't be proved + to a single type, a turbofish call is a compile error + (`xphp.undetermined_receiver`) rather than a runtime-broken dispatch. - Same-class merge: post-branch type kept when every reachable arm assigns the same class. +- Enclosing-parameter method bounds (`contains`) grounded against + the receiver, or a compile error (`xphp.bound_unprovable`) when the + element type can't be determined. +- Erasure lowering of a direct-input `` method (one `E`-typed member + per instantiation), so a forwarded self-call + (`probe{ $this->contains::(…) }`) compiles and runs; a forward to + a non-erasable method is a compile error (`xphp.unspecializable_self_call`). ### Anonymous templates @@ -146,9 +169,16 @@ upcoming one. ### Variance - `+T` and `-T` markers on type parameters. -- Position rules enforced at parse time (covariant in return, - contravariant in param, both forbidden in properties, ctor, - bounds, defaults). +- Position rules enforced at compile time (covariant in return, + contravariant in param, any variance in a plain non-promoted + constructor parameter or a *private* property — declared or promoted; + both forbidden in public/protected properties — including + public/protected promoted constructor params — by-reference params, + bounds, and defaults). +- Real-typed construction: a `+T`/`-T` constructor parameter keeps its + concrete type (nothing erased), so construction is runtime-type-checked. +- A `final` variant class is rejected (a `final` class can't anchor the + `extends` edge). - Subtype edges emitted between specializations (`Producer` actually extends `Producer` when `Banana extends Fruit`). @@ -179,6 +209,27 @@ upcoming one. - RFC-aligned call-site syntax (`Name::<...>` turbofish). - Empty turbofish (`Name::<>`) for all-defaults templates. +### Validation and diagnostics + +- `xphp check` — a validate-only gate that emits no code: it runs + every generic validation and reports **all** problems in one pass + (collect-all), each with a `file:line` location and a stable code, + exiting 0 (clean) / 1 (errors) / 2 (bad input). +- Structured diagnostics with `text`, `json`, and `github` + (Actions-annotation) renderers, so the same check drives both local + use and CI. +- Undeclared-type-parameter and over-arity validation (a member, + bound, or default that names a stray/typo'd type; more type + arguments than the template declares). +- Unresolved-generic-call detection: a turbofish method call whose + generic method exists on neither the receiver nor any ancestor is a + compile-time error instead of a silent runtime fatal. +- PHPStan over the compiled output: `check` compiles to a throwaway + directory, runs *your* PHPStan over the monomorphized code (one + representative per template), and maps findings back to the `.xphp` + template. Opt-out via `--no-phpstan`; an absent binary degrades to a + non-failing Warning; never bundled in the PHAR. + --- ## Next @@ -205,8 +256,28 @@ to ship. - Generic type aliases (e.g. `type Pair = ...`). - Variance edges on trait-owned templates. -- Branching narrowing precision: today conservatively de-specializes - when arms disagree; will track unions with runtime dispatch instead. +- Branching narrowing precision: today a turbofish call on a receiver + whose branch arms disagree is a compile error; could track unions with + runtime dispatch instead. +- Per-instantiation checking of a **direct concrete** `$this`-self-call's + enclosing-parameter bound (`$this->contains::()`, today a compile + error), to accept the calls that hold for the instantiations actually used. + (The common *forwarding* shape already works via erasure lowering — see + Shipped.) + +### Generic completeness + +Gaps in already-shipped generics, deferred rather than designed out: + +- `$this`-capturing and `static` generic closures: both are rejected + today; lifting them (rewrite `$this->x` to a passed parameter; route + `static` closures through the dispatcher) is planned. +- Generic methods inherited through traits: resolution follows + `extends` / `implements` ancestors, but a generic method reached only + via a `use`d trait is not yet resolved. +- Trait composition for variance and bounds: the variance-position + validator doesn't walk trait-imported method signatures, and bound + satisfaction doesn't follow trait chains — both currently unmodeled. ### Module surface @@ -238,7 +309,7 @@ to ship. ### Ecosystem -- phpstan / psalm bridge. +- Psalm bridge (the PHPStan bridge has shipped — see Shipped above). - REPL / playground. - Migration tooling: lift PHPDoc `@template` annotations to xphp generic params. diff --git a/docs/syntax/closures-and-arrows.md b/docs/syntax/closures-and-arrows.md index e9b3128..e175553 100644 --- a/docs/syntax/closures-and-arrows.md +++ b/docs/syntax/closures-and-arrows.md @@ -86,8 +86,9 @@ ref-ness is preserved end-to-end. [caveats](../caveats.md#this-capturing-arrows-and-closures-rejected). - > ⚠️ **`static` closures rejected** — `static function(...)` is - rejected because there's no `$this`-binding target on the - dispatcher. Use a named generic function instead. See + rejected because generic static closures can't yet be specialized at + the call site (a capability gap, not a binding one). Use a named + generic function at file scope instead. See [caveats](../caveats.md#static-closures-not-supported). - > ⚠️ **Variance markers not allowed** — `+T` / `-T` are rejected on diff --git a/docs/syntax/index.md b/docs/syntax/index.md index ab2c1d8..ae22a5f 100644 --- a/docs/syntax/index.md +++ b/docs/syntax/index.md @@ -59,8 +59,8 @@ class Sortable> {} // F-bounded class Pair {} // Variance -class Producer<+T> { public function get(): T; } // covariant -class Consumer<-T> { public function set(T $x): void; } // contravariant +abstract class Producer<+T> { abstract public function get(): T; } // covariant +abstract class Consumer<-T> { abstract public function set(T $x): void; } // contravariant // Default type params class Cache {} diff --git a/docs/syntax/methods-and-functions.md b/docs/syntax/methods-and-functions.md index d58bd42..730dd5a 100644 --- a/docs/syntax/methods-and-functions.md +++ b/docs/syntax/methods-and-functions.md @@ -75,22 +75,62 @@ swap_T_e5f3...(1, 'one'); - Receiver-type analysis picks the right specialization when the receiver is `$this`, a typed param, a typed property, or a local `$x = new Foo()` assignment. +- A generic method declared on a base class is callable through + inheritance — see below. + +## Inheritance + +A generic method declared on a base (or abstract) class is callable via +turbofish on a **subclass** receiver, for instance, static, and nullsafe +calls. Resolution walks the receiver's ancestor chain (nearest first), and +the specialization is emitted **once** on the declaring class, so every +subclass inherits the single body. A subclass that redeclares the method +shadows the inherited one. + +```php +abstract class AbstractCollection { + // A generic helper shared by every concrete collection. + public function wrap(U $value): U { + return $value; + } +} +class Collection extends AbstractCollection {} + +$c = new Collection::(); +// `wrap` isn't declared on Collection — it resolves to +// AbstractCollection::wrap, specialized once on the base and inherited: +$s = $c->wrap::('hi'); +``` + +A turbofish call to a generic method that exists on neither the receiver +nor any of its ancestors is a **compile-time error** +([`xphp.unresolved_generic_call`](../errors.md#diagnostic-codes)), caught at +build time instead of fataling at runtime with "Call to undefined method". + +> Resolution follows `extends`/`implements` ancestors. A generic method +> reached only through a `use`d trait is not resolved through inheritance. ## Caveats - > ⚠️ Branching narrowing precision: if `$x` is reassigned inside a - branch and the arms disagree on the class, post-branch calls drop - to a non-specialized path rather than picking a possibly-wrong - class. See + branch and the arms disagree on the class, the receiver's type is + undetermined and a post-branch turbofish call is a compile error + (`xphp.undetermined_receiver`) rather than a silently de-specialized + call. See [caveats](../caveats.md#branching-narrowing-precision-loss). -- > ⚠️ Receiver-type tracking only follows local-scope assignments - and parameter types. A `$this->prop` that flows through a getter - doesn't propagate its concrete class to later call sites. +- > ⚠️ Receiver-type tracking follows declared parameter and property + types, `$this`, local `new` assignments, a value returned by a + **method** or chained call (`$x = $repo->get(); $x->m::()`), and a + branch whose arms agree. A value from a **free function** (`$x = make()`) + isn't tracked — give such a local a typed parameter/property hop, or + the turbofish call fails as an undetermined receiver. ## See also - Test fixture: `test/fixture/compile/generic_method/` - Test fixture: `test/fixture/compile/generic_function/` +- Test fixture: `test/fixture/compile/generic_method_through_inheritance/` +- Test fixture: `test/fixture/compile/generic_static_method_through_inheritance/` - Related: [closures and arrows](closures-and-arrows.md), [turbofish](turbofish.md) diff --git a/docs/syntax/turbofish.md b/docs/syntax/turbofish.md index a3ee7d9..b7b3c46 100644 --- a/docs/syntax/turbofish.md +++ b/docs/syntax/turbofish.md @@ -74,26 +74,57 @@ $id('T_', 42); template is all-defaulted. - Bare `new Foo;` (no `(` or `::<>`) also works for all-defaulted class templates — see [defaults](defaults.md). +- **The type argument is not inferred from the call arguments.** A + turbofish-less call (`$x->pick('a')` instead of + `$x->pick::('a')`) is a compile error + (`xphp.missing_type_argument`), not a silent skip — `xphp check` + catches a forgotten turbofish at build time rather than letting it + fatal at runtime. A generic **method** whose type parameters are all + defaulted may still be called bare; a named generic **function** or + **closure** has no bare or empty-turbofish form, so it always needs + an explicit turbofish. (A first-class callable `pick(...)` creates a + closure rather than calling, and is left alone.) ## Receiver-type analysis (instance methods) For instance-method turbofish `$x->m::(...)`, the compiler needs to -know what class `$x` holds to pick the right method template. It uses: +know what class `$x` holds to pick the right method template. The +receiver's type is determined from: -1. The receiver's declared type: typed parameter, typed property, +1. A declared type: a typed parameter, a typed property (`$this->prop`), or `$this`. -2. Local-scope tracking on `$x = new Foo()` assignments. - -If the analysis can't prove a single class (e.g., `$x` reassigned -inside a branch where the arms disagree), the call drops to a -non-specialized path rather than picking a possibly-wrong class. +2. A local assigned from `new Foo()`, a method return, a chained call, + or a `self`/`static` factory. +3. A branch whose arms all agree on the same class. + +If the analysis can't prove a single class — `$x` is an untyped +`foreach` variable, or it's reassigned across a branch whose arms +disagree — the turbofish call **can't be specialized**. The generic +method is stripped from its class, so a non-specialized call would fatal +at runtime ("undefined method"); rather than emit that, the compiler +reports a **compile-time error** +([`xphp.undetermined_receiver`](../errors.md#diagnostic-codes)). Give the +receiver a statically-known type. + +Once the receiver class is known, the method is resolved through its +**inheritance chain** (nearest ancestor first), so a generic method +declared on a base class is callable on a subclass receiver. The same +holds for the static (`Sub::m::()`) and nullsafe (`$x?->m::()`) +shapes. See +[methods and functions → inheritance](methods-and-functions.md#inheritance). + +A turbofish call whose generic method can't be resolved on the receiver +or any of its ancestors is a **compile-time error** +([`xphp.unresolved_generic_call`](../errors.md#diagnostic-codes)) rather +than a silent pass-through that fatals at runtime. ## Caveats - > ⚠️ **Branching narrowing precision** — receiver-type analysis - conservatively de-specializes after `$x` is reassigned across - branches whose arms disagree. See - [caveats](../caveats.md#branching-narrowing-precision-loss). + conservatively refuses to ground `$x` after it's reassigned across + branches whose arms disagree; a turbofish call there is a compile + error (`xphp.undetermined_receiver`), not a silent de-specialization. + See [caveats](../caveats.md#branching-narrowing-precision-loss). ## See also diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index 0ff0628..ce0659d 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -83,10 +83,173 @@ once it sees `public int $value`. - A concrete arg that the compiler can't reason about (not in the source set, not a built-in) fails with a clear "cannot prove satisfaction" message — widen the bound or add the type to the - source set. + source set. The same "not in the source set" condition on a + *variance* type argument is a non-failing warning rather than an + error — see [variance](variance.md#unprovable-variance-edges). - Bounds are an **invariant position** for variance markers — `+T` - or `-T` are rejected inside a bound expression. See - [variance](variance.md). + or `-T` are rejected inside a bound expression (whether as a bare + leaf, `class Pair<+T, U : T>`, or nested, `Sortable<+T : Box>`). + See [variance](variance.md). + +## Bounding a method type parameter by the enclosing class parameter + +A method-level type parameter may be bounded by one of the **enclosing +class's** type parameters. This is the sound way to give a covariant +`<+E>` collection an element-consuming method without dropping to +`mixed`: the argument is constrained to a subtype of the element type, +while the covariant `+E` never enters a parameter position. + +```php +class Box<+E> { + // U is a method type parameter (invariant), bounded by the class's E. + public function contains(U $value): bool { /* ... */ } +} + +$box = new Box::(); +$box->contains::(new Banana()); // OK — Banana is a subtype of Fruit +``` + +At the call site the bound `E` is **grounded** to the receiver's +concrete type argument (`Fruit` for a `Box`), then checked +like any other bound. A genuine violation +(`Box` then `->contains::(...)`) is rejected, with the +message naming the grounded type (`Fruit`). The receiver's argument is +threaded through `extends`/`implements`, so a method declared on a +generic interface/base and inherited by a concrete collection grounds +the same way. The receiver's type is determined from a typed parameter +or `$this->prop`, a `new`-constructed local, a value returned by a +method or chained call (or a `self`/`static` factory), and a branch +whose arms agree on the same parameterised type. + +### Ground or fail + +If the receiver's type argument genuinely **can't** be determined — a +raw `Box` parameter with no type argument, a branch whose arms construct +different `Box<...>` types, or a `$this->m::<...>()` self-call inside the +class body (where `E` is the class's own parameter, abstract until the +class is instantiated) — the bound **cannot be proven**, so it is a +**compile error** (`xphp.bound_unprovable`) rather than an unchecked +call: + +```php +function pick(Box $b): bool { // raw Box — no element type + return $b->contains::(new Banana()); + // ^ cannot verify `U : E`: bind the receiver to a typed local + // (`Box $b`) so its type argument is known. +} +``` + +This upholds [Maximum Runtime Safety](../../README.md#2-maximum-runtime-safety): +a knowable type is never dropped, and an unprovable bound never becomes a +silent accept or a runtime check — you either ground it or the build +fails, with a message pointing at the fix. A *static* method whose bound +names a class parameter fails the same way: a class type parameter has no +value in a static context, so there is nothing to ground it to. + +A **direct, concrete** `$this`-self-call (a hardcoded turbofish type on `$this`) +is an intentionally loud limitation — its bound is checkable only once the class +is instantiated, so for now it fails (the *forwarding* form below is the way to +make it compile and run): + +```php +class Box<+E> { + public function contains(U $value): bool { /* ... */ } + + public function probe(): bool { + // E is the class's own parameter, abstract until Box is instantiated; + // whether `Banana : E` holds is instance-dependent (Box yes, + // Box no), so this fails rather than risk an unchecked call. + return $this->contains::(new Banana()); + } +} +``` + +Move such a call to a context where the receiver has a concrete element type +(e.g. a free function taking `Box $b`) — **or make the method itself +generic and forward the parameter:** + +```php +class Box<+E> { + public function contains(U $value): bool { /* ... */ } + + // ✅ Forwarding a method parameter compiles and runs: both methods take U + // only as a direct input, so each lowers to one `E`-typed member per + // instantiation and the forward resolves to it. + public function probe(U $value): bool { + return $this->contains::($value); + } +} +``` + +A method whose enclosing-bounded parameter is used **only** as a top-level +input (`U $value`) is lowered by erasing `U` to its bound `E`: one +`contains_(Fruit)` member per `Box`, rather than one per call-site +type. So a forwarded `$this->contains::()` rewrites to that member and runs. +The direct `$this->contains::()` above (a *concrete* turbofish on +`$this`) still fails — its bound is checked only on the abstract template — but +the forwarding form is the idiomatic way to call an element-consuming method +from within the class. A parameter used anywhere else (nested `Box`, a +return, `new U`) keeps the per-call lowering and a forwarded self-call to it is +still a compile error. + +### Element-typed methods on a covariant interface + +The method may be declared on a covariant **interface** and called through a +covariant **upcast** — the shape a collections library uses: + +```php +interface Collection<+E> { + public function contains(U $value): bool; +} +abstract class AbstractColl<+E> implements Collection { + public function __construct(private E ...$items) {} + public function contains(U $value): bool { /* ... */ } +} +class ListColl<+E> extends AbstractColl {} + +function anyProduct(Collection $c): bool { + return $c->contains::(new Product()); +} +$books = new ListColl::(new Book()); // Book <: Product +anyProduct($books); // OK — upcast to Collection +``` + +Each interface specialization declares its own erased member +(`Collection` has `contains_`, `Collection` has +`contains_` — distinct, so the covariant edge never narrows a +parameter). When the element-consuming body sits on a **parent-less covariant +base** that passes its type parameters straight to the interface — the +`AbstractColl<+E> implements Collection` shape above — the implementation is +**inherited** through the covariant chain. + +When inheritance can't carry it — 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. Its bounded parameter widens to the supertype argument (`U → Product`), +while the body reads the source's own element type (`E → Book`); that split is +sound because the source's element is a subtype of the supertype, so reading the +instance's own backing state through the widened parameter is type-safe. A class +upcast to several supertypes gets one such member each (distinct mangled names — +no redeclaration). + +> The body reads at the source's element type, not the supertype. For a method +> whose body inspects `E` structurally — `return $value instanceof E;` — the +> directly-emitted member tests against the source's concrete element (`Book`), +> the runtime instance's actual element type. + +A handful of shapes have no sound emittable member and remain a compile error +(`xphp.unschedulable_covariant_upcast`) rather than a runtime fault — ground or +fail: + +- **No class body** — the method is truly abstract or its only body is supplied + through a trait (trait-imported members aren't modelled in the type hierarchy; + see [ADR-0019](../adr/0019-trait-members-are-not-modeled-in-the-type-hierarchy.md)). +- **The return type names the element parameter** — e.g. `first(U $fallback): E`. + Direct emission would return the widened (supertype) value through the narrower + element return type, a runtime `TypeError`. (Such a method still works through the + inheritance path, which grounds the whole member at one argument.) +- **Parameters bounded by different enclosing parameters** — `pick`. + No single member can be derived for a non-uniform bound. ## Caveats diff --git a/docs/syntax/variance.md b/docs/syntax/variance.md index 237079c..4a16695 100644 --- a/docs/syntax/variance.md +++ b/docs/syntax/variance.md @@ -14,7 +14,10 @@ declare(strict_types=1); namespace App; -// Covariant: T appears in return positions only +// Covariant: T appears in return positions (and in a private property). +// A *private* `T` property is variance-exempt — PHP doesn't type-check private +// slots across the `extends` chain — so the backing field keeps its real type +// (a public/protected `T` property would be rejected; see the rules below). class Producer<+T> { public function __construct(private T $item) {} public function get(): T { return $this->item; } @@ -51,10 +54,12 @@ specializations: namespace XPHP\Generated\App\Producer; class T_ implements \App\Producer { + public function __construct(private \App\Fruit $item) { ... } // real type, runtime-checked public function get(): \App\Fruit { ... } } class T_ extends T_ implements \App\Producer { + public function __construct(private \App\Banana $item) { ... } // narrowed; `__construct` is LSP-exempt and the private slot isn't checked across the edge public function get(): \App\Banana { ... } } ``` @@ -65,27 +70,146 @@ including reflection and `instanceof`. For contravariant `-T`, the edge flips: `Consumer extends Consumer`. +### Nested type-arguments (a generic as a type-argument) + +A covariant slot can hold another generic as its argument, and the edge +composes through it — including when the inner argument is a generic of a +**different but related template**. A covariant `Tuple<+A, +B>` holding a +covariant container relates by *its* element relationship: + +```php +interface Collection<+E> { /* … */ } +class ImmutableList<+E> implements Collection { /* … */ } +interface Tuple<+A, +B> { /* … */ } +class Couple<+A, +B> implements Tuple { /* … */ } + +// Book <: Product, ImmutableList implements Collection, all covariant ⇒ +// Tuple, Tag> ⊑ Tuple, Tag> +``` + +The compiler proves the argument relationship `ImmutableList ⊑ +Collection` by threading the subtype's element up its +`implements`/`extends` chain to the supertype's template +(`ImmutableList` → `Collection`) and comparing under the inner +template's variance (`Book ⊑ Product` under `Collection`'s covariant `E`), +then emits the `Tuple` edge. So a `Couple, Tag>` is +usable where a `Tuple, Tag>` is required, with the +covariance honoured at runtime (`instanceof`, type hints), not only at +`check`. The edge is emitted only when the relationship is *positively* +provable — an unrelated or unprovable inner pair stays conservative (no +edge), never a bogus one that would PHP-fatal at autoload. + +### Unprovable variance edges + +An `extends` edge only emits when the compiler can **prove** the element relationship +from the source set. If an element type is not in the compiled `.xphp` source set and +isn't a recognized PHP built-in — typically a plain-`.php` domain class — the compiler +can't prove it, so the edge is silently skipped. The specializations still work in +isolation, but they aren't linked: passing a `Producer` where a +`Producer` is expected then fails at **runtime** with a `TypeError`, because the +covariant edge never formed. + +`xphp check` reports this as a **non-failing warning** at the instantiation site +(`xphp.variance_edge_unprovable`), naming the type and pointing at "add it to the source +set." It mirrors the bounds "cannot prove satisfaction" check for the same condition (see +[type bounds](type-bounds.md)); the fix is the same — include the element type in the +source set the compiler builds its hierarchy from, so the edge can be proven and emitted. + ## Rules -Position rules enforced at parse time: - -| Position | `+T` allowed? | `-T` allowed? | -|-----------------------------------------|---------------|---------------| -| Method return type | ✅ | ❌ | -| Method parameter | ❌ | ✅ | -| Mutable property | ❌ | ❌ | -| Readonly property | ❌ | ❌ | -| Constructor parameter | ❌ | ❌ | -| Bound expression | ❌ | ❌ | -| Default expression | ❌ | ❌ | - -The strict-invariance rule on properties and constructors is forced -by the runtime model. Under bound-erasure (the RFC's path) variance -doesn't materialise as `extends` edges, so the issue doesn't arise. -xphp emits real `extends` chains between specialised classes, and -PHP enforces invariant property types and constructor signatures -across those chains regardless of `readonly` — a covariant property -would PHP-fatal at autoload when the variance edge lands. +Position rules, enforced at compile time over the collected definitions +(`Registry::validateVariancePositions`): + +| Position | `+T` allowed? | `-T` allowed? | +|---------------------------------------|---------------|---------------| +| Method return type | ✅ | ❌ | +| Method parameter | ❌ | ✅ | +| By-reference parameter (`T &$x`) | ❌ | ❌ | +| Constructor parameter (plain) | ✅ | ✅ | +| Private property (mutable/readonly) | ✅ | ✅ | +| Private promoted constructor property | ✅ | ✅ | +| Public/protected property | ❌ | ❌ | +| Public/protected promoted property | ❌ | ❌ | +| Bound expression | ❌ | ❌ | +| Default expression | ❌ | ❌ | + +The strict-invariance rule on **public/protected properties** (mutable, +readonly, and promoted-constructor) is forced by the runtime model: xphp emits +real `extends` chains between specialised classes, and PHP enforces invariant +property types across those chains for visible members regardless of `readonly` +— a covariant one would PHP-fatal at autoload when the variance edge lands. + +A **private property** (declared or promoted, mutable or readonly) is the +exception: PHP does **not** type-check private property types across an `extends` +chain — a private slot is per-declaring-scope and is never inherited — so each +specialisation keeps its own real-typed field (`private Banana $item` / +`private Fruit $item`) with no fatal. A private member is also invisible to the +externally-visible variance surface, so it may carry any variance soundly, and +xphp emits it with its **real** substituted type. (Detection is by the `private` +visibility bit: a `readonly`-only promoted parameter is implicitly public, and an +asymmetric `public private(set)` property is externally readable, so both stay +strictly invariant.) + +A **by-reference parameter** (`function f(T &$x)`) is likewise invariant: the +caller's variable is both read and written back through the reference, so it acts +as input *and* output — neither `+T` nor `-T` is sound. This holds in method, +constructor, and nested closure/arrow signatures. + +A **plain (non-promoted) constructor parameter** is the exception: it may +carry `+T` / `-T` at any variance, and xphp emits it with its **real** +substituted type. A constructor parameter isn't part of the externally-visible +variance surface (a constructor is never reached through an upcast reference — +the same reason Kotlin exempts constructor parameters from variance checks), and +PHP exempts `__construct` from LSP signature checks, so the specialisations' +constructors may legitimately differ across the edge. That's what lets a +covariant immutable collection take *type-checked* construction input (see +below). A *promoted* constructor parameter is a property, so it follows the +property rules above: a public/protected one stays strictly invariant, while a +**private** one is exempt and keeps its real type — which is exactly what makes +the covariant single-value `Producer<+T>` shape at the top of this page work. + +### Covariant immutable collections (typed construction) + +A covariant container can take its element type in its constructor — the +backbone of a read-only `List`-style collection: + +```php +class ImmutableList<+T> { + private array $items; + public function __construct(T ...$items) { $this->items = $items; } + public function get(int $i): T { return $this->items[$i]; } +} + +function firstProduct(ImmutableList $items): Product { return $items->get(0); } + +// Covariance: an ImmutableList is accepted where ImmutableList +// is expected, because Book extends Product. +$books = new ImmutableList::(new Book(), new Book()); +$p = firstProduct($books); +``` + +The constructor parameter keeps its real element type on every specialisation +(`Book ...$items` on `ImmutableList`, `Product ...$items` on +`ImmutableList`), and `ImmutableList` still `extends +ImmutableList` without a PHP fatal — PHP doesn't signature-check +`__construct` across the chain. (A variant class **cannot be declared `final`**: +its specializations are linked by `extends` edges, which a `final` class can't +anchor, so `final` on a `+T`/`-T` class is rejected at compile time.) + +> ✅ **Construction is runtime-type-checked.** Because the constructor parameter +> keeps its real type, PHP enforces it at construction: building an +> `ImmutableList` from a non-`Book` throws a `TypeError`. You get both +> covariance *and* a real construction-time guarantee — nothing is erased. The +> one property shape that can't carry a real `T` is a **non-private** (public or +> protected) stored property — PHP enforces invariant property types across the +> edge for visible members. A **private** stored property *can* hold a real `T` +> (see the single-value `Producer<+T>` at the top), so a covariant single-value +> container needs no `mixed` backing at all. A *multi-element* collection like +> `ImmutableList` is different: many elements live in one `private array $items` +> field, which xphp emits without a value-type annotation — so it compiles and +> runs fine but trips the optional `xphp check` PHPStan pass at level 6+ (the +> untyped `array` property has no iterable value type; see +> [caveats](../caveats.md#covariant-array-backed-collections-trip-the-xphp-check-phpstan-pass)). ### Inner-template variance composition @@ -106,6 +230,28 @@ invariant, so the outer `+T` is rejected. The validator walks every generic class's method signatures, bounds, and defaults to apply this composition. +Composition can also *permit* a position that looks wrong at a glance. +A consuming method that takes a **contravariant** generic is sound on a +covariant class: + +```php +interface Comparator<-T> { public function compare(T $a, T $b): int; } + +class Box<+E> { + public function pick(Comparator $c): ?E { /* … */ } // ALLOWED +} +``` + +`E` is in a contravariant slot (`Comparator<-T>`) inside a contravariant +parameter position — contra ∘ contra = **covariant**, which a covariant +`+E` may occupy. (Under an upcast, a `Box` viewed as `Box` +takes a `Comparator`, which by contravariance compares the `Book` +elements — sound.) This is the element-consuming counterpart to the +covariant immutable constructor: a `mixed`-free, fluent +`sortedWith`/`minWith`/`pick` on a covariant collection. A **direct** +covariant `E` in a parameter (`pick(E $x)`) stays rejected — only the +*composed* position is covariant here, not the bare one. + ## Caveats - > ⚠️ **Not allowed on closures or arrows** — anonymous templates diff --git a/infection.json5 b/infection.json5 index c0d36a2..64ad07e 100644 --- a/infection.json5 +++ b/infection.json5 @@ -1,5 +1,10 @@ { "$schema": "vendor/infection/infection/resources/schema.json", + // Per-mutant timeout. Raised from the 10s default because the StaticAnalysis + // (PHPStan) tests shell out to a real `phpstan` subprocess, so a mutant whose + // covering set includes them needs well over 10s to run — otherwise it false- + // times-out instead of being killed by the fast pure tests in the same set. + "timeout": 120, "source": { "directories": [ "src" @@ -291,8 +296,14 @@ "XPHP\\Transpiler\\Monomorphize\\Specializer::specialize", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeMethod", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeFunction", + // InnerVarianceValidator::isExemptVariantConstructorParam: the promoted-param + // `return false` guard is unreachable in practice — a covariant class with a + // promoted (property) type-param is flagged by the variance-POSITION pass + // first, and inner-variance skips position-flagged definitions, so this branch + // never decides anything observable. + "XPHP\\Transpiler\\Monomorphize\\InnerVarianceValidator::isExemptVariantConstructorParam", // Specializer::substituteTypeRef: dropping `return $ref` on the empty-args - // branch falls through to array_map over empty + ctor, producing an + // branch falls through to array_map over empty + constructor, producing an // equivalent TypeRef. Pure micro-optimisation, no observable difference. "XPHP\\Transpiler\\Monomorphize\\Specializer::substituteTypeRef", // ByteOffsetMap::fromReplacements: removing the `return self::identity()` @@ -763,12 +774,14 @@ // Inside the cycle-safe BFS of TypeHierarchy::isSubtype, `$visited[$cur] = true` // is only consumed via `isset($visited[$cur])` — the value is never read. The // TrueValue → false mutation is observationally identical (the KEY is what tracks - // visit-state, not the VALUE). + // visit-state, not the VALUE). TypeHierarchy::ancestorChain uses the same + // `$seen[$next] = true` + `isset()` dedup, so its TrueValue mutant is equivalent too. // Same pattern in GenericMethodCompiler::rewriteCallSites — `$alreadyGenerated[key] = true` // is consumed via `isset()`, never via value read. "TrueValue": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\TypeHierarchy::isSubtype", + "XPHP\\Transpiler\\Monomorphize\\TypeHierarchy::ancestorChain", "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", // VarianceEdgeEmitter::filterDirectSupers: the // `$impliedByAnother = true; break;` shortcut. Mutating diff --git a/phpstan.neon b/phpstan.neon index eab1b77..c158993 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,4 +1,4 @@ parameters: - level: 7 + level: 9 paths: - src diff --git a/src/Config/Manifest.php b/src/Config/Manifest.php new file mode 100644 index 0000000..0d2ff7d --- /dev/null +++ b/src/Config/Manifest.php @@ -0,0 +1,29 @@ + $sources This package's own `.xphp` source roots (relative). The parser + * substitutes `["."]` (the manifest's own dir) when the key is absent. + * @param list $include Other packages to pull in transitively — each a dir or a glob + * (`*`/`?`/`[…]` per segment, or `**` for recursive discovery). The parser substitutes `[]` when absent. + * @param ?string $target Optional build output dir (entry manifest only; CLI overrides). + * @param ?string $cache Optional generated-class cache dir (entry manifest only; CLI overrides). + */ + public function __construct( + public array $sources, + public array $include, + public ?string $target, + public ?string $cache, + ) { + } +} diff --git a/src/Config/ManifestParser.php b/src/Config/ManifestParser.php new file mode 100644 index 0000000..2e4c4a7 --- /dev/null +++ b/src/Config/ManifestParser.php @@ -0,0 +1,87 @@ +getMessage()), previous: $e); + } + + // `{}` and `[]` both decode to `[]`; treat the empty case as an empty object (defaults). + // Only a non-empty JSON array (a list) is a genuine "not an object" error. + if (!is_array($data) || ($data !== [] && array_is_list($data))) { + throw new RuntimeException(sprintf('Invalid %s: top level must be a JSON object.', $label)); + } + + return new Manifest( + self::stringList($data, 'sources', $label) ?? ['.'], + self::stringList($data, 'include', $label) ?? [], + self::optionalString($data, 'target', $label), + self::optionalString($data, 'cache', $label), + ); + } + + /** + * @param array $data + * @return ?list null when the key is absent (caller applies its default). + */ + private static function stringList(array $data, string $key, string $label): ?array + { + if (!array_key_exists($key, $data)) { + return null; + } + $value = $data[$key]; + if (!is_array($value) || !array_is_list($value)) { + throw new RuntimeException(sprintf('Invalid %s: "%s" must be an array of strings.', $label, $key)); + } + $out = []; + foreach ($value as $entry) { + if (!is_string($entry)) { + throw new RuntimeException(sprintf('Invalid %s: "%s" must contain only strings.', $label, $key)); + } + $out[] = $entry; + } + + return $out; + } + + /** + * @param array $data + */ + private static function optionalString(array $data, string $key, string $label): ?string + { + if (!array_key_exists($key, $data)) { + return null; + } + $value = $data[$key]; + if (!is_string($value)) { + throw new RuntimeException(sprintf('Invalid %s: "%s" must be a string.', $label, $key)); + } + + return $value; + } +} diff --git a/src/Config/ManifestResolver.php b/src/Config/ManifestResolver.php new file mode 100644 index 0000000..b8186f6 --- /dev/null +++ b/src/Config/ManifestResolver.php @@ -0,0 +1,229 @@ +manifestPathFor($entry); + $entryManifest = $this->parseFile($entryPath); + + $visited = []; + $seenRoots = []; + /** @var array $rootByFile */ + $rootByFile = []; + $this->walk($entryPath, $entryManifest, $visited, $seenRoots, $rootByFile); + + $entryDir = dirname($entryPath); + + return new ResolvedSources( + new FilepathArray(...array_keys($rootByFile)), + $rootByFile, + $entryManifest->target !== null ? self::join($entryDir, $entryManifest->target) : null, + $entryManifest->cache !== null ? self::join($entryDir, $entryManifest->cache) : null, + ); + } + + /** + * @param array $visited realpath of each visited manifest (dedup + cycle guard) + * @param array $seenRoots realpath of each enumerated source root (dedup) + * @param array $rootByFile accumulated filepath → root + */ + private function walk(string $manifestPath, Manifest $manifest, array &$visited, array &$seenRoots, array &$rootByFile): void + { + $key = self::realKey($manifestPath); + if (isset($visited[$key])) { + return; + } + // @infection-ignore-all TrueValue -- dedup/cycle-guard keys on isset(), which tests key + // existence not value, so the assigned literal is immaterial (cycle + diamond tests pin it). + $visited[$key] = true; + $dir = dirname($manifestPath); + + foreach ($manifest->sources as $src) { + $root = self::join($dir, $src); + if (!is_dir($root)) { + throw new RuntimeException(sprintf( + 'Invalid %s: source "%s" is not a directory (%s).', + $manifestPath, + $src, + $root, + )); + } + $rootKey = self::realKey($root); + if (isset($seenRoots[$rootKey])) { + continue; + } + // @infection-ignore-all TrueValue -- root dedup keys on isset() (existence, not value), + // so the assigned literal is immaterial; the diamond test pins single-enumeration. + $seenRoots[$rootKey] = true; + foreach ($this->finder->find($root)->filepaths as $file) { + if (str_ends_with($file, '.xphp')) { + $rootByFile[$file] = $root; + } + } + } + + foreach ($manifest->include as $entry) { + $isGlob = self::isGlob($entry); + $dirs = $isGlob ? $this->expandGlob($dir, $entry) : [self::join($dir, $entry)]; + foreach ($dirs as $candidate) { + $childManifest = $candidate . '/' . self::MANIFEST_FILENAME; + if (!is_file($childManifest)) { + if ($isGlob) { + continue; // a glob over-matches non-xphp dirs — skip those silently. + } + throw new RuntimeException(sprintf( + 'Invalid %s: include "%s" has no %s (%s).', + $manifestPath, + $entry, + self::MANIFEST_FILENAME, + $childManifest, + )); + } + $this->walk($childManifest, $this->parseFile($childManifest), $visited, $seenRoots, $rootByFile); + } + } + } + + /** Resolve $entry (a manifest file or a dir containing one) to a manifest filepath. */ + private function manifestPathFor(string $entry): string + { + if (is_file($entry)) { + return $entry; + } + if (is_dir($entry)) { + $path = $entry . '/' . self::MANIFEST_FILENAME; + if (is_file($path)) { + return $path; + } + throw new RuntimeException(sprintf('No %s found in %s.', self::MANIFEST_FILENAME, $entry)); + } + throw new RuntimeException(sprintf('Manifest path does not exist: %s.', $entry)); + } + + private function parseFile(string $manifestPath): Manifest + { + return $this->parser->parse($this->reader->read($manifestPath), $manifestPath); + } + + /** + * Expand a glob (relative to $base) to the directories it matches. + * + * A `**` (globstar) anywhere means recursive discovery: every directory under the literal + * prefix (the path up to the first `**`) that contains its own `xphp.json`. So `vendor/**` + * finds every installed xphp package at any depth. Otherwise the native libc glob runs with + * `GLOB_ONLYDIR` (single-segment `*`/`?`/`[…]`, directories only, sorted) — composer's flat + * `vendor//` layout is also matched by a two-segment single-star glob. + * + * @return list + */ + private function expandGlob(string $base, string $pattern): array + { + $full = self::join($base, $pattern); + + if (str_contains($full, '**')) { + // Recurse from the literal prefix (everything before the first `**`). + return self::discoverManifestDirs(explode('**', $full)[0]); + } + + $matches = glob($full, GLOB_ONLYDIR); + + return $matches === false ? [] : $matches; + } + + /** + * Recursively find every directory under $base that contains an `xphp.json`. $base may carry a + * trailing slash; a non-existent base yields no matches. + * + * @return list + */ + private static function discoverManifestDirs(string $base): array + { + if (!is_dir($base)) { + return []; + } + $dirs = []; + /** @var iterable<\SplFileInfo> $iterator */ + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS), + ); + foreach ($iterator as $entry) { + if ($entry->getFilename() === self::MANIFEST_FILENAME) { + $dirs[] = $entry->getPath(); + } + } + // @infection-ignore-all -- ordering only; the resolved source set is order-independent, and + // native glob() returns sorted too — this just keeps `**` discovery deterministic. + sort($dirs); + + return $dirs; + } + + private static function isGlob(string $s): bool + { + return strpbrk($s, '*?[') !== false; + } + + /** Join $rel onto $base unless $rel is already absolute; `.` resolves to $base. */ + private static function join(string $base, string $rel): string + { + if (str_starts_with($rel, '/')) { + return $rel; + } + if ($rel === '.' || $rel === './') { + return $base; + } + + return $base . '/' . $rel; + } + + /** realpath() for stable dedup keys, falling back to the raw path when it doesn't resolve. */ + private static function realKey(string $path): string + { + $real = realpath($path); + + return $real !== false ? $real : $path; + } +} diff --git a/src/Config/ResolvedSources.php b/src/Config/ResolvedSources.php new file mode 100644 index 0000000..75bd486 --- /dev/null +++ b/src/Config/ResolvedSources.php @@ -0,0 +1,28 @@ + $rootByFile absolute `.xphp` filepath → its source-root dir + * @param ?string $target absolute build-output dir from the entry manifest (null if unset) + * @param ?string $cache absolute generated-class cache dir from the entry manifest (null if unset) + */ + public function __construct( + public FilepathArray $files, + public array $rootByFile, + public ?string $target, + public ?string $cache, + ) { + } +} diff --git a/src/Config/SourceResolver.php b/src/Config/SourceResolver.php new file mode 100644 index 0000000..fbecec0 --- /dev/null +++ b/src/Config/SourceResolver.php @@ -0,0 +1,74 @@ +fromDirectory($sourceDir); + } + + $manifest = $config ?? $this->autodetect($workingDir); + if ($manifest === null) { + throw new RuntimeException( + 'No sources to compile: pass a source directory, --config , or add an xphp.json to the working directory.', + ); + } + + return $this->manifestResolver->resolve($manifest); + } + + private function fromDirectory(string $dir): ResolvedSources + { + $files = $this->finder->find($dir) + ->filter(static fn (string $filepath): bool => str_ends_with($filepath, '.xphp')); + + $rootByFile = []; + // @infection-ignore-all -- in single-dir mode this map just mirrors `$dir`, which is also + // Compiler::compile's scalar-base fallback, so an empty map emits to the identical paths; + // the map is load-bearing only for manifest multi-root (covered in CompileCommandTest). + foreach ($files->filepaths as $filepath) { + $rootByFile[$filepath] = $dir; + } + + return new ResolvedSources($files, $rootByFile, null, null); + } + + private function autodetect(string $workingDir): ?string + { + $candidate = $workingDir . '/' . ManifestResolver::MANIFEST_FILENAME; + + return is_file($candidate) ? $candidate : null; + } +} diff --git a/src/Console/ApplicationConsole.php b/src/Console/ApplicationConsole.php index bed4708..7554aeb 100644 --- a/src/Console/ApplicationConsole.php +++ b/src/Console/ApplicationConsole.php @@ -7,10 +7,14 @@ use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard as StandardPrinter; use Symfony\Component\Console\Application; +use XPHP\Config\ManifestResolver; +use XPHP\Config\SourceResolver; +use XPHP\Console\Command\CheckCommand; use XPHP\Console\Command\CompileCommand; use XPHP\FileSystem\FileFinder; use XPHP\FileSystem\FileReader; use XPHP\FileSystem\FileWriter; +use XPHP\StaticAnalysis\StaticAnalysisGate; use XPHP\Transpiler\Monomorphize\Compiler; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\SpecializedClassGenerator; @@ -35,17 +39,19 @@ public function __construct( $phpParser = (new ParserFactory())->createForHostVersion(); $printer = new StandardPrinter(); - $this->addCommand(new CompileCommand( - $fileFinder, - new Compiler( - $fileReader, - $fileWriter, - new XphpSourceParser($phpParser), - new Specializer(), - new SpecializedClassGenerator($printer, $fileWriter), - $printer, - $hashLength, - ), - )); + $compiler = new Compiler( + $fileReader, + $fileWriter, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $fileWriter), + $printer, + $hashLength, + ); + + $sourceResolver = new SourceResolver($fileFinder, new ManifestResolver($fileReader, $fileFinder)); + + $this->addCommand(new CompileCommand($sourceResolver, $compiler)); + $this->addCommand(new CheckCommand($sourceResolver, $compiler, new StaticAnalysisGate($compiler))); } } diff --git a/src/Console/Command/CheckCommand.php b/src/Console/Command/CheckCommand.php new file mode 100644 index 0000000..bab3320 --- /dev/null +++ b/src/Console/Command/CheckCommand.php @@ -0,0 +1,125 @@ + [--format=text|json|github] [--no-phpstan] + * [--phpstan-bin=PATH] [--phpstan-config=PATH]` + * + * Validates the generic code without emitting any output, reporting every + * diagnostic in one run. When the generic checks pass, it then runs the + * consumer's PHPStan over the compiled (concrete) output and merges those + * findings into the same report — one gate, one exit code. + * + * Exit codes: 0 = clean, 1 = at least one error-severity diagnostic, 2 = + * operational failure (bad source dir or unknown format). + */ +#[AsCommand('check', 'Validate generic .xphp code and report diagnostics without emitting output')] +final class CheckCommand extends Command +{ + public function __construct( + private readonly SourceResolver $sourceResolver, + private readonly Compiler $compiler, + private readonly StaticAnalysisGate $staticAnalysisGate, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('source', InputArgument::OPTIONAL, 'Directory containing .xphp source files (omit when using --config or an auto-detected xphp.json)') + ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to an xphp.json manifest (or a dir containing one)') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Output format: text, json, or github', 'text') + ->addOption('no-phpstan', null, InputOption::VALUE_NONE, 'Skip the PHPStan pass over the compiled output') + ->addOption('phpstan-bin', null, InputOption::VALUE_REQUIRED, 'Path to the PHPStan binary (default: vendor/bin/phpstan, then $PATH)') + ->addOption('phpstan-config', null, InputOption::VALUE_REQUIRED, 'Path to the PHPStan config (default: auto-detect phpstan.neon[.dist])'); + } + + protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + // getArgument()/getOption() are typed `mixed`; narrow rather than blind-cast (PHPStan + // level 9 rejects casting mixed). + $sourceArg = $input->getArgument('source'); + $configOpt = $input->getOption('config'); + + $formatOption = $input->getOption('format'); + $renderer = $this->rendererFor(is_string($formatOption) ? $formatOption : ''); + if ($renderer === null) { + $output->writeln('Unknown format (expected: text, json, github)'); + return self::INVALID; + } + + // @infection-ignore-all -- getcwd() is effectively always a string; the `?: '.'` fallback + // resolves identically to the live cwd for auto-detection and PHPStan config lookup. + $cwd = getcwd() ?: '.'; + try { + $resolved = $this->sourceResolver->resolve( + is_string($sourceArg) ? $sourceArg : null, + is_string($configOpt) ? $configOpt : null, + $cwd, + ); + } catch (RuntimeException $e) { + // @infection-ignore-all -- the banner is decoration; message content is asserted. + $output->writeln('' . $e->getMessage() . ''); + return self::INVALID; + } + + $sources = $resolved->files; + $diagnostics = $this->compiler->check($sources); + + // Only layer PHPStan on when the generic checks pass: invalid generics can't be + // compiled to the concrete output PHPStan needs, and reporting both at once would + // just be noise on top of the real (generic) errors. + if (!$diagnostics->hasErrors() && $input->getOption('no-phpstan') !== true) { + $binOption = $input->getOption('phpstan-bin'); + $configOption = $input->getOption('phpstan-config'); + $findings = $this->staticAnalysisGate->analyze( + $sources, + // @infection-ignore-all -- rootByFile is authoritative for the temp-workspace emit; + // this scalar base is an unused fallback, and PHPStan resolves by symbol not path. + is_string($sourceArg) ? $sourceArg : '', + $cwd, + is_string($binOption) ? $binOption : null, + is_string($configOption) ? $configOption : null, + $resolved->rootByFile, + ); + foreach ($findings as $finding) { + $diagnostics->add($finding); + } + } + + $output->write($renderer->render($diagnostics->all())); + + return $diagnostics->hasErrors() ? self::FAILURE : self::SUCCESS; + } + + private function rendererFor(string $format): ?DiagnosticRenderer + { + return match ($format) { + 'text' => new TextRenderer(), + 'json' => new JsonRenderer(), + 'github' => new GithubRenderer(), + default => null, + }; + } +} diff --git a/src/Console/Command/CompileCommand.php b/src/Console/Command/CompileCommand.php index 67bdb42..a775e9e 100644 --- a/src/Console/Command/CompileCommand.php +++ b/src/Console/Command/CompileCommand.php @@ -4,50 +4,89 @@ namespace XPHP\Console\Command; +use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use XPHP\FileSystem\FileFinder; +use XPHP\Config\SourceResolver; use XPHP\Transpiler\Monomorphize\Compiler; +/** + * `xphp compile [ [ []]] [--config=PATH] [--target=DIR] [--cache=DIR]` + * + * Single-dir form (back-compatible): `xphp compile src dist cache`. Manifest form: omit the source + * and supply `--config ` (or run where an `xphp.json` is auto-detected) to compile a + * package together with its declared sources and transitively-included packages in one pass. + * Output dirs resolve as: `--target`/`--cache` option > manifest value > positional arg > default. + */ #[AsCommand('compile')] final class CompileCommand extends Command { public function __construct( - private readonly FileFinder $fileFinder, + private readonly SourceResolver $sourceResolver, private readonly Compiler $compiler, ) { parent::__construct(); } - public function configure(): void + protected function configure(): void { $this - ->addArgument('source', InputArgument::REQUIRED, 'Directory containing .xphp source files') - ->addArgument('target', InputArgument::OPTIONAL, 'Directory to emit rewritten .php files', 'dist') - ->addArgument('cache', InputArgument::OPTIONAL, 'Directory for generated specialized classes', '.xphp-cache'); + ->addArgument('source', InputArgument::OPTIONAL, 'Directory containing .xphp source files (omit when using --config or an auto-detected xphp.json)') + ->addArgument('target', InputArgument::OPTIONAL, 'Directory to emit rewritten .php files (default: dist)') + ->addArgument('cache', InputArgument::OPTIONAL, 'Directory for generated specialized classes (default: .xphp-cache)') + ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to an xphp.json manifest (or a dir containing one)') + ->addOption('target', null, InputOption::VALUE_REQUIRED, 'Emit dir (overrides the manifest and positional arg)') + ->addOption('cache', null, InputOption::VALUE_REQUIRED, 'Cache dir (overrides the manifest and positional arg)'); } - public function execute( + protected function execute( InputInterface $input, OutputInterface $output, ): int { - $sourceDir = (string) $input->getArgument('source'); - $targetDir = (string) $input->getArgument('target'); - $cacheDir = (string) $input->getArgument('cache'); + $sourceArg = self::stringOrNull($input->getArgument('source')); + $configOpt = self::stringOrNull($input->getOption('config')); - if (!is_dir($sourceDir)) { - $output->writeln("Source directory not found: {$sourceDir}"); + // @infection-ignore-all -- getcwd() is effectively always a string under any run; the + // `?: '.'` fallback resolves identically to the live cwd for auto-detection. + $cwd = getcwd() ?: '.'; + try { + $resolved = $this->sourceResolver->resolve($sourceArg, $configOpt, $cwd); + } catch (RuntimeException $e) { + // @infection-ignore-all -- the banner is decoration; the message content is + // what's asserted, so reordering/removing the wrapping tags is behaviourally immaterial. + $output->writeln('' . $e->getMessage() . ''); return self::FAILURE; } - $sources = $this->fileFinder - ->find($sourceDir) - ->filter(static fn (string $filepath): bool => str_ends_with($filepath, '.xphp')); + // Output dirs: option > (manifest value | positional arg) > default. The manifest value and + // the positional arg never coexist (positional target/cache require a positional source, + // which manifest mode lacks), so each mode picks its own fallback — keeping every step + // reachable rather than chaining a dead manifest-vs-positional link. + $targetOpt = self::stringOrNull($input->getOption('target')); + $cacheOpt = self::stringOrNull($input->getOption('cache')); + if ($sourceArg !== null) { + $target = $targetOpt ?? self::stringOrNull($input->getArgument('target')) ?? 'dist'; + $cache = $cacheOpt ?? self::stringOrNull($input->getArgument('cache')) ?? '.xphp-cache'; + } else { + $target = $targetOpt ?? $resolved->target ?? 'dist'; + $cache = $cacheOpt ?? $resolved->cache ?? '.xphp-cache'; + } - $result = $this->compiler->compile($sources, $sourceDir, $targetDir, $cacheDir); + // `$resolved->rootByFile` is authoritative for emit paths; the scalar base is only a + // fallback for any unmapped file (none here), so the source arg (or empty) suffices. + // @infection-ignore-all -- rootByFile covers every file, so the scalar base is never read. + $base = $sourceArg ?? ''; + $result = $this->compiler->compile( + $resolved->files, + $base, + $target, + $cache, + $resolved->rootByFile, + ); $output->writeln(sprintf( 'Compiled %d source file(s); generated %d specialized class(es).', @@ -57,4 +96,9 @@ public function execute( return self::SUCCESS; } + + private static function stringOrNull(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } } diff --git a/src/Diagnostics/Diagnostic.php b/src/Diagnostics/Diagnostic.php new file mode 100644 index 0000000..327ba01 --- /dev/null +++ b/src/Diagnostics/Diagnostic.php @@ -0,0 +1,31 @@ +`) that + * surfaced a finding inside a template body — only set for Phase-2 (PHPStan) + * diagnostics mapped back to a declaration. + * + * Immutable. + */ +final readonly class Diagnostic +{ + public function __construct( + public Severity $severity, + public string $code, + public string $message, + public ?SourceLocation $location = null, + public ?string $triggeredBy = null, + public DiagnosticSource $source = DiagnosticSource::Xphp, + ) { + } +} diff --git a/src/Diagnostics/DiagnosticCollector.php b/src/Diagnostics/DiagnosticCollector.php new file mode 100644 index 0000000..72036d9 --- /dev/null +++ b/src/Diagnostics/DiagnosticCollector.php @@ -0,0 +1,51 @@ + */ + private array $diagnostics = []; + + public function add(Diagnostic $diagnostic): void + { + $this->diagnostics[] = $diagnostic; + } + + /** + * @return list In insertion order. + */ + public function all(): array + { + return $this->diagnostics; + } + + /** + * True iff any collected diagnostic fails the gate (Error severity). + */ + public function hasErrors(): bool + { + foreach ($this->diagnostics as $diagnostic) { + if ($diagnostic->severity->isFailing()) { + return true; + } + } + + return false; + } + + public function count(): int + { + return count($this->diagnostics); + } +} diff --git a/src/Diagnostics/DiagnosticSource.php b/src/Diagnostics/DiagnosticSource.php new file mode 100644 index 0000000..3df627e --- /dev/null +++ b/src/Diagnostics/DiagnosticSource.php @@ -0,0 +1,16 @@ + $diagnostics + */ + public function render(array $diagnostics): string; +} diff --git a/src/Diagnostics/Renderer/GithubRenderer.php b/src/Diagnostics/Renderer/GithubRenderer.php new file mode 100644 index 0000000..8623eba --- /dev/null +++ b/src/Diagnostics/Renderer/GithubRenderer.php @@ -0,0 +1,62 @@ + `::warning`, `Notice` -> `::notice`. Message and property values + * are escaped per GitHub's workflow-command rules (newlines/commas/colons). + */ +final class GithubRenderer implements DiagnosticRenderer +{ + public function render(array $diagnostics): string + { + $lines = []; + foreach ($diagnostics as $d) { + $command = match ($d->severity) { + Severity::Error => 'error', + Severity::Warning => 'warning', + Severity::Notice => 'notice', + }; + + $props = []; + if ($d->location !== null) { + $props[] = 'file=' . self::escapeProperty($d->location->file); + $props[] = 'line=' . $d->location->line; + if ($d->location->column !== null) { + $props[] = 'col=' . $d->location->column; + } + } + + $prefix = $props === [] ? '::' . $command : '::' . $command . ' ' . implode(',', $props); + // GitHub annotations carry no separate "triggered by" field, so fold the + // instantiation that surfaced a (PHPStan) finding into the message — it's + // the key context for a body error reported at a template declaration. + $message = $d->triggeredBy !== null + ? $d->message . ' (triggered by ' . $d->triggeredBy . ')' + : $d->message; + $lines[] = $prefix . '::' . self::escapeData($message); + } + + return $lines === [] ? '' : implode(PHP_EOL, $lines) . PHP_EOL; + } + + private static function escapeData(string $value): string + { + return str_replace(['%', "\r", "\n"], ['%25', '%0D', '%0A'], $value); + } + + private static function escapeProperty(string $value): string + { + return str_replace(['%', "\r", "\n", ':', ','], ['%25', '%0D', '%0A', '%3A', '%2C'], $value); + } +} diff --git a/src/Diagnostics/Renderer/JsonRenderer.php b/src/Diagnostics/Renderer/JsonRenderer.php new file mode 100644 index 0000000..34fb44f --- /dev/null +++ b/src/Diagnostics/Renderer/JsonRenderer.php @@ -0,0 +1,50 @@ + $d->severity->value, + 'code' => $d->code, + 'message' => $d->message, + 'source' => $d->source->value, + 'triggeredBy' => $d->triggeredBy, + 'file' => $d->location?->file, + 'line' => $d->location?->line, + 'column' => $d->location?->column, + ]; + } + + return json_encode( + ['diagnostics' => $items], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, + ) . PHP_EOL; + } +} diff --git a/src/Diagnostics/Renderer/TextRenderer.php b/src/Diagnostics/Renderer/TextRenderer.php new file mode 100644 index 0000000..ac81b8b --- /dev/null +++ b/src/Diagnostics/Renderer/TextRenderer.php @@ -0,0 +1,53 @@ + + * + * The message may be multi-line; the location/code sit on their own line so + * multi-line messages stay readable. + */ +final class TextRenderer implements DiagnosticRenderer +{ + public function render(array $diagnostics): string + { + if ($diagnostics === []) { + return 'No problems found.' . PHP_EOL; + } + + $blocks = []; + foreach ($diagnostics as $d) { + $lines = [sprintf('%s: %s', $d->severity->value, $d->message)]; + $lines[] = ' ' . $this->locationSuffix($d); + if ($d->triggeredBy !== null) { + $lines[] = ' triggered by ' . $d->triggeredBy; + } + $blocks[] = implode(PHP_EOL, $lines); + } + + return implode(PHP_EOL . PHP_EOL, $blocks) . PHP_EOL; + } + + private function locationSuffix(Diagnostic $d): string + { + $code = '[' . $d->code . ']'; + if ($d->location === null) { + return $code; + } + $at = $d->location->file . ':' . $d->location->line; + if ($d->location->column !== null) { + $at .= ':' . $d->location->column; + } + + return 'at ' . $at . ' ' . $code; + } +} diff --git a/src/Diagnostics/Severity.php b/src/Diagnostics/Severity.php new file mode 100644 index 0000000..22fcae1 --- /dev/null +++ b/src/Diagnostics/Severity.php @@ -0,0 +1,26 @@ + $rootByFile per-file source root for multi-root emit (see Compiler::compile) + */ + public static function inTempDir( + Compiler $compiler, + FilepathArray $sources, + string $sourceDir, + string $tmpBase, + ?array $rootByFile = null, + ): self { + $root = rtrim($tmpBase, '/') . '/xphp-check-' . bin2hex(random_bytes(8)); + + return self::compile($compiler, $sources, $sourceDir, $root, $rootByFile); + } + + /** + * Compile into an explicit $root (deterministic; used by tests). + * + * @param ?array $rootByFile per-file source root for multi-root emit (see Compiler::compile) + */ + public static function compile( + Compiler $compiler, + FilepathArray $sources, + string $sourceDir, + string $root, + ?array $rootByFile = null, + ): self { + $distDir = $root . '/dist'; + $cacheDir = $root . '/cache'; + + // The Compiler's writer creates intermediate directories as it emits, so + // $root needs no pre-creation; an empty source set simply writes nothing. + $result = $compiler->compile($sources, $sourceDir, $distDir, $cacheDir, $rootByFile); + + // Canonicalize the analysable dirs: PHPStan reports findings under + // realpath()'d paths (symlinks resolved, e.g. macOS /var -> /private/var), + // so the representative file paths built from these must be canonical too + // or the finding->representative join silently misses (a false clean pass). + return new self( + $root, + self::canonical($distDir), + self::canonical($cacheDir . '/' . self::GENERATED_SUBDIR), + $result->registry, + ); + } + + /** realpath() the dir if it exists; otherwise keep the constructed path (no files to match). */ + private static function canonical(string $dir): string + { + $real = realpath($dir); + + return $real !== false ? $real : $dir; + } + + /** Recursively delete the workspace. Safe to call when $root was never created. */ + public function cleanup(): void + { + self::removeRecursively($this->root); + } + + private static function removeRecursively(string $path): void + { + if (!is_dir($path)) { + return; + } + + $entries = scandir($path); + if ($entries === false) { + return; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $full = $path . '/' . $entry; + // Check is_link() BEFORE is_dir(): is_dir() follows a symlink to a + // directory, so recursing on it would delete the link TARGET's contents + // (outside the workspace). Always just unlink links. + if (is_link($full)) { + unlink($full); + } elseif (is_dir($full)) { + self::removeRecursively($full); + } else { + unlink($full); + } + } + + rmdir($path); + } +} diff --git a/src/StaticAnalysis/PhpStanConfigResolver.php b/src/StaticAnalysis/PhpStanConfigResolver.php new file mode 100644 index 0000000..7624da6 --- /dev/null +++ b/src/StaticAnalysis/PhpStanConfigResolver.php @@ -0,0 +1,44 @@ +workingDir . '/' . $name; + if (is_file($candidate)) { + return $candidate; + } + } + + return null; + } +} diff --git a/src/StaticAnalysis/PhpStanFinding.php b/src/StaticAnalysis/PhpStanFinding.php new file mode 100644 index 0000000..5a88b99 --- /dev/null +++ b/src/StaticAnalysis/PhpStanFinding.php @@ -0,0 +1,22 @@ + $pathDirs directories from `$PATH`, already split */ + public function __construct( + private string $workingDir, + private array $pathDirs, + ) { + } + + public static function fromEnvironment(string $workingDir): self + { + // getenv returns string|false; an empty/blank PATH explodes to harmless + // empty-string dirs (no real directory is named ''), so the only case worth + // guarding is the false (unset) one. + $path = getenv('PATH'); + $dirs = is_string($path) ? explode(PATH_SEPARATOR, $path) : []; + + return new self($workingDir, $dirs); + } + + public function locate(?string $explicit): ?string + { + if ($explicit !== null && $explicit !== '') { + return self::usable($explicit) ? $explicit : null; + } + + $vendorBin = $this->workingDir . '/vendor/bin/phpstan'; + if (self::usable($vendorBin)) { + return $vendorBin; + } + + foreach ($this->pathDirs as $dir) { + $candidate = rtrim($dir, '/') . '/phpstan'; + if (self::usable($candidate)) { + return $candidate; + } + } + + return null; + } + + private static function usable(string $path): bool + { + return is_file($path); + } +} diff --git a/src/StaticAnalysis/PhpStanOutputParser.php b/src/StaticAnalysis/PhpStanOutputParser.php new file mode 100644 index 0000000..bd2e4e8 --- /dev/null +++ b/src/StaticAnalysis/PhpStanOutputParser.php @@ -0,0 +1,78 @@ + $data) { + if (!is_string($file) || !is_array($data) || !isset($data['messages']) || !is_array($data['messages'])) { + continue; + } + foreach ($data['messages'] as $message) { + if (!is_array($message) || !isset($message['message']) || !is_string($message['message'])) { + continue; + } + // Generated-class findings map to the template DECLARATION line, not the + // finding's own line, so a missing/odd line is harmless — default to 0. + $line = isset($message['line']) && is_int($message['line']) ? $message['line'] : 0; + $identifier = isset($message['identifier']) && is_string($message['identifier']) + ? $message['identifier'] + : null; + $findings[] = new PhpStanFinding($file, $line, $message['message'], $identifier); + } + } + + // Valid JSON but only top-level (file-less) errors means PHPStan itself had a + // problem (e.g. an unmatched ignore pattern) — not a clean pass. + $topLevel = $decoded['errors'] ?? []; + if ($findings === [] && is_array($topLevel) && $topLevel !== []) { + $joined = implode("\n", array_map(static fn (mixed $e): string => is_string($e) ? $e : '', $topLevel)); + + return PhpStanResult::failed(trim($joined) !== '' ? $joined : 'PHPStan reported a general error'); + } + + return PhpStanResult::ok($findings); + } + + private static function failureMessage(string $stdout, string $stderr): string + { + $stderr = trim($stderr); + if ($stderr !== '') { + return $stderr; + } + $stdout = trim($stdout); + if ($stdout !== '') { + return $stdout; + } + + return 'PHPStan produced no analysable output'; + } +} diff --git a/src/StaticAnalysis/PhpStanResult.php b/src/StaticAnalysis/PhpStanResult.php new file mode 100644 index 0000000..7ded66b --- /dev/null +++ b/src/StaticAnalysis/PhpStanResult.php @@ -0,0 +1,33 @@ + $findings */ + private function __construct( + public bool $ranOk, + public array $findings, + public ?string $errorOutput, + ) { + } + + /** @param list $findings */ + public static function ok(array $findings): self + { + return new self(true, $findings, null); + } + + public static function failed(string $errorOutput): self + { + return new self(false, [], $errorOutput); + } +} diff --git a/src/StaticAnalysis/PhpStanResultMapper.php b/src/StaticAnalysis/PhpStanResultMapper.php new file mode 100644 index 0000000..3de5cab --- /dev/null +++ b/src/StaticAnalysis/PhpStanResultMapper.php @@ -0,0 +1,70 @@ + $findings + * @param list $representatives + * @return list + */ + public static function map(array $findings, array $representatives): array + { + $byFile = []; + foreach ($representatives as $representative) { + $byFile[$representative->filePath] = $representative; + } + + $diagnostics = []; + foreach ($findings as $finding) { + $representative = $byFile[$finding->file] ?? null; + + $diagnostics[] = $representative !== null + ? new Diagnostic( + Severity::Error, + self::code($finding), + $finding->message, + new SourceLocation($representative->declFile, $representative->declLine), + $representative->label, + DiagnosticSource::PhpStan, + ) + : new Diagnostic( + Severity::Error, + self::code($finding), + $finding->message, + null, + null, + DiagnosticSource::PhpStan, + ); + } + + return $diagnostics; + } + + private static function code(PhpStanFinding $finding): string + { + return $finding->identifier !== null ? 'phpstan.' . $finding->identifier : 'phpstan.error'; + } +} diff --git a/src/StaticAnalysis/PhpStanRunner.php b/src/StaticAnalysis/PhpStanRunner.php new file mode 100644 index 0000000..999b515 --- /dev/null +++ b/src/StaticAnalysis/PhpStanRunner.php @@ -0,0 +1,116 @@ +` is invoked (rather than the bin directly) so a resolved + * path needs no execute bit and a `.phar`/proxy works the same way. + */ +final readonly class PhpStanRunner +{ + private const int TIMEOUT_SECONDS = 300; + private const int DEFAULT_LEVEL = 5; + + public function __construct(private string $phpstanBin) + { + } + + /** + * @param list $analysePaths absolute representative file paths to analyse + * @param list $scanDirectories absolute dirs scanned for symbol resolution (dist, Generated, vendor) + * @param ?string $consumerConfig absolute path to the consumer's neon, or null + * @param string $ephemeralConfigPath where to write the generated config (inside the workspace) + */ + public function run( + array $analysePaths, + array $scanDirectories, + ?string $consumerConfig, + string $ephemeralConfigPath, + ): PhpStanResult { + file_put_contents($ephemeralConfigPath, self::buildConfig($scanDirectories, $consumerConfig)); + + $process = new Process(self::buildCommand($this->phpstanBin, $ephemeralConfigPath, $analysePaths)); + // @infection-ignore-all MethodCallRemoval -- the timeout is a runaway guard; + // dropping it leaves Symfony's 60s default, which produces an identical result + // for every analysable input. Not observable without a >60s-hanging fixture. + $process->setTimeout(self::TIMEOUT_SECONDS); + $process->run(); + + return PhpStanOutputParser::parse($process->getOutput(), $process->getErrorOutput()); + } + + /** + * The argv used to invoke PHPStan. `php ` (not the bin directly) so a + * resolved path needs no execute bit and a `.phar`/proxy works the same way. + * Exposed (static, pure) so the exact command is unit-testable. + * + * @param list $analysePaths + * @return list + */ + public static function buildCommand(string $phpstanBin, string $configPath, array $analysePaths): array + { + return [ + PHP_BINARY, + $phpstanBin, + 'analyse', + '--no-progress', + '--error-format=json', + // Lift the default 128M ceiling: scanning the consumer's vendor dir for + // symbol resolution routinely exceeds it, which would fatal (no JSON) and + // degrade the gate to a Warning. -1 defers to the OS rather than imposing + // an arbitrary cap that's wrong for large projects. + '--memory-limit=-1', + '--configuration=' . $configPath, + ...$analysePaths, + ]; + } + + /** + * The ephemeral NEON the run writes. Exposed (static, pure) so its exact + * shape is unit-testable without invoking PHPStan. + * + * @param list $scanDirectories + */ + public static function buildConfig(array $scanDirectories, ?string $consumerConfig): string + { + $lines = []; + + if ($consumerConfig !== null) { + $lines[] = 'includes:'; + $lines[] = ' - ' . self::neonString($consumerConfig); + } + + $lines[] = 'parameters:'; + if ($consumerConfig === null) { + // No consumer config to inherit a level from — pick a modest default + // so PHPStan still does something useful. + $lines[] = ' level: ' . self::DEFAULT_LEVEL; + } + $lines[] = ' scanDirectories:'; + foreach ($scanDirectories as $dir) { + $lines[] = ' - ' . self::neonString($dir); + } + + return implode("\n", $lines) . "\n"; + } + + private static function neonString(string $value): string + { + return '"' . addcslashes($value, '"\\') . '"'; + } +} diff --git a/src/StaticAnalysis/Representative.php b/src/StaticAnalysis/Representative.php new file mode 100644 index 0000000..42753a8 --- /dev/null +++ b/src/StaticAnalysis/Representative.php @@ -0,0 +1,29 @@ + + */ + public static function select(Registry $registry, string $generatedDir): array + { + /** @var array> $byTemplate */ + $byTemplate = []; + foreach ($registry->instantiations() as $instantiation) { + $byTemplate[$instantiation->templateFqn][] = $instantiation; + } + + $representatives = []; + foreach ($byTemplate as $templateFqn => $instantiations) { + // Invariant: the parser emits template FQNs without a leading backslash, so + // the instantiation's templateFqn matches the key recordDefinition stored + // under — this lookup hits for every defined template. + $definition = $registry->definition($templateFqn); + if ($definition === null) { + // Instantiated-but-never-defined: there's no declaration to map a + // finding back to. The generic checks already report this; skip it + // here rather than emit a finding with no source location. + continue; + } + + usort( + $instantiations, + static fn (GenericInstantiation $a, GenericInstantiation $b): int + => strcmp($a->generatedFqn, $b->generatedFqn), + ); + $chosen = $instantiations[0]; + + $representatives[] = new Representative( + $chosen->generatedFqn, + self::fqnToPath($chosen->generatedFqn, $generatedDir), + $templateFqn, + self::label($chosen), + $definition->sourceFile, + $definition->templateAst->getStartLine(), + ); + } + + usort( + $representatives, + static fn (Representative $a, Representative $b): int => strcmp($a->generatedFqn, $b->generatedFqn), + ); + + return $representatives; + } + + private static function fqnToPath(string $generatedFqn, string $generatedDir): string + { + $prefix = Registry::GENERATED_NAMESPACE_PREFIX . '\\'; + $relative = str_starts_with($generatedFqn, $prefix) + ? substr($generatedFqn, strlen($prefix)) + : $generatedFqn; + + return rtrim($generatedDir, '/') . '/' . str_replace('\\', '/', $relative) . '.php'; + } + + private static function label(GenericInstantiation $instantiation): string + { + $args = array_map( + static fn (TypeRef $type): string => $type->toDisplayString(), + $instantiation->concreteTypes, + ); + + return $instantiation->templateFqn . '<' . implode(', ', $args) . '>'; + } +} diff --git a/src/StaticAnalysis/StaticAnalysisGate.php b/src/StaticAnalysis/StaticAnalysisGate.php new file mode 100644 index 0000000..4b20145 --- /dev/null +++ b/src/StaticAnalysis/StaticAnalysisGate.php @@ -0,0 +1,123 @@ + $rootByFile per-file source root for multi-root emit (see Compiler::compile) + * @return list + */ + public function analyze( + FilepathArray $sources, + string $sourceDir, + string $workingDir, + ?string $explicitBin, + ?string $explicitConfig, + ?array $rootByFile = null, + ): array { + $bin = PhpStanLocator::fromEnvironment($workingDir)->locate($explicitBin); + if ($bin === null) { + return [new Diagnostic( + Severity::Warning, + self::CODE_UNAVAILABLE, + 'PHPStan was not found (looked for --phpstan-bin, then vendor/bin/phpstan, then $PATH); ' + . 'skipping static analysis. Pass --no-phpstan to silence this.', + null, + null, + DiagnosticSource::PhpStan, + )]; + } + + $config = (new PhpStanConfigResolver($workingDir))->resolve($explicitConfig); + $workspace = CompiledWorkspace::inTempDir($this->compiler, $sources, $sourceDir, sys_get_temp_dir(), $rootByFile); + try { + $representatives = RepresentativeSelector::select($workspace->registry, $workspace->generatedDir); + if ($representatives === []) { + // No generic instantiations → no specialized code for PHPStan to add over + // the generic checks. Nothing to do. + return []; + } + + $result = (new PhpStanRunner($bin))->run( + array_map(static fn (Representative $r): string => $r->filePath, $representatives), + self::buildScanDirectories($workspace->distDir, $workspace->generatedDir, $workingDir), + $config, + $workspace->root . '/phpstan-ephemeral.neon', + ); + + if (!$result->ranOk) { + return [new Diagnostic( + Severity::Warning, + self::CODE_RUN_FAILED, + 'PHPStan could not complete: ' . self::summarize($result->errorOutput), + null, + null, + DiagnosticSource::PhpStan, + )]; + } + + return PhpStanResultMapper::map($result->findings, $representatives); + } finally { + $workspace->cleanup(); + } + } + + /** + * Dirs PHPStan scans for symbol resolution: the compiled output plus the + * consumer's vendor (when present) so generated code resolves its + * dependencies. Pure/static so the vendor-inclusion logic is unit-testable. + * + * @return list + */ + public static function buildScanDirectories(string $distDir, string $generatedDir, string $workingDir): array + { + $directories = [$distDir, $generatedDir]; + + $vendorDir = $workingDir . '/vendor'; + if (is_dir($vendorDir)) { + $directories[] = $vendorDir; + } + + return $directories; + } + + /** + * Cap a PHPStan failure blob so a fatal that dumps a long trace (or a flood of + * "unknown class" lines) doesn't become a single unreadable diagnostic message. + */ + public static function summarize(?string $errorOutput, int $max = 500): string + { + if ($errorOutput === null || $errorOutput === '') { + return 'unknown error'; + } + + return strlen($errorOutput) > $max ? substr($errorOutput, 0, $max) . '…' : $errorOutput; + } +} diff --git a/src/Transpiler/Monomorphize/CallSiteRewriter.php b/src/Transpiler/Monomorphize/CallSiteRewriter.php index 2bf821c..8528512 100644 --- a/src/Transpiler/Monomorphize/CallSiteRewriter.php +++ b/src/Transpiler/Monomorphize/CallSiteRewriter.php @@ -69,7 +69,7 @@ public function leaveNode(Node $node): Node|int|null // (so user code's `$x instanceof OriginalName` keeps working). // Generic trait -> just drop (no instanceof against traits). if ($node instanceof Class_ || $node instanceof Interface_) { - // @infection-ignore-all — Interface_'s ctor defaults `stmts` to [] + // @infection-ignore-all — Interface_'s constructor defaults `stmts` to [] // so `['stmts' => []]` and `[]` are observationally identical. return new Interface_($node->name, ['stmts' => []], $node->getAttributes()); } diff --git a/src/Transpiler/Monomorphize/ClosureDispatcher.php b/src/Transpiler/Monomorphize/ClosureDispatcher.php index 7caeecf..e9efa14 100644 --- a/src/Transpiler/Monomorphize/ClosureDispatcher.php +++ b/src/Transpiler/Monomorphize/ClosureDispatcher.php @@ -121,7 +121,7 @@ public function dispatch( continue; } $seenTags[$tag] = true; - $spec = $this->buildSpecialization( + $specialization = $this->buildSpecialization( $template, $args, $typeParams, @@ -130,8 +130,8 @@ public function dispatch( $hashLength, $useClauses, ); - $declarations[] = $spec['function']; - $arms[] = ['tag' => $tag, 'mangledFqn' => $spec['mangledFqn']]; + $declarations[] = $specialization['function']; + $arms[] = ['tag' => $tag, 'mangledFqn' => $specialization['mangledFqn']]; } $dispatcher = $this->buildDispatcherClosure( $template, diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 12868d2..ed51051 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -4,8 +4,13 @@ namespace XPHP\Transpiler\Monomorphize; +use PhpParser\Error as PhpParserError; use PhpParser\PrettyPrinter\Standard as StandardPrinter; use RuntimeException; +use XPHP\Diagnostics\Diagnostic; +use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\Diagnostics\Severity; +use XPHP\Diagnostics\SourceLocation; use XPHP\FileSystem\FileReader; use XPHP\FileSystem\FileWriter; use XPHP\FileSystem\FilepathArray; @@ -31,6 +36,9 @@ { public const MAX_SPECIALIZATION_DEPTH = 16; + /** Diagnostic code for a file that failed to parse during `xphp check`. */ + public const CODE_PARSE_ERROR = 'xphp.parse_error'; + public function __construct( private FileReader $fileReader, private FileWriter $fileWriter, @@ -42,20 +50,24 @@ public function __construct( ) { } + /** + * @param ?array $rootByFile Optional map of absolute source filepath → the + * source root it was found under, used to compute each emitted file's relative (PSR-4) path. + * When null (the single-source-dir form), every file is relative to $sourceDir, exactly as + * before. When supplied (manifest / multi-root form), each file is relative to its own root, + * so a second root's files don't flatten; $sourceDir is the fallback for any unmapped file. + */ public function compile( FilepathArray $sources, string $sourceDir, string $targetDir, string $cacheDir, + ?array $rootByFile = null, ): CompileResult { // Phase 0: parse every source up front. The TypeHierarchy (used to validate generic // bounds at recordInstantiation time) needs to see every class/interface/trait // declaration *before* any instantiation is recorded, so parsing has to finish first. - $astPerFile = []; - foreach ($sources->filepaths as $filepath) { - $content = $this->fileReader->read($filepath); - $astPerFile[$filepath] = $this->sourceParser->parse($content); - } + $astPerFile = $this->parseAll($sources); $hierarchy = TypeHierarchy::fromAstPerFile($astPerFile); $registry = new Registry($this->hashLength, $hierarchy); @@ -69,6 +81,11 @@ public function compile( // concrete `Box` reference gets collected and specialized through the usual // class-level path. The hierarchy is passed through so method/function-level // `T: Bound` is validated at compile time too (same shape as the class-level path). + // Reject a stray/undeclared type parameter in a generic method/function/closure + // signature BEFORE specialization runs (process() strips the templates from the + // AST in compile-mode, so this must precede it). + UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy); + $methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy); $methodCompiler->process($astPerFile); @@ -84,6 +101,17 @@ public function compile( // bad declaration like `class Box` fails BEFORE any // padded instantiation is recorded), then collect instantiations -- including // bare `new Foo;` shapes for templates whose every param has a default. + // Variance-position rules (covariant T in input, contravariant T in output, + // bound/default/property/constructor invariance, F-bounded variance). Moved + // here from the parser so all definitions are present and `xphp check` can + // collect across files; compile-mode still throws on the first violation. + // Runs BEFORE the defaults-vs-bounds check so that, when a class has both, + // the variance error surfaces first (the order it surfaced at parse time). + $registry->validateVariancePositions(); + // Undeclared type names in member signatures (a stray/typo'd type param) + // fail before defaults/instantiation so a non-existent reference never + // reaches emission as broken PHP. + $registry->validateUndeclaredTypeParameters(); $registry->validateDefaultsAgainstBounds(); // Inner-template variance composition: every template's variance // markers are known by now, so cases the parse-time validator @@ -98,6 +126,7 @@ public function compile( // Phase 2: fixed-point specialization loop. /** @var array $specializedAsts keyed by generated FQCN */ $specializedAsts = []; + $closer = new SpecializationCloser($hierarchy, new VarianceSubtyping($hierarchy), $this->specializer, $this->hashLength); $depth = 0; while (true) { $countBefore = count($registry->instantiations()); @@ -111,25 +140,31 @@ public function compile( $definition = $registry->definition($instantiation->templateFqn); if ($definition === null) { - throw new RuntimeException(sprintf( - 'Generic template "%s" was instantiated but never defined (generated as: %s).', - $instantiation->templateFqn, - $generatedFqn, - )); + throw new RuntimeException( + Registry::undefinedTemplateMessage($instantiation->templateFqn, $generatedFqn), + ); } $substitution = array_combine($definition->typeParamNames(), $instantiation->concreteTypes); $specialized = $this->specializer->specialize( $definition->templateAst, $substitution, + $this->hashLength, ); $specializedAsts[$generatedFqn] = $specialized; $collector->collect([$specialized], ""); } + // Close the specialization set under the covariant-upcast implementation requirement: a + // covariant upcast to an interface specialization carrying an erased (abstract) method + // needs the concrete supertype specialization that implements it, which the substitution + // walk above never discovers (an upcast is usage, not substitution). Schedules it here so + // the next iteration specializes it; the variance edge emitter then inherits the member. + $closerAdded = $closer->close($registry, $specializedAsts); + $countAfter = count($registry->instantiations()); - if (!$newlyProcessed) { + if (!$newlyProcessed && !$closerAdded) { break; } @@ -139,11 +174,7 @@ public function compile( $depth++; if ($depth > self::MAX_SPECIALIZATION_DEPTH) { - throw new RuntimeException(sprintf( - 'Nested generic specialization exceeded depth %d. Latest registry: %s', - self::MAX_SPECIALIZATION_DEPTH, - implode(', ', array_keys($registry->instantiations())), - )); + throw new RuntimeException(self::unconvergedSpecializationMessage($registry)); } } @@ -176,14 +207,30 @@ public function compile( $this->specializedClassGenerator->emit($classAst, $generatedFqn, $cacheDir); } - // Phase 4: rewrite + emit user source files. + // Phase 4: rewrite + emit user source files. Each file's relative (PSR-4) path is computed + // against its own source root (the manifest/multi-root form), falling back to $sourceDir + // for the single-dir form. Two roots that would emit a file to the same target path is a + // hard error, not a silent overwrite. + $emittedBy = []; foreach ($astPerFile as $filepath => $ast) { $rewrittenAst = $rewriter->rewrite($ast); $code = $this->printer->prettyPrintFile($rewrittenAst); - $relPath = self::relativePath($sourceDir, $filepath); + $base = $rootByFile[$filepath] ?? $sourceDir; + $relPath = self::relativePath($base, $filepath); $targetPath = rtrim($targetDir, '/') . '/' . preg_replace('/\.xphp$/', '.php', $relPath); + if (isset($emittedBy[$targetPath])) { + throw new RuntimeException(sprintf( + 'Emit path collision: "%s" and "%s" both map to "%s" — two source roots contain ' + . 'a file at the same relative path. Rename one or separate the roots.', + $emittedBy[$targetPath], + $filepath, + $targetPath, + )); + } + $emittedBy[$targetPath] = $filepath; + $targetSubdir = dirname($targetPath); if (!is_dir($targetSubdir)) { mkdir($targetSubdir, 0o755, true); @@ -209,6 +256,100 @@ public function compile( ); } + /** + * Validate-only pass for `xphp check`: parse, build the hierarchy, collect definitions, + * then validate (defaults-vs-bounds) and collect instantiations (bounds, missing args) + * with a DiagnosticCollector so every generic error is gathered instead of throwing on + * the first. Stops after validation — it never specializes or emits, so a partially-invalid + * registry never reaches the fixed-point loop. Returns the collected diagnostics. + * + * Includes method/function/closure-level generic checks: GenericMethodCompiler runs in + * validate-only mode (`emit: false`) so it collects bound / missing-arg / duplicate-function / + * closure-rejection diagnostics without specializing or emitting. + * + * Per-file resilience: a file that fails to parse is reported as a diagnostic and skipped, + * so the remaining files are still checked (unlike compile(), which fails fast). + */ + public function check(FilepathArray $sources): DiagnosticCollector + { + $diagnostics = new DiagnosticCollector(); + $astPerFile = []; + foreach ($sources->filepaths as $filepath) { + // Read OUTSIDE the try so an I/O failure surfaces as itself, not a mislabeled + // "parse error" — only parsing is treated as a per-file, recoverable diagnostic. + $content = $this->fileReader->read($filepath); + try { + $astPerFile[$filepath] = $this->sourceParser->parse($content); + } catch (PhpParserError $e) { + $line = $e->getStartLine(); + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_PARSE_ERROR, + $e->getMessage(), + // @infection-ignore-all GreaterThan/IncrementInteger/DecrementInteger -- nikic + // emits either a real line (>= 1) or the sentinel -1 for position-less errors; + // every `> 0` boundary variant routes -1 to the same `?: 1` fallback, so the + // mutants are equivalent. The real-line path is pinned by CheckCommandTest + // (Broken.xphp -> line 11). + new SourceLocation($filepath, $line > 0 ? $line : 1), + )); + } catch (RuntimeException $e) { + // xphp-specific parse-time rejections from the parser (e.g. variance markers on + // methods) — these carry no line, so the diagnostic points at the file (line 1). + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_PARSE_ERROR, + $e->getMessage(), + new SourceLocation($filepath, 1), + )); + } + } + + $hierarchy = TypeHierarchy::fromAstPerFile($astPerFile); + $registry = new Registry($this->hashLength, $hierarchy, $diagnostics); + $collector = new RegistryCollector($registry); + + foreach ($astPerFile as $filepath => $ast) { + $collector->collectDefinitions($ast, $filepath); + } + $registry->validateVariancePositions(); + $registry->validateUndeclaredTypeParameters(); + UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy, $diagnostics); + $registry->validateDefaultsAgainstBounds(); + $registry->validateInnerVariance(); + foreach ($astPerFile as $filepath => $ast) { + $collector->collectInstantiations($ast, $filepath); + } + $registry->collectUndefinedTemplates($diagnostics); + + // Method/function/closure-level generic checks: run GenericMethodCompiler in validate-only + // mode (emit: false) so it collects bound / missing-arg / duplicate-function / closure-rejection + // diagnostics without specializing or mutating the (discarded) AST. + // @infection-ignore-all FalseValue -- `emit: true` is observably equivalent here: the + // validation calls (which produce the diagnostics) run in BOTH modes; `emit` only governs + // append/strip/finalize side-effects on `$astPerFile`, which is local and discarded. So + // flipping it changes only wasted work, not the collected diagnostics. `emit: false` is the + // correct (no-wasted-work, no-mutation) choice. + (new GenericMethodCompiler($this->hashLength, $hierarchy, $diagnostics))->process($astPerFile, emit: false); + + return $diagnostics; + } + + /** + * Parse every source file into an AST keyed by filepath. + * + * @return array> + */ + private function parseAll(FilepathArray $sources): array + { + $astPerFile = []; + foreach ($sources->filepaths as $filepath) { + $astPerFile[$filepath] = $this->sourceParser->parse($this->fileReader->read($filepath)); + } + + return $astPerFile; + } + private static function relativePath(string $base, string $filepath): string { $base = rtrim($base, '/') . '/'; @@ -217,5 +358,56 @@ private static function relativePath(string $base, string $filepath): string } return basename($filepath); } + + /** + * Build a localized diagnostic for a specialization set that didn't converge: identify the type + * family whose arguments nest the deepest (the tip of the growing tower) and name it, instead of + * dumping the whole registry. The common cause is a method whose return type re-wraps the receiver's + * own type family in a growing form (`groupBy(): Map>` where Map's views re-expose List). + */ + private static function unconvergedSpecializationMessage(Registry $registry): string + { + $deepest = null; + $deepestDepth = -1; + foreach ($registry->instantiations() as $instantiation) { + $depth = 0; + foreach ($instantiation->concreteTypes as $arg) { + $depth = max($depth, self::typeRefDepth($arg)); + } + if ($depth > $deepestDepth) { + $deepestDepth = $depth; + $deepest = $instantiation; + } + } + + $family = $deepest === null ? '(unknown)' : $deepest->templateFqn; + $example = $deepest === null + ? '(none)' + : $deepest->templateFqn . '<' . implode(', ', array_map( + static fn (TypeRef $r): string => $r->canonical(), + $deepest->concreteTypes, + )) . '>'; + + return sprintf( + 'Generic specialization did not converge (exceeded depth %d): the type family rooted at "%s" ' + . 'grows without bound — e.g. "%s". This happens when a member\'s type re-wraps the receiver\'s ' + . 'own type family in a growing form (for example `groupBy(): Map>`, where Map\'s ' + . 'views re-expose List). Break the cycle: give the member a non-self-reintroducing type, or ' + . 'split the derivation so the growing type isn\'t reached through an unbounded chain.', + self::MAX_SPECIALIZATION_DEPTH, + $family, + $example, + ); + } + + /** The maximum nesting depth of a TypeRef's generic-argument tree (a non-generic ref is depth 0). */ + private static function typeRefDepth(TypeRef $ref): int + { + $max = 0; + foreach ($ref->args as $arg) { + $max = max($max, self::typeRefDepth($arg)); + } + return $ref->args === [] ? 0 : 1 + $max; + } } diff --git a/src/Transpiler/Monomorphize/EnclosingBoundErasure.php b/src/Transpiler/Monomorphize/EnclosingBoundErasure.php new file mode 100644 index 0000000..a854446 --- /dev/null +++ b/src/Transpiler/Monomorphize/EnclosingBoundErasure.php @@ -0,0 +1,255 @@ + { contains(U $value): bool }`) can be lowered by **erasing `U` + * to its bound `E`** — i.e. specialized once per class instantiation (`contains_(Fruit)`) + * instead of once per call-site turbofish (`contains_(Banana)`). + * + * Erasure is sound only when every enclosing-bounded type parameter appears **exclusively** as a + * top-level input parameter (`U $value`). If it appears nested (`Box`), in the return type, or + * structurally in the body (`new U`, `instanceof U`), the concrete `U` is observable and erasing it + * to `E` would change behaviour — those keep the per-`U` lowering. A type parameter *forwarded* in a + * turbofish self-call (`$this->m::()`) is fine: it lives in `ATTR_METHOD_GENERIC_ARGS`, an + * attribute the AST `Name` walk never visits, so it's correctly invisible here. + * + * Conservative by construction: any uncertainty answers "not erasable", which falls back to the + * existing per-`U` path (and the compile error for an unspecializable forward) — never to unsound + * erasure. + */ +final class EnclosingBoundErasure +{ + /** + * @param list $methodParams the method's own generic parameters (with their bounds) + * @param list $classParamNames the enclosing class's type-parameter names + */ + public static function isErasable(ClassMethod $method, array $methodParams, array $classParamNames): bool + { + // Every type parameter must be enclosing-bounded (``). This rejects both a method with + // no enclosing-bounded parameter at all (``, `` → empty `$bounded`) and a + // mixed method (``) whose `W` needs real per-`W` specialization. + $bounded = self::enclosingBoundedNames($methodParams, $classParamNames); + if (count($bounded) !== count($methodParams)) { + return false; + } + + // Each parameter is either a bare bounded name (`U $value` — the only allowed occurrence) or + // must not reference a bounded name at all (a nested `Box` makes the concrete U observable). + $seenAsInput = []; + foreach ($method->params as $param) { + $type = $param->type; + if ($type instanceof Name + && count($type->getParts()) === 1 + && in_array($type->toString(), $bounded, true) + ) { + $seenAsInput[] = $type->toString(); + continue; + } + if (self::typeReferencesBounded($type, $bounded)) { + return false; + } + } + + // Every bounded parameter must be used as a direct input at least once (else it is only + // structural / unused — not the erasable shape). + foreach ($bounded as $name) { + if (!in_array($name, $seenAsInput, true)) { + return false; + } + } + + // The return type must not mention a bounded name, and the body must not use one structurally + // (a forwarded turbofish lives in an attribute and is invisible to this Name search). + if (self::typeReferencesBounded($method->returnType, $bounded)) { + return false; + } + return !self::bodyUsesBounded($method, $bounded); + } + + /** + * Whether the method's **return type** references one of the enclosing class type parameters (`E`). + * Used by direct upcast emission: that path grounds the body's class parameter to the upcast + * source's OWN concrete (a subtype of the supertype the member is emitted at), which is sound for + * body reads but NOT for a return position — a return type grounded to the subtype while the bounded + * parameter widens to the supertype lets a supertype value escape through a subtype return (a runtime + * `TypeError`). Such a shape can't be emitted directly and must fail loudly. + * + * Only the return type is inspected: an enclosing parameter is covariant (`+E`), so variance checking + * forbids it from appearing in any method *parameter* position before this point — the return type is + * the only signature position it can legally occupy. A bounded method parameter is typed by the + * *method* generic (`U`), not by `E`, so it never matches here either. + * + * @param list $enclosingParamNames + */ + public static function returnTypeReferencesEnclosing(ClassMethod $method, array $enclosingParamNames): bool + { + return self::typeReferencesBounded($method->returnType, $enclosingParamNames); + } + + /** + * Whether a type node (a parameter/return type: a Name, a nullable/union/intersection of them) + * mentions a bounded name — bare (`U`) or nested in another type's args (`Box`). + * + * @param list $bounded + */ + private static function typeReferencesBounded(?Node $type, array $bounded): bool + { + if ($type instanceof NullableType) { + return self::typeReferencesBounded($type->type, $bounded); + } + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return self::anyTypeReferencesBounded($type->types, $bounded); + } + if ($type instanceof Name) { + if (count($type->getParts()) === 1 && in_array($type->toString(), $bounded, true)) { + return true; + } + return self::genericArgsReferenceBounded($type, $bounded); + } + return false; // scalar Identifier, or no type + } + + /** + * @param array $members + * @param list $bounded + */ + private static function anyTypeReferencesBounded(array $members, array $bounded): bool + { + foreach ($members as $member) { + if (self::typeReferencesBounded($member, $bounded)) { + return true; + } + } + return false; + } + + /** + * Whether any AST `Name` in the method body mentions a bounded name structurally — `new U`, + * `instanceof U`, `U::CONST`, or a nested `new Box::()`. A turbofish forward (`$this->m::()`) + * keeps its args in `ATTR_METHOD_GENERIC_ARGS`, which is not an AST child, so it is not found here. + * + * @param list $bounded + */ + private static function bodyUsesBounded(ClassMethod $method, array $bounded): bool + { + if ($method->stmts === null) { + return false; + } + /** @var list $names */ + $names = (new NodeFinder())->findInstanceOf($method->stmts, Name::class); + foreach ($names as $name) { + if (count($name->getParts()) === 1 && in_array($name->toString(), $bounded, true)) { + return true; + } + if (self::genericArgsReferenceBounded($name, $bounded)) { + return true; + } + } + return false; + } + + /** + * Whether a Name's `ATTR_GENERIC_ARGS` (the `<...>` of a generic type reference) contains a + * bounded type-parameter reference (`Box` / `Map`). + * + * @param list $bounded + */ + private static function genericArgsReferenceBounded(Name $name, array $bounded): bool + { + $args = $name->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (!is_array($args)) { + return false; + } + /** @var list $args */ + foreach ($args as $arg) { + if (self::refTreeHasBounded($arg, $bounded)) { + return true; + } + } + return false; + } + + /** + * The TypeRef list an erasable method is mangled on: each enclosing-bounded parameter contributes + * the **concrete** value of its bound's class parameter (`U : E` on `Box` → `Fruit`). The + * call site and the class specialization both compute this from the same `$classConcrete` map, so + * they produce the byte-identical mangled name (`contains_T_`) — the cross-cutting invariant. + * + * @param list $methodParams + * @param array $classConcrete class-parameter name → its concrete TypeRef + * @return list + */ + public static function mangleArgs(array $methodParams, array $classConcrete): array + { + $out = []; + foreach ($methodParams as $param) { + if ($param->bound instanceof BoundLeaf && isset($classConcrete[$param->bound->type->name])) { + $out[] = $classConcrete[$param->bound->type->name]; + } + } + return $out; + } + + /** + * Whether a TypeRef tree contains a type-parameter reference to one of $bounded (the `U` in + * `Box` / `Map`). + * + * @param list $bounded + */ + public static function refTreeHasBounded(TypeRef $ref, array $bounded): bool + { + if ($ref->isTypeParam && in_array($ref->name, $bounded, true)) { + return true; + } + foreach ($ref->args as $arg) { + if (self::refTreeHasBounded($arg, $bounded)) { + return true; + } + } + return false; + } + + /** + * The names of the method's type parameters whose bound references an enclosing class parameter. + * + * @param list $methodParams + * @param list $classParamNames + * @return list + */ + private static function enclosingBoundedNames(array $methodParams, array $classParamNames): array + { + $out = []; + foreach ($methodParams as $param) { + if ($param->bound !== null && self::boundIsEnclosingLeaf($param->bound, $classParamNames)) { + $out[] = $param->name; + } + } + return $out; + } + + /** + * Whether the bound is *exactly* a single enclosing class type parameter (``). A compound + * bound (``) is deliberately excluded: erasing `U` to `E` would drop the + * `\Stringable` half, so it must keep the per-`U` lowering (where the whole bound is checked). + * + * @param list $classParamNames + */ + private static function boundIsEnclosingLeaf(BoundExpr $bound, array $classParamNames): bool + { + return $bound instanceof BoundLeaf + && $bound->type->isTypeParam + && in_array($bound->type->name, $classParamNames, true); + } +} diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 124411e..7237d68 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -45,6 +45,10 @@ use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use RuntimeException; +use XPHP\Diagnostics\Diagnostic; +use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\Diagnostics\Severity; +use XPHP\Diagnostics\SourceLocation; /** * Specializes method-scoped generics: `function NAME(...)` inside a class body, called via @@ -59,22 +63,45 @@ * 3. Strip the original generic-method ClassMethod from each class. * 4. Rewrite each StaticCall's Identifier name to the mangled form. * - * MVP limitations (called out so they're not silently surprising): - * - Static call sites only — `ClassFqn::method(...)`. Instance calls `$obj->method(...)` - * are not yet supported because the compiler has no way to know the runtime class of `$obj` - * without proper type inference. - * - Methods declared on non-generic classes only. Calling a generic method on a generic class - * (where the method has its own distinct type-param) requires merging two type-param scopes; - * that's a follow-up. + * Supported call shapes: static (`ClassFqn::method::(...)`), instance and nullsafe + * (`$obj->method::(...)`, `$obj?->method::(...)`) via receiver-type analysis, and + * free functions / generic closures / arrows. A generic method declared on a generic OR a + * non-generic class works; the method's own type-params are a scope disjoint from the class's, + * so `class Box { public function map(...) }` specializes `U` independently of `T`. + * + * Inheritance: an instance/nullsafe turbofish call resolves the method through the receiver's + * ancestor chain (`resolveMethodTemplate`), and the specialization is emitted onto the + * *declaring* class so every subclass inherits the single copy. A subclass override (same + * method redeclared) shadows the inherited one. + * + * Limitations: * - Bound validation on method-level type-params fires when a TypeHierarchy is wired in * (compiler always passes one); if none is given (bare unit tests), bounds become * advisory — matching the class-level Registry's behavior. + * - Inheritance resolution walks `extends`/`implements` ancestors only; a generic method + * reached solely through a `use`d trait is not resolved (the trait isn't in the hierarchy). */ final class GenericMethodCompiler { + /** Stable diagnostic codes for method/function/closure-level generic errors collected by `check`. */ + public const CODE_DUPLICATE_GENERIC_FUNCTION = 'xphp.duplicate_generic_function'; + public const CODE_UNSUPPORTED_THIS_CAPTURE = 'xphp.closure_this_capture'; + public const CODE_UNSUPPORTED_STATIC_CLOSURE = 'xphp.static_closure'; + public const CODE_UNRESOLVED_GENERIC_CALL = 'xphp.unresolved_generic_call'; + public const CODE_BOUND_UNPROVABLE = 'xphp.bound_unprovable'; + public const CODE_UNDETERMINED_RECEIVER = 'xphp.undetermined_receiver'; + public const CODE_UNSPECIALIZABLE_SELF_CALL = 'xphp.unspecializable_self_call'; + + /** + * @param ?DiagnosticCollector $diagnostics When null (the default — `xphp compile`), every + * method/function/closure-level generic error throws as before, byte-identical. When provided + * (by `xphp check` with `process(..., emit: false)`), each is appended as a Diagnostic and the + * pass continues, so all are reported in one run. + */ public function __construct( private readonly int $hashLength = Registry::DEFAULT_HASH_HEX_LENGTH, private readonly ?TypeHierarchy $hierarchy = null, + private readonly ?DiagnosticCollector $diagnostics = null, ) { } @@ -85,8 +112,13 @@ public function __construct( * * @param array> $astSet keyed by an arbitrary string id (filepath * or ""). The values are the top-level statements of each AST. + * @param bool $emit When true (default, compile) the pass specializes, appends, and strips + * templates as before. When false (`xphp check`) it walks for VALIDATION only — no append-flush, + * no strip, no closure-dispatcher finalize. The traversal still rewrites call-site nodes on the + * (discarded) per-file AST, but templates are deep-cloned so nothing shared is mutated; only + * diagnostics are produced. */ - public function process(array &$astSet): void + public function process(array &$astSet, bool $emit = true): void { /** @var array $methodTemplates keyed by "classFqn::methodName" */ $methodTemplates = []; @@ -112,14 +144,24 @@ public function process(array &$astSet): void // files) with both paths, matching the shape `Registry::recordDefinition` // uses for generic classes. Silently overwriting the first body — which is // the prior behavior here — costs a real refactoring footgun. - foreach ($perFileFns as $fqn => $_template) { + foreach ($perFileFns as $fqn => $duplicate) { if (isset($functionTemplates[$fqn])) { - throw new RuntimeException(sprintf( + $message = sprintf( 'Generic function template "%s" already declared (in %s); duplicate declaration in %s.', $fqn, $functionSourceByFqn[$fqn], (string) $astKey, - )); + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_DUPLICATE_GENERIC_FUNCTION, + $message, + new SourceLocation((string) $astKey, $duplicate->getStartLine()), + )); + continue; + } + throw new RuntimeException($message); } } @@ -166,20 +208,36 @@ public function process(array &$astSet): void $functionNamespaceByFqn, $alreadyGenerated, $topLevelAppends, + (string) $astKey, + $emit, ); - foreach ($topLevelAppends as $specialized) { - $ast[] = $specialized; + if ($emit) { + foreach ($topLevelAppends as $specialized) { + $ast[] = $specialized; + } } } unset($ast); + // Validate-only (check) stops here: no template stripping or append-flush. The discarded + // per-file AST may carry the traversal's in-place call-site rewrites, but nothing shared is. + if (!$emit) { + return; + } + // Strip the original method templates from their owning classes. foreach ($methodTemplates as $key => $template) { // @infection-ignore-all — explode limit 2 vs 3: the key never contains // more than one `::` so the third capture would be empty in either case. [$classFqn, $methodName] = explode('::', $key, 2); $class = $classByFqn[$classFqn] ?? null; - if ($class !== null) { + if ($class === null) { + continue; + } + // An erasable `` method is KEPT on its (generic) class so the Specializer can erase + // it into a concrete `contains_T_(E)` member per instantiation. The generic class is + // lowered to a marker interface in the user file, so the kept template never reaches output. + if (!self::isErasableMethodOnClass($template, $class)) { $this->stripMethod($class, $methodName); } } @@ -314,13 +372,16 @@ private function rewriteCallSites( array $functionNamespaceByFqn, array &$alreadyGenerated, array &$topLevelAppends, + string $currentFile, + bool $emit, ): void { $hashLength = $this->hashLength; $hierarchy = $this->hierarchy; + $diagnostics = $this->diagnostics; // @infection-ignore-all — see rationale above the indexTemplates visitor: defensive // guards and call-shape mutations are masked by the surrounding pipeline's // type-strict invariants. End-to-end coverage from GenericMethodIntegrationTest. - $visitor = new class($methodTemplates, $classByFqn, $functionTemplates, $functionNamespaceByFqn, $alreadyGenerated, $hashLength, $hierarchy, $topLevelAppends) extends NodeVisitorAbstract { + $visitor = new class($methodTemplates, $classByFqn, $functionTemplates, $functionNamespaceByFqn, $alreadyGenerated, $hashLength, $hierarchy, $topLevelAppends, $diagnostics, $currentFile) extends NodeVisitorAbstract { private string $currentNamespace = ''; private ?Namespace_ $currentNamespaceNode = null; /** @var array alias => fqn */ @@ -353,6 +414,31 @@ private function rewriteCallSites( * @var array */ private array $currentScopeLocalTypes = []; + /** + * Parallel side-tables to the two type maps above, carrying each tracked receiver's + * generic type arguments (`$b` of declared type `Box` → `[Product]`). Kept in + * lockstep with the string maps through scope push/restore and branch reset/leave; a + * branch that assigns a variable DROPS its args (never merges them), so a post-branch + * receiver falls back to lenient grounding rather than risking a stale/ambiguous arg. + * Read by `resolveReceiverTypeArgs` to ground a method-generic bound that references an + * enclosing class type parameter against the receiver's concrete arguments. + * + * @var array> + */ + private array $currentScopeParamTypeArgs = []; + /** @var array> */ + private array $currentScopeLocalTypeArgs = []; + /** + * Memo for {@see resolveCallReturn}, keyed by `spl_object_id` of the call node. A call + * node sits at one fixed program point, so its declared-return-type resolution is + * deterministic; without the memo a chained receiver re-descends both the FQN and the + * args branch at every hop, which is O(2^N) in chain depth. With it, each hop resolves + * once. The value is the resolution result (or `null`); presence is tested with + * `array_key_exists` so a cached `null` is honoured. + * + * @var array}|null> + */ + private array $callReturnCache = []; /** * Variable name -> the Closure or ArrowFunction AST node that was * assigned to it (only when the closure carries @@ -398,9 +484,11 @@ private function rewriteCallSites( * Function_/ClassMethod/Closure/ArrowFunction boundaries. On enter we push the * outgoing `(params, locals, branches)` triple; on leave we pop and restore. * Branch snapshots are nested per-scope so that branches inside a closure - * don't leak to branches in the enclosing function. + * don't leak to branches in the enclosing function. The closure-template maps + * are snapshotted too so a generic closure assigned in one scope doesn't leak + * into a sibling scope where the same variable names an unrelated callable. * - * @var list, locals: array, branches: list, assigned: array, perBranchTypes: list>, armIndex: int}>}> + * @var list, locals: array, paramArgs: array>, localArgs: array>, branches: list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, perBranchArgs: list>>, armIndex: int}>, closureTemplates: array, closureContexts: array}> */ private array $scopeSnapshots = []; /** @@ -439,7 +527,7 @@ private function rewriteCallSites( * keeps `$x` instead of invalidating. See `P5.1-same-class-merge.md` * and the `computeMergedTypes` / `canMergeOnLeave` helpers below. * - * @var list, assigned: array, perBranchTypes: list>, armIndex: int}> + * @var list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, perBranchArgs: list>>, armIndex: int}> */ private array $branchSnapshots = []; @@ -460,6 +548,8 @@ public function __construct( private int $hashLength, private ?TypeHierarchy $hierarchy, public array &$topLevelAppends, + private readonly ?DiagnosticCollector $diagnostics, + private readonly string $currentFile, ) { } @@ -496,14 +586,27 @@ public function enterNode(Node $node): null // inside the closure are independent of branches in the parent. $parentParams = $this->currentScopeParamTypes; $parentLocals = $this->currentScopeLocalTypes; + $parentParamArgs = $this->currentScopeParamTypeArgs; + $parentLocalArgs = $this->currentScopeLocalTypeArgs; $this->scopeSnapshots[] = [ 'params' => $parentParams, 'locals' => $parentLocals, + 'paramArgs' => $parentParamArgs, + 'localArgs' => $parentLocalArgs, 'branches' => $this->branchSnapshots, + // Closure-template tracking is per-scope too: a generic closure assigned to `$f` + // in one function must NOT leak into a sibling scope where `$f` is an unrelated + // callable, or a bare `$f(...)` there would be misreported. + 'closureTemplates' => $this->currentScopeClosureTemplates, + 'closureContexts' => $this->currentScopeClosureContexts, ]; $this->currentScopeParamTypes = []; $this->currentScopeLocalTypes = []; + $this->currentScopeParamTypeArgs = []; + $this->currentScopeLocalTypeArgs = []; $this->branchSnapshots = []; + $this->currentScopeClosureTemplates = []; + $this->currentScopeClosureContexts = []; // For closures: `use ($x)` explicitly imports outer variables. // Copy each imported name's type from the parent scope so the @@ -521,6 +624,12 @@ public function enterNode(Node $node): null if ($importedType !== null) { $this->currentScopeParamTypes[$importedName] = $importedType; } + $importedArgs = $parentParamArgs[$importedName] + ?? $parentLocalArgs[$importedName] + ?? null; + if ($importedArgs !== null) { + $this->currentScopeParamTypeArgs[$importedName] = $importedArgs; + } } } @@ -534,6 +643,12 @@ public function enterNode(Node $node): null foreach ($parentLocals as $importedName => $importedType) { $this->currentScopeParamTypes[$importedName] = $importedType; } + foreach ($parentParamArgs as $importedName => $importedArgs) { + $this->currentScopeParamTypeArgs[$importedName] = $importedArgs; + } + foreach ($parentLocalArgs as $importedName => $importedArgs) { + $this->currentScopeParamTypeArgs[$importedName] = $importedArgs; + } } // Declared parameter types overwrite any imported same-named @@ -551,6 +666,16 @@ public function enterNode(Node $node): null } if ($type instanceof Name) { $this->currentScopeParamTypes[$param->var->name] = $this->resolveClassName($type); + // Side-table the param's generic args (`Box $b` → [Product]) so a + // bound referencing the enclosing class param can be grounded. A shadowing + // param without generic args clears any imported stale entry. + $paramArgs = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (is_array($paramArgs)) { + /** @var list $paramArgs */ + $this->currentScopeParamTypeArgs[$param->var->name] = $paramArgs; + } else { + unset($this->currentScopeParamTypeArgs[$param->var->name]); + } } } } @@ -562,8 +687,10 @@ public function enterNode(Node $node): null if (self::isBranchingParent($node)) { $this->branchSnapshots[] = [ 'snapshot' => $this->currentScopeLocalTypes, + 'localArgsSnapshot' => $this->currentScopeLocalTypeArgs, 'assigned' => [], 'perBranchTypes' => [], + 'perBranchArgs' => [], // If_'s body is the first arm (armIndex=0). // Switch_ / Match_ have no parent body arm; the first // sibling enter promotes armIndex from -1 to 0. @@ -584,9 +711,17 @@ public function enterNode(Node $node): null $this->branchSnapshots[$top]['assigned'], $this->currentScopeLocalTypes, ); + $this->branchSnapshots[$top]['perBranchArgs'][] = + self::captureArmArgs( + $this->branchSnapshots[$top]['assigned'], + $this->currentScopeLocalTypeArgs, + ); } $this->branchSnapshots[$top]['armIndex']++; $this->currentScopeLocalTypes = $this->branchSnapshots[$top]['snapshot']; + // Args track the string map: each arm restarts from the pre-branch snapshot, so a + // var an earlier arm set can't leak its args into a sibling arm. + $this->currentScopeLocalTypeArgs = $this->branchSnapshots[$top]['localArgsSnapshot']; } // Stage B flow typing: `$x = new ClassName(...)` records `$x`'s receiver // type for later MethodCall sites in the same scope. Lexical last-write @@ -611,6 +746,36 @@ public function enterNode(Node $node): null && $node->expr->class instanceof Name ) { $this->currentScopeLocalTypes[$assignedName] = $this->resolveClassName($node->expr->class); + // Side-table the constructed type's generic args (`new Box::()` → + // [Product]); a non-generic `new` clears any stale args from a prior write. + $newArgs = $node->expr->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (is_array($newArgs)) { + /** @var list $newArgs */ + $this->currentScopeLocalTypeArgs[$assignedName] = $newArgs; + } else { + unset($this->currentScopeLocalTypeArgs[$assignedName]); + } + } elseif ($node->expr instanceof MethodCall + || $node->expr instanceof NullsafeMethodCall + || $node->expr instanceof StaticCall + ) { + // `$x = $repo->find()` / `$x = $this->getBox()`: track the call's declared + // return type so a later `$x->m::<…>()` is grounded. An unresolvable call + // clears any stale tracked type rather than leaving a wrong one in place. + $return = $this->resolveCallReturn($node->expr); + if ($return === null) { + unset( + $this->currentScopeLocalTypes[$assignedName], + $this->currentScopeLocalTypeArgs[$assignedName], + ); + } else { + $this->currentScopeLocalTypes[$assignedName] = $return[0]; + if ($return[1] !== []) { + $this->currentScopeLocalTypeArgs[$assignedName] = $return[1]; + } else { + unset($this->currentScopeLocalTypeArgs[$assignedName]); + } + } } // Track anonymous generic templates: `$id = fn(T $x) => $x` // or `$id = function(T $x): T { ... }`. The FuncCall-on- @@ -655,7 +820,11 @@ public function leaveNode(Node $node): ?Node if ($snapshot !== null) { $this->currentScopeParamTypes = $snapshot['params']; $this->currentScopeLocalTypes = $snapshot['locals']; + $this->currentScopeParamTypeArgs = $snapshot['paramArgs']; + $this->currentScopeLocalTypeArgs = $snapshot['localArgs']; $this->branchSnapshots = $snapshot['branches']; + $this->currentScopeClosureTemplates = $snapshot['closureTemplates']; + $this->currentScopeClosureContexts = $snapshot['closureContexts']; } else { // Defensive: matched enter/leave count is invariant of the // NodeTraverser; the else-branch is only reachable if the AST @@ -663,7 +832,11 @@ public function leaveNode(Node $node): ?Node // pop on the next leave. $this->currentScopeParamTypes = []; $this->currentScopeLocalTypes = []; + $this->currentScopeParamTypeArgs = []; + $this->currentScopeLocalTypeArgs = []; $this->branchSnapshots = []; + $this->currentScopeClosureTemplates = []; + $this->currentScopeClosureContexts = []; } } // Branching parents: pop the frame, restore the pre-branch local @@ -683,15 +856,21 @@ public function leaveNode(Node $node): ?Node $popped['assigned'], $this->currentScopeLocalTypes, ); + $popped['perBranchArgs'][] = self::captureArmArgs( + $popped['assigned'], + $this->currentScopeLocalTypeArgs, + ); } // Restore to pre-branch state. $this->currentScopeLocalTypes = $popped['snapshot']; + $this->currentScopeLocalTypeArgs = $popped['localArgsSnapshot']; // P5.1 same-class merge: try to keep variables whose // every reachable arm assigned the same FQN, instead // of unconditionally invalidating below. $merged = self::computeMergedTypes($node, $popped); + $mergedArgs = self::computeMergedArgs($node, $popped); foreach ($popped['assigned'] as $assignedName => $_true) { if (isset($merged[$assignedName])) { @@ -699,6 +878,14 @@ public function leaveNode(Node $node): ?Node } else { unset($this->currentScopeLocalTypes[$assignedName]); } + // Keep the receiver's type args only when the class merged AND every arm + // agreed on the args (by canonical()); a differing-arm receiver + // (Box vs Box) drops its args and stays undeterminable. + if (isset($merged[$assignedName], $mergedArgs[$assignedName])) { + $this->currentScopeLocalTypeArgs[$assignedName] = $mergedArgs[$assignedName]; + } else { + unset($this->currentScopeLocalTypeArgs[$assignedName]); + } if ($this->branchSnapshots !== []) { $parentTop = count($this->branchSnapshots) - 1; $this->branchSnapshots[$parentTop]['assigned'][$assignedName] = true; @@ -762,6 +949,23 @@ private static function captureArmTypes(array $assigned, array $currentTypes): a return $out; } + /** + * Per-arm receiver type args for the `assigned` set, parallel to captureArmTypes. + * `null` means "this arm did not finish with tracked args for that name". + * + * @param array $assigned + * @param array> $currentArgs + * @return array> + */ + private static function captureArmArgs(array $assigned, array $currentArgs): array + { + $out = []; + foreach ($assigned as $name => $_true) { + $out[$name] = $currentArgs[$name] ?? null; + } + return $out; + } + /** * Same-class merge eligibility: only the all-arms-reachable * branching parents can participate. Loops always have an @@ -862,6 +1066,54 @@ private static function computeMergedTypes(Node $node, array $popped): array return $merged; } + /** + * Same as computeMergedTypes, but for the receiver type args: name -> args for every var + * whose every reachable arm ended with the SAME args (compared by `TypeRef::canonical()`). + * Empty when the merge isn't allowed or an arm is missing args for the name. The caller + * additionally gates on the FQN having merged, so args are kept only for a fully-agreed + * receiver. + * + * @param array{perBranchArgs: list>>, assigned: array} $popped + * @return array> + */ + private static function computeMergedArgs(Node $node, array $popped): array + { + if (!self::canMergeOnLeave($node)) { + return []; + } + if (count($popped['perBranchArgs']) !== self::expectedArmCount($node)) { + return []; + } + $merged = []; + foreach ($popped['assigned'] as $name => $_true) { + $firstKey = null; + /** @var list|null $firstArgs */ + $firstArgs = null; + $allAgree = true; + foreach ($popped['perBranchArgs'] as $i => $armArgs) { + $args = $armArgs[$name] ?? null; + if ($args === null) { + $allAgree = false; + break; + } + $key = implode(',', array_map(static fn (TypeRef $a): string => $a->canonical(), $args)); + if ($i === 0) { + $firstKey = $key; + $firstArgs = $args; + continue; + } + if ($key !== $firstKey) { + $allAgree = false; + break; + } + } + if ($allAgree && $firstArgs !== null) { + $merged[$name] = $firstArgs; + } + } + return $merged; + } + private function rewriteStaticCall(StaticCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); @@ -875,10 +1127,14 @@ private function rewriteStaticCall(StaticCall $node): ?Node $classFqn = $this->resolveClassName($node->class); $methodName = $node->name->toString(); $key = $classFqn . '::' . $methodName; - $template = $this->methodTemplates[$key] ?? null; - if ($template === null) { - return null; - } + // Resolve through the inheritance chain (same as the instance path): + // a static generic method declared on a base is callable as + // `Sub::m::<...>()` and resolves via static-method inheritance. + $resolved = $this->resolveMethodTemplate($classFqn, $methodName); + if ($resolved === null) { + return $this->reportUnresolvedTurbofishOrSkip($classFqn, $methodName, $node); + } + [$template, $declaringFqn] = $resolved; $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); if (!is_array($params)) { return null; @@ -889,36 +1145,62 @@ private function rewriteStaticCall(StaticCall $node): ?Node // through padArgsWithDefaults too so partial-arg shapes are // filled in the same way class-level instantiations are. if (!is_array($args)) { - if (!self::hasAllDefaults($params)) { + // A first-class-callable (`$obj->m(...)` / `Box::m(...)`) creates a Closure rather + // than invoking, so leave it alone. + if ($node->isFirstClassCallable()) { return null; } + // Bare call (no turbofish): fall through to padArgsWithDefaults, which pads an + // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise. A + // method generic can't infer its type argument from the call args, so a bare call to a + // non-all-default generic is an error — not a silent skip that emits a call to the + // stripped method and fatals at runtime. $args = []; } /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or empty after the all-defaults branch above). */ - $padded = Registry::padArgsWithDefaults($params, $args, $key); + $location = new SourceLocation($this->currentFile, $node->getStartLine()); + $padded = Registry::padArgsWithDefaults($params, $args, $key, $this->diagnostics, $location); if (!self::allConcrete($padded) || count($params) !== count($padded)) { return null; } $args = $padded; if ($this->hierarchy !== null) { - Registry::checkBounds( + // A class type parameter is unbound in a static context (no instance to ground + // `E`), so pass no receiver args and never defer: a class-param bound on a static + // method is genuinely unprovable and fails. The call's own turbofish args are + // still threaded, so a method-own sibling bound (``) is grounded. + $checkedParams = $this->groundBounds( $params, $args, + $classFqn, + [], + $declaringFqn, + false, + $classFqn . '::' . $methodName, + $location, + ); + Registry::checkBounds( + $checkedParams, + $args, $this->hierarchy, $classFqn . '::' . $methodName . '<' . self::formatArgList($args) . '>', + $this->diagnostics, + $location, ); } $mangled = self::mangleName($methodName, $args, $this->hashLength); - $generatedKey = $classFqn . '::' . $mangled; + // Emit onto the declaring class (see the instance path) so subclasses + // inherit the single specialization; dedup by the declaring FQN. + $generatedKey = $declaringFqn . '::' . $mangled; if (!isset($this->alreadyGenerated[$generatedKey])) { $substitution = []; foreach ($params as $i => $param) { $substitution[$param->name] = $args[$i]; } $specialized = (new Specializer())->specializeMethod($template, $substitution, $mangled); - $owner = $this->classByFqn[$classFqn] ?? null; + $owner = $this->classByFqn[$declaringFqn] ?? null; if ($owner !== null) { // Buffer the append (see rewriteFuncCall for the rationale). $this->pendingAppends[] = [$owner, $specialized]; @@ -947,11 +1229,12 @@ private function rewriteStaticCall(StaticCall $node): ?Node * - `$param->method::(...)` -- receiver is the function/method * parameter's declared type (snapshot in $currentScopeParamTypes). * - * Receivers we currently can't resolve (returns null -> no - * specialization, marker drops silently; user's call site becomes a - * normal MethodCall to a method that doesn't exist post-strip, surfacing - * a runtime "undefined method" error). Stage B will widen the receiver - * sources to local-variable assignments. + * When the receiver's type can't be resolved, a *turbofish* call can't be + * specialized — the generic method only exists as mangled specializations, + * so leaving it would emit a call to a method that doesn't exist and fatal + * at runtime. Ground-or-fail: report it at compile time (see + * `reportUndeterminedReceiverOrSkip`). An ordinary (non-turbofish) call on + * an unresolved receiver is none of our business and passes through. */ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): ?Node { @@ -962,50 +1245,128 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): $classFqn = $this->resolveReceiverFqn($node->var); if ($classFqn === null) { - return null; + return $this->reportUndeterminedReceiverOrSkip($node->name->toString(), $node); } $methodName = $node->name->toString(); $key = $classFqn . '::' . $methodName; - $template = $this->methodTemplates[$key] ?? null; - if ($template === null) { - return null; - } + // Resolve through the inheritance chain: a generic method declared on + // a base class is callable on a subclass receiver. $declaringFqn is + // where the template actually lives, so the specialization is emitted + // there and inherited (see resolveMethodTemplate). + $resolved = $this->resolveMethodTemplate($classFqn, $methodName); + if ($resolved === null) { + return $this->reportUnresolvedTurbofishOrSkip($classFqn, $methodName, $node); + } + [$template, $declaringFqn] = $resolved; $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); if (!is_array($params)) { return null; } /** @var list $params — set as a list by XphpSourceParser::resolveAndAttach. */ if (!is_array($args)) { - if (!self::hasAllDefaults($params)) { + // A first-class-callable (`$obj->m(...)` / `Box::m(...)`) creates a Closure rather + // than invoking, so leave it alone. + if ($node->isFirstClassCallable()) { return null; } + // Bare call (no turbofish): fall through to padArgsWithDefaults, which pads an + // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise. A + // method generic can't infer its type argument from the call args, so a bare call to a + // non-all-default generic is an error — not a silent skip that emits a call to the + // stripped method and fatals at runtime. $args = []; } /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or empty after the all-defaults branch above). */ - $padded = Registry::padArgsWithDefaults($params, $args, $key); - if (!self::allConcrete($padded) || count($params) !== count($padded)) { + $location = new SourceLocation($this->currentFile, $node->getStartLine()); + $padded = Registry::padArgsWithDefaults($params, $args, $key, $this->diagnostics, $location); + // Arity first: in `check` mode padArgsWithDefaults collects an arity diagnostic and + // returns the (still mis-sized) args, so an arity problem must short-circuit here + // before the concreteness check — otherwise a too-many/missing-arg self-call would + // also draw a spurious `unspecializable_self_call` on top of the real arity error. + if (count($params) !== count($padded)) { + return null; + } + if (!self::allConcrete($padded)) { + // A non-concrete turbofish arg is an abstract type parameter forwarded from the + // enclosing generic method (`probe{ $this->contains::(...) }`). On a + // `$this`-rooted receiver this can't be specialized at the template — the arg is + // concrete only per instantiation. When the target is ERASABLE the Specializer + // rewrites this self-call to the target's E-mangled name per instantiation, so it + // resolves; leave it for that pass. Otherwise it would emit a bare `$this->m(...)` + // to a method that was never specialized (a runtime fatal) — so report it. + if ($this->receiverRootedAtThis($node->var) + && !$this->isErasableTarget($template, $params, $declaringFqn) + ) { + return $this->reportUnspecializableSelfCall($methodName, $location); + } return null; } $args = $padded; if ($this->hierarchy !== null) { - Registry::checkBounds( + // Ground a method-generic bound that references an enclosing class type + // parameter (``) against the receiver's concrete arguments, threaded + // to the method's declaring class. An ungroundable bound is a compile error; the + // `$this`-rooted flag only tailors the diagnostic's remedy (a self-call can't + // bind to a typed local), it does not suppress the failure. + $receiverArgs = $this->resolveReceiverTypeArgs($node->var); + $checkedParams = $this->groundBounds( $params, $args, + $classFqn, + $receiverArgs, + $declaringFqn, + $this->receiverRootedAtThis($node->var), + $classFqn . '::' . $methodName, + $location, + ); + Registry::checkBounds( + $checkedParams, + $args, $this->hierarchy, $classFqn . '::' . $methodName . '<' . self::formatArgList($args) . '>', + $this->diagnostics, + $location, ); + + // Erasable `` method: the bound is checked above, but the call lowers to + // the E-mangled name keyed on the RECEIVER's element type (not the turbofish arg), + // and the member is emitted by the Specializer per class instantiation — so there + // is no per-call append here. Both sides key on EnclosingBoundErasure::mangleArgs, + // producing the same name. + $declaringClass = $this->classByFqn[$declaringFqn] ?? null; + $classParams = $declaringClass?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (is_array($classParams)) { + /** @var list $classParams */ + $classParamNames = array_map(static fn (TypeParam $p): string => $p->name, $classParams); + if (EnclosingBoundErasure::isErasable($template, $params, $classParamNames)) { + $classConcrete = $this->classSubstitutionFor($classFqn, $receiverArgs, $declaringFqn); + if ($classConcrete !== []) { + $erased = self::mangleName( + $methodName, + EnclosingBoundErasure::mangleArgs($params, $classConcrete), + $this->hashLength, + ); + $node->name = new Identifier($erased, $node->name->getAttributes()); + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); + return $node; + } + } + } } $mangled = self::mangleName($methodName, $args, $this->hashLength); - $generatedKey = $classFqn . '::' . $mangled; + // Key emission + dedup by the DECLARING class, not the receiver: the + // specialization lands on the base and every subclass inherits the one + // copy. Keying by receiver would append a duplicate per subclass. + $generatedKey = $declaringFqn . '::' . $mangled; if (!isset($this->alreadyGenerated[$generatedKey])) { $substitution = []; foreach ($params as $i => $param) { $substitution[$param->name] = $args[$i]; } $specialized = (new Specializer())->specializeMethod($template, $substitution, $mangled); - $owner = $this->classByFqn[$classFqn] ?? null; + $owner = $this->classByFqn[$declaringFqn] ?? null; if ($owner !== null) { $this->pendingAppends[] = [$owner, $specialized]; $this->alreadyGenerated[$generatedKey] = true; @@ -1018,6 +1379,225 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): return $node; } + /** + * Resolve a generic-method template by receiver FQN, walking up the + * inheritance chain when the method is declared on an ancestor. + * + * Returns the template paired with the FQN of the class that actually + * declares it -- the specialization is emitted onto that declaring class + * so every subclass inherits it through the existing class-level extends + * edge. A direct hit on the receiver's own class wins over the ancestor + * walk (a subclass override shadows an inherited method). Returns null + * when neither the receiver nor any ancestor declares the method. + * + * @return array{0: ClassMethod, 1: string}|null [template, declaringFqn] + */ + private function resolveMethodTemplate(string $receiverFqn, string $methodName): ?array + { + $direct = $this->methodTemplates[$receiverFqn . '::' . $methodName] ?? null; + if ($direct !== null) { + return [$direct, $receiverFqn]; + } + if ($this->hierarchy === null) { + return null; + } + foreach ($this->hierarchy->ancestorChain($receiverFqn) as $ancestorFqn) { + $inherited = $this->methodTemplates[$ancestorFqn . '::' . $methodName] ?? null; + if ($inherited !== null) { + return [$inherited, $ancestorFqn]; + } + } + return null; + } + + /** + * Find any method declaration (generic OR not) by receiver FQN, walking the inheritance + * chain for an inherited declaration. Unlike {@see resolveMethodTemplate} this reads the + * full class AST, so a plain getter whose return type is what we want to track is found. + * + * @return array{0: ClassMethod, 1: string}|null [method, declaringFqn] + */ + private function findMethodDeclaration(string $receiverFqn, string $methodName): ?array + { + $direct = $this->findMethodOn($receiverFqn, $methodName); + if ($direct !== null) { + return [$direct, $receiverFqn]; + } + if ($this->hierarchy === null) { + return null; + } + foreach ($this->hierarchy->ancestorChain($receiverFqn) as $ancestorFqn) { + $inherited = $this->findMethodOn($ancestorFqn, $methodName); + if ($inherited !== null) { + return [$inherited, $ancestorFqn]; + } + } + return null; + } + + private function findMethodOn(string $classFqn, string $methodName): ?ClassMethod + { + $owner = $this->classByFqn[$classFqn] ?? null; + if ($owner === null) { + return null; + } + foreach ($owner->stmts as $stmt) { + if ($stmt instanceof ClassMethod && $stmt->name->toString() === $methodName) { + return $stmt; + } + } + return null; + } + + /** + * Handle a turbofish call whose generic method couldn't be resolved on + * the receiver or any ancestor. A *turbofish* call (carries + * ATTR_METHOD_GENERIC_ARGS) to a non-existent generic method is a real + * user error -- reported here instead of being silently left in place + * to fatal at runtime with "Call to undefined method". An ordinary + * (non-turbofish) call has no generic args and is none of our business, + * so it passes through untouched. + * + * Collect-or-throw, matching the seam: with a collector (`check`) append + * a diagnostic and continue; without one (`compile`) throw. + */ + private function reportUnresolvedTurbofishOrSkip(string $receiverFqn, string $methodName, Node $node): null + { + $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + if (!is_array($args)) { + // Plain (non-turbofish) call -- not a generic-resolution failure. + return null; + } + $message = self::unresolvedGenericCallMessage($receiverFqn, $methodName); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMethodCompiler::CODE_UNRESOLVED_GENERIC_CALL, + $message, + new SourceLocation($this->currentFile, $node->getStartLine()), + )); + return null; + } + throw new RuntimeException($message); + } + + private static function unresolvedGenericCallMessage(string $receiverFqn, string $methodName): string + { + // Phrased as "could not be resolved ... on " rather than + // asserting the method is absent everywhere: the instance path walks + // ancestors, but the static path doesn't yet, so an absolute "not on + // any ancestor" claim would be wrong for an inherited static method. + return sprintf( + 'Generic method `%s::%s::<...>()` could not be resolved to a declared generic ' + . 'method on `%s`. Check the method name or the receiver\'s type.', + $receiverFqn, + $methodName, + $receiverFqn, + ); + } + + /** + * A turbofish instance call (`$x->m::<...>()`) whose receiver type couldn't be determined. + * The call can't be specialized — the generic method only exists as mangled + * specializations — so leaving it would emit a call to a non-existent method that fatals + * at runtime. Ground-or-fail: collect-or-throw at compile time. An ordinary (non-turbofish) + * call has no generic args and passes through untouched (PHP resolves it normally). + */ + private function reportUndeterminedReceiverOrSkip(string $methodName, Node $node): null + { + if (!is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS))) { + return null; + } + $message = sprintf( + 'Cannot determine the receiver\'s type for the generic call `%s::<...>()`. 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.', + $methodName, + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMethodCompiler::CODE_UNDETERMINED_RECEIVER, + $message, + new SourceLocation($this->currentFile, $node->getStartLine()), + )); + return null; + } + throw new RuntimeException($message); + } + + /** + * A `$this`-rooted self-call (`$this->m::()`) forwards an abstract type parameter to a + * generic method. It can't be specialized at the template — the arg is concrete only per + * instantiation — and would otherwise emit a bare call to a stripped method that fatals at + * runtime. Collect-or-throw at compile time. (A future erasure lowering will make the + * common, direct-input shape of this compile and run.) + */ + private function reportUnspecializableSelfCall(string $methodName, SourceLocation $location): null + { + $message = sprintf( + 'Cannot specialize the self-call `$this->%s::<...>()`: 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.', + $methodName, + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMethodCompiler::CODE_UNSPECIALIZABLE_SELF_CALL, + $message, + $location, + )); + return null; + } + throw new RuntimeException($message); + } + + /** + * A turbofish-less call to a generic free function or closure (`$label` is its FQN or + * `$var`). Unlike a method, a named generic function / closure has no bare or empty-turbofish + * form, so any bare call is missing its type arguments regardless of defaults. Collect-or-throw. + */ + private function reportMissingTurbofishArguments(string $label, SourceLocation $location): void + { + $message = sprintf( + 'Generic call `%s(...)` is missing its type arguments: a generic function or closure ' + . 'takes no inference, so it must be called with an explicit turbofish `%s::<...>(...)`.', + $label, + $label, + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + Registry::CODE_MISSING_TYPE_ARGUMENT, + $message, + $location, + )); + return; + } + throw new RuntimeException($message); + } + + /** + * Whether the called method is an erasable `` method on its declaring class — i.e. + * the Specializer will lower it (and rewrite a forwarded self-call to it) per instantiation. + * + * @param list $params + */ + private function isErasableTarget(ClassMethod $template, array $params, string $declaringFqn): bool + { + $class = $this->classByFqn[$declaringFqn] ?? null; + $classParams = $class?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($classParams)) { + return false; + } + /** @var list $classParams */ + $names = array_map(static fn (TypeParam $p): string => $p->name, $classParams); + return EnclosingBoundErasure::isErasable($template, $params, $names); + } + /** * Resolve the static type (FQN) of a method-call receiver expression. * Returns null when the receiver type can't be determined -- the caller @@ -1068,13 +1648,403 @@ private function resolveReceiverFqn(Node $receiver): ?string } } } + // A chained call (`$this->getBox()->m::<…>()`): the receiver is itself a call, so its + // type is that call's declared return type. + if ($receiver instanceof MethodCall + || $receiver instanceof NullsafeMethodCall + || $receiver instanceof StaticCall + ) { + $return = $this->resolveCallReturn($receiver); + return $return === null ? null : $return[0]; + } return null; } + /** + * Whether the receiver expression bottoms out at `$this` — directly (`$this->m`), through + * a property (`$this->prop->m`), or through a chain (`$this->getBox()->m`). Used only to + * tailor the `xphp.bound_unprovable` remedy: a `$this`-rooted self-call references the + * enclosing class's own (abstract) type parameter, so the "bind to a typed local" advice + * doesn't apply and a self-call-specific message is shown instead. It does NOT change + * whether the bound fails — an unprovable bound always fails. + */ + private function receiverRootedAtThis(Node $receiver): bool + { + if ($receiver instanceof Variable) { + return $receiver->name === 'this'; + } + if ($receiver instanceof MethodCall + || $receiver instanceof NullsafeMethodCall + || $receiver instanceof PropertyFetch + ) { + return $this->receiverRootedAtThis($receiver->var); + } + return false; + } + + /** + * The receiver's concrete generic type arguments, parallel to {@see resolveReceiverFqn}: + * - `$this` -> the enclosing class's own type params, as identity TypeRefs (a + * method-generic bound on the uninstantiated template can't be + * grounded to a concrete, so this stays a type-param and drops). + * - `$var` -> the side-tabled args for that parameter / local. + * - `$this->prop` -> the property type's generic args. + * Empty when unknown -- the grounding step then falls back to lenient. + * + * @return list + */ + private function resolveReceiverTypeArgs(Node $receiver): array + { + if ($receiver instanceof Variable && is_string($receiver->name)) { + if ($receiver->name === 'this') { + $owner = $this->currentClassFqn !== null + ? ($this->classByFqn[$this->currentClassFqn] ?? null) + : null; + $params = $owner?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($params)) { + return []; + } + /** @var list $params */ + return array_map( + static fn (TypeParam $p): TypeRef => new TypeRef($p->name, isTypeParam: true), + $params, + ); + } + return $this->currentScopeParamTypeArgs[$receiver->name] + ?? $this->currentScopeLocalTypeArgs[$receiver->name] + ?? []; + } + if ($receiver instanceof PropertyFetch + && $receiver->var instanceof Variable + && $receiver->var->name === 'this' + && $receiver->name instanceof Identifier + && $this->currentClassFqn !== null + ) { + $owner = $this->classByFqn[$this->currentClassFqn] ?? null; + if ($owner !== null) { + $propName = $receiver->name->toString(); + foreach ($owner->stmts as $stmt) { + if (!$stmt instanceof Property) { + continue; + } + foreach ($stmt->props as $prop) { + if ($prop->name->toString() !== $propName) { + continue; + } + $type = $stmt->type; + if ($type instanceof NullableType) { + $type = $type->type; + } + if ($type instanceof Name) { + $propArgs = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + /** @var list $result */ + $result = is_array($propArgs) ? $propArgs : []; + return $result; + } + } + } + } + } + // A chained call (`$this->getBox()->m::<…>()`): the receiver's args are the return + // type's grounded args. + if ($receiver instanceof MethodCall + || $receiver instanceof NullsafeMethodCall + || $receiver instanceof StaticCall + ) { + $return = $this->resolveCallReturn($receiver); + return $return === null ? [] : $return[1]; + } + return []; + } + + /** + * The class FQN and concrete generic type-args of a method/static call's DECLARED return + * type, or `null` when the return type isn't a determinable class. This is what lets a + * receiver whose type comes from a call — `$x = $repo->find(); $x->m::<…>()`, a chained + * `$this->getBox()->m::<…>()`, or a `self`/`static`/`parent`-returning factory — be + * grounded instead of treated as opaque. + * + * The return type's own generic args may reference the CALLED method's class parameters + * (`Repo { getBox(): Box }`); those are grounded through the call receiver's args + * (so `Box` on a `Repo` receiver becomes `Box`). When the receiver's args + * are themselves abstract (a `$this` self-call inside the template, or an unknown + * receiver) the arg stays a type parameter and the downstream grounding drops it — no + * determinate type is ever invented. + * + * @return array{0: string, 1: list}|null [returnFqn, groundedReturnArgs] + */ + private function resolveCallReturn(Node $call): ?array + { + $key = spl_object_id($call); + if (array_key_exists($key, $this->callReturnCache)) { + return $this->callReturnCache[$key]; + } + return $this->callReturnCache[$key] = $this->computeCallReturn($call); + } + + /** + * The uncached body of {@see resolveCallReturn}; always go through the memoizing wrapper. + * + * @return array{0: string, 1: list}|null + */ + private function computeCallReturn(Node $call): ?array + { + if ($call instanceof StaticCall) { + if (!$call->class instanceof Name || !$call->name instanceof Identifier) { + return null; + } + $receiverFqn = $this->resolveClassName($call->class); + $classArgs = $call->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + /** @var list $receiverArgs */ + $receiverArgs = is_array($classArgs) ? $classArgs : []; + } elseif ($call instanceof MethodCall || $call instanceof NullsafeMethodCall) { + if (!$call->name instanceof Identifier) { + return null; + } + $receiverFqn = $this->resolveReceiverFqn($call->var); + if ($receiverFqn === null) { + return null; + } + $receiverArgs = $this->resolveReceiverTypeArgs($call->var); + } else { + return null; + } + + // A non-generic getter (`getBox(): Box`) is the common return source and is NOT + // in $methodTemplates (which holds only generic methods), so resolve against the full + // class AST, walking ancestors for an inherited declaration. + $resolved = $this->findMethodDeclaration($receiverFqn, $call->name->toString()); + if ($resolved === null) { + return null; + } + [$method, $declaringFqn] = $resolved; + $returnType = $method->returnType; + if ($returnType instanceof NullableType) { + $returnType = $returnType->type; + } + if (!$returnType instanceof Name) { + return null; + } + + // `self` / `static` / `parent` return the receiver's own generic instance: its class + // and args carry through unchanged. (The parser strips the `<…>` off a pseudo-type + // and records no marker, so these names carry no ATTR_TEMPLATE_FQN.) + if (in_array(strtolower($returnType->toString()), ['self', 'static', 'parent'], true)) { + return [$receiverFqn, $receiverArgs]; + } + + // A parameterised return type (`Box<…>`) carries its head FQN on ATTR_TEMPLATE_FQN and + // its args on ATTR_GENERIC_ARGS; a plain class return type carries ATTR_RESOLVED_FQN. + $returnFqn = $returnType->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + if (!is_string($returnFqn)) { + $plainFqn = $returnType->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + return is_string($plainFqn) ? [$plainFqn, []] : null; + } + $returnArgs = $returnType->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + /** @var list $returnArgs */ + $returnArgs = is_array($returnArgs) ? $returnArgs : []; + + // Ground the return args through the receiver: `getBox(): Box` on a `Repo` + // receiver yields `Box`. A concrete return parameterisation has no class-param + // leaves, so the substitution is a no-op for it. + $classSubst = $this->classSubstitutionFor($receiverFqn, $receiverArgs, $declaringFqn); + if ($classSubst !== []) { + $returnArgs = array_map( + static fn (TypeRef $arg): TypeRef => Specializer::substituteTypeRef($arg, $classSubst), + $returnArgs, + ); + } + return [$returnFqn, $returnArgs]; + } + + /** + * Ground each method type-param's bound against the receiver's concrete arguments and the + * call's own turbofish arguments. + * + * Builds a substitution from (a) the declaring class's parameters to the receiver's + * arguments (threaded up the inheritance chain) and (b) the method's own parameters to + * this call's turbofish arguments — so a bound that references a SIBLING method parameter + * (``) grounds to that argument too. Method parameters shadow class parameters + * of the same name (the inner scope wins). It then rewrites every bound leaf with the + * combined map. + * + * When a leaf is still a bare type parameter afterwards the bound is UNPROVABLE — the + * receiver's type argument for it isn't determinable here. Maximum Runtime Safety forbids + * silently accepting it, so this is a compile error (`xphp.bound_unprovable`); it is never + * dropped. A bound that doesn't reference an enclosing/sibling param (a real class, or an + * F-bounded `Comparable` leaf) is unaffected and checked exactly as before. + * + * `$receiverIsThis` only tailors the diagnostic's remedy: a `$this`-rooted self-call can't + * "bind to a typed local" (the receiver is `$this`), and its enclosing parameter is + * abstract until the class is instantiated — a per-instantiation re-check (the future + * relaxation) would turn this hard error into a real check. It does NOT suppress the + * error: a `$this->m::()` self-call against an enclosing-param bound is checkable + * per instantiation and currently checked by nobody, so it must fail rather than slip + * through. (A self-call with no concrete turbofish never reaches grounding — the + * all-concrete guard at the call site bails first — so it is unaffected.) + * + * @param list $params + * @param list $methodArgs the call's turbofish args, positionally per param + * @param list $receiverArgs + * @return list + */ + private function groundBounds( + array $params, + array $methodArgs, + string $receiverFqn, + array $receiverArgs, + string $declaringFqn, + bool $receiverIsThis, + string $context, + SourceLocation $location, + ): array { + $subst = $this->classSubstitutionFor($receiverFqn, $receiverArgs, $declaringFqn); + // Method-own params shadow class params of the same name, so they are layered last. + foreach ($params as $i => $param) { + if (isset($methodArgs[$i])) { + $subst[$param->name] = $methodArgs[$i]; + } + } + + $checked = []; + foreach ($params as $param) { + if ($param->bound === null) { + $checked[] = $param; + continue; + } + $grounded = Registry::substituteBound($param->bound, $subst); + if (self::boundHasUngroundedLeaf($grounded)) { + $this->failUnprovableBound($param->name, $param->bound, $context, $receiverIsThis, $location); + // In check mode failUnprovableBound collects and returns; drop the now-checked + // bound so the pass can continue and surface any further diagnostics. + $checked[] = new TypeParam($param->name, null, $param->default, $param->variance); + continue; + } + $checked[] = new TypeParam($param->name, $grounded, $param->default, $param->variance); + } + return $checked; + } + + /** + * A method-generic bound references an enclosing/sibling type parameter whose concrete + * value isn't determinable at this call site. Collect-or-throw (matching the seam): with + * a collector (`check`) append the diagnostic and continue; without one (`compile`) throw. + */ + private function failUnprovableBound(string $paramName, BoundExpr $bound, string $context, bool $receiverIsThis, SourceLocation $location): void + { + $message = self::unprovableBoundMessage($paramName, $bound, $context, $receiverIsThis); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMethodCompiler::CODE_BOUND_UNPROVABLE, + $message, + $location, + )); + return; + } + throw new RuntimeException($message); + } + + private static function unprovableBoundMessage(string $paramName, BoundExpr $bound, string $context, bool $receiverIsThis): string + { + $boundText = Registry::formatBound($bound); + if ($receiverIsThis) { + return sprintf( + 'Cannot verify generic bound `%s : %s` for %s 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->%s::<...>(...)`), or don\'t turbofish an enclosing-parameter-' + . 'bounded method on `$this`. (A future per-instantiation bound check will relax this.)', + $paramName, + $boundText, + $context, + // The method name is the tail of the "Class::method" context. + substr($context, (int) strrpos($context, ':') + 1), + ); + } + return sprintf( + 'Cannot verify generic bound `%s : %s` for %s: 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.', + $paramName, + $boundText, + $context, + ); + } + + /** + * The declaring-class-parameter => receiver-argument substitution, or `[]` when the + * receiver's arguments can't be threaded to the declaring class (no hierarchy, an + * unreachable/ambiguous chain, an arity mismatch, or a missing declaring class). + * + * @param list $receiverArgs + * @return array + */ + private function classSubstitutionFor(string $receiverFqn, array $receiverArgs, string $declaringFqn): array + { + if ($this->hierarchy === null) { + return []; + } + $declArgs = $this->hierarchy->resolveInheritedArgs($receiverFqn, $receiverArgs, $declaringFqn); + if ($declArgs === null) { + return []; + } + $owner = $this->classByFqn[$declaringFqn] ?? null; + $params = $owner?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($params) || count($params) !== count($declArgs)) { + return []; + } + /** @var list $params */ + $subst = []; + foreach ($params as $i => $param) { + $subst[$param->name] = $declArgs[$i]; + } + return $subst; + } + + /** + * Whether any leaf of the bound is still a bare type parameter (`isTypeParam`) — i.e. an + * enclosing class/method parameter that substitution couldn't ground. A leaf naming a + * real class with type-param ARGS (`Comparable`) is not "ungrounded": the hierarchy + * checks it erased on the leaf name, exactly as today. + */ + private static function boundHasUngroundedLeaf(BoundExpr $bound): bool + { + if ($bound instanceof BoundLeaf) { + return $bound->type->isTypeParam; + } + if ($bound instanceof BoundIntersection || $bound instanceof BoundUnion) { + foreach ($bound->operands as $operand) { + if (self::boundHasUngroundedLeaf($operand)) { + return true; + } + } + } + return false; + } + private function rewriteFuncCall(FuncCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); if (!is_array($args)) { + // Bare (turbofish-less) call. A first-class-callable (`f(...)` / `$f(...)`) creates a + // Closure rather than invoking, so leave it alone. Otherwise a generic free function + // or tracked generic closure has no bare or empty-turbofish form (a named generic + // takes no inference and the dispatcher needs concrete args), so ANY bare call to one + // is a missing-type-arguments error — not a silent skip that emits a call to the + // stripped `f_T_<…>`. Both lookups hold ONLY generics, so a non-generic bare call + // resolves to nothing and is left untouched — no false positives. + if (!$node->isFirstClassCallable()) { + $bare = $this->resolveBareGenericCall($node); + if ($bare !== null) { + $this->reportMissingTurbofishArguments( + $bare[1], + new SourceLocation($this->currentFile, $node->getStartLine()), + ); + } + } return null; } /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach. */ @@ -1121,6 +2091,8 @@ private function rewriteFuncCall(FuncCall $node): ?Node $args, $this->hierarchy, $fqn . '<' . self::formatArgList($args) . '>', + $this->diagnostics, + new SourceLocation($this->currentFile, $node->getStartLine()), ); } @@ -1204,7 +2176,7 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): null // A future commit can rewrite `$this->v` to a lifted // param. $flavor = $template instanceof ArrowFunction ? 'arrow' : 'closure'; - throw new RuntimeException(sprintf( + $message = sprintf( 'Generic %s `$%s::<...>(...)` captures `$this`, ' . 'which is not yet supported. Rewrite as a method ' . 'on the enclosing class, or extract the value of ' @@ -1213,15 +2185,35 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): null $flavor, $varName, $flavor, - )); + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMethodCompiler::CODE_UNSUPPORTED_THIS_CAPTURE, + $message, + new SourceLocation($this->currentFile, $node->getStartLine()), + )); + return null; + } + throw new RuntimeException($message); } if ($template instanceof Closure && $template->static) { - throw new RuntimeException(sprintf( + $message = sprintf( 'Generic static closures cannot yet be specialized at ' . 'call sites. Rewrite the call site for `$%s::<...>(...)` ' . 'to use a named generic function at file scope.', $varName, - )); + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMethodCompiler::CODE_UNSUPPORTED_STATIC_CLOSURE, + $message, + new SourceLocation($this->currentFile, $node->getStartLine()), + )); + return null; + } + throw new RuntimeException($message); } $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); @@ -1234,7 +2226,8 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): null // generic closure / arrow. Padding throws when leading // required params are missing -- the throw surfaces with // a clear `Registry::padArgsWithDefaults` message. - $args = Registry::padArgsWithDefaults($params, $args, 'closure<' . $varName . '>'); + $location = new SourceLocation($this->currentFile, $node->getStartLine()); + $args = Registry::padArgsWithDefaults($params, $args, 'closure<' . $varName . '>', $this->diagnostics, $location); if (count($params) !== count($args)) { return null; } @@ -1244,6 +2237,8 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): null $args, $this->hierarchy, 'closure<' . self::formatArgList($args) . '>', + $this->diagnostics, + $location, ); } $context = $this->currentScopeClosureContexts[$varName] ?? null; @@ -1324,32 +2319,74 @@ private function resolveClassName(Name $name): string } /** - * @param list $args + * For a turbofish-less call, return the called generic template's `[params, label]` — a + * registered generic free function (by resolved FQN) or a tracked generic closure (by + * variable) — or null when the callee isn't a generic template (non-generic calls, unknown + * variables). The label is the diagnostic's template name. + * + * @return array{0: list, 1: string}|null */ - private static function allConcrete(array $args): bool + private function resolveBareGenericCall(FuncCall $node): ?array { - foreach ($args as $a) { - if (!$a->isConcrete()) { - return false; + if ($node->name instanceof Name) { + $fqn = $this->resolveGenericFunctionFqn($node->name); + if ($fqn === null) { + return null; + } + $params = $this->functionTemplates[$fqn] + ->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + /** @var list|null $params */ + return is_array($params) ? [$params, $fqn] : null; + } + if ($node->name instanceof Variable && is_string($node->name->name)) { + $template = $this->currentScopeClosureTemplates[$node->name->name] ?? null; + if ($template === null) { + return null; } + $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + /** @var list|null $params */ + return is_array($params) ? [$params, '$' . $node->name->name] : null; } - return true; + return null; } /** - * True iff every TypeParam in the template carries a default. - * Bare calls (no `::<...>`) can specialize only against all-defaults - * templates -- otherwise there's no way to derive the type-args. - * - * @param list $params + * Resolve a (turbofish-less) function-call name to the FQN of a registered GENERIC function + * template, or null if it isn't one. `functionTemplates` holds only generic functions, so a + * non-generic call (e.g. `strlen`) resolves to null and is left untouched. Mirrors PHP's + * function name resolution: fully-qualified and `use`-aliased names resolve directly; an + * unqualified name tries the current namespace first, then falls back to the global scope. */ - private static function hasAllDefaults(array $params): bool + private function resolveGenericFunctionFqn(Name $name): ?string { - if ($params === []) { - return false; + if ($name instanceof FullyQualified || str_starts_with($name->toString(), '\\')) { + $fqn = ltrim($name->toString(), '\\'); + return isset($this->functionTemplates[$fqn]) ? $fqn : null; } - foreach ($params as $param) { - if ($param->default === null) { + $raw = $name->toString(); + $first = self::firstSegment($raw); + if (isset($this->useMap[$first])) { + $fqn = $this->useMap[$first] . substr($raw, strlen($first)); + return isset($this->functionTemplates[$fqn]) ? $fqn : null; + } + if ($this->currentNamespace !== '') { + $namespaced = $this->currentNamespace . '\\' . $raw; + if (isset($this->functionTemplates[$namespaced])) { + return $namespaced; + } + } + // Global-scope fallback (PHP resolves an unqualified function to global when the + // namespaced one doesn't exist). + return isset($this->functionTemplates[$raw]) ? $raw : null; + } + + /** + * @param list $args + */ + private static function allConcrete(array $args): bool + { + foreach ($args as $a) { + if (!$a->isConcrete()) { return false; } } @@ -1361,7 +2398,7 @@ private static function hasAllDefaults(array $params): bool */ private static function mangleName(string $shortName, array $args, int $hashLength): string { - return $shortName . '_T_' . Registry::canonicalHash($args, $hashLength); + return Registry::mangledMethodName($shortName, $args, $hashLength); } /** @@ -1391,6 +2428,12 @@ private static function lastSegment(string $name): string $traverser->addVisitor($visitor); $traverser->traverse($ast); + // Validate-only (check) skips all emission: no dispatcher materialization, no buffered + // appends. The traversal above already produced the diagnostics via the call-site checks. + if (!$emit) { + return; + } + // Pass 2 of the closure-dispatcher pipeline: materialize a dispatcher // closure per recorded template, replace the original Assign's RHS, // append specialized declarations, and rewrite each collected call @@ -1461,6 +2504,23 @@ private function finalizeClosureDispatchers(object $visitor, int $hashLength): v } } + /** + * Whether a generic-method template on `$class` is erasable (``, `U` direct-input only) — + * in which case it is kept for the Specializer to lower per instantiation rather than stripped. + */ + private static function isErasableMethodOnClass(ClassMethod $template, ClassLike $class): bool + { + $methodParams = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + $classParams = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($methodParams) || !is_array($classParams)) { + return false; + } + /** @var list $methodParams */ + /** @var list $classParams */ + $classParamNames = array_map(static fn (TypeParam $p): string => $p->name, $classParams); + return EnclosingBoundErasure::isErasable($template, $methodParams, $classParamNames); + } + private function stripMethod(ClassLike $class, string $methodName): void { $newStmts = []; @@ -1514,11 +2574,12 @@ public function __construct(private bool &$found) { } /** - * @infection-ignore-all -- pure perf optimization. Mutating the - * early-return or the instanceof guard just disables the fast-path - * exit; the subsequent rewriteCallSites pass is idempotent for - * files with no matching call sites, so observable behavior is - * identical with or without this pre-scan firing. + * @infection-ignore-all -- this pre-scan keeps the rewrite pass alive for files that have no + * NAMED generic templates but do use anonymous generics. It fires on a variable turbofish + * call (`$f::<…>()`) AND on a generic closure/arrow TEMPLATE assignment — the latter so a + * BARE call to that closure (`$f('x')`, no turbofish) is still traversed and diagnosed rather + * than silently skipped. (Inner anonymous visitor: Infection's blind spot — the closure-call + * diagnosis is covered behaviorally by the bare-closure-call accept/reject tests.) */ public function enterNode(Node $node): null { @@ -1531,6 +2592,12 @@ public function enterNode(Node $node): null ) { $this->found = true; } + if ($node instanceof Assign + && ($node->expr instanceof Closure || $node->expr instanceof ArrowFunction) + && is_array($node->expr->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)) + ) { + $this->found = true; + } return null; } }); diff --git a/src/Transpiler/Monomorphize/InnerVarianceValidator.php b/src/Transpiler/Monomorphize/InnerVarianceValidator.php new file mode 100644 index 0000000..69e430e --- /dev/null +++ b/src/Transpiler/Monomorphize/InnerVarianceValidator.php @@ -0,0 +1,395 @@ + { f(): Container }` where `Container`'s slot is + * invariant), the effective variance at that position is the composition of the + * outer position and the inner slot's variance. This catches cases the + * position validator can't, because the effective variance only resolves once + * the inner template's slot variances are known. + * + * Runs over collected definitions (`Registry::validateInnerVariance`). With a + * DiagnosticCollector it gathers every violation in the definition (each located + * at the offending member) and continues; without one it throws the first — + * byte-identical to the previous behavior. + */ +final class InnerVarianceValidator +{ + /** Stable diagnostic code for an inner-variance composition violation. */ + public const CODE_INNER_VARIANCE = 'xphp.inner_variance'; + + /** @var array */ + private array $definitions; + + /** @var array */ + private array $varianceMap; + + /** @var list */ + private array $violations = []; + + /** + * @param array $definitions + * @param array $varianceMap + */ + private function __construct(array $definitions, array $varianceMap) + { + $this->definitions = $definitions; + $this->varianceMap = $varianceMap; + } + + /** + * @param array $definitions All definitions, for inner-slot lookup. + */ + public static function assertComposition( + GenericDefinition $definition, + array $definitions, + ?DiagnosticCollector $diagnostics = null, + ?string $file = null, + ): void { + $varianceMap = self::buildVarianceMap($definition->typeParams); + // @infection-ignore-all -- optimization only: with an empty variance map every leaf + // check is a no-op (no name is in the map), so removing this guard walks the body + // pointlessly but produces the same (empty) result. + if ($varianceMap === []) { + return; + } + + $validator = new self($definitions, $varianceMap); + $validator->collect($definition); + if ($validator->violations === []) { + return; + } + + if ($diagnostics === null) { + throw new RuntimeException($validator->violations[0]['message']); + } + + foreach ($validator->violations as $violation) { + $location = ($violation['line'] !== null && $file !== null) + ? new SourceLocation($file, $violation['line']) + : null; + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_INNER_VARIANCE, + $violation['message'], + $location, + )); + } + } + + private function collect(GenericDefinition $definition): void + { + $label = $definition->templateShortName; + $declarationLine = $definition->templateAst->getStartLine(); + + foreach ($definition->templateAst->getMethods() as $method) { + $isConstructor = $method->name->toLowerString() === '__construct'; + foreach ($method->params as $param) { + // Constructor params (promoted or not) get Invariant outer + // position -- PHP's class-compat rules enforce invariance on + // constructor signatures regardless of param flavor. `getProperties()` + // below skips promoted ones (they're `Param`, not `Property`), + // so each promoted property is walked exactly once. + // + // Two exemptions skip a param entirely: + // - A non-promoted constructor param typed by a bare + // covariant/contravariant type-param — a constructor parameter + // isn't part of the externally-visible variance surface + // (constructors aren't called through upcast references), and PHP + // exempts `__construct` from LSP, so the real type is emitted with + // no hazard. + // - A *private* promoted property (handled just below) — PHP doesn't + // type-check private slots across the chain. + // Inner-generic constructor params (e.g. `Container`) and + // visible (public/protected) promoted properties are still checked. + if ($isConstructor && $this->isExemptVariantConstructorParam($param)) { + continue; + } + // A *private* promoted constructor property is exempt: PHP does not + // type-check private property types across an `extends` chain, and a + // private slot is invisible to the variance surface, so it imposes no + // composition constraint regardless of its (possibly inner-generic) + // shape. Detect via the PRIVATE bit — a readonly-only promoted param + // (no visibility bit, implicitly public) is NOT skipped. This is a + // separate skip from `isExemptVariantConstructorParam` on purpose: + // that helper matches only bare single-segment type-params, so it + // would still (wrongly) walk `private Container $x`. + if ($isConstructor && ($param->flags & Modifiers::PRIVATE) !== 0) { + continue; + } + // A by-reference parameter is read AND written back, so it's an + // invariant outer position regardless of method vs constructor + // (e.g. a by-ref of a covariant container `f(Container &$x)`). + $outerPos = ($isConstructor || $param->byRef) + ? Variance::Invariant + : Variance::Contravariant; + if ($param->type !== null) { + // A NON-PROMOTED constructor param is exempt from the position pass entirely, so + // this pass keeps ownership of a non-bare DIRECT type-param there (`?T`); the bare-`T` + // immutable shape was already exempted above. A PROMOTED constructor property + // (`public T $item`) is NOT exempt from the position pass (it reports it as a + // 'constructor parameter'), so this pass must cede its direct leaf to avoid a + // double-report — only its NESTED leaves stay here. Every non-constructor position + // cedes its direct leaves too (reportDirect = false). + $reportDirect = $isConstructor && $param->flags === 0; + $this->walkPhpType($param->type, $outerPos, $label, null, null, reportDirect: $reportDirect); + } + } + if ($method->returnType !== null) { + $this->walkPhpType($method->returnType, Variance::Covariant, $label, null, null); + } + } + foreach ($definition->templateAst->getProperties() as $prop) { + // A private property is exempt (PHP doesn't type-check private slots + // across the `extends` chain; invisible to the variance surface), so it + // imposes no inner-variance constraint regardless of shape — only a + // *visible* (public/protected) typed property is walked. + if (!$prop->isPrivate() && $prop->type !== null) { + $this->walkPhpType($prop->type, Variance::Invariant, $label, null, null); + } + } + foreach ($definition->typeParams as $typeParam) { + if ($typeParam->bound !== null) { + $this->walkBoundExpr($typeParam->bound, $label, $declarationLine); + } + if ($typeParam->default !== null) { + $this->walkTypeRef($typeParam->default, Variance::Invariant, $label, null, null, $declarationLine); + } + } + } + + /** + * A non-promoted constructor parameter whose type is a bare single-segment + * covariant/contravariant type-param. Constructor parameters are exempt from + * variance-position checks (a constructor isn't part of the visible variance + * surface, and PHP exempts `__construct` from LSP), so the inner-variance walk + * skips these — the real type is emitted as-is. + */ + private function isExemptVariantConstructorParam(Param $param): bool + { + if ($param->flags !== 0) { + return false; // promoted param == property; stays strictly invariant. + } + if ($param->byRef) { + return false; // by-ref is read + written back == invariant; not exempt. + } + $type = $param->type; + if (!$type instanceof Name) { + return false; + } + $parts = $type->getParts(); + if (count($parts) !== 1) { + return false; // inner-generic / qualified type — not a bare type-param, keep checking. + } + $variance = $this->varianceMap[$parts[0]] ?? null; + return $variance !== null && $variance !== Variance::Invariant; + } + + /** + * @infection-ignore-all -- semantic-equivalent mutants in this walker: + * fall-through returns for unhandled node kinds, `??`-suppressed null-safe + * slot lookups, and Union/Intersection branch swaps (both recurse the same). + */ + private function walkPhpType( + Node $type, + Variance $position, + string $outerLabel, + ?string $innerLabel, + ?int $innerSlot, + bool $reportDirect = false, + ): void { + if ($type instanceof Identifier) { + return; + } + if ($type instanceof Name) { + $parts = $type->getParts(); + if (count($parts) === 1 && isset($this->varianceMap[$parts[0]])) { + $this->assertLeaf($parts[0], $this->varianceMap[$parts[0]], $position, $outerLabel, $innerLabel, $innerSlot, $type->getStartLine(), $reportDirect); + } + $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (is_array($args)) { + $innerFqn = $type->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + $innerDef = is_string($innerFqn) + ? ($this->definitions[ltrim($innerFqn, '\\')] ?? null) + : null; + $nextInnerLabel = $innerDef !== null ? $innerDef->templateShortName : $type->toString(); + foreach ($args as $i => $arg) { + if (!$arg instanceof TypeRef) { + continue; + } + $slotVariance = $innerDef?->typeParams[$i]->variance ?? Variance::Invariant; + $this->walkTypeRef($arg, self::compose($position, $slotVariance), $outerLabel, $nextInnerLabel, $i, $type->getStartLine()); + } + } + return; + } + if ($type instanceof NullableType) { + $this->walkPhpType($type->type, $position, $outerLabel, $innerLabel, $innerSlot, $reportDirect); + return; + } + if ($type instanceof UnionType || $type instanceof IntersectionType) { + foreach ($type->types as $sub) { + $this->walkPhpType($sub, $position, $outerLabel, $innerLabel, $innerSlot, $reportDirect); + } + return; + } + if ($type instanceof ComplexType) { + return; + } + } + + /** + * @infection-ignore-all -- same equivalence rationale as `walkPhpType`. + */ + private function walkTypeRef( + TypeRef $ref, + Variance $position, + string $outerLabel, + ?string $innerLabel, + ?int $innerSlot, + ?int $line, + ): void { + if ($ref->isScalar) { + return; + } + if ($ref->isTypeParam && isset($this->varianceMap[$ref->name])) { + $this->assertLeaf($ref->name, $this->varianceMap[$ref->name], $position, $outerLabel, $innerLabel, $innerSlot, $line); + } + if ($ref->args === []) { + return; + } + $innerDef = $this->definitions[ltrim($ref->name, '\\')] ?? null; + $nextInnerLabel = $innerDef !== null ? $innerDef->templateShortName : $ref->name; + foreach ($ref->args as $i => $sub) { + $slotVariance = $innerDef?->typeParams[$i]->variance ?? Variance::Invariant; + $this->walkTypeRef($sub, self::compose($position, $slotVariance), $outerLabel, $nextInnerLabel, $i, $line); + } + } + + /** + * @infection-ignore-all -- BoundUnion / BoundIntersection share the same + * `operands` walk; the InstanceOf_ / LogicalOr mutants on the discriminator + * are observably identical for any non-Leaf bound expression. + */ + private function walkBoundExpr(BoundExpr $expr, string $outerLabel, ?int $line): void + { + if ($expr instanceof BoundLeaf) { + $this->walkTypeRef($expr->type, Variance::Invariant, $outerLabel, null, null, $line); + return; + } + if ($expr instanceof BoundUnion || $expr instanceof BoundIntersection) { + foreach ($expr->operands as $operand) { + $this->walkBoundExpr($operand, $outerLabel, $line); + } + } + } + + /** + * @infection-ignore-all -- the `Variance::Invariant => ''` arm of the + * sigil-builder `match` is unreachable: Invariant declared variance passes + * every allowed-list, so this records nothing before the sigil is built. + */ + private function assertLeaf( + string $paramName, + Variance $declared, + Variance $effective, + string $outerLabel, + ?string $innerLabel, + ?int $innerSlot, + ?int $line, + bool $reportDirect = false, + ): void { + // This composing pass reports only NESTED leaves — a type-param inside a type constructor's + // arguments (`innerSlot !== null`), where the effective variance is the composition of the + // outer position with the inner slot. A DIRECT occurrence (a bare type-param as the + // param/return/property/bound/default type, `innerSlot === null`) is owned by + // VariancePositionValidator; reporting it here too would double-report it. The ONE exception is + // a non-bare *constructor* parameter (`?T`, where the bare-`T` immutable-construction shape is + // already exempted before the walk): the position pass exempts constructor params entirely, so + // this pass keeps ownership of that direct case via `$reportDirect`. + if ($innerSlot === null && !$reportDirect) { + return; + } + $allowed = match ($effective) { + Variance::Invariant => [Variance::Invariant], + Variance::Covariant => [Variance::Invariant, Variance::Covariant], + Variance::Contravariant => [Variance::Invariant, Variance::Contravariant], + }; + if (in_array($declared, $allowed, true)) { + return; + } + // $declared is Covariant or Contravariant at this point — the Invariant + // case passes every allowed-list and early-returns above. + $sigil = $declared === Variance::Covariant ? '+' : '-'; + $where = $innerLabel !== null + ? sprintf(' (via slot %d of %s)', $innerSlot, $innerLabel) + : ''; + $this->violations[] = [ + 'message' => sprintf( + 'Variance violation in template %s: type-parameter %s%s appears in %s-only position%s.', + $outerLabel, + $sigil, + $paramName, + $effective->value, + $where, + ), + 'line' => $line, + ]; + } + + private static function compose(Variance $position, Variance $innerSlot): Variance + { + return match ($innerSlot) { + Variance::Invariant => Variance::Invariant, + Variance::Covariant => $position, + Variance::Contravariant => match ($position) { + Variance::Covariant => Variance::Contravariant, + Variance::Contravariant => Variance::Covariant, + Variance::Invariant => Variance::Invariant, + }, + }; + } + + /** + * @param list $typeParams + * @return array + * + * @infection-ignore-all -- FalseValue mutator on `$hasVariance = false` is + * observably identical: for all-Invariant templates, walking is a no-op + * (Invariant is allowed at every effective position), so the "skip the + * walk" optimization isn't testable. + */ + private static function buildVarianceMap(array $typeParams): array + { + $hasVariance = false; + $map = []; + foreach ($typeParams as $tp) { + $map[$tp->name] = $tp->variance; + if ($tp->variance !== Variance::Invariant) { + $hasVariance = true; + } + } + return $hasVariance ? $map : []; + } +} diff --git a/src/Transpiler/Monomorphize/NamespaceContext.php b/src/Transpiler/Monomorphize/NamespaceContext.php index 7a4932c..3c09f3c 100644 --- a/src/Transpiler/Monomorphize/NamespaceContext.php +++ b/src/Transpiler/Monomorphize/NamespaceContext.php @@ -84,6 +84,17 @@ public function currentNamespace(): string return $this->currentNamespace; } + /** + * Whether a bare name's first segment is brought into scope by a `use` + * import. Used to spare imported (and therefore deliberate) class references + * from the undeclared-type-parameter check: an imported name is the author's + * explicit statement that the type lives elsewhere, so it's never "suspect". + */ + public function isImported(string $name): bool + { + return isset($this->useMap[self::firstSegment($name)]); + } + private static function firstSegment(string $name): string { $pos = strpos($name, '\\'); diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index de6fa00..cc0ec2f 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -14,9 +14,21 @@ use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\UnionType; use RuntimeException; +use XPHP\Diagnostics\Diagnostic; +use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\Diagnostics\Severity; +use XPHP\Diagnostics\SourceLocation; final class Registry { + /** Stable diagnostic codes (machine identifiers tooling can match on). */ + public const CODE_BOUND_VIOLATION = 'xphp.bound_violation'; + public const CODE_MISSING_TYPE_ARGUMENT = 'xphp.missing_type_argument'; + public const CODE_TOO_MANY_TYPE_ARGUMENTS = 'xphp.too_many_type_arguments'; + public const CODE_DEFAULT_BOUND_VIOLATION = 'xphp.default_bound_violation'; + public const CODE_UNDEFINED_TEMPLATE = 'xphp.undefined_template'; + public const CODE_VARIANCE_EDGE_UNPROVABLE = 'xphp.variance_edge_unprovable'; + /** * All specialized classes live under this namespace prefix; the full target FQCN * mirrors the original template's namespace and ends with a hash-based class name. @@ -39,9 +51,16 @@ final class Registry /** @var array Keyed by full generated FQCN. */ private array $instantiations = []; + /** + * @param ?DiagnosticCollector $diagnostics When null (the default, used by `xphp compile`), + * validation failures throw as before — byte-identical behavior. When provided (by + * `xphp check`), bound violations are appended to the collector and recording continues, + * so every error of the validation phase is reported in one run. + */ public function __construct( private readonly int $hashLength = self::DEFAULT_HASH_HEX_LENGTH, private readonly ?TypeHierarchy $hierarchy = null, + private readonly ?DiagnosticCollector $diagnostics = null, ) { self::validateHashLength($this->hashLength); } @@ -57,6 +76,11 @@ public function recordDefinition( string $sourceFile, ): void { if (isset($this->definitions[$templateFqn])) { + // NB: cross-file duplicate class templates are filtered out earlier by + // RegistryCollector's `!isAlreadyRecorded()` guard, so this throw is only + // reachable via the generic-function path. Surfacing duplicate definitions in + // `xphp check` would require reworking that guard (and would change compile-mode + // semantics), so it is intentionally NOT part of the collector seam — deferred. throw new RuntimeException(sprintf( 'Generic template "%s" already declared (in %s); duplicate declaration in %s.', $templateFqn, @@ -87,18 +111,25 @@ public function recordDefinition( * generated FQCN, throws with a self-contained error message explaining how to raise XPHP_HASH_LENGTH. * * @param list $args + * @param ?SourceLocation $callSite The `.xphp` position of the instantiation site, used to + * locate a bound-violation diagnostic in check-mode. Nested sub-instantiations inherit the + * enclosing site (they have no distinct source token). Ignored in throw-mode. */ - public function recordInstantiation(string $templateFqn, array $args): GenericInstantiation - { - $args = $this->padWithDefaults($templateFqn, $args); + public function recordInstantiation( + string $templateFqn, + array $args, + ?SourceLocation $callSite = null, + ): GenericInstantiation { + $args = $this->padWithDefaults($templateFqn, $args, $callSite); foreach ($args as $arg) { if ($arg->isGeneric()) { - $this->recordInstantiation($arg->name, $arg->args); + $this->recordInstantiation($arg->name, $arg->args, $callSite); } } - $this->validateBounds($templateFqn, $args); + $this->validateBounds($templateFqn, $args, $callSite); + $this->validateVarianceEdgeProvability($templateFqn, $args, $callSite); $generatedFqn = self::generatedFqn($templateFqn, $args, $this->hashLength); $template = ltrim($templateFqn, '\\'); @@ -137,7 +168,7 @@ public function recordInstantiation(string $templateFqn, array $args): GenericIn * @param list $args * @return list */ - private function padWithDefaults(string $templateFqn, array $args): array + private function padWithDefaults(string $templateFqn, array $args, ?SourceLocation $callSite = null): array { $definition = $this->definitions[ltrim($templateFqn, '\\')] ?? null; if ($definition === null) { @@ -147,6 +178,8 @@ private function padWithDefaults(string $templateFqn, array $args): array $definition->typeParams, $args, ltrim($templateFqn, '\\'), + $this->diagnostics, + $callSite, ); } @@ -158,9 +191,10 @@ private function padWithDefaults(string $templateFqn, array $args): array * templates) so the padding semantics stay identical regardless of * the call-site shape. * - * Throws when a non-defaulted param is missing and there are fewer - * supplied args than required. Returns `$args` unchanged when the - * supplied count already matches or exceeds the param count. + * When `$diagnostics` is null (compile, and every `GenericMethodCompiler` call) a missing + * non-defaulted param throws as before. With a collector (check) it appends a Diagnostic and + * returns the partial padding gathered so far, so the run continues to surface other errors. + * Returns `$args` unchanged when the supplied count already matches or exceeds the param count. * * @param list $params * @param list $args @@ -170,25 +204,47 @@ public static function padArgsWithDefaults( array $params, array $args, string $templateLabel, + ?DiagnosticCollector $diagnostics = null, + ?SourceLocation $callSite = null, ): array { $supplied = count($args); $needed = count($params); - if ($supplied >= $needed) { + if ($supplied > $needed) { + // Over-arity: more type arguments than the template declares. Reported + // instead of silently truncating the extras. Returning $args unchanged + // lets the downstream arity guards skip specialization for this instantiation. + $message = self::tooManyTypeArgumentsMessage($templateLabel, $supplied, $needed); + if ($diagnostics !== null) { + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_TOO_MANY_TYPE_ARGUMENTS, + $message, + $callSite, + )); + + return $args; + } + throw new RuntimeException($message); + } + if ($supplied === $needed) { return $args; } $padded = $args; for ($i = $supplied; $i < $needed; $i++) { if ($params[$i]->default === null) { - throw new RuntimeException(sprintf( - 'Generic template "%s" was instantiated with %d type argument(s) ' - . 'but parameter `%s` (position %d) has no default; supply it ' - . 'explicitly or add defaults to every preceding required parameter.', - $templateLabel, - $supplied, - $params[$i]->name, - $i + 1, - )); + $message = self::missingTypeArgumentMessage($templateLabel, $supplied, $params[$i]->name, $i + 1); + if ($diagnostics !== null) { + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_MISSING_TYPE_ARGUMENT, + $message, + $callSite, + )); + + return $padded; + } + throw new RuntimeException($message); } $subst = []; foreach ($padded as $j => $concrete) { @@ -199,6 +255,42 @@ public static function padArgsWithDefaults( return $padded; } + /** + * Single source of truth for the too-many-type-arguments message. + */ + private static function tooManyTypeArgumentsMessage( + string $templateLabel, + int $supplied, + int $needed, + ): string { + return sprintf( + 'Generic template "%s" declares %d type parameter(s) but was instantiated with %d type argument(s); remove the extra argument(s).', + $templateLabel, + $needed, + $supplied, + ); + } + + /** + * Single source of truth for the missing-required-type-argument message. + */ + private static function missingTypeArgumentMessage( + string $templateLabel, + int $supplied, + string $paramName, + int $position, + ): string { + return sprintf( + 'Generic template "%s" was instantiated with %d type argument(s) ' + . 'but parameter `%s` (position %d) has no default; supply it ' + . 'explicitly or add defaults to every preceding required parameter.', + $templateLabel, + $supplied, + $paramName, + $position, + ); + } + /** * Declaration-time bound check on fully-concrete defaults. Defaults that * reference earlier type-params can't be checked here because the bound @@ -223,6 +315,15 @@ public function validateDefaultsAgainstBounds(): void if (!$param->default->isConcrete()) { continue; } + // A bound that references a sibling type parameter (`U : T`) can't be validated at + // declaration time -- the sibling is abstract here. It is grounded and checked at + // instantiation (validateBounds substitutes the concrete sibling arg), so defer it. + // (A template that is never instantiated therefore never checks such a default; that + // is inherent -- you can't prove `default <: T` without a concrete `T` -- and admits + // no unsafe instantiation, since every actual use is checked.) + if (self::boundReferencesSiblingParam($param->bound)) { + continue; + } $verdict = self::evaluateBound( $param->bound, $param->default, @@ -241,354 +342,158 @@ public function validateDefaultsAgainstBounds(): void . 'it satisfies "%s".', $boundDisplay, ); - throw new RuntimeException(sprintf( - "Default for generic parameter `%s` of \"%s\" violates the parameter's bound.\n" - . " bound: %s\n" - . " default: %s\n" - . " reason: %s", + $message = self::defaultBoundViolationMessage( $param->name, $definition->templateFqn, $boundDisplay, $defaultDisplay, $reason, - )); + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_DEFAULT_BOUND_VIOLATION, + $message, + new SourceLocation($definition->sourceFile, $definition->templateAst->getStartLine()), + )); + continue; + } + throw new RuntimeException($message); } } } /** - * Inner-template variance composition pass. Runs after `collectDefinitions` - * but before `collectInstantiations`, so every template's variance markers - * are known and a bad declaration fails before any padded instantiation - * amplifies the error. - * - * The parse-time validator at `VariancePositionValidator` already rejects - * direct misuses like `class P<+T> { function f(T $x): void }` (T as param - * with covariance). It also recurses into `xphp:genericArgs` but propagates - * the SAME outer allowed-list -- which is wrong: when T appears as the i-th - * arg of an inner template `Container` whose X is invariant, T's - * effective position is *invariant* regardless of the outer position. - * - * This pass tightens the verdict whenever the inner template is in the - * registry, applying the composition rule: - * - * compose(V_outer_position, V_inner_slot): - * V_inner == Invariant -> Invariant (inner forces strict) - * V_inner == Covariant -> V_outer (transparent) - * V_inner == Contravariant -> flip(V_outer) (covariant <-> contravariant) - * - * The leaf check is "T's declared variance must be in the allowed-list for - * the effective position": - * - * allowed_for(Invariant) = {Invariant} - * allowed_for(Covariant) = {Invariant, Covariant} - * allowed_for(Contravariant) = {Invariant, Contravariant} - * - * Conservative-unknown: when the inner template isn't in the registry - * (vendor classes, in-progress files), treat its slots as Invariant. - * Sound (rejects more than necessary); users with vendor templates can - * either register them or remove variance markers on the outer template. - * - * @infection-ignore-all -- surviving mutants in this method, the walkers, - * and assertLeaf are all semantic equivalents: - * - `strtolower((string) $method->name)` -- PHP method names are - * case-insensitive at dispatch, but PhpParser stores them as - * written. No fixture uses an uppercased `__CONSTRUCT`, so the - * `strtolower` mutator survives without observable difference. - * - Fall-through `return` removals on `Identifier`, post-`Name`, - * post-`NullableType` -- the next branch checks `instanceof X` and - * fails for the prior type, so removing the `return` is a no-op. - * - `$x?->prop ?? $default` -- PHP 8.4's `??` suppresses property-on-null - * errors, so the NullSafe mutator (`?->` -> `->`) is observably - * identical to the original. - * - InstanceOf_ / LogicalOr swaps on `Union||Intersection` -- both - * `->types`/`->operands` branches walk the same way; for inputs - * that aren't either, the prior `Name`/`BoundLeaf` branches already - * returned. - * - LogicalAnd in `$ref->isTypeParam && isset($map[$ref->name])` -- - * no fixture creates a stray type-param ref outside the variance - * map, so the OR variant produces the same accept/reject decision. - * - MatchArmRemoval on `Variance::Invariant => ''` in the sigil - * builder -- Invariant declared never reaches the throw (Invariant - * passes every allowed-list), so the arm is observably unreachable. + * Report every recorded instantiation whose template was never defined. In `xphp compile` + * this surfaces as a thrown error inside the specialization loop; `xphp check` doesn't run + * that loop, so it detects the same condition here by comparing recorded instantiations + * against the definition set. No source location is attached — the instantiation does not + * retain its call site — but the message names the template and its generated FQCN. */ - public function validateInnerVariance(): void + public function collectUndefinedTemplates(DiagnosticCollector $diagnostics): void { - foreach ($this->definitions as $definition) { - $varianceMap = self::buildVarianceMap($definition->typeParams); - if ($varianceMap === []) { - continue; - } - $label = $definition->templateShortName; - foreach ($definition->templateAst->getMethods() as $method) { - $isCtor = strtolower((string) $method->name) === '__construct'; - foreach ($method->params as $param) { - // Constructor params (promoted or not) get Invariant outer - // position -- PHP's class-compat rules enforce invariance on - // ctor signatures regardless of param flavor. `getProperties()` - // below skips promoted ones (they're `Param`, not `Property`), - // so each promoted property is walked exactly once. - $outerPos = $isCtor ? Variance::Invariant : Variance::Contravariant; - if ($param->type !== null) { - $this->walkPhpType($param->type, $varianceMap, $outerPos, $label, null, null); - } - } - if ($method->returnType !== null) { - $this->walkPhpType( - $method->returnType, - $varianceMap, - Variance::Covariant, - $label, - null, - null, - ); - } - } - foreach ($definition->templateAst->getProperties() as $prop) { - if ($prop->type !== null) { - $this->walkPhpType( - $prop->type, - $varianceMap, - Variance::Invariant, - $label, - null, - null, - ); - } - } - foreach ($definition->typeParams as $typeParam) { - if ($typeParam->bound !== null) { - $this->walkBoundExpr($typeParam->bound, $varianceMap, $label); - } - if ($typeParam->default !== null) { - $this->walkTypeRef( - $typeParam->default, - $varianceMap, - Variance::Invariant, - $label, - null, - null, - ); - } + foreach ($this->instantiations as $generatedFqn => $instantiation) { + if (!isset($this->definitions[$instantiation->templateFqn])) { + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_UNDEFINED_TEMPLATE, + self::undefinedTemplateMessage($instantiation->templateFqn, $generatedFqn), + )); } } } /** - * @param list $typeParams - * @return array - * - * @infection-ignore-all -- FalseValue mutator on `$hasVariance = false` - * is observably identical: for all-Invariant templates, walking is a - * no-op (Invariant is allowed at every effective position), so the - * "skip the walk" optimization isn't testable. + * Single source of truth for the "instantiated but never defined" message, shared by the + * compile-time throw (Compiler) and the check-time diagnostic. */ - private static function buildVarianceMap(array $typeParams): array + public static function undefinedTemplateMessage(string $templateFqn, string $generatedFqn): string { - $hasVariance = false; - $map = []; - foreach ($typeParams as $tp) { - $map[$tp->name] = $tp->variance; - if ($tp->variance !== Variance::Invariant) { - $hasVariance = true; - } - } - return $hasVariance ? $map : []; + return sprintf( + 'Generic template "%s" was instantiated but never defined (generated as: %s).', + $templateFqn, + $generatedFqn, + ); } /** - * @param array $varianceMap - * - * @infection-ignore-all -- see `validateInnerVariance` docblock for the - * catalog of semantic-equivalent mutants in this walker (fall-through - * returns, `??`-suppressed null-safe ops, Union/Intersection swaps). - */ - private function walkPhpType( - Node $type, - array $varianceMap, - Variance $position, - string $outerLabel, - ?string $innerLabel, - ?int $innerSlot, - ): void { - if ($type instanceof Identifier) { - return; - } - if ($type instanceof Name) { - $parts = $type->getParts(); - if (count($parts) === 1 && isset($varianceMap[$parts[0]])) { - self::assertLeaf( - $parts[0], - $varianceMap[$parts[0]], - $position, - $outerLabel, - $innerLabel, - $innerSlot, - ); - } - $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); - if (is_array($args)) { - $innerFqn = $type->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); - $innerDef = is_string($innerFqn) - ? ($this->definitions[ltrim($innerFqn, '\\')] ?? null) - : null; - $nextInnerLabel = $innerDef !== null ? $innerDef->templateShortName : $type->toString(); - foreach ($args as $i => $arg) { - if (!$arg instanceof TypeRef) { - continue; - } - $slotVariance = $innerDef?->typeParams[$i]->variance ?? Variance::Invariant; - $this->walkTypeRef( - $arg, - $varianceMap, - self::compose($position, $slotVariance), - $outerLabel, - $nextInnerLabel, - $i, - ); - } - } - return; - } - if ($type instanceof NullableType) { - $this->walkPhpType($type->type, $varianceMap, $position, $outerLabel, $innerLabel, $innerSlot); - return; - } - if ($type instanceof UnionType || $type instanceof IntersectionType) { - foreach ($type->types as $sub) { - $this->walkPhpType($sub, $varianceMap, $position, $outerLabel, $innerLabel, $innerSlot); - } - return; - } - if ($type instanceof ComplexType) { - return; - } + * Single source of truth for the default-violates-bound message. + */ + private static function defaultBoundViolationMessage( + string $paramName, + string $templateFqn, + string $boundDisplay, + string $defaultDisplay, + string $reason, + ): string { + return sprintf( + "Default for generic parameter `%s` of \"%s\" violates the parameter's bound.\n" + . " bound: %s\n" + . " default: %s\n" + . " reason: %s", + $paramName, + $templateFqn, + $boundDisplay, + $defaultDisplay, + $reason, + ); } /** - * @param array $varianceMap + * Variance-position check over every collected definition (moved out of the parser so + * `xphp check` can collect all violations across files in one run). Delegates to + * {@see VariancePositionValidator}: with this Registry's collector it gathers diagnostics + * at each offending member; without one (compile) it throws the first violation. * - * @infection-ignore-all -- same equivalence rationale as `walkPhpType`. - */ - private function walkTypeRef( - TypeRef $ref, - array $varianceMap, - Variance $position, - string $outerLabel, - ?string $innerLabel, - ?int $innerSlot, - ): void { - if ($ref->isScalar) { - return; - } - if ($ref->isTypeParam && isset($varianceMap[$ref->name])) { - self::assertLeaf( - $ref->name, - $varianceMap[$ref->name], - $position, - $outerLabel, - $innerLabel, - $innerSlot, - ); - } - if ($ref->args === []) { - return; - } - $innerDef = $this->definitions[ltrim($ref->name, '\\')] ?? null; - $nextInnerLabel = $innerDef !== null ? $innerDef->templateShortName : $ref->name; - foreach ($ref->args as $i => $sub) { - $slotVariance = $innerDef?->typeParams[$i]->variance ?? Variance::Invariant; - $this->walkTypeRef( - $sub, - $varianceMap, - self::compose($position, $slotVariance), - $outerLabel, - $nextInnerLabel, - $i, + * @return list Template FQNs that had at least one position violation, so the + * inner-variance pass can skip them (the issue is already reported) — mirroring + * compile-mode, where the position check fails fast before inner-variance runs. + */ + public function validateVariancePositions(): array + { + $flagged = []; + foreach ($this->definitions as $templateFqn => $definition) { + $hadViolation = VariancePositionValidator::assertPositions( + $definition->templateAst, + $definition->typeParams, + $this->diagnostics, + $definition->sourceFile, ); + if ($hadViolation) { + $flagged[] = $templateFqn; + } } + + return $flagged; } /** - * @param array $varianceMap - * - * @infection-ignore-all -- BoundUnion / BoundIntersection share the same - * `operands` walk; the InstanceOf_ / LogicalOr mutants on the discriminator - * are observably identical for any non-Leaf bound expression. - */ - private function walkBoundExpr( - BoundExpr $expr, - array $varianceMap, - string $outerLabel, - ): void { - if ($expr instanceof BoundLeaf) { - $this->walkTypeRef( - $expr->type, - $varianceMap, - Variance::Invariant, - $outerLabel, - null, - null, - ); + * Undeclared-type check over every collected definition (delegates to + * {@see UndeclaredTypeParameterValidator}): a generic member naming a type + * that is neither a declared type parameter nor a known type — e.g. the `T` + * in `interface Foo { add(T $x): void; }`. With this Registry's collector + * it gathers every finding (each at the offending member); without one + * (compile) it throws the first. Skipped when no hierarchy was attached + * (bare-Registry tests have nothing to resolve names against). + */ + public function validateUndeclaredTypeParameters(): void + { + if ($this->hierarchy === null) { return; } - if ($expr instanceof BoundUnion || $expr instanceof BoundIntersection) { - foreach ($expr->operands as $operand) { - $this->walkBoundExpr($operand, $varianceMap, $outerLabel); - } - } - } - private static function compose(Variance $position, Variance $innerSlot): Variance - { - return match ($innerSlot) { - Variance::Invariant => Variance::Invariant, - Variance::Covariant => $position, - Variance::Contravariant => match ($position) { - Variance::Covariant => Variance::Contravariant, - Variance::Contravariant => Variance::Covariant, - Variance::Invariant => Variance::Invariant, - }, - }; + foreach ($this->definitions as $templateFqn => $definition) { + UndeclaredTypeParameterValidator::assert( + $definition->templateAst, + $definition->typeParams, + $templateFqn, + $this->hierarchy, + $this->diagnostics, + $definition->sourceFile, + ); + } } /** - * @infection-ignore-all -- the `Variance::Invariant => ''` arm of the - * sigil-builder `match` is unreachable: Invariant declared variance - * passes every allowed-list, so this method early-returns before the - * sigil construction. MatchArmRemoval on that arm is observably - * identical. + * Inner-template variance composition check over every collected definition (delegates to + * {@see InnerVarianceValidator}). With this Registry's collector it gathers every violation + * (each located at the offending member); without one (compile) it throws the first. + * + * Runs on every definition: this composing pass and {@see validateVariancePositions} own disjoint + * responsibilities — the position pass reports DIRECT occurrences of a variant type-param, this one + * the type-constructor-NESTED occurrences (composing the inner slot's variance) — so there is no + * double-reporting and no template to skip. */ - private static function assertLeaf( - string $paramName, - Variance $declared, - Variance $effective, - string $outerLabel, - ?string $innerLabel, - ?int $innerSlot, - ): void { - $allowed = match ($effective) { - Variance::Invariant => [Variance::Invariant], - Variance::Covariant => [Variance::Invariant, Variance::Covariant], - Variance::Contravariant => [Variance::Invariant, Variance::Contravariant], - }; - if (in_array($declared, $allowed, true)) { - return; + public function validateInnerVariance(): void + { + foreach ($this->definitions as $definition) { + InnerVarianceValidator::assertComposition( + $definition, + $this->definitions, + $this->diagnostics, + $definition->sourceFile, + ); } - // $declared is Covariant or Contravariant at this point — the Invariant - // case passes every allowed-list and early-returns above. - $sigil = $declared === Variance::Covariant ? '+' : '-'; - $where = $innerLabel !== null - ? sprintf(' (via slot %d of %s)', $innerSlot, $innerLabel) - : ''; - throw new RuntimeException(sprintf( - 'Variance violation in template %s: type-parameter %s%s appears in %s-only position%s.', - $outerLabel, - $sigil, - $paramName, - $effective->value, - $where, - )); } /** @@ -609,7 +514,7 @@ private static function assertLeaf( * * @param list $args */ - private function validateBounds(string $templateFqn, array $args): void + private function validateBounds(string $templateFqn, array $args, ?SourceLocation $callSite = null): void { if ($this->hierarchy === null) { return; @@ -619,10 +524,104 @@ private function validateBounds(string $templateFqn, array $args): void return; } self::checkBounds( - $definition->typeParams, + self::groundSiblingBounds($definition->typeParams, $args), $args, $this->hierarchy, self::formatInstantiation(ltrim($templateFqn, '\\'), $args), + $this->diagnostics, + $callSite, + ); + } + + /** + * Warn (don't fail) when a variant template is instantiated over an element type the + * compiler can't see, so its covariant/contravariant `extends` edge is silently dropped. + * + * A covariant/contravariant `extends` edge between two specializations only emits when + * `TypeHierarchy::isSubtype` can *prove* the element relationship. When an element type is + * not in the `.xphp` source set and not a PHP built-in, that verdict is `null` and + * `VarianceEdgeEmitter` skips the edge — autoload-safe, but the author gets no signal and + * covariance degrades into a runtime `TypeError` far from the cause. The *bounds* path + * already rejects the same `null` verdict loudly; this mirrors that contract for variance + * with a non-failing Warning. + * + * Per-instantiation, single-endpoint: each unprovable element type is flagged at its own + * instantiation site, which collectively covers every unprovable edge while keeping the + * call-site location (a deferred pass would lose it — `GenericInstantiation` doesn't retain + * it). Leaf-only: a nested same-template generic arg (`Producer>`) is covered when + * its own recursive instantiation is recorded above, where `Box`'s own `Book` arg is checked. + * + * A Warning only has a sink in check-mode (a collector is attached); `xphp compile` builds + * the Registry without one, and its edge-skipping output is unchanged. (Note: `CallSiteRewriter` + * re-records instantiations location-less in compile Phase 3 — harmless while compile has no + * sink; a future compile sink must account for it.) + * + * @param list $args + */ + private function validateVarianceEdgeProvability(string $templateFqn, array $args, ?SourceLocation $callSite = null): void + { + if ($this->hierarchy === null || $this->diagnostics === null) { + return; + } + $definition = $this->definitions[ltrim($templateFqn, '\\')] ?? null; + if ($definition === null || count($definition->typeParams) !== count($args)) { + return; + } + foreach ($definition->typeParams as $i => $param) { + // Only covariant/contravariant positions form `extends` edges; an invariant + // position requires identical args, so an unprovable type loses no edge there. + if ($param->variance === Variance::Invariant) { + continue; + } + $arg = $args[$i]; + // Scalars and type-params never form class edges; a generic arg is leaf-only-deferred + // (its inner leaves are checked when its own instantiation is recorded). + if ($arg->isScalar || $arg->isTypeParam || $arg->isGeneric()) { + continue; + } + // A type known to the hierarchy (in-source or a PHP built-in) yields a provable + // true/false verdict — only the "not declared" case is the unprovable `null`. + if ($this->hierarchy->isDeclared($arg->name)) { + continue; + } + $this->diagnostics->add(new Diagnostic( + Severity::Warning, + self::CODE_VARIANCE_EDGE_UNPROVABLE, + self::varianceEdgeUnprovableMessage( + self::formatInstantiation(ltrim($templateFqn, '\\'), $args), + $param->name, + $param->variance, + $arg->toDisplayString(), + ), + $callSite, + )); + } + } + + /** + * User-facing text for an unprovable variance edge. Mirrors the bounds "not in the source + * set … cannot prove" phrasing so the two read consistently; kept as its own builder because + * the variance message names the parameter's variance rather than a bound. + */ + private static function varianceEdgeUnprovableMessage( + string $instantiationLabel, + string $paramName, + Variance $variance, + string $typeDisplay, + ): string { + $marker = $variance === Variance::Covariant ? '+' : '-'; + return sprintf( + "Variance edge cannot be proven while instantiating %s.\n" + . " type parameter %s%s is %s, but %s is not in the source set the hierarchy was built from (and is not a recognized PHP built-in),\n" + . " so the compiler cannot prove its subtype edges — this specialization is not linked to related ones and the %s relationship silently does not apply at runtime.\n\n" + . " Add %s to the source set the hierarchy is built from to enable the edge.", + $instantiationLabel, + $marker, + $paramName, + $variance->value, + $typeDisplay, + $variance->value, + $typeDisplay, ); } @@ -637,6 +636,11 @@ private function validateBounds(string $templateFqn, array $args): void * `$instantiationLabel` is the human-readable context string that opens the error * (e.g. `"App\Box"` or `"App\Util::identity"`). * + * When `$diagnostics` is null (the default — `xphp compile`, and every `GenericMethodCompiler` + * call site) a violation throws as before (byte-identical message). When a collector is + * supplied (`xphp check`), each violation is appended as a `Diagnostic` and the loop continues, + * so all violating parameters of one instantiation are reported in a single run. + * * @param list $typeParams * @param list $args */ @@ -645,9 +649,12 @@ public static function checkBounds( array $args, TypeHierarchy $hierarchy, string $instantiationLabel, + ?DiagnosticCollector $diagnostics = null, + ?SourceLocation $callSite = null, ): void { - // Arity mismatch is a different error class (caught upstream); skip silently here - // so that the existing pipeline can produce the more specific message. + // Arity mismatch is a different error class, reported upstream by + // padArgsWithDefaults (under-arity → missing-type-argument, over-arity → + // too-many-type-arguments); skip the bound check here for the partial tuple. if (count($typeParams) !== count($args)) { return; } @@ -675,18 +682,129 @@ public static function checkBounds( $concrete->toDisplayString(), $boundDisplay, ); - throw new RuntimeException(sprintf( - "Generic bound violated while instantiating %s.\n" - . " type parameter %s is bounded by %s\n" - . " but the supplied concrete type is %s\n\n" - . " %s", + $message = self::boundViolationMessage( $instantiationLabel, $param->name, $boundDisplay, $concrete->toDisplayString(), $detail, + ); + if ($diagnostics !== null) { + $diagnostics->add(new Diagnostic(Severity::Error, self::CODE_BOUND_VIOLATION, $message, $callSite)); + continue; + } + throw new RuntimeException($message); + } + } + + /** + * Build the user-facing bound-violation message. Single source of truth shared by the + * throw path (`xphp compile`) and the diagnostic path (`xphp check`) so the two can never + * drift — the exact text is pinned by `expectExceptionMessage` assertions. + */ + private static function boundViolationMessage( + string $instantiationLabel, + string $paramName, + string $boundDisplay, + string $concreteDisplay, + string $detail, + ): string { + return sprintf( + "Generic bound violated while instantiating %s.\n" + . " type parameter %s is bounded by %s\n" + . " but the supplied concrete type is %s\n\n" + . " %s", + $instantiationLabel, + $paramName, + $boundDisplay, + $concreteDisplay, + $detail, + ); + } + + /** + * Substitute type-param leaves inside a BoundExpr tree, returning a fresh tree. + * + * Grounds a method-generic bound that references an enclosing class type parameter + * (``) against the receiver's concrete type arguments before the bound is + * checked: each `BoundLeaf`'s `TypeRef` is rewritten via + * {@see Specializer::substituteTypeRef}, so a leaf `E` becomes the receiver's concrete + * `Product`, while a leaf the map does not mention is returned unchanged (and a leaf that + * stays a type-param signals an ungroundable bound to the caller). `Bound*` are immutable, + * so a fresh tree is built; the compound branches recurse so DNF shapes ground throughout. + * + * @param array $subst + */ + public static function substituteBound(BoundExpr $bound, array $subst): BoundExpr + { + if ($bound instanceof BoundLeaf) { + return new BoundLeaf(Specializer::substituteTypeRef($bound->type, $subst)); + } + if ($bound instanceof BoundIntersection) { + return new BoundIntersection(...array_map( + static fn (BoundExpr $op): BoundExpr => self::substituteBound($op, $subst), + $bound->operands, + )); + } + if ($bound instanceof BoundUnion) { + return new BoundUnion(...array_map( + static fn (BoundExpr $op): BoundExpr => self::substituteBound($op, $subst), + $bound->operands, )); } + // Defensive: BoundExpr is an abstract base and we own every subtype. Unreachable in + // any test, but keep the return shape consistent (mirrors evaluateBound). + return $bound; + } + + /** + * Ground each type parameter's bound against the supplied (padded) args, so a bound that + * references a sibling parameter (`class Pair`) is checked against the concrete arg, + * not the literal parameter name. Counts must match -- an arity mismatch is reported upstream + * (padArgsWithDefaults) and is left ungrounded so checkBounds early-returns on it. + * + * @param list $params + * @param list $args + * @return list + */ + private static function groundSiblingBounds(array $params, array $args): array + { + if (count($params) !== count($args)) { + return $params; + } + $subst = []; + foreach ($params as $i => $param) { + $subst[$param->name] = $args[$i]; + } + + return array_map( + static fn (TypeParam $p): TypeParam => $p->bound === null + ? $p + : new TypeParam($p->name, self::substituteBound($p->bound, $subst), $p->default, $p->variance), + $params, + ); + } + + /** + * Whether the bound has a top-level leaf that is a bare type parameter (a sibling reference like + * `U : T`) -- which can only be checked once that parameter is bound. A leaf naming a real class + * with type-param ARGS (an F-bound `T : Comparable`) is NOT such a reference: it is checked + * erased on the leaf name, so it stays a declaration-time check. + */ + private static function boundReferencesSiblingParam(BoundExpr $bound): bool + { + if ($bound instanceof BoundLeaf) { + return $bound->type->isTypeParam; + } + if ($bound instanceof BoundIntersection || $bound instanceof BoundUnion) { + foreach ($bound->operands as $operand) { + if (self::boundReferencesSiblingParam($operand)) { + return true; + } + } + } + + return false; } /** @@ -745,8 +863,11 @@ private static function evaluateBound(BoundExpr $bound, TypeRef $concrete, TypeH * - Intersection -> "A & B & C" * - Union -> "A | B | C" * - DNF -> "(A & B) | C" (parens around inner intersections) + * + * Public so the method-generic compiler can render the same bound form in its + * "bound unprovable" diagnostic. */ - private static function formatBound(BoundExpr $bound): string + public static function formatBound(BoundExpr $bound): string { if ($bound instanceof BoundLeaf) { return $bound->type->name; @@ -926,6 +1047,18 @@ public static function canonicalHash(array $args, int $hashLength = self::DEFAUL return substr(hash('sha256', $canonical), 0, $hashLength); } + /** + * The single source of truth for a mangled generic-method name: `m_T_`. The + * call site and the Specializer both build erased-method names through here so they agree byte for + * byte (the cross-cutting invariant that a rewritten call resolves to the emitted member). + * + * @param list $args + */ + public static function mangledMethodName(string $shortName, array $args, int $hashLength = self::DEFAULT_HASH_HEX_LENGTH): string + { + return $shortName . '_T_' . self::canonicalHash($args, $hashLength); + } + /** * Read XPHP_HASH_LENGTH from the environment, falling back to the default. * Throws on garbage values (non-numeric, out of range) so misconfiguration fails loud at boot. diff --git a/src/Transpiler/Monomorphize/RegistryCollector.php b/src/Transpiler/Monomorphize/RegistryCollector.php index 006e42a..205730f 100644 --- a/src/Transpiler/Monomorphize/RegistryCollector.php +++ b/src/Transpiler/Monomorphize/RegistryCollector.php @@ -13,6 +13,7 @@ use PhpParser\Node\Stmt\Use_; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; +use XPHP\Diagnostics\SourceLocation; /** * Walks an AST and feeds generic definitions and instantiations into a Registry. @@ -133,7 +134,11 @@ public function enterNode(Node $node): null if (is_array($args)) { /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach. */ if (is_string($fqn) && self::allConcrete($args)) { - $this->registry->recordInstantiation($fqn, $args); + $this->registry->recordInstantiation( + $fqn, + $args, + new SourceLocation($this->currentFile, $node->getStartLine()), + ); } } } @@ -178,6 +183,9 @@ private function synthesizeBareNewIfAllDefaults(Name $name): void // instantiation downstream -- only the path differs. The split exists to // record the synthesis at the same time as the attribute attach so that the // fixed-point loop's nested-instantiation walk picks it up in the same pass. + // Call-site location is threaded for the explicit-turbofish path (the bound-violation + // case); the all-defaults bare-`new` path can only fail bounds via a default, which is + // reported at the definition site, so it records without a call-site here. $this->registry->recordInstantiation($resolved, []); } diff --git a/src/Transpiler/Monomorphize/SpecializationCloser.php b/src/Transpiler/Monomorphize/SpecializationCloser.php new file mode 100644 index 0000000..008f228 --- /dev/null +++ b/src/Transpiler/Monomorphize/SpecializationCloser.php @@ -0,0 +1,464 @@ + { contains(E2 $value): bool }`) is lowered by erasing `E2` to + * its bound `E` — one member per class instantiation, mangled on that class's own `E`. So + * `Collection` declares the abstract `contains_(Book)` and `Collection` declares + * a DISTINCT abstract `contains_(Product)` (distinct names are required: a single shared + * name would make `contains(Book)` override `contains(Product)` with a narrower parameter — a + * contravariant LSP fatal). + * + * When a concrete `ListColl` (`Book <: Product`) is upcast to `Collection`, the + * covariant interface edge `Collection extends Collection` makes it an instance of + * `Collection`, which demands a concrete `contains_(Product)`. Nothing in the + * `Book` world implements it, and the class that would (`AbstractColl`, inherited down the + * covariant chain) is never discovered by the ordinary fixed-point loop — that loop only follows + * substitution, and a covariant upcast is a usage relationship. The result is emitted PHP that fatals + * at class load. + * + * This closer fills the gap: for every interface specialization that carries an erasable method, and + * every concrete class specialization that is — by a strict covariant upcast — an instance of it, it + * schedules the declaring class specialized at the supertype's arguments. The variance edge emitter + * then wires ` extends ` and the concrete member is inherited. Where the implementation + * cannot be carried that way (the declaring class itself has a source parent that would block the + * variance edge, the method body lives only in a trait, or the supertype arguments can't be threaded) + * it raises a compile error rather than emit code that would fatal at load — ground or fail. + * + * Runs inside the Compiler's Phase 2 fixed-point loop, off the registry alone (it needs the recorded + * instantiations + definitions, not the specialized ASTs). + */ +final readonly class SpecializationCloser +{ + /** Diagnostic code for an upcast whose concrete implementation cannot be scheduled. */ + public const CODE_UNSCHEDULABLE_UPCAST = 'xphp.unschedulable_covariant_upcast'; + + public function __construct( + private TypeHierarchy $hierarchy, + private VarianceSubtyping $subtyping, + private Specializer $specializer, + private int $hashLength, + ) { + } + + /** + * Close the specialization set for the covariant upcasts present in the registry. Two paths supply + * an upcast's erased member: scheduling the declaring class so the variance edge inherits it (the + * primary, WI-09 path — records a new instantiation), or — when inheritance can't carry it (the + * declaring class has a source parent, or implements only a parent interface) — emitting the member + * directly onto the upcast-source's specialized class. Returns true when it recorded at least one new + * instantiation, so the caller re-runs the fixed-point loop. + * + * @param array $specializedAsts keyed by generated FQCN + */ + public function close(Registry $registry, array &$specializedAsts): bool + { + $countBefore = count($registry->instantiations()); + + /** @var list}> $interfaceSpecs */ + $interfaceSpecs = []; + /** @var list $concreteSpecs */ + $concreteSpecs = []; + foreach ($registry->instantiations() as $instantiation) { + $definition = $registry->definition($instantiation->templateFqn); + if ($definition === null) { + continue; + } + $ast = $definition->templateAst; + if ($ast instanceof Interface_) { + $erasable = $this->erasableMethodNames($definition); + if ($erasable !== []) { + $interfaceSpecs[] = [$instantiation, $erasable]; + } + } elseif ($ast instanceof Class_) { + // @infection-ignore-all -- the `!isAbstract()` filter is an optimization, not a + // correctness condition: an abstract class included here would only schedule the SAME + // declaring class the closer reaches via its concrete subclasses (idempotent), so every + // mutant of the negation produces the identical final registry. + if (!$ast->isAbstract()) { + $concreteSpecs[] = $instantiation; + } + } + } + + foreach ($interfaceSpecs as [$interfaceSpec, $methodNames]) { + foreach ($concreteSpecs as $concreteSpec) { + $this->closeOne($interfaceSpec, $methodNames, $concreteSpec, $registry, $specializedAsts); + } + } + + // @infection-ignore-all GreaterThan GreaterThanNegotiation -- this is the fixed-point signal + // for the caller's loop ("did I add anything?"). A too-permissive comparison (>= / <) reports + // "added" when nothing was, which the Compiler loop turns into non-termination, caught by the + // integration compiles (they would hang rather than return); the boundary itself isn't a + // separately assertable value. + return count($registry->instantiations()) > $countBefore; + } + + /** + * @param list $methodNames erasable method names declared on the interface + * @param array $specializedAsts + */ + private function closeOne( + GenericInstantiation $interfaceSpec, + array $methodNames, + GenericInstantiation $concreteSpec, + Registry $registry, + array &$specializedAsts, + ): void { + $interfaceFqn = $interfaceSpec->templateFqn; + $concreteFqn = $concreteSpec->templateFqn; + + // The concrete class must implement the interface (the interface is an ancestor). + if (!in_array($interfaceFqn, $this->hierarchy->ancestorChain($concreteFqn), true)) { + return; + } + + // Thread the concrete receiver's args up to the interface: the interface's args as witnessed + // from this concrete spec. Null = unreachable / ambiguous (conflicting diamond paths). + $asSeen = $this->hierarchy->resolveInheritedArgs( + $concreteFqn, + $concreteSpec->concreteTypes, + $interfaceFqn, + ); + if ($asSeen === null) { + return; + } + + $interfaceDef = $registry->definition($interfaceFqn); + + // The concrete already implements THIS interface spec directly (no upcast): its own erased + // member already satisfies it — nothing to schedule. + // @infection-ignore-all -- this early return is an optimization the strict-upcast check below + // subsumes: when `$asSeen` equals the spec's args, isVarianceSubtype() returns false (no + // differing arg) and control returns anyway; `$interfaceDef === null` is unreachable (the spec + // entered $interfaceSpecs only after its definition resolved). Every mutant of this condition + // yields the same outcome; the strict-upcast path (and its non-subtype return) stays covered. + if ($interfaceDef === null || $this->argsEqual($asSeen, $interfaceSpec->concreteTypes)) { + return; + } + + // Is it a STRICT covariant upcast — ` <: `? Only then does + // the concrete inherit the interface spec's (distinctly-named) abstract erased member. + if (!$this->subtyping->isVarianceSubtype( + $asSeen, + $interfaceSpec->concreteTypes, + $interfaceDef->typeParams, + $registry, + )) { + return; + } + + foreach ($methodNames as $methodName) { + $this->scheduleImplementer($interfaceSpec, $concreteSpec, $methodName, $registry, $specializedAsts); + } + } + + /** + * Supply `$methodName`'s erased member for the upcast. Primary path (WI-09): when the body's + * declaring class is parent-less and threads its parameters through to this interface unchanged, + * schedule it at the supertype args and let the variance edge inherit it. Otherwise — the declaring + * class has a source parent (so the covariant edge can't be emitted under single inheritance), or it + * doesn't thread to this interface (it implements only a parent interface, or reorders its params) — + * emit the member directly onto the upcast-source class instead. Only a body that can't be found on + * any `Class_` in the ancestry (trait-supplied / interface-only) hard-fails. + * + * @param array $specializedAsts + */ + private function scheduleImplementer( + GenericInstantiation $interfaceSpec, + GenericInstantiation $concreteSpec, + string $methodName, + Registry $registry, + array &$specializedAsts, + ): void { + $declaring = $this->declaringClassWithBody($concreteSpec->templateFqn, $methodName, $registry); + if ($declaring === null) { + throw new RuntimeException($this->unschedulableMessage( + $interfaceSpec, + $methodName, + 'its implementation is not declared on a class in the hierarchy (a trait-supplied or ' + . 'interface-only body cannot be inherited through the covariant edge, nor emitted directly)', + )); + } + + $supertypeArgs = $interfaceSpec->concreteTypes; + $declaringDef = $registry->definition($declaring); + // @infection-ignore-all -- `?->` is defensive: declaringClassWithBody() returned $declaring via a + // resolved class definition, so registry->definition($declaring) is non-null here. + $declaringAst = $declaringDef?->templateAst; + $parentLess = $declaringAst instanceof Class_ && $declaringAst->extends === null; + $threaded = $this->hierarchy->resolveInheritedArgs($declaring, $supertypeArgs, $interfaceSpec->templateFqn); + $threadsIdentically = $threaded !== null && $this->argsEqual($threaded, $supertypeArgs); + + if ($parentLess && $threadsIdentically) { + // Inheritance can carry it: schedule the declaring class at the supertype args; Phase 2.5 + // emits ` extends ` and the member is inherited. + $registry->recordInstantiation($declaring, $supertypeArgs); + return; + } + + $this->emitDirectly($interfaceSpec, $concreteSpec, $declaring, $methodName, $registry, $specializedAsts); + } + + /** + * Emit the erased member directly onto the upcast-source class's specialized AST. The member's NAME + * and parameter come from the interface's declaration at the supertype args (so it byte-matches the + * abstract member `` exposes); the BODY comes from the declaring class, specialized + * with a SPLIT substitution — the declaring class's own parameters take the upcast-source's concretes + * (so the body reads the inherited backing state), while the bounded method parameter widens to the + * supertype arg. Sound because `sub <: super`. Self-contained (no covariant edge). + * + * @param array $specializedAsts + */ + private function emitDirectly( + GenericInstantiation $interfaceSpec, + GenericInstantiation $concreteSpec, + string $declaringFqn, + string $methodName, + Registry $registry, + array &$specializedAsts, + ): void { + $interfaceDef = $registry->definition($interfaceSpec->templateFqn); + $declaringDef = $registry->definition($declaringFqn); + // @infection-ignore-all -- defensive: both definitions resolved upstream (the interface spec + // entered $interfaceSpecs with a definition; declaringClassWithBody returned a defined class). + if ($interfaceDef === null || $declaringDef === null) { + return; + } + + // The supertype value the method's bound takes — from the INTERFACE's own declaration, so the + // mangled name matches the abstract member `` declares. + $interfaceMethod = self::methodNamed($interfaceDef->templateAst, $methodName); + // @infection-ignore-all -- defensive `?->`: $interfaceMethod always resolves here (see below). + $interfaceParams = $interfaceMethod?->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + // @infection-ignore-all -- defensive: the method name came from this interface's + // erasableMethodNames(), so $interfaceMethod and its method-generic params always resolve here; + // the `?->` and this null / non-array guard never fire. + if ($interfaceMethod === null || !is_array($interfaceParams)) { + return; + } + /** @var list $interfaceParams */ + $boundReferent = self::boundReferentName($interfaceParams); + // @infection-ignore-all FalseValue -- `false` vs `true` is equivalent: either non-int $boundIndex + // routes a null bound-referent to the same hard-fail (`!is_int(...)` below). + $boundIndex = $boundReferent === null + ? false + : array_search($boundReferent, $interfaceDef->typeParamNames(), true); + if (!is_int($boundIndex) || !isset($interfaceSpec->concreteTypes[$boundIndex])) { + // The parameters aren't uniformly bounded by ONE leaf interface parameter (a rare shape like + // ``). Direct emission can't derive the single mangled member — fail loudly + // rather than emit nothing and leave the interface's abstract member unimplemented. + throw new RuntimeException($this->unschedulableMessage( + $interfaceSpec, + $methodName, + 'its parameters are not uniformly bounded by one enclosing type parameter, so the ' + . 'member cannot be emitted directly', + )); + } + $superValue = $interfaceSpec->concreteTypes[$boundIndex]; + + $mangled = Registry::mangledMethodName( + $methodName, + EnclosingBoundErasure::mangleArgs($interfaceParams, [$boundReferent => $superValue]), + $this->hashLength, + ); + + // Idempotency: never append a member the upcast-source spec already carries (its own erased + // member, or one already emitted) — a PHP redeclaration is a load fatal. + $upcastAst = $specializedAsts[$concreteSpec->generatedFqn] ?? null; + if (!$upcastAst instanceof Class_ || self::methodNamed($upcastAst, $mangled) !== null) { + return; + } + + // The body, from the declaring class, with the split substitution. + $declaringMethod = self::methodNamed($declaringDef->templateAst, $methodName); + // @infection-ignore-all -- defensive `?->`: $declaringMethod always resolves here (see below). + $declaringParams = $declaringMethod?->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + // @infection-ignore-all -- defensive: declaringClassWithBody found an erasable, bodied method of + // this name on this class, so it resolves here with method-generic params; the `?->` and this + // null / non-array guard never fire. + if ($declaringMethod === null || !is_array($declaringParams)) { + return; + } + /** @var list $declaringParams */ + // The split substitution grounds the body's class parameter to the upcast source's OWN concrete + // (a subtype of the supertype the member is emitted at). That is sound for body reads, but if the + // class parameter also appears in the RETURN type, the member would return a supertype value (the + // widened bounded parameter) through a subtype return — a runtime TypeError. Direct emission can't + // ground that soundly; fail loudly instead. (A covariant parameter can't appear in an input + // position, so the return type is the only signature slot it can occupy.) + if (EnclosingBoundErasure::returnTypeReferencesEnclosing($declaringMethod, $declaringDef->typeParamNames())) { + throw new RuntimeException($this->unschedulableMessage( + $interfaceSpec, + $methodName, + 'its enclosing type parameter appears in the method return type, which direct emission ' + . 'cannot ground soundly against the upcast source', + )); + } + $declaringConcrete = $this->hierarchy->resolveInheritedArgs( + $concreteSpec->templateFqn, + $concreteSpec->concreteTypes, + $declaringFqn, + ); + if ($declaringConcrete === null) { + throw new RuntimeException($this->unschedulableMessage( + $interfaceSpec, + $methodName, + sprintf('its body class "%s" can\'t be grounded against the upcast source', $declaringFqn), + )); + } + $subst = array_combine($declaringDef->typeParamNames(), $declaringConcrete); + foreach ($declaringParams as $param) { + $subst[$param->name] = $superValue; // the bounded method param widens to the supertype arg + } + + $member = $this->specializer->specializeMethod($declaringMethod, $subst, $mangled); + $upcastAst->stmts[] = $member; + // No re-collection of the body is needed: it substitutes the class parameter to the + // upcast-source's OWN concrete — the same value as the mandatory inherited erased member the + // upcast-source already carries at that arg — so any generic instantiation in the body has + // already been discovered and specialized through that member. + } + + /** The first method named `$name` on a ClassLike, or null. */ + private static function methodNamed(\PhpParser\Node\Stmt\ClassLike $ast, string $name): ?ClassMethod + { + foreach ($ast->getMethods() as $method) { + if ($method->name->toString() === $name) { + return $method; + } + } + return null; + } + + /** + * The single enclosing-class parameter all the method's params are bounded by (the erasable shape), + * or null if the params aren't uniformly bounded by one leaf. + * + * @param list $params + */ + private static function boundReferentName(array $params): ?string + { + $referent = null; + foreach ($params as $param) { + if (!$param->bound instanceof BoundLeaf) { + return null; + } + $name = $param->bound->type->name; + if ($referent !== null && $referent !== $name) { + return null; + } + $referent = $name; + } + return $referent; + } + + /** + * The nearest class in `$concreteFqn`'s ancestry (including itself) that declares `$methodName` + * as an erasable method WITH a body, or null if none does (interface-only / trait-only). + */ + private function declaringClassWithBody(string $concreteFqn, string $methodName, Registry $registry): ?string + { + $candidates = array_merge([$concreteFqn], $this->hierarchy->ancestorChain($concreteFqn)); + foreach ($candidates as $candidate) { + $definition = $registry->definition($candidate); + // @infection-ignore-all -- defensive skip of an ancestor that is not a generic class + // template (a non-generic base with no recorded definition, or an interface/trait). Either + // half short-circuits to the same `continue`, and the meaningful work — matching the method + // name and confirming erasability below — stays covered. + if ($definition === null || !$definition->templateAst instanceof Class_) { + continue; + } + foreach ($definition->templateAst->getMethods() as $method) { + // @infection-ignore-all -- the `stmts === null` half skips an abstract (bodyless) + // declaration of the same name; either half routes to the same `continue`, and the + // erasability confirmation below is the meaningful gate (covered by the success tests, + // where the matching method is found past a non-matching constructor). + if ($method->name->toString() !== $methodName || $method->stmts === null) { + continue; + } + $params = $method->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (!is_array($params)) { + continue; + } + /** @var list $params */ + if (EnclosingBoundErasure::isErasable($method, $params, $definition->typeParamNames())) { + return $candidate; + } + } + } + return null; + } + + /** + * The names of an interface's methods that lower to an erased (abstract) member. + * + * @return list + */ + private function erasableMethodNames(GenericDefinition $definition): array + { + $out = []; + foreach ($definition->templateAst->getMethods() as $method) { + $params = $method->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (!is_array($params)) { + continue; + } + /** @var list $params */ + if (EnclosingBoundErasure::isErasable($method, $params, $definition->typeParamNames())) { + $out[] = $method->name->toString(); + } + } + // @infection-ignore-all ArrayOneItem -- the collected names drive per-method scheduling + // (closeOne loops them), and for a covariant collection every element-consuming method declares + // its body on the same covariant base, so each name routes to the same declaring class and the + // same idempotent recordInstantiation. Truncating this list leaves the scheduled set unchanged. + return $out; + } + + /** + * @param list $a + * @param list $b + */ + private function argsEqual(array $a, array $b): bool + { + if (count($a) !== count($b)) { + return false; + } + foreach ($a as $i => $ref) { + if ($ref->canonical() !== $b[$i]->canonical()) { + return false; + } + } + return true; + } + + private function unschedulableMessage( + GenericInstantiation $interfaceSpec, + string $methodName, + string $reason, + ): string { + $args = implode(', ', array_map(static fn (TypeRef $r): string => $r->canonical(), $interfaceSpec->concreteTypes)); + return sprintf( + "[%s] A covariant upcast requires implementing the erased method \"%s\" from \"%s<%s>\", but %s.\n" + . ' Provide a concrete implementation reachable through a single covariant chain — e.g. give the ' + . 'declaring class no other parent, or move the method body onto the covariant base class.', + self::CODE_UNSCHEDULABLE_UPCAST, + $methodName, + $interfaceSpec->templateFqn, + $args, + $reason, + ); + } +} diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index 1fe295f..d6a5e6e 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -5,6 +5,9 @@ namespace XPHP\Transpiler\Monomorphize; use PhpParser\Node; +use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Expr\NullsafeMethodCall; +use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Name\FullyQualified; @@ -41,10 +44,24 @@ final class Specializer /** * @param array $substitution Type-param name → concrete TypeRef. * + * Type parameters in every position — including constructor parameters — are + * substituted to their *concrete* type; nothing is erased. PHP exempts + * `__construct` from LSP signature checks, so a `T`-typed constructor parameter + * specializes to its real type (`Banana ...$items`) and stays valid across the + * variance `extends` chain, giving a real runtime type check at construction. + * A `T`-typed *public/protected property* (mutable, readonly, or promoted) is the + * one shape that can't cross the edge — PHP enforces invariant property types across + * the chain for visible members — and is rejected upstream by the variance-position + * validator, not erased here. A `T`-typed *private* property DOES cross the edge and + * is substituted to its real type (PHP doesn't type-check private slots across the + * chain; each specialization re-emits its own field + accessor). A `final` variant + * class is likewise rejected upstream (a `final` class can't anchor a variance + * `extends` edge), so no `final` needs stripping here. + * * The cloned class's `name` is intentionally NOT set here — SpecializedClassGenerator::emit * is the single source of truth for the final shortname (derived from the generated FQCN). */ - public function specialize(ClassLike $template, array $substitution): ClassLike + public function specialize(ClassLike $template, array $substitution, int $hashLength = Registry::DEFAULT_HASH_HEX_LENGTH): ClassLike { $originalTemplateFqn = $template->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); @@ -66,11 +83,135 @@ public function specialize(ClassLike $template, array $substitution): ClassLike } } + // Lower an erasable `` method (kept on the template by the method compiler) into a + // concrete, E-mangled member: `contains(U)` on Box becomes `contains_T_` + // taking `Fruit`. Done before the class-wide substitution; the lowered members are already + // concrete, so the substitution leaves them untouched. + $cloned->stmts = $this->lowerErasableMethods($cloned->stmts, $substitution, $hashLength); + self::runSubstitutingVisitor($cloned, $substitution); return $cloned; } + /** + * Replace each erasable generic method with its E-erased concrete form; non-erasable methods and + * non-method statements pass through unchanged. + * + * @param array<\PhpParser\Node\Stmt> $stmts + * @param array $classConcrete class-parameter name → concrete TypeRef + * @return list<\PhpParser\Node\Stmt> + */ + private function lowerErasableMethods(array $stmts, array $classConcrete, int $hashLength): array + { + $classParamNames = array_keys($classConcrete); + + // First pass: every erasable method's E-mangled name. Used to rewrite `$this->m::<...>()` + // self-calls between erasable methods to the same names the call sites produce. + $erasedNames = []; + foreach ($stmts as $stmt) { + if ($stmt instanceof ClassMethod) { + $methodParams = $stmt->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (is_array($methodParams)) { + /** @var list $methodParams */ + if (EnclosingBoundErasure::isErasable($stmt, $methodParams, $classParamNames)) { + $erasedNames[$stmt->name->toString()] = Registry::mangledMethodName( + $stmt->name->toString(), + EnclosingBoundErasure::mangleArgs($methodParams, $classConcrete), + $hashLength, + ); + } + } + } + } + + // Second pass: erase each erasable method into a concrete member. + $out = []; + foreach ($stmts as $stmt) { + if ($stmt instanceof ClassMethod && isset($erasedNames[$stmt->name->toString()])) { + /** @var list $methodParams */ + $methodParams = $stmt->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + $out[] = $this->eraseMethod($stmt, $methodParams, $classConcrete, $erasedNames[$stmt->name->toString()], $erasedNames); + continue; + } + $out[] = $stmt; + } + return $out; + } + + /** + * Erase one method: substitute each enclosing-bounded type parameter (and any class parameter) to + * its concrete bound, rename to the E-mangled name, drop method-genericness, and rewrite any + * `$this->m::<...>()` self-call to a fellow erasable method to that method's E-mangled name. + * + * @param list $methodParams + * @param array $classConcrete + * @param array $erasedNames erasable method name → its E-mangled name + */ + private function eraseMethod(ClassMethod $method, array $methodParams, array $classConcrete, string $mangled, array $erasedNames): ClassMethod + { + $subst = $classConcrete; + foreach ($methodParams as $param) { + // @infection-ignore-all — invariantly true: isErasable guarantees every method parameter + // is a single-leaf enclosing-class bound, so `$param->bound` IS a BoundLeaf and its + // referent IS a key of $classConcrete. The guard is defensive against a non-erasable call. + if ($param->bound instanceof BoundLeaf && isset($classConcrete[$param->bound->type->name])) { + $subst[$param->name] = $classConcrete[$param->bound->type->name]; + } + } + + $lowered = $this->specializeMethod($method, $subst, $mangled); + self::rewriteErasableSelfCalls($lowered, $erasedNames); + + return $lowered; + } + + /** + * Rewrite `$this->m::<...>()` (and the nullsafe form) where `m` is a fellow erasable method to its + * E-mangled name, stripping the now-meaningless turbofish. The forwarded type argument is erased, + * so the call keys on the enclosing class's `E` exactly like every other call to `m`. + * + * @param array $erasedNames + */ + private static function rewriteErasableSelfCalls(ClassMethod $method, array $erasedNames): void + { + $traverser = new NodeTraverser(); + $traverser->addVisitor(new class($erasedNames) extends NodeVisitorAbstract { + /** @param array $erasedNames */ + public function __construct(private array $erasedNames) + { + } + + public function leaveNode(Node $node): ?Node + { + if (!$node instanceof MethodCall && !$node instanceof NullsafeMethodCall) { + return null; + } + // @infection-ignore-all — defensive receiver-shape guard: only a literal `$this->m()` + // with an Identifier name is rewritten. The alternate (`&&`) forms would attempt to + // process non-`$this` / dynamic-name calls, which an erasable method body never pairs + // with an erasable target — the `erasedNames` lookup below would miss them regardless. + if (!$node->var instanceof Variable + || $node->var->name !== 'this' + || !$node->name instanceof Identifier + ) { + return null; + } + $mangled = $this->erasedNames[$node->name->toString()] ?? null; + if ($mangled === null) { + return null; + } + $node->name = new Identifier($mangled, $node->name->getAttributes()); + // @infection-ignore-all — clearing the now-stale turbofish attribute is hygiene only: + // the pretty-printer never emits ATTR_METHOD_GENERIC_ARGS, and no pass reads it on an + // already-specialized class, so its removal is unobservable in the output. + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); + return $node; + } + }); + $traverser->traverse([$method]); + } + /** * Specialize a single generic method: clone the ClassMethod, substitute every * Name reference to a type-param with the matching concrete TypeRef, drop the @@ -155,6 +296,7 @@ public function leaveNode(Node $node): ?Node $args = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); if (is_array($args) && $args !== []) { + /** @var list $args — ATTR_GENERIC_ARGS is a TypeRef list (set by XphpSourceParser); the type-hint lets array_map infer the callback's parameter as TypeRef. */ $substituted = array_map( fn (TypeRef $a): TypeRef => Specializer::substituteTypeRef($a, $this->substitution), $args, diff --git a/src/Transpiler/Monomorphize/TypeHierarchy.php b/src/Transpiler/Monomorphize/TypeHierarchy.php index 8c388e4..01154a7 100644 --- a/src/Transpiler/Monomorphize/TypeHierarchy.php +++ b/src/Transpiler/Monomorphize/TypeHierarchy.php @@ -57,10 +57,23 @@ ]; /** + * `$ancestors` powers the erased `isSubtype`/`ancestorChain` queries. `$superTypeArgs` and + * `$typeParamNames` additionally model the *parameterized* supertype edges — the type + * arguments each `extends`/`implements` clause passes (`implements Collection`) and each + * class's own declared parameter names — so `resolveInheritedArgs` can thread a receiver's + * concrete arguments up the chain to a method's declaring class. Both default to empty: a + * hierarchy built without them (e.g. hand-constructed in a test, or from a non-xphp AST) still + * answers the erased queries, and `resolveInheritedArgs` simply finds nothing to ground. + * * @param array> $ancestors map> + * @param array> $superTypeArgs map> + * @param array> $typeParamNames map> */ - public function __construct(private array $ancestors) - { + public function __construct( + private array $ancestors, + private array $superTypeArgs = [], + private array $typeParamNames = [], + ) { } /** @@ -71,10 +84,12 @@ public function __construct(private array $ancestors) public static function fromAstPerFile(array $astPerFile): self { $ancestors = []; + $superTypeArgs = []; + $typeParamNames = []; foreach ($astPerFile as $ast) { - self::collectFromAst($ast, $ancestors); + self::collectFromAst($ast, $ancestors, $superTypeArgs, $typeParamNames); } - return new self($ancestors); + return new self($ancestors, $superTypeArgs, $typeParamNames); } /** @@ -124,20 +139,162 @@ public function isSubtype(string $concrete, string $bound): ?bool return false; } + /** + * Whether $fqn names a type the source set knows about: a class/interface/trait + * declared in a scanned `.xphp` file (every such ClassLike is a key in the + * ancestor map) or a built-in PHP interface/class. Used by the + * undeclared-type-parameter check to tell a real (in-project or built-in) type + * from a name that resolves to nothing. + */ + public function isDeclared(string $fqn): bool + { + $fqn = ltrim($fqn, '\\'); + + return isset($this->ancestors[$fqn]) || in_array($fqn, self::BUILTIN_TYPES, true); + } + + /** + * Transitive ancestors of $fqn, nearest-first and de-duplicated, excluding + * $fqn itself. Breadth-first over the direct-ancestor map, so the closest + * declaring class is visited before its grandparents. + * + * Powers inherited generic-method resolution: when `$sub->m::()` can't + * bind `m` on the receiver's own class, the caller walks this chain and + * binds to the nearest ancestor that declares the generic method. An + * unknown $fqn yields an empty list. + * + * @return list + */ + public function ancestorChain(string $fqn): array + { + $fqn = ltrim($fqn, '\\'); + + $seen = []; + $chain = []; + $queue = $this->ancestors[$fqn] ?? []; + while ($queue !== []) { + $next = array_shift($queue); + if (isset($seen[$next])) { + continue; + } + $seen[$next] = true; + $chain[] = $next; + foreach ($this->ancestors[$next] ?? [] as $grandAncestor) { + $queue[] = $grandAncestor; + } + } + + return $chain; + } + + /** + * Thread a receiver's concrete type arguments up the parameterized supertype chain. + * + * Given a receiver of static type `$subFqn<$subArgs>` and a `$superFqn` reachable through its + * `extends`/`implements` clauses, returns the type arguments that `$superFqn`'s OWN parameters + * are bound to as witnessed from that receiver. For `class ArrayList<+E> implements Collection`, + * `resolveInheritedArgs('App\ArrayList', [Product], 'App\Collection')` yields `[Product]`. At each + * hop the current class's parameters are substituted with the current arguments into the supertype + * clause's arguments (so nested clauses like `implements Foo>` ground throughout). + * + * Returns null on any gap — an unreachable target, a non-parameterized/arity-mismatched hop, or a + * cyclic/expansive edge — and on ambiguity: when more than one path grounds the target to + * non-equal arguments. The caller reads null as "cannot ground; fall back to lenient". A direct + * hit (`$subFqn === $superFqn`) returns `$subArgs` unchanged. + * + * @param list $subArgs + * @return list|null + */ + public function resolveInheritedArgs(string $subFqn, array $subArgs, string $superFqn): ?array + { + /** @var array> $groundings canonical-args => the grounded args (dedup) */ + $groundings = []; + $this->groundPaths(ltrim($subFqn, '\\'), $subArgs, ltrim($superFqn, '\\'), [], $groundings); + + // 0 groundings = unreachable; >1 distinct = ambiguous (conflicting paths). Either way: null. + if (count($groundings) !== 1) { + return null; + } + + return array_values($groundings)[0]; + } + + /** + * Depth-first walk of the parameterized supertype edges, accumulating every distinct grounding of + * `$superFqn` into `$groundings` (keyed by canonical args, so a diamond that agrees collapses to + * one and a diamond that conflicts yields two). `$onPath` is the set of FQNs on the current path, + * passed by value so siblings stay independent (diamonds work) while a repeat on one path — a + * regular cycle or an expansive `A implements A>` recursion — terminates that path. + * + * @param list $args + * @param array $onPath + * @param array> $groundings + */ + private function groundPaths(string $fqn, array $args, string $superFqn, array $onPath, array &$groundings): void + { + if ($fqn === $superFqn) { + $groundings[self::argsKey($args)] = $args; + return; + } + if (isset($onPath[$fqn])) { + return; // cycle / expansive recursion on this path — a gap, not a grounding. + } + $params = $this->typeParamNames[$fqn] ?? []; + if (count($params) !== count($args)) { + return; // arity mismatch (incl. a non-parameterized hop carrying args) — a gap. + } + $subst = []; + foreach ($params as $i => $name) { + $subst[$name] = $args[$i]; + } + // @infection-ignore-all TrueValue -- the on-path guard keys on isset() (existence, not + // value), so the assigned literal is immaterial; the cycle/expansive tests pin termination. + $onPath[$fqn] = true; + foreach ($this->superTypeArgs[$fqn] ?? [] as $clause) { + $nextArgs = array_map( + static fn (TypeRef $a): TypeRef => Specializer::substituteTypeRef($a, $subst), + $clause->args, + ); + $this->groundPaths(ltrim($clause->name, '\\'), $nextArgs, $superFqn, $onPath, $groundings); + } + } + + /** + * Canonical key for an argument list, used to dedup groundings and detect conflict. + * + * @param list $args + */ + private static function argsKey(array $args): string + { + return implode(',', array_map(static fn (TypeRef $a): string => $a->canonical(), $args)); + } + /** * @param list $ast * @param array> $ancestors out-param accumulator + * @param array> $superTypeArgs out-param: parameterized direct supertypes + * @param array> $typeParamNames out-param: each class's own param names + * @param-out array> $ancestors + * @param-out array> $superTypeArgs + * @param-out array> $typeParamNames */ - private static function collectFromAst(array $ast, array &$ancestors): void + private static function collectFromAst(array $ast, array &$ancestors, array &$superTypeArgs, array &$typeParamNames): void { // @infection-ignore-all — the inner visitor is a flat AST walk over namespace/use - // /classlike nodes; mutations on its `?->`, `??` lastSegment fallback and - // `ltrim('\\')` defensives all toggle paths that are masked by nikic's - // representation (FQ names come without a leading backslash, anonymous namespaces - // aren't part of any fixture). End-to-end coverage from TypeHierarchyTest. + // /classlike nodes; mutations on its `?->`, `??` lastSegment fallback, the + // `ltrim('\\')` defensives and the `is_array` attribute guards all toggle paths that + // are masked by nikic's representation (FQ names come without a leading backslash, + // anonymous namespaces aren't part of any fixture, and a non-generic clause simply + // carries no ATTR_GENERIC_ARGS). The parameterized-supertype + param-name capture is + // end-to-end covered by TypeHierarchyTest (incl. a real-parser, aliased-arg case); the + // load-bearing grounding logic lives in resolveInheritedArgs, which IS mutation-tested. $visitor = new class extends NodeVisitorAbstract { /** @var array> */ public array $collected = []; + /** @var array> parameterized direct supertypes per class */ + public array $superArgs = []; + /** @var array> own type-param names per class */ + public array $paramNames = []; private string $currentNamespace = ''; /** @var array alias => FQN */ private array $useMap = []; @@ -161,22 +318,50 @@ public function enterNode(Node $node): null } if ($node instanceof ClassLike && $node->name !== null) { $selfFqn = $this->qualify($node->name->toString()); - $directAncestors = []; + /** @var list $clauses extends + implements clause names */ + $clauses = []; if ($node instanceof Class_) { if ($node->extends !== null) { - $directAncestors[] = $this->resolveName($node->extends); + $clauses[] = $node->extends; } - foreach ($node->implements as $iface) { - $directAncestors[] = $this->resolveName($iface); + foreach ($node->implements as $interface) { + $clauses[] = $interface; } } elseif ($node instanceof Interface_) { - foreach ($node->extends as $iface) { - $directAncestors[] = $this->resolveName($iface); + foreach ($node->extends as $interface) { + $clauses[] = $interface; } } // Trait_ has no formal ancestors — uses-of-traits are statements inside the body // and would only matter for shared-method bounds, which we don't model. + $directAncestors = []; + $parameterized = []; + foreach ($clauses as $clause) { + $fqn = $this->resolveName($clause); + $directAncestors[] = $fqn; + // The clause Name carries the xphp parser's resolved generic args + // (`implements Collection` → [TypeRef(E, isTypeParam)]); a non-generic + // clause has none. The head FQN comes from resolveName so it keys the same + // way as the bare-ancestor map. + $rawArgs = $clause->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + $clauseArgs = []; + foreach (is_array($rawArgs) ? $rawArgs : [] as $arg) { + if ($arg instanceof TypeRef) { + $clauseArgs[] = $arg; + } + } + $parameterized[] = new TypeRef($fqn, $clauseArgs); + } $this->collected[$selfFqn] = $directAncestors; + $this->superArgs[$selfFqn] = $parameterized; + $rawParams = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + $paramNames = []; + foreach (is_array($rawParams) ? $rawParams : [] as $param) { + if ($param instanceof TypeParam) { + $paramNames[] = $param->name; + } + } + $this->paramNames[$selfFqn] = $paramNames; } return null; } @@ -227,5 +412,11 @@ private static function lastSegment(string $name): string foreach ($visitor->collected as $fqn => $direct) { $ancestors[$fqn] = $direct; } + foreach ($visitor->superArgs as $fqn => $supers) { + $superTypeArgs[$fqn] = $supers; + } + foreach ($visitor->paramNames as $fqn => $names) { + $typeParamNames[$fqn] = $names; + } } } diff --git a/src/Transpiler/Monomorphize/TypeRef.php b/src/Transpiler/Monomorphize/TypeRef.php index ebe605b..68a2889 100644 --- a/src/Transpiler/Monomorphize/TypeRef.php +++ b/src/Transpiler/Monomorphize/TypeRef.php @@ -23,6 +23,12 @@ public function __construct( public array $args = [], public bool $isScalar = false, public bool $isTypeParam = false, + // Set by the parser for a bare, single-segment, non-imported bound/default + // name used inside a generic context that is NOT a declared type parameter — + // the bound/default analogue of XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE + // (TypeRefs carry no AST attributes). The undeclared-type validator flags it + // when `name` resolves to no declared/built-in type. + public bool $suspectUndeclared = false, ) { } diff --git a/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php b/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php new file mode 100644 index 0000000..ee6fa8f --- /dev/null +++ b/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php @@ -0,0 +1,365 @@ + { public function add(T $x): void; }`, which would otherwise + * be silently emitted as a reference to a non-existent class `\App\Foo\T`. + * + * Works off the parser's {@see XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE} + * tag: the parser stamps it on a bare, single-segment, non-imported class name + * used inside a generic context after excluding declared params, scalars, + * fully-qualified names, and generic-arg-bearing names. So a tagged name is + * exactly "a real type or a stray type parameter"; a finding is a tagged name + * whose resolved FQN is not {@see TypeHierarchy::isDeclared}. + * + * Walks the same signature positions as {@see VariancePositionValidator} + * (properties, method/constructor params, returns, and nested closure/arrow + * signatures) — NOT `extends`/`implements`/`new`/`catch` or method bodies. + * Bounds and defaults are checked separately (they're TypeRef trees, not AST + * type nodes). Runs over collected definitions; with a `DiagnosticCollector` it + * gathers every finding (each at the offending member line) and continues, + * without one it throws the first (shared message builder). + */ +final class UndeclaredTypeParameterValidator +{ + /** Stable diagnostic code for an undeclared type used in a generic member. */ + public const CODE_UNDECLARED_TYPE = 'xphp.undeclared_type'; + + /** @var list */ + private array $violations = []; + + private function __construct( + private readonly TypeHierarchy $hierarchy, + private readonly string $context, + ) { + } + + /** + * Validate a generic class/interface/trait template's member signatures and the + * type names used in its type-parameter bounds and defaults. + * + * @param list $params the template's declared type parameters + */ + public static function assert( + ClassLike $node, + array $params, + string $templateFqn, + TypeHierarchy $hierarchy, + ?DiagnosticCollector $diagnostics = null, + ?string $file = null, + ): void { + $validator = new self($hierarchy, 'template `' . $templateFqn . '`'); + $validator->collect($node); + $validator->collectBoundsAndDefaults($params, $node->getStartLine()); + self::report($validator->violations, $diagnostics, $file); + } + + /** + * Validate generic method/function/closure signatures declared OUTSIDE a generic + * template (a generic method on a plain class, a free generic function, a generic + * closure/arrow). Those nested inside a generic template are owned by {@see assert} + * (via its member walk), so they're skipped here to avoid reporting the same node + * twice. + * + * @param array> $astPerFile keyed by filepath + */ + public static function assertMethodLevel( + array $astPerFile, + TypeHierarchy $hierarchy, + ?DiagnosticCollector $diagnostics = null, + ): void { + /** @var list $findings */ + $findings = []; + foreach ($astPerFile as $file => $ast) { + foreach (self::findMethodLevelGenerics($ast) as $generic) { + $validator = new self($hierarchy, $generic['context']); + $validator->checkCallable($generic['node']); + foreach ($validator->violations as $violation) { + $findings[] = $violation + ['file' => $file]; + } + } + } + + if ($findings === []) { + return; + } + if ($diagnostics === null) { + throw new RuntimeException($findings[0]['message']); + } + foreach ($findings as $finding) { + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_UNDECLARED_TYPE, + $finding['message'], + new SourceLocation($finding['file'], $finding['line']), + )); + } + } + + /** + * @param list $violations + */ + private static function report(array $violations, ?DiagnosticCollector $diagnostics, ?string $file): void + { + if ($violations === []) { + return; + } + if ($diagnostics === null) { + // Compile-mode: fail fast on the first finding rather than emit broken PHP. + throw new RuntimeException($violations[0]['message']); + } + foreach ($violations as $violation) { + $location = $file !== null ? new SourceLocation($file, $violation['line']) : null; + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_UNDECLARED_TYPE, + $violation['message'], + $location, + )); + } + } + + /** + * Find every generic method/function/closure NOT enclosed by a generic template. + * + * @param list $ast + * @return list + */ + private static function findMethodLevelGenerics(array $ast): array + { + $visitor = new class extends NodeVisitorAbstract { + public int $genericClassDepth = 0; + /** @var list */ + public array $found = []; + + public function enterNode(Node $node): null + { + // Skip generics nested in a generic template — its member walk owns them. + // Known limitation: a generic method on an ANONYMOUS class buried in a + // generic class's method body is owned by neither pass (the member walk + // doesn't recurse into anon classes, and depth>0 skips it here). That shape + // can't be specialized downstream anyway, so it's a latent edge, not a regression. + if ($node instanceof ClassLike && $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS) !== null) { + $this->genericClassDepth++; + } + if ($this->genericClassDepth === 0 + && $node instanceof FunctionLike + && $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS) !== null + ) { + $this->found[] = ['node' => $node, 'context' => self::contextLabel($node)]; + } + return null; + } + + public function leaveNode(Node $node): null + { + if ($node instanceof ClassLike && $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS) !== null) { + $this->genericClassDepth--; + } + return null; + } + + private static function contextLabel(FunctionLike $node): string + { + return match (true) { + $node instanceof ClassMethod => 'method `' . $node->name->toString() . '`', + $node instanceof Function_ => 'function `' . $node->name->toString() . '`', + $node instanceof ArrowFunction => 'arrow function', + default => 'closure', + }; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + return $visitor->found; + } + + private static function undeclaredTypeMessage(string $name, string $context): string + { + return sprintf( + 'Type `%s` used in %s is not a declared type parameter and does not resolve to a known class, interface, or trait. Declare it as a type parameter, or import (`use`) / fully-qualify it if it names a real type.', + $name, + $context, + ); + } + + private function collect(ClassLike $node): void + { + // Walks every member of this generic template, including the signatures of + // any generic methods nested in it (so a stray name in `Box{ map(U) }` + // is caught here). Method/function/closure generics declared OUTSIDE a generic + // template (e.g. on a plain class, or a free function) are validated separately + // so the two passes never report the same node twice. + foreach ($node->getProperties() as $property) { + if ($property->type !== null) { + $this->checkType($property->type); + } + } + foreach ($node->getMethods() as $method) { + $this->checkCallable($method); + } + } + + /** + * Check a callable's signature (params + return) and any closures nested in its + * body. Shared by the class-member walk and the standalone method-level pass. + */ + private function checkCallable(FunctionLike $callable): void + { + foreach ($callable->getParams() as $param) { + if ($param->type !== null) { + $this->checkType($param->type); + } + } + $returnType = $callable->getReturnType(); + if ($returnType !== null) { + $this->checkType($returnType); + } + $stmts = $callable->getStmts(); + if ($stmts !== null) { + $this->walkBodyForNestedClosures($stmts); + } + } + + /** + * Walk a body looking for closure / arrow SIGNATURES (params + return types) — + * a generic context's stray type can appear there too. Mirrors + * {@see VariancePositionValidator::walkBodyForNestedClosures}; bodies' other + * expressions (`new X`, etc.) are intentionally not checked here. + */ + private function walkBodyForNestedClosures(mixed $node): void + { + if ($node instanceof Closure || $node instanceof ArrowFunction) { + foreach ($node->getParams() as $param) { + if ($param->type !== null) { + $this->checkType($param->type); + } + } + $returnType = $node->getReturnType(); + if ($returnType !== null) { + $this->checkType($returnType); + } + // Don't stop — a closure body may contain further closures. + } + + if (is_array($node)) { + foreach ($node as $child) { + $this->walkBodyForNestedClosures($child); + } + } elseif ($node instanceof Node) { + foreach ($node->getSubNodeNames() as $subName) { + $this->walkBodyForNestedClosures($node->$subName); + } + } + } + + /** + * Check the type names used in the declared parameters' bounds and defaults + * (which are TypeRef trees, not AST type nodes — they carry the suspect flag on + * the TypeRef itself). All locate at the declaration line; duplicates of the + * same undeclared name (e.g. `` or two params bounded by `Bad`) + * collapse to one finding. + * + * @param list $params + */ + private function collectBoundsAndDefaults(array $params, int $declarationLine): void + { + $seen = []; + foreach ($params as $param) { + if ($param->bound !== null) { + $this->collectSuspectInBound($param->bound, $declarationLine, $seen); + } + if ($param->default !== null) { + $this->collectSuspectInTypeRef($param->default, $declarationLine, $seen); + } + } + } + + /** @param array $seen */ + private function collectSuspectInBound(BoundExpr $bound, int $line, array &$seen): void + { + if ($bound instanceof BoundLeaf) { + $this->collectSuspectInTypeRef($bound->type, $line, $seen); + return; + } + // @infection-ignore-all LogicalOrAllSubExprNegation -- a non-leaf BoundExpr is + // always an Intersection or a Union, so this assert is a phpstan type-narrowing + // tautology; negating its operands still holds. Purely a type guard, not behavior. + assert($bound instanceof BoundIntersection || $bound instanceof BoundUnion); + foreach ($bound->operands as $operand) { + $this->collectSuspectInBound($operand, $line, $seen); + } + } + + /** @param array $seen */ + private function collectSuspectInTypeRef(TypeRef $ref, int $line, array &$seen): void + { + if ($ref->suspectUndeclared && !$this->hierarchy->isDeclared($ref->name) && !isset($seen[$ref->name])) { + // @infection-ignore-all TrueValue -- only the KEY's presence matters (isset above); + // the stored value is never read, so true vs false is observably identical. + $seen[$ref->name] = true; + $this->violations[] = [ + 'message' => self::undeclaredTypeMessage(self::shortName($ref->name), $this->context), + 'line' => $line, + ]; + } + foreach ($ref->args as $inner) { + $this->collectSuspectInTypeRef($inner, $line, $seen); + } + } + + private static function shortName(string $fqn): string + { + $pos = strrpos($fqn, '\\'); + + return $pos === false ? $fqn : substr($fqn, $pos + 1); + } + + private function checkType(Node $type): void + { + if ($type instanceof Name) { + $fqn = $type->getAttribute(XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE); + if (is_string($fqn) && !$this->hierarchy->isDeclared($fqn)) { + $this->violations[] = [ + 'message' => self::undeclaredTypeMessage($type->toString(), $this->context), + 'line' => $type->getStartLine(), + ]; + } + } elseif ($type instanceof NullableType) { + $this->checkType($type->type); + } elseif ($type instanceof UnionType || $type instanceof IntersectionType) { + foreach ($type->types as $inner) { + $this->checkType($inner); + } + } + // Identifier (scalar / pseudo-type) and any other ComplexType: nothing to check. + } +} diff --git a/src/Transpiler/Monomorphize/Variance.php b/src/Transpiler/Monomorphize/Variance.php index bc457f1..4ede913 100644 --- a/src/Transpiler/Monomorphize/Variance.php +++ b/src/Transpiler/Monomorphize/Variance.php @@ -10,16 +10,26 @@ * - `Invariant` (the default, no prefix): T can appear in both input and * output positions; specializations are unrelated unless their args are * identical. - * - `Covariant` (`+T`): T can appear in method return positions only. - * `T1 <: T2` lifts to `Box <: Box`. - * - `Contravariant` (`-T`): T can appear in method parameter positions only. - * `T1 <: T2` lifts to `Box <: Box` (flipped). + * - `Covariant` (`+T`): T can appear in method return positions (and in a + * plain constructor parameter — see below). `T1 <: T2` lifts to + * `Box <: Box`. + * - `Contravariant` (`-T`): T can appear in method parameter positions (and in + * a plain constructor parameter). `T1 <: T2` lifts to `Box <: Box` + * (flipped). * - * Property positions (mutable AND readonly), constructor parameters, bounds, - * and defaults are strict-invariant for both `+T` and `-T` -- PHP enforces - * invariant property types and constructor signature compat across `extends` - * chains regardless of `readonly`, so a covariance allowance there would - * PHP-fatal at autoload when the variance edge emits. + * A **public/protected** property position (mutable AND readonly, including a + * public/protected *promoted* constructor parameter), bounds, and defaults are + * strict-invariant for both `+T` and `-T`: PHP enforces invariant property types + * across `extends` chains regardless of `readonly`, so a covariance allowance + * there would PHP-fatal at autoload when the variance edge emits. A **private** + * property (declared or promoted, mutable or readonly), by contrast, may carry + * any variance: PHP does not type-check private slots across the chain and a + * private slot is invisible to the variance surface, so the real substituted + * type is emitted soundly. A **by-reference** parameter (`T &$x`) is invariant — + * it is read and written back, acting as input and output at once. A plain + * (non-promoted, non-by-ref) constructor parameter may also carry any variance: + * a constructor isn't part of the visible variance surface and PHP exempts + * `__construct` from LSP, so its real type is emitted. * * String-backed so the registry JSON serializes cleanly. */ diff --git a/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php b/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php index b4b824c..d813209 100644 --- a/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php +++ b/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php @@ -17,27 +17,20 @@ * this adds `extends Producer_Fruit_` to the cloned `Producer_Banana` * Class_ node. * - * Three rules per arg-pair `(arg_i, variance_i)`: - * - Invariant: arg1.canonical() == arg2.canonical() - * - Covariant: isNestedSubtype(arg1, arg2) - * - Contravariant: isNestedSubtype(arg2, arg1) - * - * Scalar args are skipped -- there's no PHP-level subtype relationship - * between scalars, so emitting an edge would PHP-fatal at autoload. - * - * `isNestedSubtype` handles both leaf (non-generic) classes and nested - * generic instantiations of the same template (recurses through the inner - * template's variance). Different templates or mixed forms return false - * conservatively -- a wrong edge would PHP-fatal at autoload, while a - * missed edge only loses an `instanceof` relationship the compiler couldn't - * prove. The bound check (`Registry::evaluateBound`) treats null verdicts - * as "reject"; the variance check treats them as "skip edge" for that same - * fatal-vs-missed-edge asymmetry. + * The subtype relationship per arg-pair (invariant: equal; covariant/contravariant: + * nested subtype) is decided by {@see VarianceSubtyping}, shared with the + * specialization closer. A wrong edge would PHP-fatal at autoload, while a missed + * edge only loses an `instanceof` relationship the compiler couldn't prove — so the + * subtype check is conservative (the bound check `Registry::evaluateBound` treats null + * verdicts as "reject"; the variance check treats them as "skip edge" for that same + * fatal-vs-missed-edge asymmetry). * * Edge shape: * - Class_ specialization -> `extends ` (single, since PHP allows - * only one class inheritance). If multiple "direct" supers exist - * (unrelated diamond), the lexicographically-first generated FQN wins + * only one class inheritance), and ONLY when the specialization has no source + * `extends` of its own — a class with a source parent keeps it (single inheritance; + * overwriting would sever the inherited member bodies). If multiple "direct" supers + * exist (unrelated diamond), the lexicographically-first generated FQN wins * deterministically. * - Interface_ specialization -> `extends , , ...` * (multi-target; PHP interfaces support it). @@ -51,8 +44,11 @@ */ final class VarianceEdgeEmitter { - public function __construct(private readonly TypeHierarchy $hierarchy) + private readonly VarianceSubtyping $subtyping; + + public function __construct(TypeHierarchy $hierarchy) { + $this->subtyping = new VarianceSubtyping($hierarchy); } /** @@ -88,7 +84,7 @@ public function emitEdges(array $specializedAsts, Registry $registry): void if ($sp1->generatedFqn === $sp2->generatedFqn) { continue; } - if ($this->isVarianceSubtype( + if ($this->subtyping->isVarianceSubtype( $sp1->concreteTypes, $sp2->concreteTypes, $definition->typeParams, @@ -124,7 +120,7 @@ private function filterDirectSupers(array $candidates, array $params, Registry $ } // sp3 is a more-specific super than sp2 if sp3 <: sp2 -- then // sp2 is reached transitively via sp3, so sp2 isn't direct. - if ($this->isVarianceSubtype( + if ($this->subtyping->isVarianceSubtype( $sp3->concreteTypes, $sp2->concreteTypes, $params, @@ -154,94 +150,6 @@ private static function hasNonInvariantParam(array $params): bool return false; } - /** - * @param list $args1 - * @param list $args2 - * @param list $params - */ - private function isVarianceSubtype( - array $args1, - array $args2, - array $params, - Registry $registry, - ): bool { - if (count($args1) !== count($args2) || count($args1) !== count($params)) { - return false; - } - $sawNonIdentity = false; - foreach ($args1 as $i => $a1) { - $a2 = $args2[$i]; - $variance = $params[$i]->variance; - - if ($a1->canonical() !== $a2->canonical()) { - $sawNonIdentity = true; - } - - // Scalar args never participate in variance edges. - if ($a1->isScalar || $a2->isScalar) { - if ($a1->canonical() !== $a2->canonical()) { - return false; - } - continue; - } - - if ($variance === Variance::Invariant) { - if ($a1->canonical() !== $a2->canonical()) { - return false; - } - continue; - } - if ($variance === Variance::Covariant) { - if (!$this->isNestedSubtype($a1, $a2, $registry)) { - return false; - } - continue; - } - // Contravariant - if (!$this->isNestedSubtype($a2, $a1, $registry)) { - return false; - } - } - // sp1 == sp2 (identical args) is filtered upstream, but defensively: - // require at least one arg to differ before claiming a subtype edge. - return $sawNonIdentity; - } - - /** - * Three-way subtype check tailored for variance edge emission. - * - * - Both non-generic: delegate to TypeHierarchy::isSubtype. - * - Both generic of the SAME template: recurse arg-wise through THAT - * template's variance. This is what makes `Producer>` - * and `Producer>` produce an edge when Box has covariant - * T -- without it, the comparison would flatten to - * `isSubtype('Box', 'Box') == true` and emit a wrong edge for the - * case where the INNER args aren't subtype-related. - * - Otherwise (different templates, or one generic one not): - * conservative false. A wrong edge would PHP-fatal at autoload. - */ - private function isNestedSubtype(TypeRef $child, TypeRef $parent, Registry $registry): bool - { - if (!$child->isGeneric() && !$parent->isGeneric()) { - return $this->hierarchy->isSubtype($child->name, $parent->name) === true; - } - if ($child->isGeneric() && $parent->isGeneric() - && ltrim($child->name, '\\') === ltrim($parent->name, '\\') - ) { - $innerDef = $registry->definition(ltrim($child->name, '\\')); - $innerParams = $innerDef !== null ? $innerDef->typeParams : []; - if ($innerParams === []) { - return false; - } - return $this->isVarianceSubtype( - $child->args, - $parent->args, - $innerParams, - $registry, - ); - } - return false; - } /** * Class_ specializations get a single `extends` (PHP allows one parent class). @@ -267,6 +175,20 @@ private static function addImplementsEdges(ClassLike $ast, array $directSupers): ); if ($ast instanceof Class_) { + // PHP allows a class exactly ONE parent. A specialized class that already carries a source + // `extends` (e.g. `class ListColl<+E> extends AbstractColl` → `ListColl_Book extends + // AbstractColl_Book`) must keep it: that parent carries the inherited member bodies and the + // source-declared `is-a` relationships. A same-template covariant super + // (`ListColl <: ListColl`) cannot ALSO be a direct parent under single + // inheritance, so we do not overwrite — the source parent wins, and the covariant *leaf* + // edge is dropped (a missed `instanceof`, never a fatal; the covariant relationship still + // holds transitively through the parent-less base chain, which is where erased members are + // carried down). Overwriting would sever the source parent and silently drop the inherited + // member — a class-load / undefined-method fatal. See also the specialization closer, which + // hard-fails the rarer case where the dropped edge would itself have carried an erased impl. + if ($ast->extends !== null) { + return; + } $ast->extends = new FullyQualified($directSupers[0]->generatedFqn); return; } diff --git a/src/Transpiler/Monomorphize/VariancePositionValidator.php b/src/Transpiler/Monomorphize/VariancePositionValidator.php index b64788b..3e9b993 100644 --- a/src/Transpiler/Monomorphize/VariancePositionValidator.php +++ b/src/Transpiler/Monomorphize/VariancePositionValidator.php @@ -4,6 +4,7 @@ namespace XPHP\Transpiler\Monomorphize; +use PhpParser\Modifiers; use PhpParser\Node; use PhpParser\Node\ComplexType; use PhpParser\Node\Expr\ArrowFunction; @@ -13,11 +14,16 @@ use PhpParser\Node\Name; use PhpParser\Node\NullableType; use PhpParser\Node\Param; +use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Property; use PhpParser\Node\UnionType; use RuntimeException; +use XPHP\Diagnostics\Diagnostic; +use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\Diagnostics\Severity; +use XPHP\Diagnostics\SourceLocation; /** * Declaration-time check that every appearance of a variance-marked type @@ -25,131 +31,211 @@ * * Position rules (PHP-compat surface): * - * - Property type (mutable OR readonly) -> Invariant only - * - Constructor parameter type -> Invariant only - * - Method/function parameter type -> Invariant or Contravariant - * - Method/function return type -> Invariant or Covariant - * - Bound expression -> Invariant only - * - Default expression -> Invariant only + * - Public/protected property (mutable OR readonly) -> Invariant only + * - Private property (mutable OR readonly) -> any variance + * - Promoted constructor parameter (public/protected) -> Invariant only (it is a visible property) + * - Promoted constructor parameter (private) -> any variance (private property) + * - Non-promoted constructor parameter -> any variance (emitted with real type) + * - Method/function parameter type -> Invariant or Contravariant + * - Method/function return type -> Invariant or Covariant + * - Bound expression -> Invariant only + * - Default expression -> Invariant only * - * Why properties are strict-invariant: PHP enforces invariant property types - * across the `extends` chain regardless of `readonly`. A covariant +T in a - * subtype property declaration would PHP-fatal at autoload when the variance - * edge `Producer_Banana extends Producer_Fruit` lands. The semantic - * argument ("readonly = output-only") doesn't override PHP's static-type - * rule. Users who need a covariant getter use a `mixed`-typed (or - * bound-typed) backing field + a method `get(): T`. + * Why public/protected properties are strict-invariant: PHP enforces invariant + * property types across the `extends` chain regardless of `readonly`. A covariant + * +T in a subtype property declaration would PHP-fatal at autoload when the + * variance edge `Producer_Banana extends Producer_Fruit` lands. The semantic + * argument ("readonly = output-only") doesn't override PHP's static-type rule. * - * Why constructors are strict-invariant: PHP applies LSP signature - * compatibility to `__construct` at autoload time on `extends` chains. - * A covariant param would PHP-fatal -- same shape as the property case. + * Why a PRIVATE property may carry any variance: PHP does NOT type-check private + * property types across an `extends` chain — a private slot is per-declaring-scope + * and is never inherited/overridden, so `Producer_Banana` may declare + * `private Banana $item` while `Producer_Fruit` declares `private Fruit $item` + * with no fatal. It is also invisible to the externally-visible variance surface. + * The Specializer emits the real substituted type there, and each specialization + * re-emits its own field + accessor, so no inherited method ever reads a + * divergent-typed private slot. This is what lets the covariant getter pattern + * `class Producer<+T> { public function __construct(private T $item) {} … }` be + * both real-typed and sound. + * + * Why a non-promoted constructor parameter may carry any variance: a + * constructor isn't part of the externally-visible variance surface (it's + * never reached through an upcast reference, the same reason Kotlin exempts + * constructor parameters), and PHP exempts `__construct` from LSP signature + * checks, so each specialization's constructor may legitimately differ. The + * Specializer emits the real substituted type there -- nothing is erased. A + * *promoted* constructor parameter is a property, so it falls under the property + * rules above: strict-invariant when public/protected, any variance when private. * * F-bounded variance (`class Sortable<+T : Comparable>`) is rejected * because `+T` appears inside its own bound (an invariant position). * * Errors include the param name, variance marker, and the position class * so the user sees what's wrong without reading the implementation. + * + * Runs over collected definitions (`Registry::validateVariancePositions`), not + * in the parser. With a `DiagnosticCollector` it gathers every violation in the + * class (each located at the offending member) and continues; without one it + * throws the first violation — byte-identical to the previous parse-time check. */ final class VariancePositionValidator { + /** Stable diagnostic code for a variance-position violation. */ + public const CODE_VARIANCE_POSITION = 'xphp.variance_position'; + + /** @var array */ + private array $varianceByName; + + /** @var list */ + private array $violations = []; + /** - * @param list $params + * @param array $varianceByName */ - public static function assertPositions(ClassLike $node, array $params): void + private function __construct(array $varianceByName) { + $this->varianceByName = $varianceByName; + } + + /** + * @param list $params + * @return bool True iff at least one violation was found (in check-mode; compile-mode + * throws before returning). Lets the caller skip the inner-variance pass for a + * definition already flagged here, avoiding a double report of the same issue. + */ + public static function assertPositions( + ClassLike $node, + array $params, + ?DiagnosticCollector $diagnostics = null, + ?string $file = null, + ): bool { $varianceByName = []; foreach ($params as $param) { if ($param->variance !== Variance::Invariant) { $varianceByName[$param->name] = $param->variance; } } + // @infection-ignore-all FalseValue -- returning true here (no variance markers) would + // only add this definition to the caller's "flagged" set, which merely skips the + // inner-variance pass for it; but a no-variance definition is already a no-op in + // inner-variance (empty variance map), so flagging it changes nothing observable. if ($varianceByName === []) { - return; + return false; + } + + $validator = new self($varianceByName); + $validator->collect($node, $params); + if ($validator->violations === []) { + return false; + } + + if ($diagnostics === null) { + // Compile-mode: fail fast on the first violation (byte-identical message). + throw new RuntimeException($validator->violations[0]['message']); } - // 1. Bound and default positions are invariant by RFC. Walk each - // param's bound expression + default TypeRef tree; reject if any - // referenced leaf carries a name whose variance isn't Invariant. + foreach ($validator->violations as $violation) { + $location = ($violation['line'] !== null && $file !== null) + ? new SourceLocation($file, $violation['line']) + : null; + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_VARIANCE_POSITION, + $violation['message'], + $location, + )); + } + + return true; + } + + /** + * @param list $params + */ + private function collect(ClassLike $node, array $params): void + { + $declarationLine = $node->getStartLine(); + + // 0. A variant class cannot be `final`. Its specializations participate + // in real `extends` subtype edges, which a `final` class cannot anchor. + // Rejecting it (rather than silently stripping `final` from the generated + // class — which would make ReflectionClass::isFinal lie) keeps source and + // emitted output honest. Only reached for variant definitions, since + // assertPositions early-returns when there are no variance markers. + if ($node instanceof Class_ && $node->isFinal()) { + // One string literal (not concatenated) keeps the message stable. + $this->record( + 'A variant class cannot be declared `final`: its specializations participate in `extends` subtype edges that a `final` class cannot anchor. Remove `final`.', + $declarationLine, + ); + } + + // 1. Bound and default positions are invariant by RFC. foreach ($params as $param) { if ($param->bound !== null) { - self::checkBoundExpr($param->bound, $varianceByName, $param->name, 'bound'); + $this->checkBoundExpr($param->bound, $param->name, 'bound', $declarationLine); } if ($param->default !== null) { - self::checkTypeRef($param->default, $varianceByName, $param->name, 'default'); + $this->checkTypeRef($param->default, $param->name, 'default', $declarationLine); } } // 2. Class-body positions: properties and methods. foreach ($node->getProperties() as $property) { - self::checkProperty($property, $varianceByName); + $this->checkProperty($property); } foreach ($node->getMethods() as $method) { - self::checkMethod($method, $varianceByName); + $this->checkMethod($method); } } - /** - * @param array $varianceByName - */ - private static function checkBoundExpr( - BoundExpr $bound, - array $varianceByName, - string $hostParam, - string $hostPosition, - ): void { + private function checkBoundExpr(BoundExpr $bound, string $hostParam, string $hostPosition, int $line): void + { if ($bound instanceof BoundLeaf) { - self::checkTypeRef($bound->type, $varianceByName, $hostParam, $hostPosition); + $this->checkTypeRef($bound->type, $hostParam, $hostPosition, $line); return; } assert($bound instanceof BoundIntersection || $bound instanceof BoundUnion); foreach ($bound->operands as $operand) { - self::checkBoundExpr($operand, $varianceByName, $hostParam, $hostPosition); + $this->checkBoundExpr($operand, $hostParam, $hostPosition, $line); } } - /** - * @param array $varianceByName - */ - private static function checkTypeRef( - TypeRef $ref, - array $varianceByName, - string $hostParam, - string $hostPosition, - ): void { - if ($ref->isTypeParam && isset($varianceByName[$ref->name])) { - $variance = $varianceByName[$ref->name]; - throw self::violationError( - paramName: $ref->name, - variance: $variance, - position: $hostPosition, - hostParam: $hostParam, + private function checkTypeRef(TypeRef $ref, string $hostParam, string $hostPosition, int $line): void + { + // Direct occurrence only: a bare type-param as a bound/default (`U : T`, `U = T`). A type-param + // NESTED inside a type constructor in a bound/default (`U : Box`) is the composing pass's job + // (its effective variance depends on the inner slot) — descending here with the uncomposed + // position would mis-judge it and double-report against InnerVarianceValidator. + if ($ref->isTypeParam && isset($this->varianceByName[$ref->name])) { + $this->record( + self::violationMessage($ref->name, $this->varianceByName[$ref->name], $hostPosition, $hostParam), + $line, ); } - foreach ($ref->args as $inner) { - self::checkTypeRef($inner, $varianceByName, $hostParam, $hostPosition); - } } - /** - * @param array $varianceByName - */ - private static function checkProperty(Property $property, array $varianceByName): void + private function checkProperty(Property $property): void { $type = $property->type; if ($type === null) { return; } - // PHP enforces invariant property types across `extends` chains - // regardless of `readonly`. Even +T on a readonly property would + // A private property is exempt: PHP does not type-check private property + // types across an `extends` chain (the slot is per-declaring-scope, never + // inherited), and it is invisible to the variance surface. So a private + // `T` property may carry any variance — like a non-promoted ctor param. + if ($property->isPrivate()) { + return; + } + // Public/protected: PHP enforces invariant property types across `extends` + // chains regardless of `readonly`. Even +T on a readonly property would // PHP-fatal at autoload when the variance edge lands. $position = $property->isReadonly() ? 'readonly property' : 'mutable property'; - self::checkPhpType($type, $varianceByName, [Variance::Invariant], $position); + $this->checkPhpType($type, [Variance::Invariant], $position); } - /** - * @param array $varianceByName - */ - private static function checkMethod(ClassMethod $method, array $varianceByName): void + private function checkMethod(ClassMethod $method): void { $name = $method->name->toLowerString(); $isConstructor = $name === '__construct'; @@ -160,22 +246,51 @@ private static function checkMethod(ClassMethod $method, array $varianceByName): ? [Variance::Invariant] : [Variance::Invariant, Variance::Contravariant]; $paramPosition = $isConstructor ? 'constructor parameter' : 'method parameter'; + // A variant class (≥1 covariant/contravariant type-param) may carry its + // type-param in a constructor parameter at any variance UNLESS the param is + // a *visible* (public/protected) promoted property. A constructor parameter + // is not part of the externally-visible variance surface (you can't call a + // constructor through an upcast reference), and PHP exempts `__construct` + // from LSP, so the specializer emits the real substituted type there with no + // soundness or autoload hazard. A *promoted* constructor param is also a + // property: a public/protected one stays strictly invariant (it would + // PHP-fatal across the chain), but a *private* promoted property is exempt + // (PHP doesn't type-check private slots across the chain). + $classIsVariant = $this->varianceByName !== []; foreach ($method->params as $param) { // @phpstan-ignore-next-line instanceof.alwaysTrue — defensive guard against nikic/php-parser PHPDoc-narrowed param collection element. if (!$param instanceof Param) { continue; } - if ($param->type !== null) { - self::checkPhpType($param->type, $varianceByName, $paramAllowed, $paramPosition); + if ($param->type === null) { + continue; + } + // A by-reference parameter is read AND written back through the + // caller's variable, so it's an invariant position regardless of + // method vs constructor — neither +T nor -T is sound there. Checked + // before the variant-constructor any-variance branch so `-T &$x` in + // a constructor is rejected too. + if ($param->byRef) { + $this->checkPhpType($param->type, [Variance::Invariant], 'by-reference parameter'); + continue; } + $isPromoted = $param->flags !== 0; + // A *visible* promoted property is public or protected: it carries a + // visibility bit other than PRIVATE. Detect via the PRIVATE bit, not the + // mere absence of a bit — a `readonly`-only promoted param has flags with + // no visibility bit and is implicitly public, so it must stay invariant. + $isVisibleProperty = $isPromoted && ($param->flags & Modifiers::PRIVATE) === 0; + $allowed = ($isConstructor && !$isVisibleProperty && $classIsVariant) + ? [Variance::Invariant, Variance::Covariant, Variance::Contravariant] + : $paramAllowed; + $this->checkPhpType($param->type, $allowed, $paramPosition); } // Return type. Constructors don't have one; for the rest, invariant // or covariant. if (!$isConstructor && $method->returnType !== null) { - self::checkPhpType( + $this->checkPhpType( $method->returnType, - $varianceByName, [Variance::Invariant, Variance::Covariant], 'method return', ); @@ -190,7 +305,7 @@ private static function checkMethod(ClassMethod $method, array $varianceByName): // then nested closures have no type-params and every name in their // signature is an outer reference. if ($method->stmts !== null) { - self::walkBodyForNestedClosures($method->stmts, $varianceByName); + $this->walkBodyForNestedClosures($method->stmts); } } @@ -201,27 +316,28 @@ private static function checkMethod(ClassMethod $method, array $varianceByName): * * Cheap hand-rolled recursive walk -- avoids spinning up a NodeTraverser * inside the per-class validator hot path. - * - * @param array $varianceByName */ - private static function walkBodyForNestedClosures(mixed $node, array $varianceByName): void + private function walkBodyForNestedClosures(mixed $node): void { if ($node instanceof Closure || $node instanceof ArrowFunction) { foreach ($node->params as $param) { // @phpstan-ignore-next-line instanceof.alwaysTrue — defensive guard against nikic/php-parser PHPDoc-narrowed param collection element. if ($param instanceof Param && $param->type !== null) { - self::checkPhpType( + // A by-reference param is an invariant position (read + written + // back), even inside a nested closure/arrow. + $allowed = $param->byRef + ? [Variance::Invariant] + : [Variance::Invariant, Variance::Contravariant]; + $this->checkPhpType( $param->type, - $varianceByName, - [Variance::Invariant, Variance::Contravariant], - 'nested closure/arrow parameter', + $allowed, + $param->byRef ? 'by-reference parameter' : 'nested closure/arrow parameter', ); } } if ($node->returnType !== null) { - self::checkPhpType( + $this->checkPhpType( $node->returnType, - $varianceByName, [Variance::Invariant, Variance::Covariant], 'nested closure/arrow return', ); @@ -231,13 +347,13 @@ private static function walkBodyForNestedClosures(mixed $node, array $varianceBy if (is_array($node)) { foreach ($node as $child) { - self::walkBodyForNestedClosures($child, $varianceByName); + $this->walkBodyForNestedClosures($child); } return; } if ($node instanceof Node) { foreach ($node->getSubNodeNames() as $subName) { - self::walkBodyForNestedClosures($node->$subName, $varianceByName); + $this->walkBodyForNestedClosures($node->$subName); } } } @@ -247,15 +363,10 @@ private static function walkBodyForNestedClosures(mixed $node, array $varianceBy * UnionType / IntersectionType), checking every Name's parts against * the variance map. * - * @param array $varianceByName * @param list $allowed */ - private static function checkPhpType( - Node $type, - array $varianceByName, - array $allowed, - string $position, - ): void { + private function checkPhpType(Node $type, array $allowed, string $position): void + { if ($type instanceof Identifier) { return; // scalar / pseudo type; never a type-param ref. } @@ -263,38 +374,31 @@ private static function checkPhpType( $parts = $type->getParts(); if (count($parts) === 1) { $name = $parts[0]; - if (isset($varianceByName[$name])) { - $variance = $varianceByName[$name]; + if (isset($this->varianceByName[$name])) { + $variance = $this->varianceByName[$name]; if (!in_array($variance, $allowed, true)) { - throw self::violationError( - paramName: $name, - variance: $variance, - position: $position, - hostParam: null, + $this->record( + self::violationMessage($name, $variance, $position, null), + $type->getStartLine(), ); } } } - // Generic args attached via xphp:genericArgs are TypeRef trees; - // recurse into them so `Box` in a parameter position is - // checked too. - $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); - if (is_array($args)) { - foreach ($args as $arg) { - if ($arg instanceof TypeRef) { - self::checkInnerTypeRef($arg, $varianceByName, $allowed, $position); - } - } - } + // A type-param NESTED inside a type constructor (`Box`, `Comparator`) is NOT judged + // here: its effective variance is the composition of this position with the referenced + // type's slot variance, which only InnerVarianceValidator resolves. Descending with this + // (uncomposed) position would wrongly reject a sound `Comparator` on a covariant `+E` + // (contra ∘ contra = covariant) and wrongly pass an unsound one. This validator owns only + // DIRECT occurrences; the composing pass owns the nested ones. return; } if ($type instanceof NullableType) { - self::checkPhpType($type->type, $varianceByName, $allowed, $position); + $this->checkPhpType($type->type, $allowed, $position); return; } if ($type instanceof UnionType || $type instanceof IntersectionType) { foreach ($type->types as $inner) { - self::checkPhpType($inner, $varianceByName, $allowed, $position); + $this->checkPhpType($inner, $allowed, $position); } return; } @@ -303,38 +407,17 @@ private static function checkPhpType( } } - /** - * @param array $varianceByName - * @param list $allowed - */ - private static function checkInnerTypeRef( - TypeRef $ref, - array $varianceByName, - array $allowed, - string $position, - ): void { - if ($ref->isTypeParam && isset($varianceByName[$ref->name])) { - $variance = $varianceByName[$ref->name]; - if (!in_array($variance, $allowed, true)) { - throw self::violationError( - paramName: $ref->name, - variance: $variance, - position: $position, - hostParam: null, - ); - } - } - foreach ($ref->args as $inner) { - self::checkInnerTypeRef($inner, $varianceByName, $allowed, $position); - } + private function record(string $message, ?int $line): void + { + $this->violations[] = ['message' => $message, 'line' => $line]; } - private static function violationError( + private static function violationMessage( string $paramName, Variance $variance, string $position, ?string $hostParam, - ): RuntimeException { + ): string { $marker = match ($variance) { Variance::Covariant => '+', Variance::Contravariant => '-', @@ -343,12 +426,12 @@ private static function violationError( $context = $hostParam !== null ? sprintf(' inside the %s of generic parameter `%s`', $position, $hostParam) : sprintf(' in %s position', $position); - return new RuntimeException(sprintf( + return sprintf( 'Generic parameter `%s%s` appears%s, which is not allowed for %s variance.', $marker, $paramName, $context, $variance->value, - )); + ); } } diff --git a/src/Transpiler/Monomorphize/VarianceSubtyping.php b/src/Transpiler/Monomorphize/VarianceSubtyping.php new file mode 100644 index 0000000..1c5de1f --- /dev/null +++ b/src/Transpiler/Monomorphize/VarianceSubtyping.php @@ -0,0 +1,155 @@ + $args1 + * @param list $args2 + * @param list $params + */ + public function isVarianceSubtype(array $args1, array $args2, array $params, Registry $registry): bool + { + if (count($args1) !== count($args2) || count($args1) !== count($params)) { + return false; + } + $sawNonIdentity = false; + foreach ($args1 as $i => $a1) { + $a2 = $args2[$i]; + $variance = $params[$i]->variance; + + if ($a1->canonical() !== $a2->canonical()) { + $sawNonIdentity = true; + } + + // Scalar args never participate in variance edges. + // @infection-ignore-all LogicalOr -- `||` vs `&&` are equivalent here: a scalar is never a + // subtype of a class (and two scalars relate only by canonical equality), so whether ONE or + // BOTH args are scalar, the canonical-equality check below yields the same verdict. The + // mixed scalar/class case (one scalar) is rejected by both forms. + if ($a1->isScalar || $a2->isScalar) { + if ($a1->canonical() !== $a2->canonical()) { + return false; + } + continue; + } + + if ($variance === Variance::Invariant) { + if ($a1->canonical() !== $a2->canonical()) { + return false; + } + continue; + } + if ($variance === Variance::Covariant) { + if (!$this->isNestedSubtype($a1, $a2, $registry)) { + return false; + } + continue; + } + // Contravariant + if (!$this->isNestedSubtype($a2, $a1, $registry)) { + return false; + } + } + // Identical args (`sp1 == sp2`) is filtered upstream, but defensively require at least one + // differing arg before claiming a subtype edge. + return $sawNonIdentity; + } + + /** + * Three-way subtype check tailored for variance reasoning. + * + * - Both non-generic: delegate to {@see TypeHierarchy::isSubtype}. + * - Both generic of the SAME template: recurse arg-wise through THAT template's variance, so + * `Producer>` and `Producer>` relate when Box has covariant T — without + * it, the comparison would flatten to `isSubtype('Box', 'Box') == true` and claim a relationship + * even when the INNER args aren't subtype-related. + * - Both generic of DIFFERENT templates: if `$child`'s template provably implements/extends + * `$parent`'s, thread `$child`'s args up to `$parent`'s template and recurse under `$parent`'s + * OWN variance — so a covariant container nested as a type-argument relates + * (`ImmutableList` as `Collection` → `ImmutableList implements Collection`, thread + * to `Collection`, then `Book ⊑ Product` under `Collection`'s covariant `E`). Stays + * conservative (no edge) unless the relationship is POSITIVELY grounded. + * - Otherwise (one generic, one not): conservative false. + * + * Trust model — the fatal-vs-missed-edge asymmetry: a wrong "yes" emits a bogus `implements` edge + * that PHP-fatals at autoload, so emit only on a positive `isSubtype(...) === true` AND a threaded + * arg tuple the recursion's arity guard ({@see isVarianceSubtype}'s `count()` check) accepts — + * `resolveInheritedArgs` can return a NON-null but wrong-arity tuple (a bare or over-supplied + * parameterized super), so that recursion is load-bearing, not inert reuse. A missed "yes" only + * loses a relationship the compiler couldn't positively prove. + * + * @infection-ignore-all UnwrapLtrim -- the `ltrim('\\')` calls are no-ops: registry/canonical names + * never carry a leading backslash (the same defensive the hierarchy collector documents), so + * unwrapping them is equivalent. The meaningful guards — `isSubtype(...) === true`, the same- vs + * different-template routing, and the inner recursion — stay mutation-covered by VarianceSubtypingTest. + */ + private function isNestedSubtype(TypeRef $child, TypeRef $parent, Registry $registry): bool + { + if (!$child->isGeneric() && !$parent->isGeneric()) { + return $this->hierarchy->isSubtype($child->name, $parent->name) === true; + } + $childName = ltrim($child->name, '\\'); + $parentName = ltrim($parent->name, '\\'); + if ($child->isGeneric() && $parent->isGeneric() && $childName === $parentName) { + $innerDef = $registry->definition($childName); + $innerParams = $innerDef !== null ? $innerDef->typeParams : []; + // @infection-ignore-all ReturnRemoval -- equivalent: removing this early return falls + // through to isVarianceSubtype() with empty params, whose arity guard + // (count(args) !== count(params)) returns false for the same non-empty args. Defensive. + if ($innerParams === []) { + return false; + } + return $this->isVarianceSubtype( + $child->args, + $parent->args, + $innerParams, + $registry, + ); + } + // Different-template generics: emit only if child's template provably implements/extends + // parent's. Thread child's args up to parent's template (the same helper SpecializationCloser + // uses) and recurse under parent's OWN params. resolveInheritedArgs may return a non-null + // wrong-arity tuple, so the recursion's count() guard is what rejects the malformed case -- never + // trust $threaded for being merely non-null. + if ($child->isGeneric() && $parent->isGeneric() + && $this->hierarchy->isSubtype($childName, $parentName) === true + ) { + $threaded = $this->hierarchy->resolveInheritedArgs($childName, $child->args, $parentName); + $parentDef = $registry->definition($parentName); + if ($threaded !== null && $parentDef !== null && $parentDef->typeParams !== []) { + return $this->isVarianceSubtype($threaded, $parent->args, $parentDef->typeParams, $registry); + } + } + return false; + } +} diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index a1d9157..3ca3597 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -76,6 +76,14 @@ final class XphpSourceParser public const ATTR_METHOD_GENERIC_PARAMS = 'xphp:methodGenericParams'; public const ATTR_METHOD_GENERIC_ARGS = 'xphp:methodGenericArgs'; + // A bare, single-segment, non-imported class-name used inside a generic context + // (a template or generic method/function/closure) that is NOT a declared type + // parameter. Carries the resolved FQN. The undeclared-type-parameter validator + // flags it when the FQN resolves to no declared type — catching `Foo` whose + // member uses an undeclared `T`. Imported / fully-qualified names are never + // tagged (the escape hatch). Advisory metadata only — not emitted. + public const ATTR_SUSPECT_UNDECLARED_TYPE = 'xphp:suspectUndeclaredType'; + public const SCALAR_TYPES = [ 'int', 'integer', 'string', 'bool', 'boolean', 'float', 'double', 'void', 'mixed', 'never', 'null', 'false', 'true', @@ -603,9 +611,12 @@ private static function parseTypeParamList( if ($i < $n && ($tokens[$i]->text === '+' || $tokens[$i]->text === '-')) { if (!$allowVariance) { throw new RuntimeException( - '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 `+T` / `-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 = $tokens[$i]->text === '+' @@ -1653,10 +1664,22 @@ private function markName(Name $node): void if (!$this->shouldQualify($node)) { return; } - $node->setAttribute( - XphpSourceParser::ATTR_RESOLVED_FQN, - $this->ctx->resolveAgainstContext($node->toString()), - ); + $name = $node->toString(); + $resolved = $this->ctx->resolveAgainstContext($name); + $node->setAttribute(XphpSourceParser::ATTR_RESOLVED_FQN, $resolved); + + // Flag a bare, single-segment, non-imported class reference used inside a + // generic context. shouldQualify() already excluded declared type-params, + // scalars, FQ names, and generic-arg-bearing names, so what's left is either + // a real (in-project / built-in) type or a stray/undeclared type parameter + // like the `T` in `interface Foo { add(T $x): void; }`. The validator + // resolves which using the declared-set; here we only record the suspicion. + if (count($node->getParts()) === 1 + && $this->hasEnclosingTypeParams() + && !$this->ctx->isImported($name) + ) { + $node->setAttribute(XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE, $resolved); + } } /** @@ -1751,11 +1774,20 @@ private function buildDefault(array $entry): ?TypeRef private function buildBoundExprNode(array $node): BoundExpr { if ($node['kind'] === 'leaf') { + $resolvedArgs = $this->resolveTypeRefList($node['args']); + // A bound that is a bare enclosing type parameter (`U : E`, or `B : A` over an + // earlier param) is kept as an `isTypeParam` TypeRef -- mirroring resolveTypeRef + // -- so the call site can ground it against the receiver's concrete argument + // rather than treating `E` as a phantom class name. + if (!$node['isFq'] && $this->isEnclosingTypeParam($node['name'])) { + return new BoundLeaf(new TypeRef($node['name'], $resolvedArgs, isTypeParam: true)); + } $fqn = $node['isFq'] ? $node['name'] : $this->resolveNameOnly($node['name']); - $resolvedArgs = $this->resolveTypeRefList($node['args']); - return new BoundLeaf(new TypeRef($fqn, $resolvedArgs)); + $suspect = !$node['isFq'] + && $this->isSuspectUndeclared($node['name']); + return new BoundLeaf(new TypeRef($fqn, $resolvedArgs, suspectUndeclared: $suspect)); } $operands = []; foreach ($node['operands'] as $op) { @@ -1770,22 +1802,10 @@ private function buildBoundExprNode(array $node): BoundExpr public function leaveNode(Node $node): null { - // Variance position check fires once per class definition, - // AFTER the body has been fully resolved -- so any Name nodes - // inside the body that carry nested ATTR_GENERIC_ARGS are - // visible to the validator. Rejects covariant T in input - // position, contravariant T in output, either in - // bound/default/property/constructor positions, and - // F-bounded variance (`+T : Box`). - if ($node instanceof ClassLike && $node->name !== null) { - $params = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); - if (is_array($params)) { - /** @var list $params — set as a list by XphpSourceParser::resolveAndAttach. */ - if ($params !== []) { - VariancePositionValidator::assertPositions($node, $params); - } - } - } + // NB: variance-position validation no longer runs here. It moved to a + // Registry validation phase (`validateVariancePositions`) that runs over + // collected definitions, so `xphp check` can collect every variance error + // across all files in one run instead of aborting at the first parse. // @infection-ignore-all -- the instanceof chain mirrors enterNode's push; // restructuring `||` as `&&` produces a leaveNode that no longer pops the // stack for any node, but the test suite's AST shapes never re-use the @@ -1826,7 +1846,27 @@ private function resolveTypeRef(TypeRef $ref): TypeRef return new TypeRef($lower, $resolvedArgs, isScalar: true); } - return new TypeRef($this->ctx->resolveAgainstContext($name), $resolvedArgs); + return new TypeRef( + $this->ctx->resolveAgainstContext($name), + $resolvedArgs, + suspectUndeclared: $this->isSuspectUndeclared($name), + ); + } + + /** + * A bare, single-segment, non-imported class name used inside a generic + * context — the suspect condition shared by the bound/default TypeRef path + * and {@see markName}'s ATTR_SUSPECT_UNDECLARED_TYPE tag. Callers have + * already excluded scalars and enclosing type-params. + */ + private function isSuspectUndeclared(string $name): bool + { + // @infection-ignore-all UnwrapStrToLower -- scalar-type tokens already arrive + // lowercased from the grammar (same rationale as resolveTypeRef's scalar guard). + return strpos($name, '\\') === false + && !in_array(strtolower($name), XphpSourceParser::SCALAR_TYPES, true) + && $this->hasEnclosingTypeParams() + && !$this->ctx->isImported($name); } private function isEnclosingTypeParam(string $name): bool @@ -1838,6 +1878,22 @@ private function isEnclosingTypeParam(string $name): bool } return false; } + + /** + * True when some enclosing scope declares type parameters — i.e. we're + * inside a generic template or a generic method/function/closure. Every + * class/method pushes a frame (empty for non-generic ones), so this asks + * whether any frame is non-empty rather than whether the stack is non-empty. + */ + private function hasEnclosingTypeParams(): bool + { + foreach ($this->typeParamStack as $scope) { + if ($scope !== []) { + return true; + } + } + return false; + } }); $traverser->traverse($ast); diff --git a/test/Config/ManifestParserTest.php b/test/Config/ManifestParserTest.php new file mode 100644 index 0000000..f2bbb3a --- /dev/null +++ b/test/Config/ManifestParserTest.php @@ -0,0 +1,106 @@ +parser = new ManifestParser(); + } + + public function testParsesAFullManifest(): void + { + $m = $this->parser->parse('{"sources":["src","tests"],"include":["vendor/*/*"],"target":"dist","cache":".xphp-cache"}'); + + self::assertSame(['src', 'tests'], $m->sources); + self::assertSame(['vendor/*/*'], $m->include); + self::assertSame('dist', $m->target); + self::assertSame('.xphp-cache', $m->cache); + } + + public function testEmptyObjectAppliesDefaults(): void + { + $m = $this->parser->parse('{}'); + + self::assertSame(['.'], $m->sources, 'sources defaults to the manifest dir'); + self::assertSame([], $m->include); + self::assertNull($m->target); + self::assertNull($m->cache); + } + + public function testOmittedSourcesAndIncludeUseDefaultsWhileOthersSet(): void + { + $m = $this->parser->parse('{"target":"out"}'); + + self::assertSame(['.'], $m->sources); + self::assertSame([], $m->include); + self::assertSame('out', $m->target); + self::assertNull($m->cache); + } + + public function testUnknownKeysAreIgnored(): void + { + $m = $this->parser->parse('{"sources":["src"],"future":{"x":1},"extra":[1,2]}'); + + self::assertSame(['src'], $m->sources); + self::assertSame([], $m->include); + } + + public function testMalformedJsonThrows(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid xphp.json'); + $this->parser->parse('{"sources": ['); + } + + public function testTopLevelArrayIsRejected(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('top level must be a JSON object'); + $this->parser->parse('["src"]'); + } + + public function testNonArraySourcesIsRejected(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"sources" must be an array of strings'); + $this->parser->parse('{"sources":"src"}'); + } + + public function testAssociativeSourcesIsRejected(): void + { + // A JSON object (non-list) for a list field is rejected. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"include" must be an array of strings'); + $this->parser->parse('{"include":{"key":"a"}}'); + } + + public function testNonStringEntryInListIsRejected(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"sources" must contain only strings'); + $this->parser->parse('{"sources":["ok",3]}'); + } + + public function testNonStringTargetIsRejected(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"target" must be a string'); + $this->parser->parse('{"target":123}'); + } + + public function testCustomLabelAppearsInErrors(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid /pkg/xphp.json'); + $this->parser->parse('{nope', '/pkg/xphp.json'); + } +} diff --git a/test/Config/ManifestResolverTest.php b/test/Config/ManifestResolverTest.php new file mode 100644 index 0000000..de0fb2a --- /dev/null +++ b/test/Config/ManifestResolverTest.php @@ -0,0 +1,273 @@ +work = sys_get_temp_dir() . '/xphp-manifest-' . uniqid('', true); + mkdir($this->work, 0o755, true); + } + + protected function tearDown(): void + { + self::rrmdir($this->work); + } + + public function testResolvesOwnSources(): void + { + $this->pkg('app', '{"sources":["src"]}', ['src/A.xphp', 'src/Sub/B.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['A.xphp', 'B.xphp'], $this->basenames($r)); + self::assertNull($r->target); + self::assertNull($r->cache); + } + + public function testOmittedSourcesDefaultsToManifestDir(): void + { + $this->pkg('app', '{}', ['Root.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['Root.xphp'], $this->basenames($r)); + // The root for that file is the manifest dir itself. + self::assertSame($this->work . '/app', array_values($r->rootByFile)[0]); + } + + public function testTargetAndCacheAreAbsolutisedFromEntryDir(): void + { + $this->pkg('app', '{"sources":["src"],"target":"dist","cache":".xphp-cache"}', ['src/A.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame($this->work . '/app/dist', $r->target); + self::assertSame($this->work . '/app/.xphp-cache', $r->cache); + } + + public function testCrossPackageExplicitInclude(): void + { + $this->pkg('app', '{"sources":["src"],"include":["../lib"]}', ['src/Use.xphp']); + $this->pkg('lib', '{"sources":["src"]}', ['src/Box.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['Box.xphp', 'Use.xphp'], $this->basenames($r)); + } + + public function testGlobDiscoverySkippingNonXphpDirs(): void + { + $this->pkg('app', '{"sources":["src"],"include":["vendor/*/*"]}', ['src/Use.xphp']); + // A plain (non-xphp) package with no manifest, named to sort FIRST among the glob matches — + // it must be skipped (not error, not short-circuit the later xphp packages). + $this->pkg('app/vendor/org/0plain', null, ['src/ignored.php']); + $this->pkg('app/vendor/org/pkga', '{"sources":["src"]}', ['src/A.xphp']); + $this->pkg('app/vendor/org/pkgb', '{"sources":["src"]}', ['src/B.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['A.xphp', 'B.xphp', 'Use.xphp'], $this->basenames($r)); + } + + public function testGlobMatchingNoDirsIsACleanNoOp(): void + { + // A vendor glob that matches nothing (no packages installed yet) is not an error. + $this->pkg('app', '{"sources":["src"],"include":["vendor/*/*"]}', ['src/Use.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['Use.xphp'], $this->basenames($r)); + } + + public function testAbsolutePathIncludeIsResolved(): void + { + $this->pkg('lib', '{"sources":["src"]}', ['src/Box.xphp']); + // An absolute include path is used as-is (not joined onto the manifest dir). + $this->pkg('app', sprintf('{"sources":["src"],"include":["%s"]}', $this->work . '/lib'), ['src/Use.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['Box.xphp', 'Use.xphp'], $this->basenames($r)); + } + + public function testRecursiveGlobDiscoversPackagesAtAnyDepth(): void + { + // `**` finds every dir with an xphp.json under the prefix, at any depth, skipping the rest. + // app's own `sources` is an explicit `src` (NOT the default ".", which would recursively + // grab packages/*.xphp directly) — so A/B are reachable ONLY through the `**` discovery. + $this->pkg('app', '{"sources":["src"],"include":["packages/**"]}', ['src/Root.xphp']); + $this->pkg('app/packages/a', '{"sources":["src"]}', ['src/A.xphp']); + $this->pkg('app/packages/nested/deep/b', '{"sources":["src"]}', ['src/B.xphp']); + mkdir($this->work . '/app/packages/plain', 0o755, true); // no xphp.json → not discovered + // A non-manifest file at depth must NOT be mistaken for a package. + file_put_contents($this->work . '/app/packages/notes.txt', 'x'); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['A.xphp', 'B.xphp', 'Root.xphp'], $this->basenames($r)); + } + + public function testRecursiveGlobOverMissingDirIsACleanNoOp(): void + { + $this->pkg('app', '{"sources":["src"],"include":["packages/**"]}', ['src/Use.xphp']); + // No `packages/` dir exists at all. + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['Use.xphp'], $this->basenames($r)); + } + + public function testExplicitIncludeWithoutManifestIsHardError(): void + { + $this->pkg('app', '{"include":["../nope"]}', []); + mkdir($this->work . '/nope', 0o755, true); // exists, but no xphp.json + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('has no xphp.json'); + $this->resolve($this->work . '/app'); + } + + public function testTransitiveAcrossThreePackages(): void + { + $this->pkg('a', '{"sources":["src"],"include":["../b"]}', ['src/A.xphp']); + $this->pkg('b', '{"sources":["src"],"include":["../c"]}', ['src/B.xphp']); + $this->pkg('c', '{"sources":["src"]}', ['src/C.xphp']); + + $r = $this->resolve($this->work . '/a'); + + self::assertSame(['A.xphp', 'B.xphp', 'C.xphp'], $this->basenames($r)); + } + + public function testDiamondResolvesSharedDependencyOnce(): void + { + $this->pkg('a', '{"include":["../b","../c"]}', []); + $this->pkg('b', '{"sources":["src"],"include":["../d"]}', ['src/B.xphp']); + $this->pkg('c', '{"sources":["src"],"include":["../d"]}', ['src/C.xphp']); + $this->pkg('d', '{"sources":["src"]}', ['src/D.xphp']); + + $r = $this->resolve($this->work . '/a'); + + // D appears exactly once despite two include paths. + self::assertSame(1, count(array_filter($this->basenames($r), static fn (string $b): bool => $b === 'D.xphp'))); + self::assertSame(['B.xphp', 'C.xphp', 'D.xphp'], $this->basenames($r)); + } + + public function testCycleTerminates(): void + { + $this->pkg('a', '{"sources":["src"],"include":["../b"]}', ['src/A.xphp']); + $this->pkg('b', '{"sources":["src"],"include":["../a"]}', ['src/B.xphp']); + + $r = $this->resolve($this->work . '/a'); // must not hang + + self::assertSame(['A.xphp', 'B.xphp'], $this->basenames($r)); + } + + public function testSelfIncludeViaSeparateEntryManifest(): void + { + // Dev-profile idiom: a separate entry manifest pulls the package's own xphp.json (its `src`) + // via include ["."], and adds `tests`. + mkdir($this->work . '/app/src', 0o755, true); + mkdir($this->work . '/app/tests', 0o755, true); + file_put_contents($this->work . '/app/xphp.json', '{"sources":["src"]}'); + file_put_contents($this->work . '/app/xphp.dev.json', '{"sources":["tests"],"include":["."]}'); + file_put_contents($this->work . '/app/src/Lib.xphp', 'work . '/app/tests/LibTest.xphp', 'resolve($this->work . '/app/xphp.dev.json'); + + self::assertSame(['Lib.xphp', 'LibTest.xphp'], $this->basenames($r)); + } + + public function testMissingSourceDirIsHardError(): void + { + $this->pkg('app', '{"sources":["nope"]}', []); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is not a directory'); + $this->resolve($this->work . '/app'); + } + + public function testNoManifestInDirIsHardError(): void + { + mkdir($this->work . '/empty', 0o755, true); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No xphp.json found'); + $this->resolve($this->work . '/empty'); + } + + public function testNonexistentEntryPathIsHardError(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not exist'); + $this->resolve($this->work . '/ghost'); + } + + public function testEntryMayBeAnExplicitManifestFile(): void + { + $this->pkg('app', '{"sources":["src"]}', ['src/A.xphp']); + + $r = $this->resolve($this->work . '/app/xphp.json'); + + self::assertSame(['A.xphp'], $this->basenames($r)); + } + + // --- helpers --- + + /** @param array $files relative path (or content-keyed) => content */ + private function pkg(string $rel, ?string $manifestJson, array $files): void + { + $dir = $this->work . '/' . $rel; + mkdir($dir, 0o755, true); + if ($manifestJson !== null) { + file_put_contents($dir . '/xphp.json', $manifestJson); + } + foreach ($files as $f) { + $path = $dir . '/' . $f; + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0o755, true); + } + file_put_contents($path, 'resolve($entry); + } + + /** @return list sorted basenames of the resolved files */ + private function basenames(ResolvedSources $r): array + { + $names = array_map(static fn (string $p): string => basename($p), $r->files->filepaths); + sort($names); + + return array_values($names); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $e) { + if ($e === '.' || $e === '..') { + continue; + } + $p = $dir . '/' . $e; + is_dir($p) ? self::rrmdir($p) : unlink($p); + } + rmdir($dir); + } +} diff --git a/test/Console/CheckCommandPhpStanTest.php b/test/Console/CheckCommandPhpStanTest.php new file mode 100644 index 0000000..cc9c346 --- /dev/null +++ b/test/Console/CheckCommandPhpStanTest.php @@ -0,0 +1,189 @@ +bin = $bin; + } + + public function testReportsBodyTypeErrorAtTheTemplateDeclaration(): void + { + $tester = $this->tester(); + $exit = $tester->execute([ + 'source' => $this->fixtureDir('body_type_error'), + '--phpstan-bin' => $this->bin, + '--phpstan-config' => $this->level5Config(), + '--format' => 'json', + ]); + + self::assertSame(1, $exit); + + /** @var array{diagnostics: list} $decoded */ + $decoded = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR); + + $phpstan = array_values(array_filter( + $decoded['diagnostics'], + static fn (array $d): bool => $d['source'] === 'phpstan', + )); + self::assertNotEmpty($phpstan, 'expected at least one phpstan diagnostic'); + + $boxError = array_values(array_filter( + $phpstan, + static fn (array $d): bool => str_contains($d['message'], 'should return int but returns string'), + )); + self::assertCount(1, $boxError); + self::assertStringEndsWith('Box.xphp', $boxError[0]['file'] ?? ''); + self::assertSame(7, $boxError[0]['line']); + self::assertStringStartsWith('phpstan.', $boxError[0]['code']); + } + + public function testCovariantPrivatePropertyGetterIsPhpStanClean(): void + { + // A covariant single-value container that stores its element in a real-typed + // `private T` property emits a `get(): Banana` over a `private Banana $item` + // field — which PHPStan can prove. Unlike an `array`-backed collection (whose + // getter returns `mixed` and trips the pass), this shape is fully clean. Run + // with the same level-5 config that catches the analogous `returns mixed` + // error, so a clean result is a real proof, not a too-lax level. + $tester = $this->tester(); + $exit = $tester->execute([ + 'source' => $this->privatePropertyFixtureDir(), + '--phpstan-bin' => $this->bin, + '--phpstan-config' => $this->level5Config(), + '--format' => 'json', + ]); + + /** @var array{diagnostics: list} $decoded */ + $decoded = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR); + $phpstan = array_values(array_filter( + $decoded['diagnostics'], + static fn (array $d): bool => $d['source'] === 'phpstan', + )); + + self::assertSame([], $phpstan, 'private-T covariant getter must produce no PHPStan diagnostics'); + self::assertSame(0, $exit); + } + + public function testNoPhpstanFlagSkipsThePassAndStaysClean(): void + { + $tester = $this->tester(); + $exit = $tester->execute([ + 'source' => $this->fixtureDir('body_type_error'), + '--no-phpstan' => true, + ]); + + self::assertSame(0, $exit); + self::assertStringContainsString('No problems found', $tester->getDisplay()); + } + + public function testMissingBinaryWarnsButDoesNotFailTheGate(): void + { + $tester = $this->tester(); + $exit = $tester->execute([ + 'source' => $this->fixtureDir('body_type_error'), + '--phpstan-bin' => '/definitely/not/a/real/phpstan', + ]); + + self::assertSame(0, $exit); // a missing optional tool is a Warning, not a failure + self::assertStringContainsString('PHPStan was not found', $tester->getDisplay()); + } + + public function testExplicitConfigIsPassedThroughToPhpStan(): void + { + // A config that includes a non-existent file fatals PHPStan → a run-failure + // Warning (exit 0), proving --phpstan-config is honoured rather than ignored. + $badConfig = sys_get_temp_dir() . '/xphp-cmd-badcfg-' . uniqid('', true) . '.neon'; + file_put_contents($badConfig, "includes:\n - /no/such/file.neon\n"); + + try { + $tester = $this->tester(); + $exit = $tester->execute([ + 'source' => $this->fixtureDir('body_type_error'), + '--phpstan-bin' => $this->bin, + '--phpstan-config' => $badConfig, + ]); + + self::assertSame(0, $exit); + self::assertStringContainsString('PHPStan could not complete', $tester->getDisplay()); + } finally { + unlink($badConfig); + } + } + + private function tester(): CommandTester + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + + return new CommandTester( + new CheckCommand( + new SourceResolver( + new NativeFileFinder(), + new ManifestResolver(new NativeFileReader(), new NativeFileFinder()), + ), + $compiler, + new StaticAnalysisGate($compiler), + ), + ); + } + + private function fixtureDir(string $fixture): string + { + return realpath(__DIR__ . '/../fixture/check/' . $fixture . '/source') + ?: throw new RuntimeException("Fixture missing: {$fixture}"); + } + + private function privatePropertyFixtureDir(): string + { + return realpath(__DIR__ . '/../fixture/compile/generic_covariant_private_property/source') + ?: throw new RuntimeException('private-property fixture missing'); + } + + private function level5Config(): string + { + return realpath(__DIR__ . '/../fixture/check/phpstan-level5.neon') + ?: throw new RuntimeException('level5 config fixture missing'); + } +} diff --git a/test/Console/CheckCommandTest.php b/test/Console/CheckCommandTest.php new file mode 100644 index 0000000..0a5b3a2 --- /dev/null +++ b/test/Console/CheckCommandTest.php @@ -0,0 +1,158 @@ +tester(); + $exit = $tester->execute(['source' => $this->fixtureDir('clean'), '--no-phpstan' => true]); + + self::assertSame(0, $exit); + self::assertStringContainsString('No problems found', $tester->getDisplay()); + } + + public function testResolvesSourcesFromConfigManifest(): void + { + // `check --config ` resolves sources from the manifest (no positional source). + $dir = sys_get_temp_dir() . '/xphp-check-cfg-' . uniqid('', true); + mkdir($dir . '/src', 0o755, true); + file_put_contents($dir . '/xphp.json', '{"sources":["src"]}'); + file_put_contents($dir . '/src/Box.xphp', " { public function get(): T { throw new \\LogicException; } }\n"); + + try { + $tester = $this->tester(); + $exit = $tester->execute(['--config' => $dir . '/xphp.json', '--no-phpstan' => true]); + + self::assertSame(0, $exit); + self::assertStringContainsString('No problems found', $tester->getDisplay()); + } finally { + self::rrmdir($dir); + } + } + + public function testGenericErrorsExitOne(): void + { + $tester = $this->tester(); + $exit = $tester->execute(['source' => $this->fixtureDir('multi_error')]); + + self::assertSame(1, $exit); + self::assertStringContainsString('bound violated', $tester->getDisplay()); + } + + public function testParseErrorIsReportedButOtherFilesStillChecked(): void + { + $tester = $this->tester(); + $exit = $tester->execute(['source' => $this->fixtureDir('parse_error'), '--format' => 'json']); + + self::assertSame(1, $exit); + /** @var array{diagnostics: list} $decoded */ + $decoded = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR); + + $byFile = []; + foreach ($decoded['diagnostics'] as $d) { + $byFile[basename($d['file'])] = $d; + } + + // A PHP syntax error: reported with its real line. + self::assertSame(Compiler::CODE_PARSE_ERROR, $byFile['Broken.xphp']['code']); + self::assertSame(11, $byFile['Broken.xphp']['line']); + // An xphp-specific parse rejection (no line): reported at line 1. + self::assertSame(Compiler::CODE_PARSE_ERROR, $byFile['ClosureVariance.xphp']['code']); + self::assertSame(1, $byFile['ClosureVariance.xphp']['line']); + // A VALID file is still checked despite the two unparseable files. + self::assertSame('xphp.bound_violation', $byFile['Use.xphp']['code']); + } + + public function testMissingSourceDirectoryExitsTwoWithMessage(): void + { + $tester = $this->tester(); + $exit = $tester->execute(['source' => sys_get_temp_dir() . '/xphp-does-not-exist-' . uniqid('', true)]); + + self::assertSame(2, $exit); + self::assertStringContainsString('Source directory not found', $tester->getDisplay()); + } + + public function testUnknownFormatExitsTwoWithMessage(): void + { + $tester = $this->tester(); + $exit = $tester->execute(['source' => $this->fixtureDir('clean'), '--format' => 'xml']); + + self::assertSame(2, $exit); + self::assertStringContainsString('Unknown format', $tester->getDisplay()); + } + + public function testGithubFormatEmitsAnnotations(): void + { + $tester = $this->tester(); + $tester->execute(['source' => $this->fixtureDir('multi_error'), '--format' => 'github']); + + self::assertStringContainsString('::error file=', $tester->getDisplay()); + } + + private function tester(): CommandTester + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + + $sourceResolver = new SourceResolver( + new NativeFileFinder(), + new ManifestResolver(new NativeFileReader(), new NativeFileFinder()), + ); + + return new CommandTester( + new CheckCommand($sourceResolver, $compiler, new StaticAnalysisGate($compiler)), + ); + } + + private function fixtureDir(string $fixture): string + { + return realpath(__DIR__ . '/../fixture/check/' . $fixture . '/source') + ?: throw new RuntimeException("Fixture missing: {$fixture}"); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/test/Console/CompileCommandTest.php b/test/Console/CompileCommandTest.php new file mode 100644 index 0000000..2901dc7 --- /dev/null +++ b/test/Console/CompileCommandTest.php @@ -0,0 +1,298 @@ +work = sys_get_temp_dir() . '/xphp-compile-cmd-' . uniqid('', true); + mkdir($this->work, 0o755, true); + } + + protected function tearDown(): void + { + self::rrmdir($this->work); + } + + public function testSingleDirPositionalFormStillWorks(): void + { + mkdir($this->work . '/src', 0o755, true); + file_put_contents($this->work . '/src/Plain.xphp', "tester(); + $exit = $tester->execute([ + 'source' => $this->work . '/src', + 'target' => $this->work . '/dist', + 'cache' => $this->work . '/cache', + ]); + + self::assertSame(Command::SUCCESS, $exit); + self::assertStringContainsString('Compiled', $tester->getDisplay()); + self::assertFileExists($this->work . '/dist/Plain.php'); + // positional cache arg honoured (registry lands there). + self::assertFileExists($this->work . '/cache/registry.json'); + } + + public function testSingleDirTargetCacheOptionsOverridePositional(): void + { + mkdir($this->work . '/src', 0o755, true); + file_put_contents($this->work . '/src/Plain.xphp', "tester()->execute([ + 'source' => $this->work . '/src', + 'target' => $this->work . '/posdist', + 'cache' => $this->work . '/poscache', + '--target' => $this->work . '/optdist', + '--cache' => $this->work . '/optcache', + ]); + + self::assertSame(Command::SUCCESS, $exit); + self::assertFileExists($this->work . '/optdist/Plain.php', '--target option wins over positional'); + self::assertFileDoesNotExist($this->work . '/posdist/Plain.php'); + self::assertFileExists($this->work . '/optcache/registry.json', '--cache option wins over positional'); + self::assertFileDoesNotExist($this->work . '/poscache/registry.json'); + } + + public function testCrossPackageConsumeViaConfigEmitsUpstreamAndRunsWithoutFatal(): void + { + // Upstream ships a generic Box as .xphp source + its manifest. + $this->writePackage('lib', '{"sources":["src"]}', [ + 'src/Box.xphp' => " { public function __construct(public T \$v) {} }\n", + ]); + // Downstream includes it and instantiates Box::, with build dirs in the manifest. + $this->writePackage('app', '{"sources":["src"],"include":["../lib"],"target":"dist","cache":"cache"}', [ + 'src/Use.xphp' => "(1);\n", + ]); + + $exit = $this->tester()->execute(['--config' => $this->work . '/app']); + self::assertSame(Command::SUCCESS, $exit); + + // Emit-all: the UPSTREAM marker is emitted into the consumer's output (not skipped) ... + self::assertFileExists($this->work . '/app/dist/Box.php', 'upstream Box marker must be emitted'); + self::assertFileExists($this->work . '/app/dist/Use.php'); + // ... and the consumer-driven specialization exists in the cache. + $specs = glob($this->work . '/app/cache/Generated/App/Box/T_*.php') ?: []; + self::assertCount(1, $specs, 'one Box specialization'); + + // And the compiled output loads + runs with NO "interface not found" / fatal. + self::assertSame('OK', $this->runCompiledOutput($this->work . '/app/dist', $this->work . '/app/cache')); + } + + public function testGlobDiscoveryViaConfig(): void + { + $this->writePackage('app', '{"sources":["src"],"include":["vendor/*/*"]}', [ + 'src/Use.xphp' => "writePackage('app/vendor/org/pkg', '{"sources":["src"]}', [ + 'src/Dep.xphp' => "work . '/app/vendor/org/plain', 0o755, true); + + $exit = $this->tester()->execute(['--config' => $this->work . '/app', '--target' => $this->work . '/out', '--cache' => $this->work . '/c']); + + self::assertSame(Command::SUCCESS, $exit); + self::assertFileExists($this->work . '/out/Use.php'); + self::assertFileExists($this->work . '/out/Dep.php', 'discovered vendor package compiled'); + } + + public function testManifestTargetCacheUsedAndOptionOverrides(): void + { + // Non-default dir names ("build"/"artifacts") so the precedence is distinguishable from the + // 'dist'/'.xphp-cache' fallbacks. + $this->writePackage('app', '{"sources":["src"],"target":"build","cache":"artifacts"}', [ + 'src/A.xphp' => "tester()->execute(['--config' => $this->work . '/app']); + self::assertFileExists($this->work . '/app/build/A.php'); + self::assertFileExists($this->work . '/app/artifacts/registry.json'); + self::assertFileDoesNotExist($this->work . '/app/dist/A.php'); + + // --target / --cache options override the manifest values. + $this->tester()->execute([ + '--config' => $this->work . '/app', + '--target' => $this->work . '/over-dist', + '--cache' => $this->work . '/over-cache', + ]); + self::assertFileExists($this->work . '/over-dist/A.php'); + self::assertFileExists($this->work . '/over-cache/registry.json'); + } + + public function testAutodetectsXphpJsonInWorkingDirectory(): void + { + $this->writePackage('app', '{"sources":["src"],"target":"dist","cache":"cache"}', [ + 'src/A.xphp' => "work . '/app'); + try { + $exit = $this->tester()->execute([]); // no source, no --config → auto-detect cwd/xphp.json + } finally { + chdir($prev !== false ? $prev : '/'); + } + + self::assertSame(Command::SUCCESS, $exit); + self::assertFileExists($this->work . '/app/dist/A.php'); + } + + public function testConfigOptionTakesPrecedenceOverAutodetectedManifest(): void + { + // cwd holds an xphp.json (the auto-detect candidate) → srcA; --config points elsewhere → srcB. + $this->writePackage('app', json_encode(['sources' => ['srcA'], 'target' => $this->work . '/distA', 'cache' => $this->work . '/cacheA']) ?: '{}', [ + 'srcA/A.xphp' => " "work . '/app/other.json', + json_encode(['sources' => ['srcB'], 'target' => $this->work . '/distB', 'cache' => $this->work . '/cacheB']) ?: '{}', + ); + + $prev = getcwd(); + chdir($this->work . '/app'); + try { + $exit = $this->tester()->execute(['--config' => $this->work . '/app/other.json']); + } finally { + chdir($prev !== false ? $prev : '/'); + } + + self::assertSame(Command::SUCCESS, $exit); + self::assertFileExists($this->work . '/distB/B.php', '--config manifest is used'); + self::assertFileDoesNotExist($this->work . '/distA/A.php', 'auto-detected manifest is NOT used when --config is given'); + } + + public function testEmptyTargetOptionFallsThroughToManifest(): void + { + // An empty --target is treated as absent (stringOrNull), so the manifest value wins — + // pins that `stringOrNull` requires BOTH is_string AND non-empty. + $this->writePackage('app', '{"sources":["src"],"target":"build","cache":"artifacts"}', [ + 'src/A.xphp' => "tester()->execute(['--config' => $this->work . '/app', '--target' => '']); + + self::assertSame(Command::SUCCESS, $exit); + self::assertFileExists($this->work . '/app/build/A.php', 'empty --target ignored; manifest target used'); + } + + public function testNoSourceProviderIsAClearFailure(): void + { + $tester = $this->tester(); + $exit = $tester->execute([]); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('No sources to compile', $tester->getDisplay()); + } + + public function testMissingSingleDirIsAClearFailure(): void + { + $tester = $this->tester(); + $exit = $tester->execute(['source' => $this->work . '/ghost']); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('Source directory not found', $tester->getDisplay()); + } + + // --- helpers --- + + private function tester(): CommandTester + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + $sourceResolver = new SourceResolver( + new NativeFileFinder(), + new ManifestResolver(new NativeFileReader(), new NativeFileFinder()), + ); + + return new CommandTester(new CompileCommand($sourceResolver, $compiler)); + } + + /** @param array $files relative path => content */ + private function writePackage(string $rel, string $manifest, array $files): void + { + $dir = $this->work . '/' . $rel; + mkdir($dir, 0o755, true); + file_put_contents($dir . '/xphp.json', $manifest); + foreach ($files as $path => $content) { + $full = $dir . '/' . $path; + if (!is_dir(dirname($full))) { + mkdir(dirname($full), 0o755, true); + } + file_put_contents($full, $content); + } + } + + /** Load the compiled output (target + generated cache) in a subprocess and require it; "OK" on no fatal. */ + private function runCompiledOutput(string $target, string $cache): string + { + $loader = $this->work . '/load.php'; + $prefixes = [ + 'XPHP\\Generated\\' => $cache . '/Generated', + 'App\\' => $target, + ]; + $script = " $base) {' . "\n" + . ' if (str_starts_with($c, $p)) {' . "\n" + . ' $f = $base . "/" . str_replace("\\\\", "/", substr($c, strlen($p))) . ".php";' . "\n" + . ' if (is_file($f)) { require_once $f; }' . "\n" + . ' }' . "\n" + . ' }' . "\n" + . "});\n" + . 'require ' . var_export($target . '/Use.php', true) . ";\n" + . "echo \"OK\\n\";\n"; + file_put_contents($loader, $script); + + $out = []; + $code = 0; + exec('php ' . escapeshellarg($loader) . ' 2>&1', $out, $code); + + return $code === 0 && in_array('OK', $out, true) ? 'OK' : implode("\n", $out); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $e) { + if ($e === '.' || $e === '..') { + continue; + } + $p = $dir . '/' . $e; + is_dir($p) ? self::rrmdir($p) : unlink($p); + } + rmdir($dir); + } +} diff --git a/test/Diagnostics/DiagnosticCollectorTest.php b/test/Diagnostics/DiagnosticCollectorTest.php new file mode 100644 index 0000000..5c525a8 --- /dev/null +++ b/test/Diagnostics/DiagnosticCollectorTest.php @@ -0,0 +1,49 @@ +all()); + self::assertFalse($c->hasErrors()); + self::assertSame(0, $c->count()); + } + + public function testAddPreservesInsertionOrder(): void + { + $c = new DiagnosticCollector(); + $first = new Diagnostic(Severity::Notice, 'a', 'first'); + $second = new Diagnostic(Severity::Warning, 'b', 'second'); + $c->add($first); + $c->add($second); + + self::assertSame([$first, $second], $c->all()); + self::assertSame(2, $c->count()); + } + + public function testHasErrorsIsFalseWithoutErrorSeverity(): void + { + $c = new DiagnosticCollector(); + $c->add(new Diagnostic(Severity::Warning, 'a', 'w')); + $c->add(new Diagnostic(Severity::Notice, 'b', 'n')); + + self::assertFalse($c->hasErrors()); + } + + public function testHasErrorsIsTrueWhenAnyErrorPresent(): void + { + $c = new DiagnosticCollector(); + $c->add(new Diagnostic(Severity::Warning, 'a', 'w')); + $c->add(new Diagnostic(Severity::Error, 'b', 'e')); + + self::assertTrue($c->hasErrors()); + } +} diff --git a/test/Diagnostics/DiagnosticTest.php b/test/Diagnostics/DiagnosticTest.php new file mode 100644 index 0000000..bec683c --- /dev/null +++ b/test/Diagnostics/DiagnosticTest.php @@ -0,0 +1,46 @@ +severity); + self::assertSame('xphp.bound_violation', $d->code); + self::assertSame('boom', $d->message); + self::assertNull($d->location); + self::assertNull($d->triggeredBy); + self::assertSame(DiagnosticSource::Xphp, $d->source); + } + + public function testAllFieldsSet(): void + { + $loc = new SourceLocation('/src/Box.xphp', 12, 5); + $d = new Diagnostic( + Severity::Warning, + 'phpstan.return.type', + 'bad return', + $loc, + 'App\\Box', + DiagnosticSource::PhpStan, + ); + + self::assertSame(Severity::Warning, $d->severity); + self::assertSame($loc, $d->location); + self::assertSame('App\\Box', $d->triggeredBy); + self::assertSame(DiagnosticSource::PhpStan, $d->source); + } + + public function testSourceBackingValues(): void + { + self::assertSame('xphp', DiagnosticSource::Xphp->value); + self::assertSame('phpstan', DiagnosticSource::PhpStan->value); + } +} diff --git a/test/Diagnostics/Renderer/RendererTest.php b/test/Diagnostics/Renderer/RendererTest.php new file mode 100644 index 0000000..e64f2c6 --- /dev/null +++ b/test/Diagnostics/Renderer/RendererTest.php @@ -0,0 +1,138 @@ + */ + private function sample(): array + { + return [ + new Diagnostic( + Severity::Error, + 'xphp.bound_violation', + 'bad bound', + new SourceLocation('/src/Box.xphp', 7, 3), + ), + new Diagnostic( + Severity::Warning, + 'phpstan.return', + 'maybe', + null, + 'App\\Box', + DiagnosticSource::PhpStan, + ), + ]; + } + + public function testTextEmpty(): void + { + self::assertSame('No problems found.' . PHP_EOL, (new TextRenderer())->render([])); + } + + public function testTextRender(): void + { + $expected = implode(PHP_EOL, [ + 'error: bad bound', + ' at /src/Box.xphp:7:3 [xphp.bound_violation]', + '', + 'warning: maybe', + ' [phpstan.return]', + ' triggered by App\\Box', + ]) . PHP_EOL; + + self::assertSame($expected, (new TextRenderer())->render($this->sample())); + } + + public function testTextOmitsColumnWhenAbsent(): void + { + $d = [new Diagnostic(Severity::Error, 'c', 'm', new SourceLocation('/a.xphp', 4))]; + self::assertStringContainsString('at /a.xphp:4 [c]', (new TextRenderer())->render($d)); + } + + public function testJsonContract(): void + { + $out = (new JsonRenderer())->render($this->sample()); + // Pin the raw formatting: pretty-printed (space after key) and slashes unescaped. + self::assertStringContainsString('"file": "/src/Box.xphp"', $out); + /** @var array{diagnostics: list>} $decoded */ + $decoded = json_decode($out, true, flags: JSON_THROW_ON_ERROR); + + self::assertSame([ + 'diagnostics' => [ + [ + 'severity' => 'error', + 'code' => 'xphp.bound_violation', + 'message' => 'bad bound', + 'source' => 'xphp', + 'triggeredBy' => null, + 'file' => '/src/Box.xphp', + 'line' => 7, + 'column' => 3, + ], + [ + 'severity' => 'warning', + 'code' => 'phpstan.return', + 'message' => 'maybe', + 'source' => 'phpstan', + 'triggeredBy' => 'App\\Box', + 'file' => null, + 'line' => null, + 'column' => null, + ], + ], + ], $decoded); + } + + public function testJsonEmpty(): void + { + self::assertSame('{' . PHP_EOL . ' "diagnostics": []' . PHP_EOL . '}' . PHP_EOL, (new JsonRenderer())->render([])); + } + + public function testGithubRender(): void + { + $expected = implode(PHP_EOL, [ + '::error file=/src/Box.xphp,line=7,col=3::bad bound', + // The second diagnostic carries triggeredBy, folded into the message. + '::warning::maybe (triggered by App\\Box)', + ]) . PHP_EOL; + + self::assertSame($expected, (new GithubRenderer())->render($this->sample())); + } + + public function testGithubEscapesMessageAndProperties(): void + { + $d = [new Diagnostic( + Severity::Notice, + 'c', + "line one\nline two", + new SourceLocation('/weird,name:x.xphp', 1), + )]; + + self::assertSame( + '::notice file=/weird%2Cname%3Ax.xphp,line=1::line one%0Aline two' . PHP_EOL, + (new GithubRenderer())->render($d), + ); + } + + public function testGithubEmpty(): void + { + self::assertSame('', (new GithubRenderer())->render([])); + } + + public function testGithubEscapesPercentAndCarriageReturnInMessage(): void + { + $d = [new Diagnostic(Severity::Error, 'c', "50%\rdone")]; + + // `%` must be escaped first (to %25), then `\r` to %0D — no double-escaping. + self::assertSame('::error::50%25%0Ddone' . PHP_EOL, (new GithubRenderer())->render($d)); + } +} diff --git a/test/Diagnostics/SeverityTest.php b/test/Diagnostics/SeverityTest.php new file mode 100644 index 0000000..f8a2099 --- /dev/null +++ b/test/Diagnostics/SeverityTest.php @@ -0,0 +1,32 @@ +isFailing()); + } + + public function testWarningIsNotFailing(): void + { + self::assertFalse(Severity::Warning->isFailing()); + } + + public function testNoticeIsNotFailing(): void + { + self::assertFalse(Severity::Notice->isFailing()); + } + + public function testBackingValues(): void + { + self::assertSame('error', Severity::Error->value); + self::assertSame('warning', Severity::Warning->value); + self::assertSame('notice', Severity::Notice->value); + } +} diff --git a/test/Diagnostics/SourceLocationTest.php b/test/Diagnostics/SourceLocationTest.php new file mode 100644 index 0000000..3584eb3 --- /dev/null +++ b/test/Diagnostics/SourceLocationTest.php @@ -0,0 +1,26 @@ +file); + self::assertSame(42, $loc->line); + self::assertNull($loc->column); + } + + public function testColumnIsPreservedWhenGiven(): void + { + $loc = new SourceLocation('/src/Box.xphp', 42, 7); + + self::assertSame(7, $loc->column); + } +} diff --git a/test/StaticAnalysis/CompiledWorkspaceTest.php b/test/StaticAnalysis/CompiledWorkspaceTest.php new file mode 100644 index 0000000..cb0652f --- /dev/null +++ b/test/StaticAnalysis/CompiledWorkspaceTest.php @@ -0,0 +1,215 @@ +scratchRoot(); + $workspace = CompiledWorkspace::compile( + $this->compiler(), + $this->boxGenericSources(), + $this->boxGenericSourceDir(), + $root, + ); + + try { + self::assertSame($root, $workspace->root); + // distDir/generatedDir are canonicalized (realpath) for PHPStan path matching. + self::assertSame(realpath($root . '/dist'), $workspace->distDir); + self::assertSame(realpath($root . '/cache/Generated'), $workspace->generatedDir); + + // Rewritten user code landed in dist/. + self::assertFileExists($workspace->distDir . '/Containers/Box.php'); + // Specialized classes landed under cache/Generated/. + $generated = glob($workspace->generatedDir . '/App/BoxGeneric/Containers/Box/*.php') ?: []; + self::assertNotEmpty($generated, 'expected specialized Box classes under generatedDir'); + + // The live registry is retained for back-mapping (decl line lookup). + self::assertNotEmpty($workspace->registry->definitions()); + self::assertNotEmpty($workspace->registry->instantiations()); + } finally { + $workspace->cleanup(); + } + } + + public function testCleanupRemovesTheWorkspace(): void + { + $root = $this->scratchRoot(); + $workspace = CompiledWorkspace::compile( + $this->compiler(), + $this->boxGenericSources(), + $this->boxGenericSourceDir(), + $root, + ); + self::assertDirectoryExists($root); + + $workspace->cleanup(); + + self::assertDirectoryDoesNotExist($root); + } + + public function testInTempDirCreatesUniqueRootBeneathBase(): void + { + $base = $this->scratchRoot(); + mkdir($base, 0o755, true); + + // Pass a trailing slash so the regex (single separator) also pins rtrim(). + $a = CompiledWorkspace::inTempDir($this->compiler(), $this->boxGenericSources(), $this->boxGenericSourceDir(), $base . '/'); + $b = CompiledWorkspace::inTempDir($this->compiler(), $this->boxGenericSources(), $this->boxGenericSourceDir(), $base . '/'); + + try { + // Exactly one separator (rtrim normalises the base) then the tag and a + // 16-hex-char suffix (bin2hex of random_bytes(8)). + self::assertMatchesRegularExpression( + '#^' . preg_quote($base, '#') . '/xphp-check-[0-9a-f]{16}$#', + $a->root, + ); + self::assertNotSame($a->root, $b->root); + self::assertDirectoryExists($a->root); + self::assertDirectoryExists($b->root); + } finally { + $a->cleanup(); + $b->cleanup(); + $this->rrmdir($base); + } + } + + public function testGeneratedDirFallsBackToConstructedPathWhenNotCreated(): void + { + // Empty sources emit no specialized classes, so cache/Generated is never + // created and realpath() returns false — canonical() must fall back to the + // constructed path (harmless: there are no representatives to match anyway). + $root = $this->scratchRoot(); + $workspace = CompiledWorkspace::compile( + $this->compiler(), + new FilepathArray(), + $this->boxGenericSourceDir(), + $root, + ); + + try { + self::assertDirectoryDoesNotExist($root . '/cache/Generated'); + self::assertSame($root . '/cache/Generated', $workspace->generatedDir); + } finally { + $workspace->cleanup(); + } + } + + public function testCleanupIsIdempotentWhenRootIsAlreadyGone(): void + { + $root = $this->scratchRoot(); + $workspace = CompiledWorkspace::compile( + $this->compiler(), + new FilepathArray(), + $this->boxGenericSourceDir(), + $root, + ); + + $workspace->cleanup(); + self::assertDirectoryDoesNotExist($root); + + // A second cleanup hits the `!is_dir` short-circuit and must not throw. + $workspace->cleanup(); + $this->addToAssertionCount(1); + } + + public function testCleanupRemovesNestedTreeWithoutFollowingSymlinks(): void + { + // A hand-built workspace: nested files + a symlink pointing OUT of the + // workspace. cleanup() must delete the tree and the link, but never touch + // the link's target contents. + $root = $this->scratchRoot(); + mkdir($root . '/a/b', 0o755, true); + file_put_contents($root . '/a/b/file.php', 'scratchRoot(); + mkdir($external, 0o755, true); + file_put_contents($external . '/keep.txt', 'precious'); + symlink($external, $root . '/a/link-to-external'); + + $workspace = CompiledWorkspace::compile( + $this->compiler(), + new FilepathArray(), + $this->boxGenericSourceDir(), + $root, + ); + + try { + $workspace->cleanup(); + + self::assertDirectoryDoesNotExist($root); + // The symlink target and its contents must survive. + self::assertFileExists($external . '/keep.txt'); + self::assertSame('precious', file_get_contents($external . '/keep.txt')); + } finally { + $this->rrmdir($external); + } + } + + private function compiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private function boxGenericSourceDir(): string + { + return realpath(__DIR__ . '/../fixture/compile/box_generic/source') + ?: throw new RuntimeException('box_generic fixture missing'); + } + + private function boxGenericSources(): FilepathArray + { + return (new NativeFileFinder()) + ->find($this->boxGenericSourceDir()) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + } + + private function scratchRoot(): string + { + return sys_get_temp_dir() . '/xphp-workspace-' . uniqid('', true); + } + + private function rrmdir(string $path): void + { + if (!is_dir($path)) { + return; + } + $entries = scandir($path) ?: []; + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $full = $path . '/' . $entry; + is_dir($full) ? $this->rrmdir($full) : unlink($full); + } + rmdir($path); + } +} diff --git a/test/StaticAnalysis/PhpStanConfigResolverTest.php b/test/StaticAnalysis/PhpStanConfigResolverTest.php new file mode 100644 index 0000000..350c238 --- /dev/null +++ b/test/StaticAnalysis/PhpStanConfigResolverTest.php @@ -0,0 +1,100 @@ +scratch = sys_get_temp_dir() . '/xphp-config-' . uniqid('', true); + if (!mkdir($this->scratch, 0o755, true) && !is_dir($this->scratch)) { + throw new RuntimeException("could not create scratch dir: {$this->scratch}"); + } + } + + protected function tearDown(): void + { + $entries = scandir($this->scratch) ?: []; + foreach ($entries as $entry) { + if ($entry !== '.' && $entry !== '..') { + unlink($this->scratch . '/' . $entry); + } + } + rmdir($this->scratch); + } + + public function testExplicitConfigWins(): void + { + $explicit = $this->scratch . '/custom.neon'; + file_put_contents($explicit, "parameters:\n"); + // An auto-detect candidate also present must be ignored in favour of the explicit one. + file_put_contents($this->scratch . '/phpstan.neon', "parameters:\n"); + + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertSame($explicit, $resolver->resolve($explicit)); + } + + public function testExplicitConfigThatDoesNotExistResolvesToNull(): void + { + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertNull($resolver->resolve($this->scratch . '/missing.neon')); + } + + public function testAutoDetectsPhpstanNeon(): void + { + $config = $this->scratch . '/phpstan.neon'; + file_put_contents($config, "parameters:\n"); + + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertSame($config, $resolver->resolve(null)); + } + + public function testAutoDetectPrefersNeonOverDistVariants(): void + { + $neon = $this->scratch . '/phpstan.neon'; + file_put_contents($neon, "parameters:\n"); + file_put_contents($this->scratch . '/phpstan.neon.dist', "parameters:\n"); + file_put_contents($this->scratch . '/phpstan.dist.neon', "parameters:\n"); + + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertSame($neon, $resolver->resolve(null)); + } + + public function testAutoDetectsNeonDistWhenPlainNeonAbsent(): void + { + $dist = $this->scratch . '/phpstan.neon.dist'; + file_put_contents($dist, "parameters:\n"); + + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertSame($dist, $resolver->resolve(null)); + } + + public function testReturnsNullWhenNoConfigPresent(): void + { + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertNull($resolver->resolve(null)); + } + + public function testEmptyExplicitStringFallsThroughToAutoDetect(): void + { + $config = $this->scratch . '/phpstan.neon'; + file_put_contents($config, "parameters:\n"); + + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertSame($config, $resolver->resolve('')); + } +} diff --git a/test/StaticAnalysis/PhpStanLocatorTest.php b/test/StaticAnalysis/PhpStanLocatorTest.php new file mode 100644 index 0000000..e248e90 --- /dev/null +++ b/test/StaticAnalysis/PhpStanLocatorTest.php @@ -0,0 +1,136 @@ +scratch = sys_get_temp_dir() . '/xphp-locator-' . uniqid('', true); + if (!mkdir($this->scratch, 0o755, true) && !is_dir($this->scratch)) { + throw new RuntimeException("could not create scratch dir: {$this->scratch}"); + } + } + + protected function tearDown(): void + { + $this->rrmdir($this->scratch); + } + + public function testExplicitPathWins(): void + { + $bin = $this->touch($this->scratch . '/custom-phpstan'); + $locator = new PhpStanLocator($this->scratch, []); + + self::assertSame($bin, $locator->locate($bin)); + } + + public function testExplicitPathThatDoesNotExistResolvesToNull(): void + { + $locator = new PhpStanLocator($this->scratch, []); + + self::assertNull($locator->locate($this->scratch . '/nope')); + } + + public function testFallsBackToConsumerVendorBin(): void + { + $vendorBin = $this->touch($this->scratch . '/vendor/bin/phpstan'); + $locator = new PhpStanLocator($this->scratch, []); + + self::assertSame($vendorBin, $locator->locate(null)); + } + + public function testFallsBackToPath(): void + { + $pathDir = $this->scratch . '/opt/bin'; + $onPath = $this->touch($pathDir . '/phpstan'); + // No vendor/bin/phpstan here, so PATH is the only resolution. + $locator = new PhpStanLocator($this->scratch, [$pathDir]); + + self::assertSame($onPath, $locator->locate(null)); + } + + public function testVendorBinTakesPrecedenceOverPath(): void + { + $vendorBin = $this->touch($this->scratch . '/vendor/bin/phpstan'); + $pathDir = $this->scratch . '/opt/bin'; + $this->touch($pathDir . '/phpstan'); + $locator = new PhpStanLocator($this->scratch, [$pathDir]); + + self::assertSame($vendorBin, $locator->locate(null)); + } + + public function testPathDirWithTrailingSlashStillResolves(): void + { + $pathDir = $this->scratch . '/opt/bin'; + $this->touch($pathDir . '/phpstan'); + // Trailing slash must be normalised (rtrim) so the candidate isn't `…/bin//phpstan`. + $locator = new PhpStanLocator($this->scratch, [$pathDir . '/']); + + self::assertSame($pathDir . '/phpstan', $locator->locate(null)); + } + + public function testReturnsNullWhenNothingResolves(): void + { + $locator = new PhpStanLocator($this->scratch, [$this->scratch . '/empty']); + + self::assertNull($locator->locate(null)); + } + + public function testEmptyExplicitStringIsTreatedAsAbsent(): void + { + $vendorBin = $this->touch($this->scratch . '/vendor/bin/phpstan'); + $locator = new PhpStanLocator($this->scratch, []); + + // '' must NOT short-circuit to "explicit"; it falls through to vendor/bin. + self::assertSame($vendorBin, $locator->locate('')); + } + + public function testFromEnvironmentSplitsPath(): void + { + $pathDir = $this->scratch . '/env/bin'; + $onPath = $this->touch($pathDir . '/phpstan'); + $original = getenv('PATH'); + putenv('PATH=' . $pathDir); + try { + $locator = PhpStanLocator::fromEnvironment($this->scratch); + self::assertSame($onPath, $locator->locate(null)); + } finally { + putenv($original === false ? 'PATH' : 'PATH=' . $original); + } + } + + private function touch(string $path): string + { + $dir = dirname($path); + if (!is_dir($dir) && !mkdir($dir, 0o755, true) && !is_dir($dir)) { + throw new RuntimeException("could not create dir: {$dir}"); + } + file_put_contents($path, "#!/usr/bin/env php\n"); + + return $path; + } + + private function rrmdir(string $path): void + { + if (!is_dir($path)) { + return; + } + $entries = scandir($path) ?: []; + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $full = $path . '/' . $entry; + is_dir($full) ? $this->rrmdir($full) : unlink($full); + } + rmdir($path); + } +} diff --git a/test/StaticAnalysis/PhpStanOutputParserTest.php b/test/StaticAnalysis/PhpStanOutputParserTest.php new file mode 100644 index 0000000..75f77e1 --- /dev/null +++ b/test/StaticAnalysis/PhpStanOutputParserTest.php @@ -0,0 +1,205 @@ + ['errors' => 0, 'file_errors' => 1], + 'files' => [ + '/tmp/gen/Box.php' => [ + 'errors' => 1, + 'messages' => [ + ['message' => 'should return int but returns string', 'line' => 15, 'identifier' => 'return.type'], + ], + ], + ], + 'errors' => [], + ], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertTrue($result->ranOk); + self::assertCount(1, $result->findings); + $finding = $result->findings[0]; + self::assertSame('/tmp/gen/Box.php', $finding->file); + self::assertSame(15, $finding->line); + self::assertSame('should return int but returns string', $finding->message); + self::assertSame('return.type', $finding->identifier); + } + + public function testIdentifierIsNullWhenAbsentAndLineDefaultsToZero(): void + { + $json = json_encode([ + 'files' => [ + '/tmp/gen/Box.php' => [ + 'messages' => [ + ['message' => 'file-level problem'], // no line, no identifier + ], + ], + ], + ], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertTrue($result->ranOk); + self::assertCount(1, $result->findings); + self::assertSame(0, $result->findings[0]->line); + self::assertNull($result->findings[0]->identifier); + } + + public function testEmptyFilesMapIsACleanRun(): void + { + $result = PhpStanOutputParser::parse('{"files":{},"errors":[]}', ''); + + self::assertTrue($result->ranOk); + self::assertSame([], $result->findings); + } + + public function testNonJsonStdoutIsAFailedRunWithStderrMessage(): void + { + $result = PhpStanOutputParser::parse('PHP Fatal error: boom', 'Configuration file not found'); + + self::assertFalse($result->ranOk); + self::assertSame([], $result->findings); + self::assertSame('Configuration file not found', $result->errorOutput); + } + + public function testFailureMessageFallsBackToStdoutWhenStderrEmpty(): void + { + $result = PhpStanOutputParser::parse('not json at all', ' '); + + self::assertFalse($result->ranOk); + self::assertSame('not json at all', $result->errorOutput); + } + + public function testFailureMessageWhenBothStreamsEmpty(): void + { + $result = PhpStanOutputParser::parse('', ''); + + self::assertFalse($result->ranOk); + self::assertSame('PHPStan produced no analysable output', $result->errorOutput); + } + + public function testValidJsonWithoutFilesKeyIsAFailedRun(): void + { + $result = PhpStanOutputParser::parse('{"totals":{"errors":0}}', 'some stderr'); + + self::assertFalse($result->ranOk); + self::assertSame('some stderr', $result->errorOutput); + } + + public function testFilesKeySetButNotAnArrayIsAFailedRun(): void + { + // 'files' present but not a map (distinguishes the middle clause of the + // is_array/isset/is_array guard from the others). + $result = PhpStanOutputParser::parse('{"files":"oops"}', 'stderr text'); + + self::assertFalse($result->ranOk); + self::assertSame('stderr text', $result->errorOutput); + } + + public function testFailureMessageTrimsStdoutWhitespace(): void + { + $result = PhpStanOutputParser::parse(' padded ', ''); + + self::assertFalse($result->ranOk); + self::assertSame('padded', $result->errorOutput); + } + + public function testTopLevelErrorsCoerceNonStringsToEmpty(): void + { + $json = json_encode([ + 'files' => [], + 'errors' => ['real error', 123], // the int must be coerced to '' by the map + ], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertFalse($result->ranOk); + self::assertSame("real error\n", $result->errorOutput); + } + + public function testWhitespaceOnlyTopLevelErrorFallsBackToGenericMessage(): void + { + $json = json_encode(['files' => [], 'errors' => [' ']], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertFalse($result->ranOk); + self::assertSame('PHPStan reported a general error', $result->errorOutput); + } + + public function testScalarJsonIsAFailedRun(): void + { + // json_decode('123') is a valid int, not an array — must be a failed run. + $result = PhpStanOutputParser::parse('123', 'stderr here'); + + self::assertFalse($result->ranOk); + self::assertSame('stderr here', $result->errorOutput); + } + + public function testTopLevelErrorsWithNoFileFindingsIsAFailedRun(): void + { + $json = json_encode([ + 'files' => [], + 'errors' => ['Ignored error pattern was not matched', 'Another general error'], + ], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertFalse($result->ranOk); + self::assertStringContainsString('Ignored error pattern was not matched', $result->errorOutput ?? ''); + self::assertStringContainsString('Another general error', $result->errorOutput ?? ''); + } + + public function testFileFindingsWinOverTopLevelErrors(): void + { + // When there ARE file findings, top-level errors don't turn it into a failure. + $json = json_encode([ + 'files' => [ + '/tmp/gen/Box.php' => ['messages' => [['message' => 'real bug', 'line' => 3]]], + ], + 'errors' => ['some general note'], + ], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertTrue($result->ranOk); + self::assertCount(1, $result->findings); + } + + public function testMalformedEntriesAreSkippedNotFatal(): void + { + $json = json_encode([ + 'files' => [ + '/tmp/ok.php' => ['messages' => [['message' => 'kept', 'line' => 1]]], + '/tmp/no-messages.php' => ['errors' => 2], // missing 'messages' + '/tmp/bad-messages.php' => ['messages' => 'not-an-array'], + '/tmp/scalar-data.php' => 'not-an-array', // data not an array → skipped + // A numeric file key decodes to an int key, exercising the is_string($file) guard. + '7' => ['messages' => [['message' => 'numeric-key', 'line' => 1]]], + '/tmp/bad-entry.php' => [ + 'messages' => [ + 'not-an-array', // skipped + ['line' => 9], // no 'message' → skipped + ['message' => 42], // non-string message → skipped + ['message' => 'also kept', 'line' => 2], + ], + ], + ], + ], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertTrue($result->ranOk); + $messages = array_map(static fn (PhpStanFinding $f): string => $f->message, $result->findings); + self::assertSame(['kept', 'also kept'], $messages); + } +} diff --git a/test/StaticAnalysis/PhpStanResultMapperTest.php b/test/StaticAnalysis/PhpStanResultMapperTest.php new file mode 100644 index 0000000..0610238 --- /dev/null +++ b/test/StaticAnalysis/PhpStanResultMapperTest.php @@ -0,0 +1,97 @@ +', + '/project/src/Box.xphp', + 7, + ); + $finding = new PhpStanFinding($rep->filePath, 15, 'should return int but returns string', 'return.type'); + + $diagnostics = PhpStanResultMapper::map([$finding], [$rep]); + + self::assertCount(1, $diagnostics); + $d = $diagnostics[0]; + self::assertSame(Severity::Error, $d->severity); + self::assertSame('phpstan.return.type', $d->code); + self::assertSame('should return int but returns string', $d->message); + self::assertSame(DiagnosticSource::PhpStan, $d->source); + self::assertSame('App\\Box', $d->triggeredBy); + self::assertNotNull($d->location); + // Anchored at the TEMPLATE declaration, not the generated file/line. + self::assertSame('/project/src/Box.xphp', $d->location->file); + self::assertSame(7, $d->location->line); + } + + public function testFindingWithoutIdentifierGetsGenericCode(): void + { + $rep = $this->representative(); + $finding = new PhpStanFinding($rep->filePath, 3, 'some error', null); + + $diagnostics = PhpStanResultMapper::map([$finding], [$rep]); + + self::assertSame('phpstan.error', $diagnostics[0]->code); + } + + public function testUnmatchedFindingIsSurfacedWithoutLocationOrTriggeredBy(): void + { + $rep = $this->representative(); + // A finding in a file that is NOT a known representative (defensive path): + // surfaced (never dropped) but with no location, so the throwaway temp-dir + // path it came from never leaks into the report. + $finding = new PhpStanFinding('/tmp/ws/dist/Other.php', 42, 'orphan error', 'foo.bar'); + + $diagnostics = PhpStanResultMapper::map([$finding], [$rep]); + + self::assertCount(1, $diagnostics); + $d = $diagnostics[0]; + self::assertSame('orphan error', $d->message); + self::assertNull($d->triggeredBy); + self::assertNull($d->location); + } + + public function testEmptyFindingsMapToNoDiagnostics(): void + { + self::assertSame([], PhpStanResultMapper::map([], [$this->representative()])); + } + + public function testMultipleFindingsEachBecomeADiagnostic(): void + { + $rep = $this->representative(); + $findings = [ + new PhpStanFinding($rep->filePath, 1, 'first', 'a.b'), + new PhpStanFinding($rep->filePath, 2, 'second', 'c.d'), + ]; + + $diagnostics = PhpStanResultMapper::map($findings, [$rep]); + + self::assertCount(2, $diagnostics); + self::assertSame(['first', 'second'], array_map(static fn ($d): string => $d->message, $diagnostics)); + } + + private function representative(): Representative + { + return new Representative( + 'XPHP\\Generated\\App\\Box\\T_abc', + '/tmp/ws/cache/Generated/App/Box/T_abc.php', + 'App\\Box', + 'App\\Box', + '/project/src/Box.xphp', + 7, + ); + } +} diff --git a/test/StaticAnalysis/PhpStanRunnerConfigTest.php b/test/StaticAnalysis/PhpStanRunnerConfigTest.php new file mode 100644 index 0000000..7fb70a7 --- /dev/null +++ b/test/StaticAnalysis/PhpStanRunnerConfigTest.php @@ -0,0 +1,76 @@ +phpstanBin = $bin; + } + + public function testReportsBodyTypeErrorInTheRepresentative(): void + { + $this->withCompiled('check/body_type_error', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir); + $result = $this->analyze($w, $reps); + + self::assertTrue($result->ranOk, 'phpstan should run cleanly; got: ' . ($result->errorOutput ?? '')); + self::assertCount(1, $result->findings); + + $finding = $result->findings[0]; + self::assertSame($reps[0]->filePath, $finding->file); + self::assertStringContainsString('should return int but returns string', $finding->message); + }); + } + + public function testCleanSourcesProduceNoFindings(): void + { + $this->withCompiled('compile/box_generic', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir); + $result = $this->analyze($w, $reps); + + self::assertTrue($result->ranOk, 'phpstan should run cleanly; got: ' . ($result->errorOutput ?? '')); + self::assertSame([], $result->findings); + }); + } + + public function testBrokenConsumerConfigIsReportedAsRunFailureNotACleanPass(): void + { + $this->withCompiled('check/body_type_error', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir); + + // A consumer config that includes a non-existent file makes PHPStan fatal + // with no JSON on stdout — must surface as a failed run, never 0 findings. + $badConfig = $w->root . '/bad-consumer.neon'; + file_put_contents($badConfig, "includes:\n - /no/such/file/phpstan.neon\n"); + + $result = (new PhpStanRunner($this->phpstanBin))->run( + array_map(static fn (Representative $r): string => $r->filePath, $reps), + [$w->distDir, $w->generatedDir], + $badConfig, + $w->root . '/ephemeral.neon', + ); + + self::assertFalse($result->ranOk); + self::assertSame([], $result->findings); + self::assertNotNull($result->errorOutput); + self::assertNotSame('', $result->errorOutput); + }); + } + + public function testFindingPathMatchesRepresentativeEvenThroughASymlinkedRoot(): void + { + // Compile into a workspace whose root is reached via a symlink. PHPStan + // reports realpath()'d paths; the representative file path must resolve to + // the same canonical form, or the finding->representative join silently + // misses and the gate falsely passes. (Regression guard for that join.) + $realBase = sys_get_temp_dir() . '/xphp-real-' . uniqid('', true); + $linkBase = sys_get_temp_dir() . '/xphp-link-' . uniqid('', true); + mkdir($realBase, 0o755, true); + symlink($realBase, $linkBase); + + $sourceDir = realpath(__DIR__ . '/../fixture/check/body_type_error/source') + ?: throw new RuntimeException('fixture missing'); + $sources = (new NativeFileFinder()) + ->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $workspace = CompiledWorkspace::compile($this->compiler(), $sources, $sourceDir, $linkBase . '/ws'); + try { + $reps = RepresentativeSelector::select($workspace->registry, $workspace->generatedDir); + $result = $this->analyze($workspace, $reps); + + self::assertTrue($result->ranOk, 'phpstan should run; got: ' . ($result->errorOutput ?? '')); + self::assertCount(1, $result->findings); + self::assertSame($reps[0]->filePath, $result->findings[0]->file); + } finally { + $workspace->cleanup(); + unlink($linkBase); + @rmdir($realBase); + } + } + + public function testResolvedButInvalidBinaryIsAFailedRunNotAnException(): void + { + $this->withCompiled('check/body_type_error', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir); + $bogusBin = $w->root . '/not-really-phpstan'; + file_put_contents($bogusBin, "run( + array_map(static fn (Representative $r): string => $r->filePath, $reps), + [$w->distDir, $w->generatedDir], + null, + $w->root . '/ephemeral.neon', + ); + + self::assertFalse($result->ranOk); + self::assertSame([], $result->findings); + }); + } + + private function analyze(CompiledWorkspace $w, array $reps): PhpStanResult + { + $vendorDir = realpath(__DIR__ . '/../../vendor') ?: throw new RuntimeException('vendor dir missing'); + + return (new PhpStanRunner($this->phpstanBin))->run( + array_map(static fn (Representative $r): string => $r->filePath, $reps), + [$w->distDir, $w->generatedDir, $vendorDir], + null, // no consumer config → default level + $w->root . '/ephemeral.neon', + ); + } + + /** @param callable(CompiledWorkspace): void $assertions */ + private function withCompiled(string $fixture, callable $assertions): void + { + $sourceDir = realpath(__DIR__ . '/../fixture/' . $fixture . '/source') + ?: throw new RuntimeException("fixture missing: {$fixture}"); + $sources = (new NativeFileFinder()) + ->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $workspace = CompiledWorkspace::compile( + $this->compiler(), + $sources, + $sourceDir, + sys_get_temp_dir() . '/xphp-runner-' . uniqid('', true), + ); + try { + $assertions($workspace); + } finally { + $workspace->cleanup(); + } + } + + private function compiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } +} diff --git a/test/StaticAnalysis/RepresentativeSelectorTest.php b/test/StaticAnalysis/RepresentativeSelectorTest.php new file mode 100644 index 0000000..5014523 --- /dev/null +++ b/test/StaticAnalysis/RepresentativeSelectorTest.php @@ -0,0 +1,143 @@ +withCompiled('compile/multi_type', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir); + + self::assertCount(2, $reps); + $templates = array_map(static fn (Representative $r): string => $r->templateFqn, $reps); + self::assertContains('App\\MultiType\\Containers\\Pair', $templates); + self::assertContains('App\\MultiType\\Containers\\Map', $templates); + + foreach ($reps as $rep) { + self::assertFileExists($rep->filePath, "representative file should exist: {$rep->filePath}"); + self::assertStringStartsWith($w->generatedDir, $rep->filePath); + self::assertGreaterThan(0, $rep->declLine); + self::assertStringContainsString('<', $rep->label); + } + }); + } + + public function testSelectionIsDeterministic(): void + { + $this->withCompiled('compile/multi_type', function (CompiledWorkspace $w): void { + $first = RepresentativeSelector::select($w->registry, $w->generatedDir); + $second = RepresentativeSelector::select($w->registry, $w->generatedDir); + + $fqns = static fn (array $reps): array + => array_map(static fn (Representative $r): string => $r->generatedFqn, $reps); + + // Stable order, and each pick is the lexicographically smallest generatedFqn. + self::assertSame($fqns($first), $fqns($second)); + $sorted = $fqns($first); + $resorted = $sorted; + sort($resorted); + self::assertSame($resorted, $sorted, 'representatives must be sorted by generatedFqn'); + }); + } + + public function testEachTemplatePicksItsLexicographicallySmallestSpecialization(): void + { + $this->withCompiled('compile/multi_type', function (CompiledWorkspace $w): void { + // The expected pick per template: min generatedFqn among its instantiations. + $minByTemplate = []; + foreach ($w->registry->instantiations() as $inst) { + $current = $minByTemplate[$inst->templateFqn] ?? null; + if ($current === null || strcmp($inst->generatedFqn, $current) < 0) { + $minByTemplate[$inst->templateFqn] = $inst->generatedFqn; + } + } + + foreach (RepresentativeSelector::select($w->registry, $w->generatedDir) as $rep) { + self::assertSame( + $minByTemplate[$rep->templateFqn], + $rep->generatedFqn, + "template {$rep->templateFqn} should be represented by its smallest generatedFqn", + ); + } + }); + } + + public function testFilePathNormalisesTrailingSlashOnGeneratedDir(): void + { + $this->withCompiled('check/body_type_error', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir . '/'); + + self::assertStringNotContainsString('//', substr($reps[0]->filePath, 1)); + self::assertFileExists($reps[0]->filePath); + }); + } + + public function testRepresentativeMapsToTemplateDeclaration(): void + { + $this->withCompiled('check/body_type_error', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir); + + self::assertCount(1, $reps); + $box = $reps[0]; + self::assertSame('App\\Check\\BodyTypeError\\Box', $box->templateFqn); + self::assertSame('App\\Check\\BodyTypeError\\Box', $box->label); + self::assertStringEndsWith('Box.xphp', $box->declFile); + self::assertSame(7, $box->declLine); // `class Box` line + }); + } + + /** @param callable(CompiledWorkspace): void $assertions */ + private function withCompiled(string $fixture, callable $assertions): void + { + $sourceDir = realpath(__DIR__ . '/../fixture/' . $fixture . '/source') + ?: throw new RuntimeException("fixture missing: {$fixture}"); + $sources = (new NativeFileFinder()) + ->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $workspace = CompiledWorkspace::compile( + $this->compiler(), + $sources, + $sourceDir, + sys_get_temp_dir() . '/xphp-repsel-' . uniqid('', true), + ); + try { + $assertions($workspace); + } finally { + $workspace->cleanup(); + } + } + + private function compiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } +} diff --git a/test/StaticAnalysis/StaticAnalysisGatePureTest.php b/test/StaticAnalysis/StaticAnalysisGatePureTest.php new file mode 100644 index 0000000..a4c333c --- /dev/null +++ b/test/StaticAnalysis/StaticAnalysisGatePureTest.php @@ -0,0 +1,59 @@ +workingDir = realpath(__DIR__ . '/../..') ?: throw new RuntimeException('repo root missing'); + $bin = realpath($this->workingDir . '/vendor/bin/phpstan'); + if ($bin === false) { + self::markTestSkipped('phpstan binary not installed (vendor/bin/phpstan)'); + } + $this->bin = $bin; + } + + public function testReportsBodyTypeErrorMappedToTemplateDeclaration(): void + { + $diagnostics = $this->gate()->analyze( + ...$this->fixtureArgs('check/body_type_error'), + explicitBin: $this->bin, + explicitConfig: $this->level5Config(), + ); + + self::assertCount(1, $diagnostics); + $d = $diagnostics[0]; + self::assertSame(Severity::Error, $d->severity); + self::assertSame(DiagnosticSource::PhpStan, $d->source); + self::assertStringContainsString('should return int but returns string', $d->message); + self::assertSame('App\\Check\\BodyTypeError\\Box', $d->triggeredBy); + self::assertNotNull($d->location); + self::assertStringEndsWith('Box.xphp', $d->location->file); + self::assertSame(7, $d->location->line); + } + + public function testWorkspaceIsCleanedUpAfterAnalysis(): void + { + $before = glob(sys_get_temp_dir() . '/xphp-check-*') ?: []; + + $this->gate()->analyze( + ...$this->fixtureArgs('check/body_type_error'), + explicitBin: $this->bin, + explicitConfig: null, + ); + + $after = glob(sys_get_temp_dir() . '/xphp-check-*') ?: []; + // The finally-cleanup must leave no workspace behind (guards UnwrapFinally). + self::assertSame($before, $after); + } + + public function testCleanSourcesProduceNoDiagnostics(): void + { + $diagnostics = $this->gate()->analyze( + ...$this->fixtureArgs('compile/box_generic'), + explicitBin: $this->bin, + explicitConfig: $this->level5Config(), + ); + + self::assertSame([], $diagnostics); + } + + public function testMissingBinaryYieldsNonFailingWarning(): void + { + $diagnostics = $this->gate()->analyze( + ...$this->fixtureArgs('check/body_type_error'), + explicitBin: '/definitely/not/a/real/phpstan', + explicitConfig: null, + ); + + self::assertCount(1, $diagnostics); + self::assertSame(StaticAnalysisGate::CODE_UNAVAILABLE, $diagnostics[0]->code); + self::assertSame(Severity::Warning, $diagnostics[0]->severity); + self::assertFalse($diagnostics[0]->severity->isFailing()); + self::assertStringContainsString('PHPStan was not found', $diagnostics[0]->message); + } + + public function testFailedRunYieldsNonFailingWarning(): void + { + $badConfig = sys_get_temp_dir() . '/xphp-bad-config-' . uniqid('', true) . '.neon'; + file_put_contents($badConfig, "includes:\n - /no/such/file.neon\n"); + + try { + $diagnostics = $this->gate()->analyze( + ...$this->fixtureArgs('check/body_type_error'), + explicitBin: $this->bin, + explicitConfig: $badConfig, + ); + + self::assertCount(1, $diagnostics); + self::assertSame(StaticAnalysisGate::CODE_RUN_FAILED, $diagnostics[0]->code); + self::assertSame(Severity::Warning, $diagnostics[0]->severity); + self::assertStringStartsWith('PHPStan could not complete: ', $diagnostics[0]->message); + } finally { + unlink($badConfig); + } + } + + public function testSourcesWithoutGenericInstantiationsProduceNoDiagnostics(): void + { + // A plain (non-generic) source has no specializations, so there's nothing + // for PHPStan to add over the generic checks. + $dir = sys_get_temp_dir() . '/xphp-plain-' . uniqid('', true); + mkdir($dir . '/source', 0o755, true); + file_put_contents( + $dir . '/source/Plain.xphp', + "find($dir . '/source') + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $diagnostics = $this->gate()->analyze($sources, $dir . '/source', $this->workingDir, $this->bin, null); + + self::assertSame([], $diagnostics); + } finally { + unlink($dir . '/source/Plain.xphp'); + rmdir($dir . '/source'); + rmdir($dir); + } + } + + private function level5Config(): string + { + // A fixed, minimal consumer config so the body-error assertions don't depend + // on (or drift with) the repo's own phpstan.neon picked up via getcwd(). + return realpath(__DIR__ . '/../fixture/check/phpstan-level5.neon') + ?: throw new RuntimeException('level5 config fixture missing'); + } + + /** @return array{FilepathArray, string, string} */ + private function fixtureArgs(string $fixture): array + { + $sourceDir = realpath(__DIR__ . '/../fixture/' . $fixture . '/source') + ?: throw new RuntimeException("fixture missing: {$fixture}"); + $sources = (new NativeFileFinder()) + ->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + return [$sources, $sourceDir, $this->workingDir]; + } + + private function gate(): StaticAnalysisGate + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + + return new StaticAnalysisGate($compiler); + } +} diff --git a/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php b/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php index 854ff4a..0981453 100644 --- a/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php +++ b/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php @@ -16,7 +16,7 @@ use XPHP\TestSupport\SnapshotHash; /** - * Regression coverage for ticket 0001: a generic that `extends`/`implements` a + * Regression coverage: a generic that `extends`/`implements` a * built-in interface — or instantiates / catches an imported class — via a bare * `use` import used to keep those bare names when relocated into the * `XPHP\Generated\...` namespace, where they resolved to a non-existent diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php new file mode 100644 index 0000000..6e8131e --- /dev/null +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -0,0 +1,537 @@ +check('multi_error'); + + self::assertTrue($diagnostics->hasErrors()); + self::assertCount(2, $diagnostics->all()); + foreach ($diagnostics->all() as $d) { + self::assertSame(Registry::CODE_BOUND_VIOLATION, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('Use.xphp', $d->location->file); + } + } + + public function testCleanSourcesProduceNoDiagnostics(): void + { + $diagnostics = $this->check('clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testDefaultBoundViolationIsCollectedByCheck(): void + { + // Exercises the validateDefaultsAgainstBounds() step of check(). + $diagnostics = $this->check('default_violation'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_DEFAULT_BOUND_VIOLATION, $diagnostics->all()[0]->code); + } + + public function testVariancePositionViolationIsCollectedByCheck(): void + { + // Exercises the validateVariancePositions() step of check(). + $diagnostics = $this->check('variance_violation'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(VariancePositionValidator::CODE_VARIANCE_POSITION, $diagnostics->all()[0]->code); + } + + public function testVarianceEdgeUnprovableIsReportedAsNonFailingWarning(): void + { + // A covariant template instantiated over an element type not in the source set: + // the `extends` edge is silently dropped today; check now reports it as a + // non-failing Warning at the instantiation site (so exit stays 0). + $diagnostics = $this->check('variance_edge_unprovable'); + + self::assertFalse($diagnostics->hasErrors(), 'a warning must not fail the gate'); + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Registry::CODE_VARIANCE_EDGE_UNPROVABLE, $d->code); + self::assertSame(\XPHP\Diagnostics\Severity::Warning, $d->severity); + self::assertNotNull($d->location); + self::assertStringEndsWith('Use.xphp', $d->location->file); + self::assertStringContainsString('Book', $d->message); + self::assertStringContainsString('not in the source set', $d->message); + } + + public function testVarianceEdgeProvableTypesProduceNoWarning(): void + { + // Same covariant template, but the element type IS declared in the source set — + // its edges are provable, so nothing is reported. + $diagnostics = $this->check('variance_edge_provable'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testCompileDoesNotFailOnUnprovableVarianceEdge(): void + { + // Compile has no warning sink; the unprovable edge is skipped exactly as before + // (autoload-safe), and compilation succeeds without throwing. + $work = sys_get_temp_dir() . '/xphp-check-compile-' . uniqid('', true); + mkdir($work, 0o755, true); + try { + $result = $this->buildCompiler()->compile( + $this->sources('variance_edge_unprovable'), + $this->sourceDir('variance_edge_unprovable'), + $work . '/dist', + $work . '/cache', + ); + self::assertGreaterThan(0, $result->generatedCount); + } finally { + self::rrmdir($work); + } + } + + public function testCompileStillThrowsOnVariancePositionViolation(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('not allowed for covariant variance'); + + $work = sys_get_temp_dir() . '/xphp-check-compile-' . uniqid('', true); + mkdir($work, 0o755, true); + try { + $this->buildCompiler()->compile($this->sources('variance_violation'), $this->sourceDir('variance_violation'), $work . '/dist', $work . '/cache'); + } finally { + self::rrmdir($work); + } + } + + public function testInnerVarianceViolationIsCollectedByCheck(): void + { + // Composition case the position check misses → only inner-variance reports it, + // and the position check does NOT also flag it (no double report). + $diagnostics = $this->check('inner_variance'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(InnerVarianceValidator::CODE_INNER_VARIANCE, $d->code); + // Located at the `Container` return type in P.xphp (line 12). + self::assertNotNull($d->location); + self::assertStringEndsWith('P.xphp', $d->location->file); + self::assertSame(12, $d->location->line); + } + + public function testMissingTypeArgumentIsCollectedByCheck(): void + { + $diagnostics = $this->check('missing_arg'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_MISSING_TYPE_ARGUMENT, $diagnostics->all()[0]->code); + self::assertNotNull($diagnostics->all()[0]->location); + } + + public function testUndefinedTemplateIsCollectedByCheck(): void + { + // Exercises the collectUndefinedTemplates() step of check(). + $diagnostics = $this->check('undefined_template'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_UNDEFINED_TEMPLATE, $diagnostics->all()[0]->code); + } + + public function testGenericFunctionBoundViolationIsCollectedByCheck(): void + { + $diagnostics = $this->check('generic_function_bound'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Registry::CODE_BOUND_VIOLATION, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('Use.xphp', $d->location->file); + self::assertSame(8, $d->location->line); + } + + public function testGenericMethodMissingArgumentIsCollectedByCheck(): void + { + $diagnostics = $this->check('generic_method_missing_arg'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Registry::CODE_MISSING_TYPE_ARGUMENT, $d->code); + self::assertNotNull($d->location); + self::assertSame(9, $d->location->line); + } + + public function testDuplicateGenericFunctionIsCollectedByCheck(): void + { + // Two functions re-declared in the second file → BOTH duplicates collected in one run + // (the loop must `continue`, not `break`, after the first). + $diagnostics = $this->check('duplicate_generic_function'); + + self::assertCount(2, $diagnostics->all()); + foreach ($diagnostics->all() as $d) { + self::assertSame(GenericMethodCompiler::CODE_DUPLICATE_GENERIC_FUNCTION, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('b.xphp', $d->location->file); + } + } + + public function testThisCapturingGenericClosureIsCollectedByCheck(): void + { + $diagnostics = $this->check('closure_this_capture'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(GenericMethodCompiler::CODE_UNSUPPORTED_THIS_CAPTURE, $d->code); + self::assertNotNull($d->location); + self::assertSame(17, $d->location->line); + } + + public function testStaticGenericClosureIsCollectedByCheck(): void + { + $diagnostics = $this->check('closure_static'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(GenericMethodCompiler::CODE_UNSUPPORTED_STATIC_CLOSURE, $d->code); + self::assertNotNull($d->location); + self::assertSame(12, $d->location->line); + } + + public function testCompileStillThrowsOnStaticGenericClosure(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('static closures cannot yet be specialized'); + $this->compileFixture('closure_static'); + } + + public function testUnresolvedGenericMethodTurbofishIsCollectedByCheck(): void + { + // A turbofish call to a generic method that exists nowhere on the receiver + // or its ancestors is a collected error (not a silent pass-through that + // would fatal at runtime). + $diagnostics = $this->check('unresolved_generic_method'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(GenericMethodCompiler::CODE_UNRESOLVED_GENERIC_CALL, $d->code); + self::assertNotNull($d->location); + self::assertSame(16, $d->location->line); + self::assertStringContainsString('nope', $d->message); + } + + public function testCompileStillThrowsOnUnresolvedGenericMethodTurbofish(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('could not be resolved'); + $this->compileFixture('unresolved_generic_method'); + } + + public function testClassAndMethodLevelErrorsAreBothCollectedInOneRun(): void + { + // Proves the validation-superset guarantee: a class-level check (variance) AND a + // method-level check (generic-function bound) both report in a single `check` run. + $diagnostics = $this->check('method_and_class_errors'); + + $codes = array_map(static fn ($d): string => $d->code, $diagnostics->all()); + self::assertContains(VariancePositionValidator::CODE_VARIANCE_POSITION, $codes); + self::assertContains(Registry::CODE_BOUND_VIOLATION, $codes); + } + + public function testCompileStillThrowsOnGenericFunctionBound(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated'); + $this->compileFixture('generic_function_bound'); + } + + public function testUndeclaredTypeParametersAreCollected(): void + { + // Two stray type names (`T`, `U`) in one template → both reported in one run; + // the declared `Z`, the scalar `int`, and the in-source class `Box` are clean. + $diagnostics = $this->check('undeclared_type_param'); + + self::assertCount(2, $diagnostics->all()); + $byName = []; + foreach ($diagnostics->all() as $d) { + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('CollectionInterface.xphp', $d->location->file); + preg_match('/Type `(\w+)`/', $d->message, $m); + $byName[$m[1]] = $d->location->line; + } + self::assertSame(['T', 'U'], array_keys($byName)); + self::assertSame(11, $byName['T']); // `add(T $element)` line + self::assertSame(13, $byName['U']); // `wrap(U $value)` line + } + + public function testUndeclaredTypesAreCaughtInEveryMemberPosition(): void + { + // property, constructor-promoted param, return, nullable param, union return, + // intersection param, and a nested closure signature — each stray name is flagged. + $diagnostics = $this->check('undeclared_type_param_positions'); + + $names = []; + foreach ($diagnostics->all() as $d) { + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $d->code); + preg_match('/Type `(\w+)`/', $d->message, $m); + $names[$m[1]] = true; + } + ksort($names); + self::assertSame( + ['Clo', 'CloRet', 'InA', 'InB', 'Nul', 'Promo', 'Prop', 'Ret', 'UnA', 'UnB'], + array_keys($names), + ); + } + + public function testUndeclaredTypesInMethodAndFunctionGenericsAreCollected(): void + { + // A generic method on a plain class and a free generic function — both + // outside any generic template — are validated by the method-level pass. + $diagnostics = $this->check('undeclared_type_param_method'); + + $contexts = []; + foreach ($diagnostics->all() as $d) { + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $d->code); + self::assertNotNull($d->location); + preg_match('/Type `(\w+)` used in (.+?) is not/', $d->message, $m); + $contexts[$m[1]] = $m[2]; + } + ksort($contexts); + self::assertSame(['B', 'C', 'D', 'E'], array_keys($contexts)); + self::assertSame('method `pick`', $contexts['B']); + self::assertSame('function `wrap`', $contexts['C']); + self::assertSame('closure', $contexts['D']); + self::assertSame('arrow function', $contexts['E']); + } + + public function testGenericMethodInsideGenericTemplateIsReportedExactlyOnce(): void + { + // The class-member walk owns it; the method-level pass skips generics nested + // in a generic template — so no double report. + $diagnostics = $this->check('undeclared_type_param_nested_method'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $diagnostics->all()[0]->code); + self::assertStringContainsString('Type `Stray`', $diagnostics->all()[0]->message); + } + + public function testCompileStillThrowsOnUndeclaredTypeInGenericFunction(): void + { + // The first finding in source order is `B` in method `pick`. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Type `B` used in method `pick`'); + $this->compileFixture('undeclared_type_param_method'); + } + + public function testUndeclaredNameInABoundIsCollected(): void + { + $diagnostics = $this->check('undeclared_bound'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $diagnostics->all()[0]->code); + self::assertStringContainsString('Type `Nonexistent`', $diagnostics->all()[0]->message); + } + + public function testUndeclaredNameInADefaultIsCollected(): void + { + $diagnostics = $this->check('undeclared_default'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $diagnostics->all()[0]->code); + self::assertStringContainsString('Type `Nonexistent`', $diagnostics->all()[0]->message); + } + + public function testUndeclaredNamesInIntersectionBoundAndGenericArgAreCollected(): void + { + // `Bad1` is a leaf of an intersection bound; `Bad2` is a type argument of a + // declared generic bound (`Holder`). Both stray names are reported. + $diagnostics = $this->check('undeclared_bound_nested'); + + $names = []; + foreach ($diagnostics->all() as $d) { + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $d->code); + preg_match('/Type `(\w+)`/', $d->message, $m); + $names[$m[1]] = true; + } + ksort($names); + self::assertSame(['Bad1', 'Bad2'], array_keys($names)); + } + + public function testBuiltinImportedAndParamRefBoundsAndDefaultsAreClean(): void + { + $diagnostics = $this->check('undeclared_bound_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testSameUndeclaredNameInBoundAndDefaultIsReportedOnce(): void + { + $diagnostics = $this->check('undeclared_bound_dedup'); + + self::assertCount(1, $diagnostics->all()); + self::assertStringContainsString('Type `Bad`', $diagnostics->all()[0]->message); + } + + public function testCompileStillThrowsOnUndeclaredBound(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Type `Nonexistent` used in template `App\\BadBound\\Box`'); + $this->compileFixture('undeclared_bound'); + } + + public function testTooManyTypeArgumentsAreCollected(): void + { + // Box declares one parameter; two instantiations pass two args each → two + // findings (not silent truncation), at their call-site lines. + $diagnostics = $this->check('too_many_type_args'); + + self::assertCount(2, $diagnostics->all()); + $lines = []; + foreach ($diagnostics->all() as $d) { + self::assertSame(Registry::CODE_TOO_MANY_TYPE_ARGUMENTS, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('Use.xphp', $d->location->file); + $lines[] = $d->location->line; + } + sort($lines); + self::assertSame([8, 9], $lines); + } + + public function testCompileStillThrowsOnTooManyTypeArguments(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('declares 1 type parameter(s) but was instantiated with 2'); + $this->compileFixture('too_many_type_args'); + } + + public function testImportedAndFullyQualifiedTypesAreNotFlaggedAsUndeclared(): void + { + $diagnostics = $this->check('undeclared_type_param_escape'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testCompileStillThrowsOnUndeclaredTypeParameter(): void + { + // Throws the FIRST finding (the `T` in add(), before `U` in wrap()), not a + // silent broken emit. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Type `T` used in template `App\\Undeclared\\CollectionInterface`'); + $this->compileFixture('undeclared_type_param'); + } + + public function testCompileStillThrowsOnGenericMethodMissingArgument(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('has no default'); + $this->compileFixture('generic_method_missing_arg'); + } + + public function testCompileStillThrowsOnDuplicateGenericFunction(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('already declared'); + $this->compileFixture('duplicate_generic_function'); + } + + public function testCompileStillThrowsOnThisCapturingGenericClosure(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('captures `$this`'); + $this->compileFixture('closure_this_capture'); + } + + public function testCompileStillThrowsOnUndefinedTemplate(): void + { + // The same condition is a hard error in compile-mode (no collector) — confirms the + // shared message builder keeps the throw path intact. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('was instantiated but never defined'); + + $work = sys_get_temp_dir() . '/xphp-check-compile-' . uniqid('', true); + mkdir($work, 0o755, true); + try { + $this->buildCompiler()->compile($this->sources('undefined_template'), $this->sourceDir('undefined_template'), $work . '/dist', $work . '/cache'); + } finally { + self::rrmdir($work); + } + } + + private function compileFixture(string $fixture): void + { + $work = sys_get_temp_dir() . '/xphp-check-compile-' . uniqid('', true); + mkdir($work, 0o755, true); + try { + $this->buildCompiler()->compile($this->sources($fixture), $this->sourceDir($fixture), $work . '/dist', $work . '/cache'); + } finally { + self::rrmdir($work); + } + } + + private function check(string $fixture): DiagnosticCollector + { + return $this->buildCompiler()->check($this->sources($fixture)); + } + + private function sources(string $fixture): FilepathArray + { + return (new NativeFileFinder()) + ->find($this->sourceDir($fixture)) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + } + + private function sourceDir(string $fixture): string + { + return realpath(__DIR__ . '/../../fixture/check/' . $fixture . '/source') + ?: throw new RuntimeException("Fixture missing: {$fixture}"); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } + + private function buildCompiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } +} diff --git a/test/Transpiler/Monomorphize/CompilerDepthCapTest.php b/test/Transpiler/Monomorphize/CompilerDepthCapTest.php index 61368ce..fd5c737 100644 --- a/test/Transpiler/Monomorphize/CompilerDepthCapTest.php +++ b/test/Transpiler/Monomorphize/CompilerDepthCapTest.php @@ -52,7 +52,12 @@ public function testRecursiveTemplateExceedsDepthCapAndThrows(): void ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); $this->expectException(RuntimeException::class); - $this->expectExceptionMessageMatches('/Nested generic specialization exceeded depth \d+\./'); + // The localized diagnostic names the growing type family (here the recursive template) instead + // of dumping the whole registry. + $this->expectExceptionMessageMatches( + '/Generic specialization did not converge \(exceeded depth \d+\): the type family rooted at ' + . '"App\\\\[^"]*Recursive"/', + ); $compiler->compile( $sources, $sourceDir, diff --git a/test/Transpiler/Monomorphize/CompilerIntegrationTest.php b/test/Transpiler/Monomorphize/CompilerIntegrationTest.php index 47c0210..09841aa 100644 --- a/test/Transpiler/Monomorphize/CompilerIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CompilerIntegrationTest.php @@ -8,6 +8,7 @@ use PhpParser\PrettyPrinter\Standard as StandardPrinter; use PHPUnit\Framework\TestCase; use XPHP\FileSystem\FileFinder\NativeFileFinder; +use XPHP\FileSystem\FilepathArray; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; use XPHP\TestSupport\SnapshotHash; @@ -109,6 +110,76 @@ public function testPsr4SourceLayoutMirrorsToTarget(): void ); } + public function testMultiRootEmitComputesEachFileRelativeToItsOwnRoot(): void + { + // Two source roots: each file must emit relative to ITS root (no flatten). Without the + // root map, the second root's file would collapse to dist/Thing.php via basename(). + $rootA = $this->workDir . '/rootA'; + $rootB = $this->workDir . '/rootB'; + mkdir($rootA . '/Containers', 0o755, true); + mkdir($rootB . '/Models', 0o755, true); + $boxFile = $rootA . '/Containers/Box.xphp'; + $thingFile = $rootB . '/Models/Thing.xphp'; + file_put_contents($boxFile, " $rootA, $thingFile => $rootB]; + + $this->buildCompiler()->compile($sources, $rootA, $this->targetDir, $this->cacheDir, $rootByFile); + + self::assertFileExists($this->targetDir . '/Containers/Box.php', 'rootA file relative to rootA'); + self::assertFileExists($this->targetDir . '/Models/Thing.php', 'rootB file relative to rootB'); + self::assertFileDoesNotExist( + $this->targetDir . '/Thing.php', + 'a second root must not flatten to the target top level', + ); + } + + public function testFileAbsentFromRootMapFallsBackToSourceDir(): void + { + // A file present in $sources but absent from $rootByFile uses $sourceDir as its base + // (the defensive `?? $sourceDir` fallback), while a mapped file uses its own root. + $rootA = $this->workDir . '/fbA'; + $rootB = $this->workDir . '/fbB'; + mkdir($rootA . '/Sub', 0o755, true); + mkdir($rootB . '/Models', 0o755, true); + $mapped = $rootB . '/Models/Mapped.xphp'; + $unmapped = $rootA . '/Sub/Unmapped.xphp'; + file_put_contents($mapped, " $rootB]; + + $this->buildCompiler()->compile($sources, $rootA, $this->targetDir, $this->cacheDir, $rootByFile); + + self::assertFileExists($this->targetDir . '/Models/Mapped.php', 'mapped file relative to its root'); + self::assertFileExists($this->targetDir . '/Sub/Unmapped.php', 'unmapped file relative to $sourceDir fallback'); + } + + public function testEmitPathCollisionAcrossRootsIsAnError(): void + { + // Two roots each holding a file at the same relative path → same target path. That must be + // a hard error, not a silent overwrite. + $rootA = $this->workDir . '/cA'; + $rootB = $this->workDir . '/cB'; + mkdir($rootA, 0o755, true); + mkdir($rootB, 0o755, true); + $a = $rootA . '/Dup.xphp'; + $b = $rootB . '/Dup.xphp'; + file_put_contents($a, " $rootA, $b => $rootB]; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Emit path collision'); + $this->buildCompiler()->compile($sources, $rootA, $this->targetDir, $this->cacheDir, $rootByFile); + } + public function testGeneratedCodeIsLoadableViaPsr4Autoloader(): void { $compiler = $this->buildCompiler(); diff --git a/test/Transpiler/Monomorphize/EnclosingBoundErasureTest.php b/test/Transpiler/Monomorphize/EnclosingBoundErasureTest.php new file mode 100644 index 0000000..752662a --- /dev/null +++ b/test/Transpiler/Monomorphize/EnclosingBoundErasureTest.php @@ -0,0 +1,219 @@ + { + public function contains(U $value): bool { return true; } + public function probe(U $value): bool { return $this->contains::($value); } + public function pair(U $a, V $b): bool { return true; } + public function addAll(Box $items): void {} + public function echoBack(U $x): U { return $x; } + public function inspect(U $x): bool { return $x instanceof U; } + public function mixedUse(U $a, Box $b): bool { return true; } + public function plain(T $x): T { return $x; } + public function viaStringable(U $x): bool { return true; } + public function compound(U $x): bool { return true; } + public function noInput(): bool { return true; } + public function nullableInput(?U $x): bool { return true; } + public function unionInput(U|Fruit $x): bool { return true; } + public function construct(U $x): bool { return (new Box::()) instanceof Box; } + public function nullableReturn(U $x): ?U { return $x; } + public function unionSecond(U $a, U|Fruit $b): bool { return true; } + public function returnsConcrete(U $x): Fruit { return new Fruit(); } + public function pairMixed(U $a, Box $b): bool { return true; } + public function unionConcrete(U $a, Fruit|Banana $b): bool { return true; } + public function mixedBounds(U $a, W $b): bool { return true; } + } + PHP; + + private function erasable(string $methodName): bool + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse(self::SOURCE); + + $finder = new NodeFinder(); + $class = $finder->findFirst($ast, static fn (Node $n): bool => $n instanceof Class_); + self::assertInstanceOf(Class_::class, $class); + $method = $finder->findFirst([$class], static fn (Node $n): bool => $n instanceof ClassMethod && $n->name->toString() === $methodName); + self::assertInstanceOf(ClassMethod::class, $method); + + /** @var list $classParams */ + $classParams = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS) ?? []; + /** @var list $methodParams */ + $methodParams = $method->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS) ?? []; + $classParamNames = array_map(static fn (TypeParam $p): string => $p->name, $classParams); + + return EnclosingBoundErasure::isErasable($method, $methodParams, $classParamNames); + } + + public function testBareInputParamIsErasable(): void + { + self::assertTrue($this->erasable('contains')); + } + + public function testForwardedTurbofishStaysErasable(): void + { + // `$this->contains::()` forwards U in an attribute the AST Name walk never sees. + self::assertTrue($this->erasable('probe')); + } + + public function testMultipleBareInputParamsAreErasable(): void + { + self::assertTrue($this->erasable('pair')); + } + + public function testNestedParamIsNotErasable(): void + { + // `Box $items` — U is observable nested in the arg, so erasing it would change behaviour. + self::assertFalse($this->erasable('addAll')); + } + + public function testReturnPositionIsNotErasable(): void + { + self::assertFalse($this->erasable('echoBack')); + } + + public function testStructuralBodyUseIsNotErasable(): void + { + // `$x instanceof U` observes the concrete U. + self::assertFalse($this->erasable('inspect')); + } + + public function testMixedTopLevelAndNestedIsNotErasable(): void + { + self::assertFalse($this->erasable('mixedUse')); + } + + public function testUnboundedMethodGenericIsNotErasable(): void + { + // No enclosing-parameter bound → not an `` method → stays per-U. + self::assertFalse($this->erasable('plain')); + } + + public function testBoundOnARealInterfaceIsNotErasable(): void + { + // `` — the bound is a real interface, not an enclosing class parameter. + self::assertFalse($this->erasable('viaStringable')); + } + + public function testCompoundBoundIsNotErasable(): void + { + // `` — erasing U to E would drop the \Stringable half; keep per-U. + self::assertFalse($this->erasable('compound')); + } + + public function testBoundedParamUnusedAsInputIsNotErasable(): void + { + // U is declared and enclosing-bounded but never a direct input — not the erasable shape. + self::assertFalse($this->erasable('noInput')); + } + + public function testNullableInputIsNotErasable(): void + { + // `?U $x` is not a bare top-level slot — conservatively not erasable. + self::assertFalse($this->erasable('nullableInput')); + } + + public function testUnionInputIsNotErasable(): void + { + // `U|Fruit $x` mentions U inside a union — not a bare top-level slot. + self::assertFalse($this->erasable('unionInput')); + } + + public function testNestedConstructionInBodyIsNotErasable(): void + { + // `new Box::()` in the body makes the concrete U observable. + self::assertFalse($this->erasable('construct')); + } + + public function testBoundedInNullableReturnIsNotErasable(): void + { + // U is a bare input but also appears in the nullable return `?U` — observable, so not erasable. + self::assertFalse($this->erasable('nullableReturn')); + } + + public function testBoundedInAUnionParamIsNotErasable(): void + { + // First param is a bare input, but a second param `U|Fruit` mentions U in a union. + self::assertFalse($this->erasable('unionSecond')); + } + + public function testBareInputWithConcreteReturnIsErasable(): void + { + // U only as a bare input; a concrete (non-U) return type doesn't block erasure. + self::assertTrue($this->erasable('returnsConcrete')); + } + + public function testSecondBoundedParamNestedIsNotErasable(): void + { + // U is a bare input but a second bounded param V appears nested in `Box`. + self::assertFalse($this->erasable('pairMixed')); + } + + public function testConcreteUnionSecondParamStaysErasable(): void + { + // A second param `Fruit|Banana` (no bounded member) doesn't block erasure of a bare-input U. + self::assertTrue($this->erasable('unionConcrete')); + } + + public function testMixedBoundedAndNonBoundedParamsIsNotErasable(): void + { + // `` — W needs real per-W specialization, so the method is not erasable. + self::assertFalse($this->erasable('mixedBounds')); + } + + public function testMangleArgsKeysOnTheBoundsConcreteValue(): void + { + $fruit = new TypeRef('App\\Fruit'); + $classConcrete = ['E' => $fruit]; + + // → [Fruit] + $u = new TypeParam('U', new BoundLeaf(new TypeRef('E', isTypeParam: true))); + self::assertEquals([$fruit], EnclosingBoundErasure::mangleArgs([$u], $classConcrete)); + + // → [Fruit, Fruit] + $v = new TypeParam('V', new BoundLeaf(new TypeRef('E', isTypeParam: true))); + self::assertEquals([$fruit, $fruit], EnclosingBoundErasure::mangleArgs([$u, $v], $classConcrete)); + + // a sibling/method-bounded param (not a class param) contributes nothing. + $w = new TypeParam('W', new BoundLeaf(new TypeRef('U', isTypeParam: true))); + self::assertEquals([$fruit], EnclosingBoundErasure::mangleArgs([$u, $w], $classConcrete)); + + // multi-class-param: on Map keyed on V's concrete. + $banana = new TypeRef('App\\Banana'); + $uv = new TypeParam('U', new BoundLeaf(new TypeRef('V', isTypeParam: true))); + self::assertEquals([$banana], EnclosingBoundErasure::mangleArgs([$uv], ['K' => new TypeRef('App\\Key'), 'V' => $banana])); + } + + public function testRefTreeHasBoundedDirectly(): void + { + self::assertTrue(EnclosingBoundErasure::refTreeHasBounded(new TypeRef('U', isTypeParam: true), ['U'])); + self::assertTrue(EnclosingBoundErasure::refTreeHasBounded( + new TypeRef('Box', [new TypeRef('U', isTypeParam: true)]), + ['U'], + )); + // A real class named like a bound, but not a type parameter, doesn't match. + self::assertFalse(EnclosingBoundErasure::refTreeHasBounded(new TypeRef('U'), ['U'])); + // A type parameter that isn't in the bounded set doesn't match. + self::assertFalse(EnclosingBoundErasure::refTreeHasBounded(new TypeRef('K', isTypeParam: true), ['U'])); + } +} diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php new file mode 100644 index 0000000..66cf158 --- /dev/null +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -0,0 +1,1920 @@ +`) against the receiver's concrete type argument — the element-consuming + * method shape on a covariant collection. Accept/reject/multi-arg pin that the bound is checked + * against the *grounded* type (not the literal `E`). When the receiver's type argument genuinely + * isn't determinable (and it isn't a `$this` self-call), the bound is unprovable and the build + * fails with `xphp.bound_unprovable` — ground or fail, never a silent accept. + */ +final class EnclosingParamBoundIntegrationTest extends TestCase +{ + private string $work; + + protected function setUp(): void + { + $this->work = sys_get_temp_dir() . '/xphp-encbound-' . uniqid('', true); + mkdir($this->work, 0o755, true); + } + + protected function tearDown(): void + { + self::rrmdir($this->work); + } + + /** Shared model: Banana extends Fruit extends Food. */ + private const MODELS = <<<'PHP' + compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + $box->contains::(new Banana()); + PHP, + ]); + + // Compiled without a bound violation, and the call site was specialized (the method was + // mangled, not left as a bare `contains`). + self::assertStringContainsString('contains_', self::read($dist, 'Use.php')); + } + + public function testInheritedEnclosingParamBoundAcceptsSubtype(): void + { + // `contains` is declared on a generic BASE; the receiver is a subclass. Grounding must + // thread the receiver's `Fruit` through `extends Base` to the base's `E`. + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Base.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + } + PHP, + 'ArrayList.xphp' => <<<'PHP' + extends Base {} + PHP, + 'Use.xphp' => <<<'PHP' + (); + $list->contains::(new Banana()); + PHP, + ]); + + $this->addToAssertionCount(1); // reaching here = compiled with no bound violation. + } + + public function testMultiArgEnclosingParamBoundGroundsTheRightParameter(): void + { + // `Pair::containsValue` — grounding must pick V (index 1), not K. If it used + // K (Food), `Banana <: Food` would also pass, so make K a type Banana is NOT a subtype of. + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Key.xphp' => " <<<'PHP' + { + public function containsValue(U $value): bool { return true; } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + $pair->containsValue::(new Banana()); + PHP, + ]); + + $this->addToAssertionCount(1); + } + + public function testEnclosingParamBoundRejectsNonSubtypeWithGroundedMessage(): void + { + // Box::contains — Fruit is NOT a subtype of Banana, so this must reject, and + // the message must show the GROUNDED bound (`Banana`), not the literal type parameter `E`. + try { + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + $box->contains::(new Fruit()); + PHP, + ]); + self::fail('Expected a bound violation for Box::contains.'); + } catch (RuntimeException $e) { + $msg = $e->getMessage(); + self::assertStringContainsString('Generic bound violated', $msg); + self::assertStringContainsString('extend/implement "App\\Banana"', $msg, 'bound must be grounded to the receiver arg Banana'); + self::assertStringNotContainsString('"E"', $msg, 'must not report the literal type parameter E'); + } + } + + public function testUnboundedMethodGenericIsUnaffected(): void + { + $dist = $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function pick(U $value): U { return $value; } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + $box->pick::(new Banana()); + PHP, + ]); + + self::assertStringContainsString('pick_', self::read($dist, 'Use.php')); + } + + public function testThisSelfCallWithConcreteTurbofishIsUnprovableAndHardFails(): void + { + // `$this->contains::()` inside the template body. Whether `Banana : E` holds is + // instance-dependent (true for Box, false for Box) and the bound checker only + // runs on the abstract template, so it is checked by nobody. Ground or fail → compile error, + // with the `$this`-specific remedy (a self-call can't bind to a typed local). + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('`$this`-rooted self-call'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + public function probe(): bool { return $this->contains::(new Banana()); } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + PHP, + ]); + } + + public function testParameterReceiverGroundsAndRejects(): void + { + // A parameter typed `Box` must ground `contains` to Banana; Fruit is not a + // subtype, so this rejects — proving the param's type args are tracked and grounded. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + $b): bool { return $b->contains::(new Fruit()); } + PHP, + ]); + } + + public function testPropertyReceiverGroundsAndRejects(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Holder.xphp' => <<<'PHP' + $b; + public function run(): bool { return $this->b->contains::(new Fruit()); } + } + PHP, + ]); + } + + public function testBranchMergeDisagreementIsUnprovableAndHardFails(): void + { + // Both arms assign a Box but with DIFFERENT args (Fruit vs Banana); the FQN merges (still Box) + // yet the args conflict and are dropped, so the receiver's element type is undeterminable. The + // `` bound can't be proven and — not being a `$this` self-call — must fail at compile + // time rather than silently drop (Maximum Runtime Safety: ground or fail). + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot verify generic bound `U : E`'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + (); } else { $box = new Box::(); } + return $box->contains::(new Food()); + } + PHP, + ]); + } + + public function testBranchMergeAgreementGroundsAndAcceptsSubtype(): void + { + // Both arms assign Box — the arms AGREE, so the merge keeps the element type and the + // receiver is determined to be Box. The bound grounds to Fruit and `Banana` (a Fruit) + // is accepted and the call specialized. + $dist = $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + (); } else { $box = new Box::(); } + return $box->contains::(new Banana()); + } + PHP, + ]); + + self::assertStringContainsString('contains_', self::read($dist, 'Use.php')); + } + + public function testBranchMergeAgreementGroundsAndRejectsNonSubtype(): void + { + // Both arms assign Box — the arms agree, so the args survive the merge and ground the + // call to Banana. `Fruit` is not a subtype of Banana, so the determined receiver rejects it + // (a knowable type is never dropped → a determinate violation is never silently accepted). + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + (); } else { $box = new Box::(); } + return $box->contains::(new Fruit()); + } + PHP, + ]); + } + + public function testArgsSurviveABranchThatDoesNotTouchTheReceiver(): void + { + // The receiver is set before the branch and never reassigned inside it, so its args survive + // and ground the call — Fruit is not a subtype of Banana, so it still rejects. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + (); + if ($c) { $unrelated = 1; } + return $box->contains::(new Fruit()); + } + PHP, + ]); + } + + public function testClosureUseReceiverGroundsAndRejects(): void + { + // `use ($box)` imports the outer local's args into the closure scope, so the bound grounds + // to Banana inside the closure body and rejects Fruit. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + (); + return function () use ($box): bool { return $box->contains::(new Fruit()); }; + } + PHP, + ]); + } + + // --- receiver type from a method return / chain / self-static --- + + public function testReturnTypedLocalReceiverGroundsAndRejects(): void + { + // `$x = $repo->getBox()` where `getBox(): Box` — the local's element type comes from + // the declared return type, grounds to Fruit, and `Food` (a supertype) is rejected. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Fruit"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Repo.xphp' => self::repo(), + 'Use.xphp' => <<<'PHP' + getBox(); + return $x->contains::(new Food()); + } + PHP, + ]); + } + + public function testChainedReturnReceiverGroundsAndAccepts(): void + { + // `$repo->getBox()->contains::()` — the chain head's return type `Box` grounds + // the bound to Fruit, and Banana (a Fruit) is accepted and specialized. + $dist = $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Repo.xphp' => self::repo(), + 'Use.xphp' => <<<'PHP' + getBox()->contains::(new Banana()); + } + PHP, + ]); + + self::assertStringContainsString('contains_', self::read($dist, 'Use.php')); + } + + public function testSelfReturningChainCarriesReceiverArgsAndRejects(): void + { + // `copy(): static` returns the receiver's own generic instance, so `$box->copy()` is still + // Box; `contains::` then rejects Fruit (not a subtype of Banana). + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function copy(): static { return $this; } + public function contains(U $value): bool { return true; } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + return $box->copy()->contains::(new Fruit()); + } + PHP, + ]); + } + + public function testStaticFactoryReturnReceiverGroundsAndRejects(): void + { + // A static call's declared return type grounds the assigned local: `Factory::make(): Box` + // makes `$x` a Box, and `contains::` rejects the supertype Food. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Fruit"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Factory.xphp' => <<<'PHP' + { return new Box::(); } + } + PHP, + 'Use.xphp' => <<<'PHP' + contains::(new Food()); + } + PHP, + ]); + } + + public function testDeepSelfReturningChainResolvesWithoutBlowup(): void + { + // Regression: resolveCallReturn must memoize. Without it, a chained receiver re-descends both + // the FQN and the args branch at every hop — O(2^N) in chain depth — and a ~24-deep `copy()` + // chain hangs. Memoized, it resolves at once, still grounding to Box and rejecting Fruit. + $chain = str_repeat('->copy()', 24); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function copy(): static { return $this; } + public function contains(U $value): bool { return true; } + } + PHP, + 'Use.xphp' => <<(); + return \$box{$chain}->contains::(new Fruit()); + } + PHP, + ]); + } + + // --- method-own sibling bound `` grounded against the call's turbofish args --- + + public function testMethodOwnSiblingBoundAcceptsSubtypeArg(): void + { + $dist = $this->compile([ + 'Models.xphp' => self::MODELS, + 'Util.xphp' => self::pairUtil(), + 'Use.xphp' => <<<'PHP' + pair::(new Fruit(), new Banana()); + } + PHP, + ]); + + self::assertStringContainsString('pair_', self::read($dist, 'Use.php')); + } + + public function testMethodOwnSiblingBoundRejectsNonSubtypeArg(): void + { + // `pair` with `` — V (Fruit) must be a subtype of U (Banana); it is + // not, so the call is rejected. The bound grounds against the call's own turbofish args. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Util.xphp' => self::pairUtil(), + 'Use.xphp' => <<<'PHP' + pair::(new Banana(), new Fruit()); + } + PHP, + ]); + } + + // --- hard-fail the genuine residual (ground or fail) --- + + public function testRawGenericReceiverIsUnprovableAndHardFails(): void + { + // A raw `Box` parameter (no type argument) gives the receiver a known class but no element + // type, so `` can't be proven. Not a `$this` self-call → compile error. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot verify generic bound `U : E`'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + contains::(new Food()); + } + PHP, + ]); + } + + public function testStaticMethodClassParamBoundIsUnprovableAndHardFails(): void + { + // A class type parameter is unbound in a static context — there is no instance to ground `E` — + // so a static method whose bound references it is always unprovable and fails. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot verify generic bound `U : E`'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public static function pick(U $value): bool { return true; } + } + PHP, + 'Use.xphp' => <<<'PHP' + (new Banana()); + } + PHP, + ]); + } + + public function testThisSelfCallUnprovableIsCollectedInCheckMode(): void + { + // The same `$this`-rooted self-call in `check` mode: collected as `xphp.bound_unprovable` + // rather than thrown, so a whole-program check reports it instead of stopping at the first. + $collector = $this->check([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + public function probe(): bool { return $this->contains::(new Banana()); } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + PHP, + ]); + + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(GenericMethodCompiler::CODE_BOUND_UNPROVABLE, $codes); + } + + public function testCheckModeCollectsTheUnprovableDiagnostic(): void + { + // In `check` mode the unprovable bound is collected as a diagnostic (with its actionable + // message and stable code) instead of throwing, so a whole-program check can report it. + $collector = $this->check([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + contains::(new Food()); + } + PHP, + ]); + + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(GenericMethodCompiler::CODE_BOUND_UNPROVABLE, $codes); + } + + public function testUntypedForeachReceiverIsUndeterminedAndHardFails(): void + { + // A foreach loop variable has no declared type, so a turbofish call on it can't be + // specialized — the generic method only exists as specializations, so leaving the call would + // emit a non-existent method that fatals at runtime. Ground or fail → compile error. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + contains::(new Banana()); + } + } + PHP, + ]); + } + + public function testUndeterminedReceiverIsCollectedInCheckMode(): void + { + // The same undeterminable-receiver call in `check` mode: collected as + // `xphp.undetermined_receiver` rather than thrown. + $collector = $this->check([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + contains::(new Banana()); + } + } + PHP, + ]); + + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(GenericMethodCompiler::CODE_UNDETERMINED_RECEIVER, $codes); + } + + // --- forwarded `$this` self-call to an erasable method: lowered, not failed --- + + private const BOX_FORWARDING = <<<'PHP' + { + public function contains(U $value): bool { return true; } + public function probe(U $value): bool { return $this->contains::($value); } + } + PHP; + + public function testErasableForwardingSelfCallIsLoweredNotHardFailed(): void + { + // `probe` forwards its parameter to `$this->contains::()` — both are erasable, so the + // Specializer lowers both to E-mangled members and rewrites the forward. This COMPILES (it was + // an interim hard-fail before erasure landed); the call site lowers to the mangled `probe_`. + $dist = $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::BOX_FORWARDING, + 'Use.xphp' => <<<'PHP' + (); + $box->probe::(new Banana()); + PHP, + ]); + + self::assertStringContainsString('probe_', self::read($dist, 'Use.php')); + } + + #[RunInSeparateProcess] + public function testErasableForwardingRunsAtRuntime(): void + { + // The non-negotiable gate: execute the emitted output. A bare `$this->contains(...)` (the old + // silent break) would fatal "undefined method" when `probe` runs; that it returns proves the + // Specializer rewrote the forward to the emitted `contains_` member. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_forwarding/source', + 'erase-fwd', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testTwoEnclosingBoundedParamsEraseAndRunAtRuntime(): void + { + // A method with two enclosing-bounded params (``) erases both to E (mangles on + // [E, E]); both widen to the bound, so `bothAreFruit::` resolves and runs. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_two_params/source', + 'erase-two', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testMultiClassParamErasureMangleKeysOnTheBoundsReferentAtRuntime(): void + { + // `containsValue` on `Map` mangles on V (Fruit), not K (string). Call-site and + // Specializer must agree on that key, or the call resolves to nothing. Executed. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_map_multiparam/source', + 'erase-map', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testTwoTurbofishTypesCollapseToOneWidenedMemberAtRuntime(): void + { + // The core erasure semantic: `contains::` and `contains::` both lower to one + // `contains_(Fruit)` member, widened to the bound, accepting each subtype. Executed. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_param_widening/source', + 'erase-widen', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testInheritedErasableMemberResolvesAtRuntime(): void + { + // `contains` declared on a generic base, called on a subclass instantiation. The + // call-site name (keyed on the receiver's E threaded to the declaring base) must match the + // Specializer's emitted member on Base, inherited by ArrayList. Executed. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_inherited/source', + 'erase-inherit', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testErasureIsVarianceSafeOnTheCovariantChainAtRuntime(): void + { + // The variance gate: Box<+E> builds a covariant extends-chain; the distinct E-mangled + // contains_ members coexist with no LSP fatal, and a Box dispatches the inherited + // contains_. Proven by executing the real compiled output. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_covariant_chain/source', + 'erase-chain', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testCovariantInterfaceUpcastResolvesTheErasedMemberAtRuntime(): void + { + // The headline gate: a `ListColl` upcast to `Collection` calls the erased + // `contains` through the interface. The concrete supertype specialization that implements + // `Collection`'s abstract erased member (`AbstractColl`) is NEVER + // instantiated explicitly — the specialization closer must schedule it, or the emitted code + // fatals at class load. Proven by executing the output. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast/source', + 'iface-upcast', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testSubInterfaceMethodDirectEmittedUnderUpcastRunsAtRuntime(): void + { + // The headline case: `indexOf` on the sub-interface `OrderedCollection<+E>`, body on + // `ListColl extends AbstractColl` (a class with a parent). Upcast `ListColl` → + // `OrderedCollection` can't inherit `indexOf_` through a covariant edge, so it's + // emitted directly onto `ListColl` (reading its inherited Book-typed $items). `contains` + // still resolves via inheritance. Executed end to end. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_direct_emit/source', + 'subiface-direct', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testDirectEmittedBodyResolvesTheClassParamToTheUpcastSourceAtRuntime(): void + { + // The split-substitution gate: a directly-emitted body that references the class `E` structurally + // (`$value instanceof E`) must resolve `E` to the upcast-source's concrete (Book), not the + // supertype (Product). The call returns false ($value instanceof Book) — true would mean E wrongly + // became Product. Executed. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_structural_class_param/source', + 'subiface-structural', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_structural_class_param/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testMultiParamCovariantInterfaceUpcastResolvesAtRuntime(): void + { + // Multi-param: `HashMap` (K invariant, +V covariant) upcast to `MMap`. + // The closer must schedule `AbstractMap` — keep K=Id, raise V to Product — and the + // erased `containsValue` (mangled on V) must resolve through the covariant chain. Executed. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast_map/source', + 'iface-upcast-map', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testVarianceEdgeDoesNotOverwriteASourceParentAtRuntime(): void + { + // A covariant class with a SOURCE parent (`ListColl<+E> extends Base`) instantiated at two + // args (Fruit, Banana). The variance edge emitter must keep each specialization's source + // `extends Base` rather than overwrite it with the same-template covariant super + // (`ListColl extends ListColl`) — overwriting would sever the inherited + // `contains_` member and fatal "undefined method". Proven by executing the output. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/variance_edge_preserves_source_parent/source', + 'variance-src-parent', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + private const PRODUCT = " { + public function contains(E2 $value): bool; + } + PHP; + private const ABSTRACT_COLL = <<<'PHP' + implements Collection { + /** @var list */ + protected array $items; + public function __construct(E ...$items) { $this->items = $items; } + public function contains(E2 $value): bool { return \in_array($value, $this->items, true); } + } + PHP; + + public function testCovariantUpcastSchedulesTheConcreteSupertypeSpecialization(): void + { + // In-process (mutation-visible) proof of the closer's success path: a `ListColl` upcast + // to `Collection` schedules the implementing supertype `AbstractColl`, which + // is never instantiated explicitly. (The runtime fixture proves it then loads + runs.) + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'AbstractColl.xphp' => self::ABSTRACT_COLL, + 'ListColl.xphp' => " extends AbstractColl {}\n", + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->contains::(new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + + $abstractColl = self::specializationsOf($result, 'App\\AbstractColl'); + self::assertContains(['App\\Product'], $abstractColl, 'the closer must schedule AbstractColl'); + self::assertContains(['App\\Book'], $abstractColl, 'AbstractColl is the natural specialization'); + } + + public function testUpcastWhenTheConcreteClassDeclaresTheMethodItself(): void + { + // The erasable body is on the CONCRETE class itself (no abstract base, no other parent). The + // declaring-class search must include the concrete class itself, and schedule ListColl. + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'ListColl.xphp' => <<<'PHP' + implements Collection { + /** @var list */ + protected array $items; + public function __construct(E ...$items) { $this->items = $items; } + public function contains(E2 $value): bool { return \in_array($value, $this->items, true); } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->contains::(new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + + $listColl = self::specializationsOf($result, 'App\\ListColl'); + self::assertContains(['App\\Product'], $listColl, 'closer must schedule ListColl when the leaf itself declares the method'); + } + + public function testInvariantInterfaceParameterSchedulesNoUpcastImplementer(): void + { + // The interface parameter is INVARIANT, so `Holder` is NOT an instance of `Inv` + // even though Book <: Product. The closer must reach the strict-upcast check and decline to + // schedule (no `Holder` / no implementer for `Inv`). + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Inv.xphp' => <<<'PHP' + { + public function contains(E2 $value): bool; + } + PHP, + 'Holder.xphp' => <<<'PHP' + implements Inv { + /** @var list */ + protected array $items; + public function __construct(E ...$items) { $this->items = $items; } + public function contains(E2 $value): bool { return \in_array($value, $this->items, true); } + } + PHP, + 'Use.xphp' => <<<'PHP' + $x): bool { return $x->contains::(new Book()); } + function withProducts(Inv $x): bool { return $x->contains::(new Product()); } + $h = new Holder::(new Book()); + $r = withBooks($h); + PHP, + ]); + + // Holder is never instantiated and the invariant interface gives no upcast, so the + // closer schedules nothing for it. + self::assertSame([['App\\Book']], self::specializationsOf($result, 'App\\Holder'), 'invariant interface → no upcast → no extra Holder'); + } + + public function testInterfaceWithANonGenericMethodStillFindsTheErasableOne(): void + { + // The interface mixes a non-generic method (`size`) with the erasable `contains`. Collecting the + // erasable names must skip `size` and keep scanning to `contains` — the closer still schedules + // the implementer under a covariant upcast. + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => <<<'PHP' + { + public function size(): int; + public function contains(E2 $value): bool; + } + PHP, + 'AbstractColl.xphp' => <<<'PHP' + implements Collection { + /** @var list */ + protected array $items; + public function __construct(E ...$items) { $this->items = $items; } + public function size(): int { return \count($this->items); } + public function contains(E2 $value): bool { return \in_array($value, $this->items, true); } + } + PHP, + 'ListColl.xphp' => " extends AbstractColl {}\n", + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->contains::(new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + + self::assertContains(['App\\Product'], self::specializationsOf($result, 'App\\AbstractColl'), 'the erasable method must be found past the non-generic one'); + } + + public function testDirectImplementationSchedulesNoExtraSpecialization(): void + { + // The concrete spec implements the interface spec it is used as DIRECTLY (no covariant upcast: + // ListColl used as Collection). The closer's argsEqual early-return must fire — no + // AbstractColl appears. + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'AbstractColl.xphp' => self::ABSTRACT_COLL, + 'ListColl.xphp' => " extends AbstractColl {}\n", + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->contains::(new Book()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + + $abstractColl = self::specializationsOf($result, 'App\\AbstractColl'); + self::assertSame([['App\\Book']], $abstractColl, 'no upcast → only AbstractColl, nothing extra scheduled'); + } + + public function testDirectEmittedMemberHasSupertypeParamAndUpcastSourceBody(): void + { + // In-process (mutation-visible) proof of the split substitution: the directly-emitted member's + // PARAMETER widens to the supertype arg (Product), while the body's class parameter E resolves to + // the upcast-source's concrete (Book). A body `$value instanceof E` becomes `instanceof \App\Book` + // even though the parameter is `\App\Product`. The wrong (conflated) substitution would emit + // `instanceof \App\Product`, and a broken arg-name mapping would leave `E` unsubstituted. + $php = $this->generatedSourceFor([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'OrderedCollection.xphp' => <<<'PHP' + extends Collection { public function firstKind(U $value): bool; } + PHP, + 'AbstractColl.xphp' => self::ABSTRACT_COLL, + 'ListColl.xphp' => <<<'PHP' + extends AbstractColl implements OrderedCollection { + public function firstKind(U $value): bool { return $value instanceof E; } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->firstKind::(new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ], 'ListColl'); + + self::assertStringContainsString('\\App\\Product $value', $php, 'direct member parameter widens to the supertype arg'); + self::assertStringContainsString('instanceof \\App\\Book', $php, 'body class parameter E resolves to the upcast-source concrete (Book)'); + self::assertStringNotContainsString('instanceof \\App\\Product', $php, 'the split must NOT resolve the body E to the supertype (Product)'); + self::assertStringNotContainsString('instanceof E;', $php, 'the body class parameter E must be substituted (not left bare — the arg-name mapping)'); + // `contains` (parent-less base, threads identically) is supplied by INHERITANCE — its member must + // NOT also be emitted directly onto ListColl. (The inheritance path returns before direct emission.) + self::assertStringNotContainsString('contains_', $php, 'an inheritance-supplied member must not also be direct-emitted onto the upcast source'); + } + + public function testMultipleDistinctBoundsHardFailsDirectEmission(): void + { + // A method whose parameters are bounded by DIFFERENT enclosing parameters (``) + // isn't the uniformly-bounded shape direct emission can derive a single member for. Rather than + // silently emit nothing (leaving the abstract member unimplemented → load fatal), it hard-fails. + $this->expectException(RuntimeException::class); + // Pin the whole diagnostic — code, the erased method, the interface, the reason, and the remedy — + // so each operand of the shared message is asserted. + $this->expectExceptionMessageMatches( + '/xphp\.unschedulable_covariant_upcast.+erased method "pick".+App\\\\BiColl.+' + . 'not uniformly bounded by one enclosing type parameter, so the member cannot be emitted ' + . 'directly.+Provide a concrete implementation.+move the method body onto the covariant ' + . 'base class/s', + ); + + $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Tag.xphp' => " <<<'PHP' + { public function pick(U $a, V $b): bool; } + PHP, + 'Mid.xphp' => " {}\n", + 'ListColl.xphp' => <<<'PHP' + extends Mid implements BiColl { + public function pick(U $a, V $b): bool { return true; } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->pick::(new Product(), new Tag()); } + $l = new ListColl::(); + $r = probe($l); + PHP, + ]); + } + + public function testEnclosingParamInSignatureHardFailsDirectEmission(): void + { + // The bounded method param widens to the supertype arg (Product), but the body's class param is + // grounded to the upcast source's OWN concrete (Book). When that class param also appears in the + // SIGNATURE — here a `: E` return type — the directly-emitted member would return a Product value + // (the widened fallback) through a `: Book` return, a runtime TypeError. The inheritance path + // grounds the whole member at one arg and handles this, but direct emission cannot, so it must + // hard-fail at compile time rather than emit a member that fatals when it runs. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches( + '/xphp\.unschedulable_covariant_upcast.+erased method "firstOr".+App\\\\OrderedCollection.+' + . 'enclosing type parameter appears in the method return type, which direct emission cannot ' + . 'ground soundly.+Provide a concrete implementation/s', + ); + + $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'OrderedCollection.xphp' => <<<'PHP' + extends Collection { public function firstOr(U $fallback): E; } + PHP, + 'AbstractColl.xphp' => self::ABSTRACT_COLL, + 'ListColl.xphp' => <<<'PHP' + extends AbstractColl implements OrderedCollection { + public function firstOr(U $fallback): E { return $this->items[0] ?? $fallback; } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c): Product { return $c->firstOr::(new Product()); } + $l = new ListColl::(); + $r = first($l); + PHP, + ]); + } + + public function testEnclosingParamInParameterPositionIsRejectedByVarianceFirst(): void + { + // Why the return-type guard need only inspect the return type: a covariant `+E` can never reach + // direct emission in a parameter position, because variance checking rejects `+E` in an input + // position long before the closer runs. This pins that ordering — the diagnostic is the variance + // error, NOT the upcast hard-fail — so the return-type-only guard is provably complete. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/`\+E` appears in method parameter position/'); + + $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'OrderedCollection.xphp' => <<<'PHP' + extends Collection { public function pairContains(U $value, E $other): bool; } + PHP, + 'AbstractColl.xphp' => self::ABSTRACT_COLL, + 'ListColl.xphp' => <<<'PHP' + extends AbstractColl implements OrderedCollection { + public function pairContains(U $value, E $other): bool { + return \in_array($value, $this->items, true) && \in_array($other, $this->items, true); + } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c, Product $p): bool { return $c->pairContains::($p, $p); } + $l = new ListColl::(); + $r = probe($l, new Product()); + PHP, + ]); + } + + public function testTwoSameBoundParamsDirectEmitUnderUpcast(): void + { + // A method with two parameters both bounded by the SAME enclosing parameter (``) + // is uniformly bounded, so direct emission derives one member mangled on [E, E] at the supertype + // args. Compiles (no hard-fail), emitted directly (no scheduling). + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'OrderedCollection.xphp' => <<<'PHP' + extends Collection { public function eitherIn(U $a, V $b): bool; } + PHP, + 'AbstractColl.xphp' => self::ABSTRACT_COLL, + 'ListColl.xphp' => <<<'PHP' + extends AbstractColl implements OrderedCollection { + public function eitherIn(U $a, V $b): bool { + return \in_array($a, $this->items, true) || \in_array($b, $this->items, true); + } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->eitherIn::(new Product(), new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + + self::assertSame([['App\\Book']], self::specializationsOf($result, 'App\\ListColl'), 'two same-bound params emit directly, no scheduling'); + } + + public function testBodyOnAParentInterfaceBaseEmitsDirectlyForTheSubInterface(): void + { + // Case 2: the body sits on a parent-less base that implements only the PARENT interface + // (`Collection`), so it can't thread to the sub-interface (`OrderedCollection`) for scheduling — + // direct emission onto the upcast source supplies it instead. + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'OrderedCollection.xphp' => <<<'PHP' + extends Collection { public function indexOf(U $value): int; } + PHP, + 'AbstractColl.xphp' => <<<'PHP' + implements Collection { + /** @var list */ + protected array $items; + public function __construct(E ...$items) { $this->items = $items; } + public function contains(U $value): bool { return \in_array($value, $this->items, true); } + public function indexOf(U $value): int { return \count($this->items); } + } + PHP, + 'ListColl.xphp' => " extends AbstractColl implements OrderedCollection {}\n", + 'Use.xphp' => <<<'PHP' + $c): int { return $c->indexOf::(new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + + // contains is scheduled via inheritance (AbstractColl); indexOf is emitted directly onto + // ListColl — so AbstractColl IS scheduled (for contains) but no ListColl is. + self::assertSame([['App\\Book']], self::specializationsOf($result, 'App\\ListColl'), 'indexOf emitted directly, ListColl not scheduled'); + self::assertContains(['App\\Product'], self::specializationsOf($result, 'App\\AbstractColl'), 'contains still scheduled via inheritance'); + } + + public function testDeclaringClassWithASourceParentEmitsTheMemberDirectly(): void + { + // The implementation lives on a class that itself extends another class, so the member can't be + // inherited through a covariant edge (single inheritance). Rather than hard-fail, the closer emits + // it DIRECTLY onto the upcast-source class — so no supertype spec is scheduled, and compile + // succeeds. + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'Mid.xphp' => " {}\n", + 'ListColl.xphp' => <<<'PHP' + extends Mid implements Collection { + /** @var list */ + protected array $items; + public function __construct(E ...$items) { $this->items = $items; } + public function contains(U $value): bool { return \in_array($value, $this->items, true); } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->contains::(new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + + // Direct emission, not scheduling: only ListColl exists — no ListColl / Mid. + self::assertSame([['App\\Book']], self::specializationsOf($result, 'App\\ListColl'), 'no supertype spec scheduled — emitted directly'); + } + + public function testReorderedImplementsClauseEmitsTheMemberDirectly(): void + { + // The declaring class implements the interface with its parameters REORDERED + // (`class Holder<+A, B> implements Pair`), so the implementing spec can't be derived by + // inversion for scheduling. Direct emission doesn't need to invert — it emits onto the + // upcast-source class with the supertype's bound value — so this now compiles (no scheduled spec). + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Id.xphp' => " <<<'PHP' + { + public function contains(U $value): bool; + } + PHP, + 'Holder.xphp' => <<<'PHP' + implements Pair { + /** @var list */ + protected array $items; + public function __construct(A ...$items) { $this->items = $items; } + public function contains(U $value): bool { return \in_array($value, $this->items, true); } + } + PHP, + 'Use.xphp' => <<<'PHP' + $p): bool { return $p->contains::(new Product()); } + $h = new Holder::(new Book()); + $r = probe($h); + PHP, + ]); + + // Emitted directly onto Holder — no reordered supertype spec scheduled. + self::assertSame([['App\\Book', 'App\\Id']], self::specializationsOf($result, 'App\\Holder'), 'reorder emits directly, no scheduling'); + } + + public function testBareInstanceMethodGenericCallFailsCompile(): void + { + // A turbofish-less call to a method generic with no all-default params can't infer its type + // argument — it must fail compile, not silently emit a call to the stripped `pick_T_<…>`. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/pick/'); + + $this->compile([ + 'Box.xphp' => "(T \$x): T { return \$x; } }\n", + 'Use.xphp' => "pick('b');\n", + ]); + } + + public function testBareInstanceMethodGenericCallIsCollectedInCheck(): void + { + // The same bare call in `check` mode is collected (not thrown), so a whole-program check reports + // it instead of a runtime fatal — the gap the ticket is about. + $collector = $this->check([ + 'Box.xphp' => "(T \$x): T { return \$x; } }\n", + 'Use.xphp' => "pick('b');\n", + ]); + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testBareCallToAnAllDefaultMethodGenericStillResolves(): void + { + // A bare call to a method generic whose type parameter is fully defaulted pads to the default and + // compiles — unchanged behavior, no false positive. + $dist = $this->compile([ + 'Box.xphp' => "(): string { return 'x'; } }\n", + 'Use.xphp' => "make();\n", + ]); + self::assertStringContainsString('make_', self::read($dist, 'Use.php')); + } + + public function testBareCallToANonGenericMethodIsUnaffected(): void + { + // A plain call to a non-generic method must not be flagged (it isn't a method-generic template). + $collector = $this->check([ + 'Box.xphp' => " "plain('x');\n", + ]); + self::assertSame([], $collector->all(), 'a non-generic bare call must not be flagged'); + } + + public function testBareStaticMethodGenericCallIsReported(): void + { + // The static path (`Box::pick('b')`) has the identical silent-skip branch — also reported. + $collector = $this->check([ + 'Box.xphp' => "(T \$x): T { return \$x; } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testBareFreeFunctionGenericCallFailsCompile(): void + { + // The free-function path skipped bare calls via a different early return; a bare call to a + // generic function must also fail rather than emit a call to the stripped `pick_T_<…>`. + $this->expectException(RuntimeException::class); + $this->compile([ + 'fns.xphp' => "(T \$x): T { return \$x; }\n", + 'Use.xphp' => "check([ + 'fns.xphp' => "(T \$x): T { return \$x; }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testBareNonGenericFreeFunctionCallIsUnaffected(): void + { + // A bare call to a non-generic free function (not in functionTemplates) must not be flagged. + $collector = $this->check([ + 'fns.xphp' => " "all(), 'a non-generic free-function bare call must not be flagged'); + } + + public function testBareGenericClosureCallIsCollectedInCheck(): void + { + // A generic closure is tracked at assignment; a bare `$f('b')` (no turbofish) was skipped via + // the func-call early return and never reached the variable-turbofish path. It must be reported. + $collector = $this->check([ + 'Use.xphp' => <<<'PHP' + (T $x): T { return $x; }; + $bad = $f('b'); + PHP, + ]); + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testBareGenericClosureCallFailsCompile(): void + { + $this->expectException(RuntimeException::class); + $this->compile([ + 'Use.xphp' => <<<'PHP' + (T $x): T { return $x; }; + $bad = $f('b'); + PHP, + ]); + } + + public function testFirstClassCallableOfAGenericFunctionIsNotFlagged(): void + { + // `pick(...)` (first-class-callable) creates a Closure, it doesn't invoke — so it must NOT be + // reported as a missing-turbofish call. + $collector = $this->check([ + 'fns.xphp' => "(T \$x): T { return \$x; }\n", + 'Use.xphp' => "all(), 'a first-class-callable of a generic function must not be flagged'); + } + + public function testBareAllDefaultFreeFunctionCallIsReported(): void + { + // A named generic function has no bare/empty-turbofish form even when all params are defaulted, + // so a bare call must be reported rather than silently padded and left to fatal at runtime. + $collector = $this->check([ + 'fns.xphp' => "(): string { return 'x'; }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testGenericClosureDoesNotLeakAcrossScopes(): void + { + // A generic closure assigned in one method must not leak its template into a sibling method + // where the same variable name is an unrelated `callable` parameter — a bare `$f('x')` there is + // a legitimate call, not a forgotten turbofish. + $collector = $this->check([ + 'C.xphp' => <<<'PHP' + (T $x): T { return $x; }; } + public function b(callable $f): mixed { return $f('x'); } + } + PHP, + ]); + self::assertSame([], $collector->all(), 'a generic closure must not leak across scopes'); + } + + public function testNullsafeForwardedSelfCallIsAlsoRewritten(): void + { + // A nullsafe forward (`$this?->contains::()`) is rewritten the same as the plain form. + $dist = $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + public function probe(U $value): bool { return $this?->contains::($value); } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + $box->probe::(new Banana()); + PHP, + ]); + + // Compiled with no unspecializable error; the call site lowered to the mangled `probe_`. + self::assertStringContainsString('probe_', self::read($dist, 'Use.php')); + } + + public function testErasableForwardingDoesNotReportUnspecializableInCheckMode(): void + { + $collector = $this->check([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::BOX_FORWARDING, + 'Use.xphp' => <<<'PHP' + (); + $box->probe::(new Banana()); + PHP, + ]); + + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertNotContains(GenericMethodCompiler::CODE_UNSPECIALIZABLE_SELF_CALL, $codes); + } + + public function testUnboundedForwardedSelfCallAlsoHardFails(): void + { + // Not bound-specific: forwarding to an UNBOUNDED generic method breaks the same way (the inner + // turbofish is stripped and the method never specialized), so it is rejected too. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('forwards a type parameter'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Holder.xphp' => <<<'PHP' + (T $x): T { return $x; } + public function forward(T $x): T { return $this->identity::($x); } + } + PHP, + 'Use.xphp' => <<<'PHP' + check([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + public function probe(U $value): bool { return $this->contains::($value); } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + PHP, + ]); + + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(Registry::CODE_TOO_MANY_TYPE_ARGUMENTS, $codes); + self::assertNotContains(GenericMethodCompiler::CODE_UNSPECIALIZABLE_SELF_CALL, $codes); + } + + // --- harness --- + + private static function repo(): string + { + return <<<'PHP' + { return new Box::(); } + } + PHP; + } + + private static function pairUtil(): string + { + return <<<'PHP' + (U $a, V $b): bool { return true; } + } + PHP; + } + + private static function box(): string + { + return <<<'PHP' + { + public function contains(U $value): bool { return true; } + } + PHP; + } + + /** + * @param array $files filename => contents + * @return string the dist (target) directory + */ + private function compile(array $files): string + { + $src = $this->work . '/' . uniqid('src', true); + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + $dist = $src . '/dist'; + $cache = $src . '/.xphp-cache'; + + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $src, $dist, $cache); + + return $dist; + } + + /** + * Like compile(), but returns the CompileResult so a test can inspect which specializations the + * registry holds (used to assert the specialization closer's effect in-process, where mutation + * testing can attribute kills — the runtime fixtures run in separate processes). + * + * @param array $files + */ + private function compileResult(array $files): CompileResult + { + $src = $this->work . '/' . uniqid('src', true); + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + return $compiler->compile($sources, $src, $src . '/dist', $src . '/.xphp-cache'); + } + + /** + * Compile `$files` and return the generated specialized PHP for one template (every spec of it), + * concatenated. Lets a test assert the SHAPE the closer's direct emission produces (member name + + * parameter type + body) in-process, where mutation testing can attribute kills — the runtime + * fixtures execute the output but run in separate processes. + * + * @param array $files + */ + private function generatedSourceFor(array $files, string $templateSegment): string + { + $src = $this->work . '/' . uniqid('src', true); + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $src, $src . '/dist', $src . '/.xphp-cache'); + + $dir = $src . '/.xphp-cache/Generated/App/' . $templateSegment; + $out = ''; + foreach (is_dir($dir) ? (scandir($dir) ?: []) : [] as $entry) { + if (str_ends_with($entry, '.php')) { + $out .= (string) file_get_contents($dir . '/' . $entry); + } + } + return $out; + } + + /** + * The concrete type-argument lists recorded for a given template FQN, each as a list of canonical + * argument strings. Lets a test assert exactly which specializations of a class were scheduled. + * + * @return list> + */ + private static function specializationsOf(CompileResult $result, string $templateFqn): array + { + $out = []; + foreach ($result->registry->instantiations() as $instantiation) { + if ($instantiation->templateFqn === $templateFqn) { + $out[] = array_map(static fn ($ref): string => $ref->canonical(), $instantiation->concreteTypes); + } + } + return $out; + } + + /** + * Run the sources through `check` mode, which collects diagnostics instead of throwing. + * + * @param array $files filename => contents + */ + private function check(array $files): DiagnosticCollector + { + $src = $this->work . '/' . uniqid('src', true); + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + return $compiler->check($sources); + } + + private static function read(string $dir, string $file): string + { + $path = $dir . '/' . $file; + return is_file($path) ? (file_get_contents($path) ?: '') : ''; + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php new file mode 100644 index 0000000..bdc7fd3 --- /dev/null +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php @@ -0,0 +1,230 @@ + */ + private array $workDirs = []; + + protected function tearDown(): void + { + foreach ($this->workDirs as $dir) { + self::rrmdir($dir); + } + $this->workDirs = []; + } + + // --- Branch-merge agreement: a determinable receiver IS grounded and checked (bare ``) --- + + public function testBranchMergedReceiverIsGroundedAndRejectsAnElementViolation(): void + { + // `pick()` calls contains:: on a Box receiver assigned in BOTH arms of an + // if/else. The arms agree, so the element type is determined (branch-merge agreement), the + // bound grounds to Fruit, and Rock — not a Fruit — is rejected at compile time. + try { + $this->compileFixture('enclosing_param_bound_lenient_drop'); + self::fail('expected a bound violation for contains:: on a branch-merged Box'); + } catch (RuntimeException $e) { + self::assertStringContainsString('Generic bound violated', $e->getMessage()); + self::assertStringContainsString('extend/implement "App\\Fruit"', $e->getMessage()); + } + } + + public function testTheSameElementViolationIsCaughtWhenTheReceiverIsGroundable(): void + { + // Identical violation, but a straight-line Box receiver — E grounds to Fruit, so the + // bound IS checked and Rock is rejected. (Control for the lenient-drop limitation.) + try { + $this->compileInline([ + 'Models.xphp' => self::MODELS_LENIENT, + 'Box.xphp' => self::BOX_CONTAINS, + 'Use.xphp' => <<<'PHP' + (); + $box->contains::(new Rock()); + PHP, + ]); + self::fail('expected a bound violation for contains:: on Box'); + } catch (RuntimeException $e) { + self::assertStringContainsString('Generic bound violated', $e->getMessage()); + self::assertStringContainsString('extend/implement "App\\Fruit"', $e->getMessage()); + } + } + + // --- Branch-merge agreement: a compound `` is grounded WHOLE and checked --- + + public function testBranchMergedReceiverIsGroundedAndRejectsACompoundViolation(): void + { + // `store()` calls register:: on a Box receiver assigned in BOTH arms of an + // if/else. The arms agree, so the element type is determined, the bound grounds to + // `\Stringable & Fruit`, and Banana — a Fruit but not \Stringable — is rejected on the + // \Stringable operand. + try { + $this->compileFixture('enclosing_param_bound_compound_drop'); + self::fail('expected a bound violation for register:: on a branch-merged Box'); + } catch (RuntimeException $e) { + self::assertStringContainsString('Generic bound violated', $e->getMessage()); + self::assertStringContainsString('Stringable', $e->getMessage()); + } + } + + public function testTheStringableOperandIsEnforcedWhenTheReceiverIsGroundable(): void + { + // Identical call, but a straight-line Box receiver — E grounds to Fruit, the bound + // becomes `\Stringable & Fruit`, and the intersection rejects Banana for not being Stringable. + // (Control for the compound-drop limitation: it proves the \Stringable operand is what's lost.) + try { + $this->compileInline([ + 'Models.xphp' => self::MODELS_COMPOUND, + 'Box.xphp' => self::BOX_REGISTER, + 'Use.xphp' => <<<'PHP' + (); + $box->register::(new Banana()); + PHP, + ]); + self::fail('expected a bound violation for register:: on Box'); + } catch (RuntimeException $e) { + self::assertStringContainsString('Generic bound violated', $e->getMessage()); + self::assertStringContainsString('Stringable', $e->getMessage()); + } + } + + // --- inline sources shared by the controls (mirror the fixtures) --- + + private const MODELS_LENIENT = <<<'PHP' + { + public function contains(U $value): bool { return true; } + } + PHP; + + private const MODELS_COMPOUND = <<<'PHP' + { + public function register(U $value): void {} + } + PHP; + + // --- harness --- + + /** Compile a fixture's `source/` dir to a fresh temp dist; returns the dist path. */ + private function compileFixture(string $name): string + { + $src = realpath(__DIR__ . '/../../fixture/compile/' . $name . '/source') + ?: throw new RuntimeException("missing fixture: {$name}"); + $out = $this->freshWorkDir(); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $this->buildCompiler()->compile($sources, $src, $out . '/dist', $out . '/cache'); + + return $out . '/dist'; + } + + /** @param array $files filename => contents */ + private function compileInline(array $files): void + { + $out = $this->freshWorkDir(); + $src = $out . '/src'; + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $this->buildCompiler()->compile($sources, $src, $out . '/dist', $out . '/cache'); + } + + private function freshWorkDir(): string + { + $dir = sys_get_temp_dir() . '/xphp-encbound-lim-' . uniqid('', true); + mkdir($dir, 0o755, true); + $this->workDirs[] = $dir; + + return $dir; + } + + private function buildCompiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private static function read(string $dir, string $file): string + { + $path = $dir . '/' . $file; + + return is_file($path) ? (file_get_contents($path) ?: '') : ''; + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php b/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php index a4ff6fb..9766ab4 100644 --- a/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php @@ -26,7 +26,7 @@ protected function setUp(): void { $this->sourceDir = realpath(__DIR__ . '/../../fixture/compile/generic_interface/source') ?: throw new RuntimeException('Fixture missing'); - $this->workDir = sys_get_temp_dir() . '/xphp-generic-iface-' . uniqid('', true); + $this->workDir = sys_get_temp_dir() . '/xphp-generic-interface-' . uniqid('', true); $this->targetDir = $this->workDir . '/dist'; $this->cacheDir = $this->workDir . '/.xphp-cache'; mkdir($this->workDir, 0o755, true); @@ -43,7 +43,7 @@ public function testGenericInterfaceSpecializesAndIsImplementedBySpecializedClas { $this->compile(); - $ifaceFqn = Registry::generatedFqn( + $interfaceFqn = Registry::generatedFqn( 'App\\GenericInterface\\Containers\\Container', [new TypeRef('App\\GenericInterface\\Models\\Plastic')], ); @@ -52,18 +52,18 @@ public function testGenericInterfaceSpecializesAndIsImplementedBySpecializedClas [new TypeRef('App\\GenericInterface\\Models\\Plastic')], ); - $ifaceFile = $this->fqnToPath($ifaceFqn); + $interfaceFile = $this->fqnToPath($interfaceFqn); $boxFile = $this->fqnToPath($boxFqn); - self::assertFileExists($ifaceFile, 'specialized interface must be emitted'); + self::assertFileExists($interfaceFile, 'specialized interface must be emitted'); self::assertFileExists($boxFile, 'specialized class must be emitted'); $boxContent = file_get_contents($boxFile); // Pin the implements target identity (snapshot's first-seen-order // normalization can't distinguish which specialized FQN gets used). - self::assertStringContainsString('implements \\' . $ifaceFqn, $boxContent); + self::assertStringContainsString('implements \\' . $interfaceFqn, $boxContent); $snapshotDir = __DIR__ . '/../../fixture/compile/generic_interface/verify/testGenericInterfaceSpecializesAndIsImplementedBySpecializedClass'; - SnapshotHash::assertMatches($snapshotDir . '/Container_Plastic.expected.php', file_get_contents($ifaceFile)); + SnapshotHash::assertMatches($snapshotDir . '/Container_Plastic.expected.php', file_get_contents($interfaceFile)); SnapshotHash::assertMatches($snapshotDir . '/Box_Plastic.expected.php', $boxContent); } @@ -116,7 +116,7 @@ public function testSpecializedClassIsInstanceOfOriginalInterfaceMarker(): void #[RunInSeparateProcess] public function testSpecializedClassIsInstanceOfSpecializedInterfaceAtRuntime(): void { - $fixture = CompiledFixture::compile($this->sourceDir, 'generic-iface-runtime'); + $fixture = CompiledFixture::compile($this->sourceDir, 'generic-interface-runtime'); $fixture->registerAutoload('App\\GenericInterface\\'); try { require __DIR__ . '/../../fixture/compile/generic_interface/verify/specialized_interface_runtime.php'; diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 18251bc..d703efe 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -501,14 +501,15 @@ public function fooId(T $x): T { return $x; } } } - public function testBranchingReassignmentInvalidatesPostBranchSpecialization(): void + public function testBranchingReassignmentMakesReceiverUndeterminedAndFailsToCompile(): void { - // Bug fix: `$x = new Foo(); if (…) { $x = new Bar(); } $x->m::()` - // used to specialize against Bar (the last lexical write) regardless - // of whether the branch fired. The conservative fix invalidates `$x` - // on the branch's exit -- the post-branch call site no longer - // specializes, and PHP throws "undefined method" at runtime instead - // of silently calling the wrong specialization. + // `$x = new Foo(); if (…) { $x = new Bar(); } $x->m::()` must never + // specialize against the last lexical write (Bar) — the branch may not + // fire. The flow analyzer invalidates `$x` on the branch's exit, so the + // receiver's type is undetermined. A turbofish call can't be specialized + // without a known receiver type, and the generic method is stripped from + // its class, so leaving the call would emit a runtime "undefined method". + // Ground or fail: this is a compile error. $dir = sys_get_temp_dir() . '/xphp-br-post-' . uniqid('', true); mkdir($dir, 0o755, true); file_put_contents($dir . '/Foo.xphp', <<<'PHP' @@ -536,16 +537,9 @@ class Bar { public function barId(T $x): T { return $x; } } PHP); try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); $this->compileFrom($dir); - $use = file_get_contents($dir . '/dist/Use.php'); - self::assertIsString($use); - // Negative invariant kept: ambiguous post-branch type must - // de-specialize, leaving the bare unmangled call. - self::assertStringNotContainsString('fooId_T_', $use); - SnapshotHash::assertMatches( - __DIR__ . '/GenericMethodIntegrationTest/testBranchingReassignmentInvalidatesPostBranchSpecialization/Use.expected.php', - $use, - ); } finally { self::rrmdir($dir); } @@ -678,14 +672,14 @@ class Foo { public function fooId(T $x): T { return $x; } } } } - public function testBranchingIfWithoutElseStillDeSpecializes(): void + public function testBranchingIfWithoutElseUndeterminedReceiverFailsToCompile(): void { - // P5.1: if-without-else has an implicit empty arm. Even when both - // reachable paths agree on Foo (the pre-branch assignment matches - // the if-body's), the merge MUST de-specialize because the - // expectedArmCount guard trips. This is deliberate -- the implicit - // arm doesn't appear in perBranchTypes, and special-casing it - // would be fragile against refactors. + // If-without-else has an implicit empty arm. Even when both reachable + // paths agree on Foo (the pre-branch assignment matches the if-body's), + // the merge MUST NOT ground the receiver because the expectedArmCount + // guard trips (the implicit arm doesn't appear in perBranchTypes, and + // special-casing it would be fragile). The receiver's type is therefore + // undetermined, so the turbofish call can't be specialized → compile error. $dir = sys_get_temp_dir() . '/xphp-br-noelse-' . uniqid('', true); mkdir($dir, 0o755, true); file_put_contents($dir . '/Foo.xphp', <<<'PHP' @@ -707,15 +701,9 @@ class Foo { public function fooId(T $x): T { return $x; } } PHP); try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); $this->compileFrom($dir); - $use = file_get_contents($dir . '/dist/Use.php'); - self::assertIsString($use); - // Negative invariant kept: if-without-else must de-specialize. - self::assertStringNotContainsString('fooId_T_', $use); - SnapshotHash::assertMatches( - __DIR__ . '/GenericMethodIntegrationTest/testBranchingIfWithoutElseStillDeSpecializes/Use.expected.php', - $use, - ); } finally { self::rrmdir($dir); } @@ -802,9 +790,10 @@ class Foo { public function fooId(T $x): T { return $x; } } } } - public function testBranchingSwitchWithoutDefaultStillDeSpecializes(): void + public function testBranchingSwitchWithoutDefaultUndeterminedReceiverFailsToCompile(): void { - // P5.1: no `default` case = implicit fall-through = no merge. + // No `default` case = implicit fall-through = no merge, so the receiver's + // type stays undetermined and the turbofish call can't be specialized. $dir = sys_get_temp_dir() . '/xphp-br-swnod-' . uniqid('', true); mkdir($dir, 0o755, true); file_put_contents($dir . '/Foo.xphp', <<<'PHP' @@ -828,15 +817,9 @@ class Foo { public function fooId(T $x): T { return $x; } } PHP); try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); $this->compileFrom($dir); - $use = file_get_contents($dir . '/dist/Use.php'); - self::assertIsString($use); - // Negative invariant kept: switch without default must de-specialize. - self::assertStringNotContainsString('fooId_T_', $use); - SnapshotHash::assertMatches( - __DIR__ . '/GenericMethodIntegrationTest/testBranchingSwitchWithoutDefaultStillDeSpecializes/Use.expected.php', - $use, - ); } finally { self::rrmdir($dir); } @@ -886,11 +869,12 @@ class Foo { public function fooId(T $x): T { return $x; } } } } - public function testBranchingOneArmAssignsUntrackedRhsStillDeSpecializes(): void + public function testBranchingOneArmAssignsUntrackedRhsUndeterminedReceiverFailsToCompile(): void { - // P5.1: one arm assigns the same class via `new Foo()`, the other - // via an untracked RHS (a function call). The untracked arm captures - // null, the merge fails, $x de-specializes. + // One arm assigns the same class via `new Foo()`, the other via an + // untracked RHS (a function call whose return type the flow analyzer + // doesn't read here). The untracked arm captures null, the merge fails, + // and the receiver's type is undetermined → the turbofish call fails. $dir = sys_get_temp_dir() . '/xphp-br-untracked-' . uniqid('', true); mkdir($dir, 0o755, true); file_put_contents($dir . '/Foo.xphp', <<<'PHP' @@ -914,15 +898,9 @@ function computeFoo(): Foo { return new Foo(); } PHP); try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); $this->compileFrom($dir); - $use = file_get_contents($dir . '/dist/Use.php'); - self::assertIsString($use); - // Negative invariant kept: untracked-RHS arm forces de-specialization. - self::assertStringNotContainsString('fooId_T_', $use); - SnapshotHash::assertMatches( - __DIR__ . '/GenericMethodIntegrationTest/testBranchingOneArmAssignsUntrackedRhsStillDeSpecializes/Use.expected.php', - $use, - ); } finally { self::rrmdir($dir); } @@ -967,11 +945,10 @@ class Foo { public function fooId(T $x): T { return $x; } } } } - public function testBranchingMatchWithoutDefaultStillDeSpecializes(): void + public function testBranchingMatchWithoutDefaultUndeterminedReceiverFailsToCompile(): void { - // P5.1: match without default = canMergeOnLeave returns false. - // (Match would runtime-throw on unmatched value, but we're - // conservative.) + // Match without default = canMergeOnLeave returns false, so the receiver's + // type stays undetermined and the turbofish call can't be specialized. $dir = sys_get_temp_dir() . '/xphp-br-mtchnod-' . uniqid('', true); mkdir($dir, 0o755, true); file_put_contents($dir . '/Foo.xphp', <<<'PHP' @@ -995,26 +972,21 @@ class Foo { public function fooId(T $x): T { return $x; } } PHP); try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); $this->compileFrom($dir); - $use = file_get_contents($dir . '/dist/Use.php'); - self::assertIsString($use); - // Negative invariant kept: match without default must de-specialize. - self::assertStringNotContainsString('fooId_T_', $use); - SnapshotHash::assertMatches( - __DIR__ . '/GenericMethodIntegrationTest/testBranchingMatchWithoutDefaultStillDeSpecializes/Use.expected.php', - $use, - ); } finally { self::rrmdir($dir); } } - public function testBranchingElseifMiddleArmDiffersStillDeSpecializes(): void + public function testBranchingElseifMiddleArmDiffersUndeterminedReceiverFailsToCompile(): void { - // P5.1: three-arm if/elseif/else where the middle arm assigns Bar - // instead of Foo. Locks the per-arm equality loop -- if the loop - // accidentally only checks the first vs last arm, this test would - // wrongly merge against Foo. + // Three-arm if/elseif/else where the middle arm assigns Bar instead of + // Foo. Locks the per-arm equality loop -- if it accidentally only checked + // the first vs last arm it would wrongly merge against Foo and ground the + // receiver. The arms disagree, so the receiver is undetermined → the + // turbofish call can't be specialized and fails to compile. $dir = sys_get_temp_dir() . '/xphp-br-elsmid-' . uniqid('', true); mkdir($dir, 0o755, true); file_put_contents($dir . '/Foo.xphp', <<<'PHP' @@ -1041,15 +1013,9 @@ class Bar { } PHP); try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); $this->compileFrom($dir); - $use = file_get_contents($dir . '/dist/Use.php'); - self::assertIsString($use); - // Negative invariant kept: middle-arm disagreement must de-specialize. - self::assertStringNotContainsString('fooId_T_', $use); - SnapshotHash::assertMatches( - __DIR__ . '/GenericMethodIntegrationTest/testBranchingElseifMiddleArmDiffersStillDeSpecializes/Use.expected.php', - $use, - ); } finally { self::rrmdir($dir); } @@ -1235,28 +1201,434 @@ public function makeParent(T $v): Parent_ { $generated = self::globRecursive($dir . '/.xphp-cache/Generated', '*.php'); self::assertGreaterThanOrEqual(2, count($generated)); - $childSpec = null; + $childSpecialization = null; foreach ($generated as $f) { if (str_contains($f, '/Child/T_')) { - $childSpec = file_get_contents($f); + $childSpecialization = file_get_contents($f); break; } } - self::assertIsString($childSpec, 'expected a Child specialization at /Child/T_*.php'); + self::assertIsString($childSpecialization, 'expected a Child specialization at /Child/T_*.php'); // Negative invariants kept: parent must not be misresolved // to a class FQN; no leftover turbofish marker. - self::assertStringNotContainsString('App\\PseudoParent\\parent', $childSpec); - self::assertStringNotContainsString('::<', $childSpec); + self::assertStringNotContainsString('App\\PseudoParent\\parent', $childSpecialization); + self::assertStringNotContainsString('::<', $childSpecialization); SnapshotHash::assertMatches( __DIR__ . '/GenericMethodIntegrationTest/testNewParentTurbofishCompilesEndToEnd/Child.expected.php', - $childSpec, + $childSpecialization, ); } finally { self::rrmdir($dir); } } + #[RunInSeparateProcess] + public function testGenericMethodResolvesThroughInheritance(): void + { + // A generic method declared on a base class resolves and + // runs when called via turbofish on a subclass receiver. The + // specialization is emitted onto the DECLARING base so every subclass + // inherits the single copy through the class-level `extends` edge -- + // it is NOT duplicated onto the receiver's own specialization. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_method_through_inheritance/source', + 'genmethod-inherit', + ); + try { + $generated = self::globRecursive($fixture->cacheDir . '/Generated', '*.php'); + + $baseSpecialization = ''; + $derivedSpecialization = ''; + foreach ($generated as $f) { + $content = file_get_contents($f); + self::assertIsString($content); + if (str_contains($f, '/Base/T_')) { + $baseSpecialization .= $content; + } + if (str_contains($f, '/Derived/T_')) { + $derivedSpecialization .= $content; + } + } + + // Both `identity` specializations ( and ) land on Base. + self::assertSame( + 2, + preg_match_all('/function identity_T_[0-9a-f]+\(/', $baseSpecialization), + 'both identity specializations emitted onto the declaring Base', + ); + // Derived inherits them; nothing is duplicated onto the subclass. + self::assertStringNotContainsString( + 'identity_T_', + $derivedSpecialization, + 'subclass inherits the base specialization; no duplicate on Derived', + ); + + $fixture->registerAutoload('App\\GenericMethodThroughInheritance'); + require __DIR__ . '/../../fixture/compile/generic_method_through_inheritance/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testStaticGenericMethodResolvesThroughInheritance(): void + { + // Static path: a static generic method declared on Base + // resolves and runs when called as `Derived::make::<...>()`. The + // specialization is emitted onto the declaring Base and reached via + // PHP's static-method inheritance. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_static_method_through_inheritance/source', + 'genmethod-static-inherit', + ); + try { + $base = file_get_contents($fixture->targetDir . '/Base.php'); + $derived = file_get_contents($fixture->targetDir . '/Derived.php'); + self::assertIsString($base); + self::assertIsString($derived); + // Both make specializations land on Base; Derived inherits them. + self::assertSame(2, preg_match_all('/function make_T_[0-9a-f]+\(/', $base)); + self::assertStringNotContainsString('make_T_', $derived); + + require __DIR__ . '/../../fixture/compile/generic_static_method_through_inheritance/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + public function testNullsafeInheritedGenericMethodResolves(): void + { + // Nullsafe instance turbofish resolves through inheritance too (it shares + // the instance path). The specialization lands on the declaring base and + // the call is rewritten while preserving the `?->` short-circuit operator. + $dir = sys_get_temp_dir() . '/xphp-inh-nullsafe-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Base.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Derived.xphp', <<<'PHP' + id::(5); + } + } + PHP); + + try { + $this->compileFrom($dir); + $base = file_get_contents($dir . '/dist/Base.php'); + $caller = file_get_contents($dir . '/dist/Caller.php'); + self::assertIsString($base); + self::assertIsString($caller); + // Specialization on the declaring base; nullsafe operator preserved. + self::assertSame(1, preg_match_all('/function id_T_[0-9a-f]+\(/', $base)); + self::assertMatchesRegularExpression('/\$d\?->id_T_[0-9a-f]+\(/', $caller); + } finally { + self::rrmdir($dir); + } + } + + public function testParentTurbofishResolvesInheritedStaticGenericMethod(): void + { + // A static generic method inherited from the parent now resolves via the + // ancestor walk (before the static path walked ancestors this was an + // "unresolved generic method" compile error). + $dir = sys_get_temp_dir() . '/xphp-inh-parent-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Base.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Child.xphp', <<<'PHP' + (1); } + } + PHP); + + try { + $this->compileFrom($dir); // must NOT throw + $base = file_get_contents($dir . '/dist/Base.php'); + $child = file_get_contents($dir . '/dist/Child.php'); + self::assertIsString($base); + self::assertIsString($child); + self::assertSame(1, preg_match_all('/function make_T_[0-9a-f]+\(/', $base)); + // The inherited static call resolved to a mangled specialization (the + // `parent::` receiver resolves to the current class, which inherits the + // base method); no leftover turbofish marker survives. + self::assertMatchesRegularExpression('/::make_T_[0-9a-f]+\(/', $child); + self::assertStringNotContainsString('make::<', $child); + } finally { + self::rrmdir($dir); + } + } + + public function testSubclassGenericMethodOverrideBindsToSubclass(): void + { + // The direct hit on the receiver's own class wins over the ancestor + // walk: a subclass that redeclares the generic method binds its own + // body, so the specialization lands on the subclass, not the base. + $dir = sys_get_temp_dir() . '/xphp-inh-override-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Base.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Derived.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + id::(5); + PHP); + + try { + $this->compileFrom($dir); + $base = file_get_contents($dir . '/dist/Base.php'); + $derived = file_get_contents($dir . '/dist/Derived.php'); + self::assertIsString($base); + self::assertIsString($derived); + // Override wins: the specialization is on Derived (the direct hit). + self::assertSame(1, preg_match_all('/function id_T_[0-9a-f]+\(/', $derived)); + self::assertStringNotContainsString('id_T_', $base); + } finally { + self::rrmdir($dir); + } + } + + public function testGenericMethodResolvesThroughMultiLevelInheritance(): void + { + // Base <- Mid <- Leaf: the method on Base resolves on a Leaf receiver, + // and the specialization lands on the nearest *declaring* ancestor + // (Base), reached via the breadth-first ancestor walk. + $dir = sys_get_temp_dir() . '/xphp-inh-chain-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Base.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Mid.xphp', <<<'PHP' + id::(9); + PHP); + + try { + $this->compileFrom($dir); + $base = file_get_contents($dir . '/dist/Base.php'); + $mid = file_get_contents($dir . '/dist/Mid.php'); + $leaf = file_get_contents($dir . '/dist/Leaf.php'); + self::assertIsString($base); + self::assertIsString($mid); + self::assertIsString($leaf); + self::assertSame(1, preg_match_all('/function id_T_[0-9a-f]+\(/', $base)); + self::assertStringNotContainsString('id_T_', $mid); + self::assertStringNotContainsString('id_T_', $leaf); + } finally { + self::rrmdir($dir); + } + } + + public function testIntermediateOverrideBindsToNearestDeclaringAncestor(): void + { + // Base declares id; Mid overrides it; Leaf inherits. A call on a Leaf + // receiver binds the NEAREST declaring ancestor (Mid) -- the breadth-first + // walk returns Mid's template first, so the specialization lands on Mid, + // not on Base. + $dir = sys_get_temp_dir() . '/xphp-inh-midoverride-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Base.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Mid.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Leaf.xphp', <<<'PHP' + id::(3); + PHP); + + try { + $this->compileFrom($dir); + $base = file_get_contents($dir . '/dist/Base.php'); + $mid = file_get_contents($dir . '/dist/Mid.php'); + $leaf = file_get_contents($dir . '/dist/Leaf.php'); + self::assertIsString($base); + self::assertIsString($mid); + self::assertIsString($leaf); + // Nearest declaring ancestor wins: the specialization lands on Mid only. + self::assertSame(1, preg_match_all('/function id_T_[0-9a-f]+\(/', $mid)); + self::assertStringNotContainsString('id_T_', $base); + self::assertStringNotContainsString('id_T_', $leaf); + } finally { + self::rrmdir($dir); + } + } + + public function testInheritedGenericMethodDedupesOnSharedBase(): void + { + // Two subclasses calling the same inherited method with the same type + // argument emit exactly ONE specialization, on the shared base (dedup + // keyed by the declaring FQN, not the receiver). + $dir = sys_get_temp_dir() . '/xphp-inh-dedup-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Base.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/A.xphp', <<<'PHP' + id::(1); + $rb = $b->id::(2); + PHP); + + try { + $this->compileFrom($dir); + $base = file_get_contents($dir . '/dist/Base.php'); + self::assertIsString($base); + self::assertSame(1, preg_match_all('/function id_T_[0-9a-f]+\(/', $base)); + } finally { + self::rrmdir($dir); + } + } + + public function testPlainNonGenericCallIsNotFlaggedAsUnresolvedGeneric(): void + { + // The unresolved-generic error fires only for turbofish calls. A plain + // (non-turbofish) call to a non-template method passes through untouched + // -- it is ordinary PHP, not a generic-resolution failure -- while a + // turbofish call to a method that DOES exist still specializes. + $dir = sys_get_temp_dir() . '/xphp-plaincall-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Box.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + nope(1); + $r = $b->get::(2); + PHP); + + try { + $this->compileFrom($dir); // must NOT throw + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + // Plain call survives untouched; the turbofish call specializes. + self::assertStringContainsString('$b->nope(1)', $use); + self::assertSame(1, preg_match_all('/get_T_[0-9a-f]+\(/', $use)); + } finally { + self::rrmdir($dir); + } + } + + public function testUnresolvedStaticGenericMethodTurbofishFailsCompilation(): void + { + // The unresolved-generic error also covers the static turbofish path: + // `Box::nope::()` where `nope` is not a generic method on Box. + $dir = sys_get_temp_dir() . '/xphp-unresolved-static-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Box.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (1); + PHP); + + try { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('could not be resolved'); + $this->compileFrom($dir); + } finally { + self::rrmdir($dir); + } + } + private function compileFrom(string $dir): void { $compiler = $this->buildCompiler(); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingElseifMiddleArmDiffersStillDeSpecializes/Use.expected.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingElseifMiddleArmDiffersStillDeSpecializes/Use.expected.php deleted file mode 100644 index dc4bd77..0000000 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingElseifMiddleArmDiffersStillDeSpecializes/Use.expected.php +++ /dev/null @@ -1,14 +0,0 @@ -fooId(20); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingIfWithoutElseStillDeSpecializes/Use.expected.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingIfWithoutElseStillDeSpecializes/Use.expected.php deleted file mode 100644 index 4ee882c..0000000 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingIfWithoutElseStillDeSpecializes/Use.expected.php +++ /dev/null @@ -1,10 +0,0 @@ -fooId(12); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingMatchWithoutDefaultStillDeSpecializes/Use.expected.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingMatchWithoutDefaultStillDeSpecializes/Use.expected.php deleted file mode 100644 index a156617..0000000 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingMatchWithoutDefaultStillDeSpecializes/Use.expected.php +++ /dev/null @@ -1,12 +0,0 @@ - $x = new Foo(), - $n === 2 => $x = new Foo(), -}; -$r = $x->fooId(19); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingOneArmAssignsUntrackedRhsStillDeSpecializes/Use.expected.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingOneArmAssignsUntrackedRhsStillDeSpecializes/Use.expected.php deleted file mode 100644 index 3459bae..0000000 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingOneArmAssignsUntrackedRhsStillDeSpecializes/Use.expected.php +++ /dev/null @@ -1,11 +0,0 @@ -fooId(17); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingReassignmentInvalidatesPostBranchSpecialization/Use.expected.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingReassignmentInvalidatesPostBranchSpecialization/Use.expected.php deleted file mode 100644 index 66ecd46..0000000 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingReassignmentInvalidatesPostBranchSpecialization/Use.expected.php +++ /dev/null @@ -1,10 +0,0 @@ -fooId(7); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingSwitchWithoutDefaultStillDeSpecializes/Use.expected.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingSwitchWithoutDefaultStillDeSpecializes/Use.expected.php deleted file mode 100644 index 29be901..0000000 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingSwitchWithoutDefaultStillDeSpecializes/Use.expected.php +++ /dev/null @@ -1,16 +0,0 @@ -fooId(15); diff --git a/test/Transpiler/Monomorphize/NamespaceContextTest.php b/test/Transpiler/Monomorphize/NamespaceContextTest.php index 48f3152..2890826 100644 --- a/test/Transpiler/Monomorphize/NamespaceContextTest.php +++ b/test/Transpiler/Monomorphize/NamespaceContextTest.php @@ -97,6 +97,37 @@ public function testEnterNamespaceResetsTheUseMap(): void self::assertSame('App\\Second\\Hello', $ctx->resolveAgainstContext('Hello')); } + public function testIsImportedTrueForAliasedFirstSegment(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexUse(self::makeUse('Other\\Vendor\\Box')); + + self::assertTrue($ctx->isImported('Box')); + self::assertTrue($ctx->isImported('Box\\Sub')); // first segment is what matters + } + + public function testIsImportedFalseForNonImportedName(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexUse(self::makeUse('Other\\Vendor\\Box')); + + self::assertFalse($ctx->isImported('T')); + self::assertFalse($ctx->isImported('Unrelated')); + } + + public function testIsImportedResetsWithNamespace(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App\\First'); + $ctx->indexUse(self::makeUse('First\\Box')); + self::assertTrue($ctx->isImported('Box')); + + $ctx->enterNamespace('App\\Second'); + self::assertFalse($ctx->isImported('Box')); + } + private static function makeUse(string $fqn, ?string $alias = null): Use_ { $useItem = new UseItem( diff --git a/test/Transpiler/Monomorphize/NestedGenericsIntegrationTest.php b/test/Transpiler/Monomorphize/NestedGenericsIntegrationTest.php index 4b25ddf..df6363d 100644 --- a/test/Transpiler/Monomorphize/NestedGenericsIntegrationTest.php +++ b/test/Transpiler/Monomorphize/NestedGenericsIntegrationTest.php @@ -43,36 +43,36 @@ public function testNestedInstantiationGeneratesInnerAndOuterSpecializations(): $result = $this->compile($sourceDir); - self::assertSame(2, $result->generatedCount, 'expected Lst + Box>'); + self::assertSame(2, $result->generatedCount, 'expected Collection + Box>'); $plastic = new TypeRef('App\\NestedInstantiation\\Models\\Plastic'); - $lstOfPlastic = new TypeRef('App\\NestedInstantiation\\Containers\\Lst', [$plastic]); + $collectionOfPlastic = new TypeRef('App\\NestedInstantiation\\Containers\\Collection', [$plastic]); - $lstFqn = Registry::generatedFqn('App\\NestedInstantiation\\Containers\\Lst', [$plastic]); - $boxFqn = Registry::generatedFqn('App\\NestedInstantiation\\Containers\\Box', [$lstOfPlastic]); + $collectionFqn = Registry::generatedFqn('App\\NestedInstantiation\\Containers\\Collection', [$plastic]); + $boxFqn = Registry::generatedFqn('App\\NestedInstantiation\\Containers\\Box', [$collectionOfPlastic]); - $lstFile = $this->fqnToPath($lstFqn); + $collectionFile = $this->fqnToPath($collectionFqn); $boxFile = $this->fqnToPath($boxFqn); - self::assertFileExists($lstFile, "expected {$lstFile}"); + self::assertFileExists($collectionFile, "expected {$collectionFile}"); self::assertFileExists($boxFile, "expected {$boxFile}"); - $lstContent = file_get_contents($lstFile); + $collectionContent = file_get_contents($collectionFile); $boxContent = file_get_contents($boxFile); - // Pin parent identities for the nested instantiation: Box>'s - // `$item` must point at the Lst specialization specifically. - self::assertStringContainsString('public \\' . $lstFqn . ' $item', $boxContent); + // Pin parent identities for the nested instantiation: Box>'s + // `$item` must point at the Collection specialization specifically. + self::assertStringContainsString('public \\' . $collectionFqn . ' $item', $boxContent); $useFile = $this->targetDir . '/Use.php'; self::assertFileExists($useFile); $useContent = file_get_contents($useFile); // Pin which specialized FQN each `new` call refers to. self::assertStringContainsString('new \\' . $boxFqn . '()', $useContent); - self::assertStringContainsString('new \\' . $lstFqn . '()', $useContent); + self::assertStringContainsString('new \\' . $collectionFqn . '()', $useContent); $snapshotDir = __DIR__ . '/../../fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations'; - SnapshotHash::assertMatches($snapshotDir . '/Lst_Plastic.expected.php', $lstContent); - SnapshotHash::assertMatches($snapshotDir . '/Box_Lst_Plastic.expected.php', $boxContent); + SnapshotHash::assertMatches($snapshotDir . '/Collection_Plastic.expected.php', $collectionContent); + SnapshotHash::assertMatches($snapshotDir . '/Box_Collection_Plastic.expected.php', $boxContent); SnapshotHash::assertMatches($snapshotDir . '/Use.expected.php', $useContent); $this->assertAllSyntacticallyValid(); diff --git a/test/Transpiler/Monomorphize/RegistryBoundsDiagnosticTest.php b/test/Transpiler/Monomorphize/RegistryBoundsDiagnosticTest.php new file mode 100644 index 0000000..a7e28d3 --- /dev/null +++ b/test/Transpiler/Monomorphize/RegistryBoundsDiagnosticTest.php @@ -0,0 +1,151 @@ +boxRegistry($collector); + $loc = new SourceLocation('/App/Box.xphp', 7); + + // Must NOT throw. + $registry->recordInstantiation('App\\Box', [new TypeRef('int', isScalar: true)], $loc); + + self::assertTrue($collector->hasErrors()); + self::assertCount(1, $collector->all()); + + $d = $collector->all()[0]; + self::assertSame(Severity::Error, $d->severity); + self::assertSame(Registry::CODE_BOUND_VIOLATION, $d->code); + self::assertSame(DiagnosticSource::Xphp, $d->source); + self::assertSame($loc, $d->location); + self::assertStringContainsString('Generic bound violated', $d->message); + self::assertStringContainsString('"int" does not extend/implement "Stringable"', $d->message); + + // Recording still completed (continue-safely): the instantiation is on file. + self::assertCount(1, $registry->instantiations()); + } + + public function testCollectedMessageHasExactText(): void + { + $collector = new DiagnosticCollector(); + $this->boxRegistry($collector) + ->recordInstantiation('App\\Box', [new TypeRef('int', isScalar: true)]); + + $expected = <<<'TXT' + Generic bound violated while instantiating App\Box. + type parameter T is bounded by Stringable + but the supplied concrete type is int + + "int" does not extend/implement "Stringable". + TXT; + + self::assertSame($expected, $collector->all()[0]->message); + } + + public function testCollectedMessageIsByteIdenticalToThrownMessage(): void + { + $thrown = null; + try { + $this->boxRegistry(null) + ->recordInstantiation('App\\Box', [new TypeRef('int', isScalar: true)]); + self::fail('expected RuntimeException in throw-mode'); + } catch (RuntimeException $e) { + $thrown = $e->getMessage(); + } + + $collector = new DiagnosticCollector(); + $this->boxRegistry($collector) + ->recordInstantiation('App\\Box', [new TypeRef('int', isScalar: true)]); + + self::assertSame($thrown, $collector->all()[0]->message); + } + + public function testMultipleViolationsCollectedInOneRun(): void + { + $collector = new DiagnosticCollector(); + $hierarchy = new TypeHierarchy([]); + $registry = new Registry(hierarchy: $hierarchy, diagnostics: $collector); + $registry->recordDefinition( + 'App\\Pair', + 'Pair', + [ + new TypeParam('A', new BoundLeaf(new TypeRef('Stringable'))), + new TypeParam('B', new BoundLeaf(new TypeRef('Countable'))), + ], + new Class_(new Identifier('Pair')), + '/App/Pair.xphp', + ); + + $registry->recordInstantiation( + 'App\\Pair', + [new TypeRef('int', isScalar: true), new TypeRef('float', isScalar: true)], + ); + + self::assertCount(2, $collector->all()); + } + + public function testNodeLineIsCapturedFromTheInstantiationSite(): void + { + // Line 5 holds the `new Box::(...)` instantiation site. + $source = <<<'XPHP' + { public function __construct(public T $v) {} } + $b = new Box::(5); + XPHP; + + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $collector = new DiagnosticCollector(); + $registry = new Registry( + hierarchy: TypeHierarchy::fromAstPerFile(['/App/Box.xphp' => $ast]), + diagnostics: $collector, + ); + $rc = new RegistryCollector($registry); + $rc->collectDefinitions($ast, '/App/Box.xphp'); + $rc->collectInstantiations($ast, '/App/Box.xphp'); + + self::assertCount(1, $collector->all()); + $loc = $collector->all()[0]->location; + self::assertNotNull($loc); + self::assertSame('/App/Box.xphp', $loc->file); + self::assertSame(5, $loc->line); + } + + private function boxRegistry(?DiagnosticCollector $collector): Registry + { + $registry = new Registry(hierarchy: new TypeHierarchy([]), diagnostics: $collector); + $registry->recordDefinition( + 'App\\Box', + 'Box', + [new TypeParam('T', new BoundLeaf(new TypeRef('Stringable')))], + new Class_(new Identifier('Box')), + '/App/Box.xphp', + ); + + return $registry; + } +} diff --git a/test/Transpiler/Monomorphize/RegistryCheckDiagnosticsTest.php b/test/Transpiler/Monomorphize/RegistryCheckDiagnosticsTest.php new file mode 100644 index 0000000..1fa6514 --- /dev/null +++ b/test/Transpiler/Monomorphize/RegistryCheckDiagnosticsTest.php @@ -0,0 +1,128 @@ +recordDefinition( + 'App\\Pair', + 'Pair', + [new TypeParam('A'), new TypeParam('B')], // B has no default + $this->classAt(1), + '/Pair.xphp', + ); + + $registry->recordInstantiation('App\\Pair', [new TypeRef('int', isScalar: true)], new SourceLocation('/Use.xphp', 8)); + + self::assertCount(1, $collector->all()); + $d = $collector->all()[0]; + self::assertSame(Registry::CODE_MISSING_TYPE_ARGUMENT, $d->code); + self::assertEquals(new SourceLocation('/Use.xphp', 8), $d->location); + self::assertSame( + 'Generic template "App\\Pair" was instantiated with 1 type argument(s) ' + . 'but parameter `B` (position 2) has no default; supply it ' + . 'explicitly or add defaults to every preceding required parameter.', + $d->message, + ); + } + + public function testDefaultBoundViolationIsCollectedAtDeclaration(): void + { + $collector = new DiagnosticCollector(); + $registry = new Registry(hierarchy: new TypeHierarchy([]), diagnostics: $collector); + $registry->recordDefinition( + 'App\\Box', + 'Box', + [new TypeParam('T', new BoundLeaf(new TypeRef('Stringable')), new TypeRef('int', isScalar: true))], + $this->classAt(4), + '/Box.xphp', + ); + + $registry->validateDefaultsAgainstBounds(); + + self::assertCount(1, $collector->all()); + $d = $collector->all()[0]; + self::assertSame(Registry::CODE_DEFAULT_BOUND_VIOLATION, $d->code); + self::assertEquals(new SourceLocation('/Box.xphp', 4), $d->location); + + $expected = <<<'TXT' + Default for generic parameter `T` of "App\Box" violates the parameter's bound. + bound: Stringable + default: int + reason: does not satisfy "Stringable". + TXT; + self::assertSame($expected, $d->message); + } + + public function testMultipleDefaultViolationsCollectedForOneDefinition(): void + { + $collector = new DiagnosticCollector(); + $registry = new Registry(hierarchy: new TypeHierarchy([]), diagnostics: $collector); + $registry->recordDefinition( + 'App\\Pair', + 'Pair', + [ + new TypeParam('A', new BoundLeaf(new TypeRef('Stringable')), new TypeRef('int', isScalar: true)), + new TypeParam('B', new BoundLeaf(new TypeRef('Countable')), new TypeRef('float', isScalar: true)), + ], + $this->classAt(1), + '/Pair.xphp', + ); + + $registry->validateDefaultsAgainstBounds(); + + // Both violating defaults are reported — the per-param loop continues past the first. + self::assertCount(2, $collector->all()); + } + + public function testUndefinedTemplateIsCollected(): void + { + $collector = new DiagnosticCollector(); + $registry = new Registry(diagnostics: $collector); + + // No definition recorded for App\Ghost. + $registry->recordInstantiation('App\\Ghost', [new TypeRef('int', isScalar: true)]); + $registry->collectUndefinedTemplates($collector); + + self::assertCount(1, $collector->all()); + $d = $collector->all()[0]; + self::assertSame(Registry::CODE_UNDEFINED_TEMPLATE, $d->code); + self::assertNull($d->location); + self::assertStringContainsString('Generic template "App\\Ghost" was instantiated but never defined', $d->message); + } + + public function testDefinedTemplatesProduceNoUndefinedDiagnostics(): void + { + $collector = new DiagnosticCollector(); + $registry = new Registry(diagnostics: $collector); + $registry->recordDefinition('App\\Box', 'Box', [new TypeParam('T')], $this->classAt(1), '/Box.xphp'); + $registry->recordInstantiation('App\\Box', [new TypeRef('int', isScalar: true)]); + + $registry->collectUndefinedTemplates($collector); + + self::assertSame([], $collector->all()); + } + + private function classAt(int $line): Class_ + { + return new Class_(new Identifier('C'), [], ['startLine' => $line]); + } +} diff --git a/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php b/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php index f4ef36b..18c2f74 100644 --- a/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php +++ b/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php @@ -18,6 +18,7 @@ use PhpParser\Node\VarLikeIdentifier; use PHPUnit\Framework\TestCase; use RuntimeException; +use XPHP\Diagnostics\DiagnosticCollector; /** * Tests `Registry::validateInnerVariance` -- the pass that composes outer @@ -31,6 +32,127 @@ */ final class RegistryInnerVarianceTest extends TestCase { + public function testDirectAndNestedViolationsAreEachReportedExactlyOnce(): void + { + // P (direct +T-in-param) is owned by the position pass; Q (a nested composition violation) is + // owned by the composing inner pass. With disjoint responsibilities (no skip handoff), the two + // passes report exactly one diagnostic each — P's `variance_position` and Q's `inner_variance`, + // with no double-report of P by the inner pass. + $collector = new DiagnosticCollector(); + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod('P', 'set', [new Param(new \PhpParser\Node\Expr\Variable('x'), type: new Name(['T']))], new Identifier('void')), + ), + $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), + $this->makeDefinition( + 'App\\Q', + 'Q', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod('Q', 'f', [], $this->genericName('App\\Container', [new TypeRef('T', isTypeParam: true)])), + ), + ], $collector); + + $flagged = $registry->validateVariancePositions(); + $registry->validateInnerVariance(); + + self::assertSame(['App\\P'], $flagged); + self::assertCount(2, $collector->all()); + $codes = array_map(static fn ($d): string => $d->code, $collector->all()); + self::assertContains(VariancePositionValidator::CODE_VARIANCE_POSITION, $codes); + self::assertContains(InnerVarianceValidator::CODE_INNER_VARIANCE, $codes); + } + + public function testDirectViolationsAreNotDoubleReportedByTheComposingPass(): void + { + // Two direct +T-in-param violations: both owned by the position pass. The composing inner pass + // reports only NESTED occurrences, so it adds nothing here — exactly two diagnostics total, both + // `variance_position`, with no double-report. + $collector = new DiagnosticCollector(); + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod('P', 'set', [new Param(new \PhpParser\Node\Expr\Variable('x'), type: new Name(['T']))], new Identifier('void')), + ), + $this->makeDefinition( + 'App\\R', + 'R', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod('R', 'set', [new Param(new \PhpParser\Node\Expr\Variable('x'), type: new Name(['T']))], new Identifier('void')), + ), + ], $collector); + + $flagged = $registry->validateVariancePositions(); + $registry->validateInnerVariance(); + + self::assertSame(['App\\P', 'App\\R'], $flagged); + self::assertCount(2, $collector->all()); + foreach ($collector->all() as $d) { + self::assertSame(VariancePositionValidator::CODE_VARIANCE_POSITION, $d->code); + } + } + + public function testNullFileProducesNullLocationWithoutError(): void + { + // Defensive: with no file, the diagnostic still emits (null location) and must not + // attempt to build a SourceLocation from a null file. + $registry = $this->registryWith([ + $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Container', [new TypeRef('T', isTypeParam: true)]), + ), + ), + ]); + $container = $registry->definition('App\\Container'); + $p = $registry->definition('App\\P'); + self::assertNotNull($container); + self::assertNotNull($p); + + $collector = new DiagnosticCollector(); + InnerVarianceValidator::assertComposition($p, ['App\\Container' => $container, 'App\\P' => $p], $collector, null); + + self::assertCount(1, $collector->all()); + self::assertNull($collector->all()[0]->location); + } + + public function testCollectModeGathersInnerVarianceDiagnosticInsteadOfThrowing(): void + { + // Same composition violation as the throw-mode test, but with a collector: + // it must be reported as a Diagnostic (not thrown), so `xphp check` continues. + $collector = new DiagnosticCollector(); + $registry = $this->registryWith([ + $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Container', [new TypeRef('T', isTypeParam: true)]), + ), + ), + ], $collector); + + $registry->validateInnerVariance(); + + self::assertCount(1, $collector->all()); + self::assertSame(InnerVarianceValidator::CODE_INNER_VARIANCE, $collector->all()[0]->code); + self::assertStringContainsString('Variance violation in template P', $collector->all()[0]->message); + } + public function testCovariantOuterInInvariantInnerSlotIsRejected(): void { // class Container {} // X is Invariant @@ -94,8 +216,8 @@ public function testCovariantOuterInContravariantInnerSlotIsRejected(): void { // class Sink<-X> {} // class P<+T> { function f(): Sink } - // Outer pos = Cov; inner slot = Contra. - // effective = flip(Cov) = Contra; +T not in {Inv, Contra} -> reject. + // Outer pos = Covariant; inner slot = Contravariant. + // effective = flip(Covariant) = Contravariant; +T not in {Invariant, Contravariant} -> reject. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Sink', @@ -126,8 +248,8 @@ public function testCovariantOuterInCovariantInnerSlotIsAccepted(): void { // class Producer<+X> {} // class P<+T> { function f(): Producer } - // Outer pos = Cov; inner slot = Cov. - // effective = Cov; +T in {Inv, Cov} -> accept. + // Outer pos = Covariant; inner slot = Covariant. + // effective = Covariant; +T in {Invariant, Covariant} -> accept. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Producer', @@ -156,8 +278,8 @@ public function testContravariantPathThroughDoubleFlipIsAccepted(): void { // class Sink<-X> {} // class P<+T> { function f(Sink $x): void } - // Outer pos = Contra (param); inner slot = Contra. - // effective = flip(Contra) = Cov; +T in {Inv, Cov} -> accept. + // Outer pos = Contravariant (param); inner slot = Contravariant. + // effective = flip(Contravariant) = Covariant; +T in {Invariant, Covariant} -> accept. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Sink', @@ -189,8 +311,8 @@ public function testInvariantOuterIsAcceptedInAnyInnerSlot(): void { // class Producer<+X> {} // class P { function f(): Producer } // T is Invariant - // effective for T: compose(Cov, Cov) = Cov; - // Invariant in {Inv, Cov} -> accept. + // effective for T: compose(Covariant, Covariant) = Covariant; + // Invariant in {Invariant, Covariant} -> accept. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Producer', @@ -270,8 +392,8 @@ public function testTwoDeepNestingComposesAllTheWay(): void // class Container {} // Invariant // class Outer<+Y> {} // Covariant // class P<+T> { function f(): Outer> } - // Effective at T's leaf: compose(Cov, Cov) = Cov; then compose(Cov, Inv) = Inv. - // +T not in {Inv} -> reject. + // Effective at T's leaf: compose(Covariant, Covariant) = Covariant; then compose(Covariant, Invariant) = Invariant. + // +T not in {Invariant} -> reject. $registry = $this->registryWith([ $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), $this->makeDefinition( @@ -303,8 +425,8 @@ public function testTwoDeepNestingComposesAllTheWay(): void public function testTwoDeepNestingAllCovariantIsAccepted(): void { - // Replace Container with Container<+X> -- compose(Cov, Cov) = Cov twice. - // +T in {Inv, Cov} -> accept. + // Replace Container with Container<+X> -- compose(Covariant, Covariant) = Covariant twice. + // +T in {Invariant, Covariant} -> accept. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Container', @@ -385,9 +507,9 @@ public function testConstructorPromotedPropertyWithVariantInnerSlotIsRejected(): { // class Container<-X> {} // class P<+T> { function __construct(public Container $c) } - // Constructor param outer-pos is Invariant; inner slot Contra. - // compose(Invariant, Contra) = Invariant; +T not in {Inv} -> reject. - // Pins the ctor-promoted-property -> Invariant outer-pos branch. + // Constructor param outer-pos is Invariant; inner slot Contravariant. + // compose(Invariant, Contravariant) = Invariant; +T not in {Invariant} -> reject. + // Pins the constructor-promoted-property -> Invariant outer-pos branch. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Container', @@ -423,8 +545,8 @@ public function testUnionTypeArmHitsInnerVarianceCheck(): void { // class Producer<-X> {} // class P<+T> { function f(): Producer|null } - // Outer pos = Cov; arm Producer's slot = Contra. - // compose(Cov, Contra) = Contra; +T not in {Inv, Contra} -> reject. + // Outer pos = Covariant; arm Producer's slot = Contravariant. + // compose(Covariant, Contravariant) = Contravariant; +T not in {Invariant, Contravariant} -> reject. $producerOrNull = new UnionType([ $this->genericName('App\\Producer', [new TypeRef('T', isTypeParam: true)]), new Identifier('null'), @@ -454,7 +576,7 @@ public function testFBoundWithContravariantInnerSlotIsRejected(): void // class Sink<-X> {} // class P<+T : Sink> {} // Bound is an Invariant outer-position (PHP class-compat); inner slot - // Contra; compose(Invariant, Contra) = Invariant; +T not in {Inv} -> + // Contravariant; compose(Invariant, Contravariant) = Invariant; +T not in {Invariant} -> // reject. Pins the type-param bound walk. $registry = $this->registryWith([ $this->makeDefinition( @@ -520,8 +642,8 @@ public function testDefaultExpressionWalkRejectsVariantInnerSlot(): void { // class Container<-X> {} // class P<+T, U = Container> {} - // Default is an Invariant outer-position; inner Container slot is Contra; - // compose(Invariant, Contra) = Invariant; +T not in {Inv} -> reject. + // Default is an Invariant outer-position; inner Container slot is Contravariant; + // compose(Invariant, Contravariant) = Invariant; +T not in {Invariant} -> reject. // Pins the type-param default walk. $registry = $this->registryWith([ $this->makeDefinition( @@ -556,8 +678,8 @@ public function testLeadingBackslashInTemplateFqnIsStripped(): void // The ltrim must strip it; otherwise the registry lookup misses the // template and we'd fall to conservative-unknown (Invariant). // Here Container's X is Covariant -- if we strip correctly, the - // composition stays Cov and accepts +T. If ltrim is removed, - // lookup misses, conservative-Inv kicks in, rejects. + // composition stays Covariant and accepts +T. If ltrim is removed, + // lookup misses, conservative-Invariant kicks in, rejects. $genericNode = new Name(['App', 'Container']); $genericNode->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, [new TypeRef('T', isTypeParam: true)]); $genericNode->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, '\\App\\Container'); @@ -583,7 +705,7 @@ public function testLeadingBackslashInTemplateFqnIsStripped(): void public function testNullableTypeWrapsInnerGenericForVarianceCheck(): void { - // class P<+T> { function f(): ?Container } where Container's X is Inv. + // class P<+T> { function f(): ?Container } where Container's X is Invariant. // The NullableType must recurse; the inner Container still triggers // the invariant rejection. Pins the NullableType walker branch. $registry = $this->registryWith([ @@ -610,9 +732,9 @@ public function testTypeRefArgsRecurseThroughKnownInnerTemplate(): void { // BoundLeaf -> TypeRef('App\\Container', [TypeRef('App\\Producer', [T])]) // outer pos via bound = Invariant - // Container's X = Cov -> compose(Inv, Cov) = Inv - // Producer's X = Inv -> compose(Inv, Inv) = Inv - // +T not in {Inv} -> reject. + // Container's X = Covariant -> compose(Invariant, Covariant) = Invariant + // Producer's X = Invariant -> compose(Invariant, Invariant) = Invariant + // +T not in {Invariant} -> reject. // Pins the TypeRef recursion path AND the inner-def lookup via TypeRef name. $registry = $this->registryWith([ $this->makeDefinition( @@ -681,8 +803,8 @@ public function testLeadingBackslashInTypeRefNameIsStripped(): void // The OUTER Outer lookup uses ATTR_TEMPLATE_FQN (walkPhpType branch); // the INNER `\App\Inner` lookup goes through walkTypeRef which uses // ltrim on `$ref->name`. If ltrim is dropped, the Inner lookup misses, - // conservative-Inv kicks in, compose(Cov, Inv) = Inv, rejects. - // With ltrim intact, Inner is found, Cov*Cov*Cov = Cov, accepts. + // conservative-Invariant kicks in, compose(Covariant, Invariant) = Invariant, rejects. + // With ltrim intact, Inner is found, Covariant*Covariant*Covariant = Covariant, accepts. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Outer', @@ -753,8 +875,8 @@ public function testInvariantDeclaredInInvariantPositionIsAccepted(): void { // class Container {} // X is Invariant // class P { function f(): Container } // U is Invariant - // U is Invariant declared; outer pos Cov; inner slot Inv; - // compose(Cov, Inv) = Inv; Invariant in {Inv} -> accept. + // U is Invariant declared; outer pos Covariant; inner slot Invariant; + // compose(Covariant, Invariant) = Invariant; Invariant in {Invariant} -> accept. // The other type-param +T gives buildVarianceMap a non-empty map so // the walk actually runs. Pins the // `Variance::Invariant => [Variance::Invariant]` allowed-list. @@ -784,9 +906,9 @@ public function testInvariantDeclaredInCovariantPositionIsAccepted(): void { // class Producer<+X> {} // class P { function f(): Producer } - // U is Invariant declared; outer pos Cov; inner slot Cov; - // compose(Cov, Cov) = Cov; Invariant in {Inv, Cov} -> accept. - // Pins the second item of `Variance::Covariant => [Inv, Cov]` allowed-list. + // U is Invariant declared; outer pos Covariant; inner slot Covariant; + // compose(Covariant, Covariant) = Covariant; Invariant in {Invariant, Covariant} -> accept. + // Pins the second item of `Variance::Covariant => [Invariant, Covariant]` allowed-list. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Producer', @@ -818,9 +940,9 @@ public function testInvariantDeclaredInContravariantPositionIsAccepted(): void { // class Producer<+X> {} // class P { function f(Producer $x): void } - // U is Invariant declared; outer pos Contra (param); inner Cov; - // compose(Contra, Cov) = Contra; Invariant in {Inv, Contra} -> accept. - // Pins the first item of `Variance::Contravariant => [Inv, Contra]` allowed-list. + // U is Invariant declared; outer pos Contravariant (param); inner Covariant; + // compose(Contravariant, Covariant) = Contravariant; Invariant in {Invariant, Contravariant} -> accept. + // Pins the first item of `Variance::Contravariant => [Invariant, Contravariant]` allowed-list. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Producer', @@ -879,12 +1001,12 @@ public function testStaticMethodReturnTypeIsWalked(): void $registry->validateInnerVariance(); } - public function testPropertyInvariantPositionRejectsCovariantOuter(): void + public function testDirectPropertyCovariantOuterIsOwnedByThePositionPass(): void { - // class P<+T> { public T $item; } - // Property slot is Invariant for outer T directly (not even via inner). - // The parse-time validator catches this; verify the new pass doesn't - // double-throw (its error path uses a different framing). + // class P<+T> { public T $item; } — a DIRECT covariant type-param in an invariant + // (visible-property) position. This is a direct occurrence, owned by the position pass; the + // composing inner pass reports only type-constructor-NESTED occurrences, so it stays SILENT here + // (no double-report). The position pass is the one that rejects it. $registry = $this->registryWith([ $this->makeDefinition( 'App\\P', @@ -894,9 +1016,11 @@ public function testPropertyInvariantPositionRejectsCovariantOuter(): void ), ]); + $registry->validateInnerVariance(); // silent on a direct occurrence — must not throw + $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('invariant-only position'); - $registry->validateInnerVariance(); + $this->expectExceptionMessage('mutable property'); + $registry->validateVariancePositions(); } // ----- helpers --------------------------------------------------------- @@ -904,9 +1028,9 @@ public function testPropertyInvariantPositionRejectsCovariantOuter(): void /** * @param list $defs */ - private function registryWith(array $defs): Registry + private function registryWith(array $defs, ?DiagnosticCollector $collector = null): Registry { - $registry = new Registry(); + $registry = new Registry(diagnostics: $collector); foreach ($defs as $def) { $registry->recordDefinition( $def->fqn, diff --git a/test/Transpiler/Monomorphize/RegistrySiblingBoundTest.php b/test/Transpiler/Monomorphize/RegistrySiblingBoundTest.php new file mode 100644 index 0000000..72b1437 --- /dev/null +++ b/test/Transpiler/Monomorphize/RegistrySiblingBoundTest.php @@ -0,0 +1,179 @@ +`) must be + * grounded against the supplied arg before checking — otherwise `U`'s bound is the literal `T`, + * which is no class, so `isSubtype("Banana","T")` is false and a valid `Pair` is + * wrongly rejected. Banana <: Fruit throughout. + */ +final class RegistrySiblingBoundTest extends TestCase +{ + /** Hierarchy: Banana <: Fruit (and Stringable); Cherry <: Fruit (not Stringable); Money <: Comparable; Apple unrelated. */ + private static function hierarchy(): TypeHierarchy + { + return new TypeHierarchy([ + 'App\\Banana' => ['App\\Fruit', 'Stringable'], + 'App\\Cherry' => ['App\\Fruit'], + 'App\\Fruit' => [], + 'App\\Apple' => [], + 'App\\Comparable' => [], + 'App\\Money' => ['App\\Comparable'], + ]); + } + + /** @param list $params */ + private static function registryFor(array $params): Registry + { + $registry = new Registry(hierarchy: self::hierarchy()); + $registry->recordDefinition('App\\Pair', 'Pair', $params, new Class_(new Identifier('Pair')), '/Pair.xphp'); + + return $registry; + } + + /** + * `class Pair` + * + * @return list + */ + private static function pairTU(): array + { + return [ + new TypeParam('T'), + new TypeParam('U', new BoundLeaf(new TypeRef('T', isTypeParam: true))), + ]; + } + + public function testSiblingBoundAcceptsWhenArgSatisfiesTheSiblingArg(): void + { + $registry = self::registryFor(self::pairTU()); + + // U = Banana must satisfy T = Fruit; Banana <: Fruit, so this is accepted (no throw). + $inst = $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Fruit'), new TypeRef('App\\Banana')]); + + self::assertSame('App\\Pair', $inst->templateFqn); + } + + public function testSiblingBoundRejectsWhenArgViolatesTheSiblingArg(): void + { + $registry = self::registryFor(self::pairTU()); + + // U = Fruit must satisfy T = Banana; Fruit is not a subtype of Banana → reject, and the + // message must show the GROUNDED bound (Banana), not the literal `T`. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated'); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Banana'), new TypeRef('App\\Fruit')]); + } + + public function testDeclTimeDefaultCheckDefersASiblingParamBound(): void + { + // `class Pair`: the default (Banana) is concrete but the bound references + // a sibling (T), so it can't be validated at declaration time — it must defer (not throw). + $registry = self::registryFor([ + new TypeParam('T'), + new TypeParam('U', new BoundLeaf(new TypeRef('T', isTypeParam: true)), new TypeRef('App\\Banana')), + ]); + + $registry->validateDefaultsAgainstBounds(); // must not throw (deferred to instantiation) + + $this->addToAssertionCount(1); + } + + public function testDeclTimeDefaultCheckContinuesPastADeferredSiblingBound(): void + { + // A deferred sibling-bound param (B : A) must only SKIP itself, not stop the loop — a later + // param with a concrete bound whose default violates it (C : Fruit = Apple) must still be + // caught at declaration time. + $registry = self::registryFor([ + new TypeParam('A'), + new TypeParam('B', new BoundLeaf(new TypeRef('A', isTypeParam: true)), new TypeRef('App\\Banana')), + new TypeParam('C', new BoundLeaf(new TypeRef('App\\Fruit')), new TypeRef('App\\Apple')), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('App\\Fruit'); + $registry->validateDefaultsAgainstBounds(); + } + + public function testDefaultedSiblingBoundIsCheckedAtInstantiation(): void + { + // Same class; instantiating `Pair` pads U to its default Banana → Banana must satisfy + // T = Fruit → Banana <: Fruit → accepted. + $registry = self::registryFor([ + new TypeParam('T'), + new TypeParam('U', new BoundLeaf(new TypeRef('T', isTypeParam: true)), new TypeRef('App\\Banana')), + ]); + + $inst = $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Fruit')]); + + self::assertSame('App\\Pair', $inst->templateFqn); + } + + public function testDefaultedSiblingBoundRejectsAtInstantiationWhenTheDefaultViolates(): void + { + // `Pair` pads U to its default Banana → Banana must satisfy T = Apple → Banana is not + // a subtype of Apple → rejected at instantiation (the deferred check fires here). + $registry = self::registryFor([ + new TypeParam('T'), + new TypeParam('U', new BoundLeaf(new TypeRef('T', isTypeParam: true)), new TypeRef('App\\Banana')), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Apple"'); + $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Apple')]); + } + + public function testBoundReferencingALaterSiblingIsGrounded(): void + { + // `class Rev` — T's bound references a LATER param. The subst map is built over all + // params first, so it grounds regardless of order. Banana <: Fruit → accepted. + $registry = self::registryFor([ + new TypeParam('T', new BoundLeaf(new TypeRef('U', isTypeParam: true))), + new TypeParam('U'), + ]); + + $inst = $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Banana'), new TypeRef('App\\Fruit')]); + + self::assertSame('App\\Pair', $inst->templateFqn); + } + + public function testIntersectionSiblingBoundGroundsTheSiblingOperand(): void + { + // `class Box2` — the sibling leaf lives inside an intersection. + // U = Cherry must satisfy (T = Banana) AND Stringable; Cherry is not a subtype of Banana → + // reject, with the grounded sibling operand (Banana) shown. + $registry = self::registryFor([ + new TypeParam('T'), + new TypeParam('U', new BoundIntersection( + new BoundLeaf(new TypeRef('T', isTypeParam: true)), + new BoundLeaf(new TypeRef('Stringable')), + )), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('App\\Banana'); + $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Banana'), new TypeRef('App\\Cherry')]); + } + + public function testFBoundIsPreservedWhenGroundedAtInstantiation(): void + { + // `class Sortable>` — grounding rewrites the inner arg `T`→Money, but the + // leaf name stays `Comparable` and is checked erased. Money <: Comparable → accepted. + $registry = self::registryFor([ + new TypeParam('T', new BoundLeaf(new TypeRef('App\\Comparable', [new TypeRef('T', isTypeParam: true)]))), + ]); + + $inst = $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Money')]); + + self::assertSame('App\\Pair', $inst->templateFqn); + } +} diff --git a/test/Transpiler/Monomorphize/RegistrySubstituteBoundTest.php b/test/Transpiler/Monomorphize/RegistrySubstituteBoundTest.php new file mode 100644 index 0000000..386f02d --- /dev/null +++ b/test/Transpiler/Monomorphize/RegistrySubstituteBoundTest.php @@ -0,0 +1,144 @@ +`) against the + * receiver's concrete type arguments before the bound is checked. Leaves are rewritten via + * Specializer::substituteTypeRef; compound bounds (intersection / union / DNF) recurse. + */ +final class RegistrySubstituteBoundTest extends TestCase +{ + /** @param array $subst */ + private static function ground(BoundExpr $bound, array $subst): BoundExpr + { + return Registry::substituteBound($bound, $subst); + } + + public function testLeafTypeParamIsGroundedToConcrete(): void + { + $result = self::ground( + new BoundLeaf(new TypeRef('E', isTypeParam: true)), + ['E' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundLeaf::class, $result); + self::assertSame('App\\Product', $result->type->name); + self::assertFalse($result->type->isTypeParam); + } + + public function testLeafNotInSubstIsLeftIntact(): void + { + $result = self::ground( + new BoundLeaf(new TypeRef('E', isTypeParam: true)), + ['X' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundLeaf::class, $result); + self::assertSame('E', $result->type->name); + self::assertTrue($result->type->isTypeParam, 'an ungroundable leaf stays a type-param so the caller can detect it'); + } + + public function testConcreteLeafSharingASubstNameIsNotGrounded(): void + { + // Only type-param leaves ground: a concrete class that happens to be named `E` + // (isTypeParam: false) is left intact even when the map has an `E` key. + $result = self::ground( + new BoundLeaf(new TypeRef('E')), + ['E' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundLeaf::class, $result); + self::assertSame('E', $result->type->name); + self::assertFalse($result->type->isTypeParam); + } + + public function testEmptySubstIsANoOp(): void + { + $result = self::ground(new BoundLeaf(new TypeRef('E', isTypeParam: true)), []); + + self::assertInstanceOf(BoundLeaf::class, $result); + self::assertSame('E', $result->type->name); + self::assertTrue($result->type->isTypeParam); + } + + public function testNestedGenericLeafIsGroundedThroughArgs(): void + { + // F-bounded-shaped leaf `Comparable` grounds its inner arg. + $result = self::ground( + new BoundLeaf(new TypeRef('Comparable', [new TypeRef('E', isTypeParam: true)])), + ['E' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundLeaf::class, $result); + self::assertSame('Comparable', $result->type->name); + self::assertCount(1, $result->type->args); + self::assertSame('App\\Product', $result->type->args[0]->name); + } + + public function testIntersectionRecursesOverEveryOperand(): void + { + $result = self::ground( + new BoundIntersection( + new BoundLeaf(new TypeRef('App\\Named')), + new BoundLeaf(new TypeRef('E', isTypeParam: true)), + ), + ['E' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundIntersection::class, $result); + self::assertCount(2, $result->operands); + self::assertInstanceOf(BoundLeaf::class, $result->operands[0]); + self::assertSame('App\\Named', $result->operands[0]->type->name); + self::assertInstanceOf(BoundLeaf::class, $result->operands[1]); + self::assertSame('App\\Product', $result->operands[1]->type->name); + } + + public function testUnionRecursesOverEveryOperand(): void + { + $result = self::ground( + new BoundUnion( + new BoundLeaf(new TypeRef('E', isTypeParam: true)), + new BoundLeaf(new TypeRef('App\\Fallback')), + ), + ['E' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundUnion::class, $result); + self::assertCount(2, $result->operands); + self::assertInstanceOf(BoundLeaf::class, $result->operands[0]); + self::assertSame('App\\Product', $result->operands[0]->type->name); + self::assertInstanceOf(BoundLeaf::class, $result->operands[1]); + self::assertSame('App\\Fallback', $result->operands[1]->type->name); + } + + public function testDnfGroundsLeafNestedInsideUnionOfIntersections(): void + { + // (E & A) | B — the type-param leaf is two levels deep; recursion must reach it. + $result = self::ground( + new BoundUnion( + new BoundIntersection( + new BoundLeaf(new TypeRef('E', isTypeParam: true)), + new BoundLeaf(new TypeRef('App\\A')), + ), + new BoundLeaf(new TypeRef('App\\B')), + ), + ['E' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundUnion::class, $result); + $inner = $result->operands[0]; + self::assertInstanceOf(BoundIntersection::class, $inner); + self::assertInstanceOf(BoundLeaf::class, $inner->operands[0]); + self::assertSame('App\\Product', $inner->operands[0]->type->name); + self::assertInstanceOf(BoundLeaf::class, $inner->operands[1]); + self::assertSame('App\\A', $inner->operands[1]->type->name); + self::assertInstanceOf(BoundLeaf::class, $result->operands[1]); + self::assertSame('App\\B', $result->operands[1]->type->name); + } +} diff --git a/test/Transpiler/Monomorphize/RegistryTest.php b/test/Transpiler/Monomorphize/RegistryTest.php index 20f208d..28162f0 100644 --- a/test/Transpiler/Monomorphize/RegistryTest.php +++ b/test/Transpiler/Monomorphize/RegistryTest.php @@ -54,7 +54,7 @@ public function testSameArgShortNameDifferentNamespacesDoNotCollide(): void public function testNestedGenericArgsAffectHashDeterministically(): void { - $nested = new TypeRef('App\\RegistryTest\\Containers\\Lst', [new TypeRef('App\\RegistryTest\\Models\\Plastic')]); + $nested = new TypeRef('App\\RegistryTest\\Containers\\Collection', [new TypeRef('App\\RegistryTest\\Models\\Plastic')]); $a = Registry::generatedFqn('App\\RegistryTest\\Containers\\Box', [$nested]); $b = Registry::generatedFqn('App\\RegistryTest\\Containers\\Box', [$nested]); @@ -91,23 +91,23 @@ public function testRecordInstantiationRecursivelyRegistersNestedInstantiations( { $registry = new Registry(); - $nested = new TypeRef('App\\RegistryTest\\Containers\\Lst', [new TypeRef('App\\RegistryTest\\Models\\Plastic')]); + $nested = new TypeRef('App\\RegistryTest\\Containers\\Collection', [new TypeRef('App\\RegistryTest\\Models\\Plastic')]); $registry->recordInstantiation('App\\RegistryTest\\Containers\\Box', [$nested]); self::assertCount(2, $registry->instantiations()); $hasBox = false; - $hasLst = false; + $hasCollection = false; foreach ($registry->instantiations() as $fqn => $_) { if (str_starts_with($fqn, 'XPHP\\Generated\\App\\RegistryTest\\Containers\\Box\\T_')) { $hasBox = true; } - if (str_starts_with($fqn, 'XPHP\\Generated\\App\\RegistryTest\\Containers\\Lst\\T_')) { - $hasLst = true; + if (str_starts_with($fqn, 'XPHP\\Generated\\App\\RegistryTest\\Containers\\Collection\\T_')) { + $hasCollection = true; } } self::assertTrue($hasBox, 'expected an outer Box specialization'); - self::assertTrue($hasLst, 'expected a transitive Lst specialization'); + self::assertTrue($hasCollection, 'expected a transitive Collection specialization'); } public function testCustomHashLengthShortensClassName(): void @@ -373,12 +373,12 @@ public function testToArrayEmitsExpectedShapeForDefinitionsAndInstantiations(): public function testToArraySerializesNestedGenericArgAsDisplayString(): void { $registry = new Registry(); - $lstOfPlastic = new TypeRef('App\\RegistryTest\\Containers\\Lst', [new TypeRef('App\\RegistryTest\\Models\\Plastic')]); - $registry->recordInstantiation('App\\RegistryTest\\Containers\\Box', [$lstOfPlastic]); + $collectionOfPlastic = new TypeRef('App\\RegistryTest\\Containers\\Collection', [new TypeRef('App\\RegistryTest\\Models\\Plastic')]); + $registry->recordInstantiation('App\\RegistryTest\\Containers\\Box', [$collectionOfPlastic]); $out = $registry->toArray(); - // Find the outer Box> entry; concreteTypes should be the angle-bracket form. + // Find the outer Box> entry; concreteTypes should be the angle-bracket form. $boxEntry = null; foreach ($out['instantiations'] as $entry) { if ($entry['template'] === 'App\\RegistryTest\\Containers\\Box') { @@ -388,7 +388,7 @@ public function testToArraySerializesNestedGenericArgAsDisplayString(): void } self::assertNotNull($boxEntry); self::assertSame( - ['App\\RegistryTest\\Containers\\Lst'], + ['App\\RegistryTest\\Containers\\Collection'], $boxEntry['concreteTypes'], ); } diff --git a/test/Transpiler/Monomorphize/RegistryVarianceEdgeDiagnosticTest.php b/test/Transpiler/Monomorphize/RegistryVarianceEdgeDiagnosticTest.php new file mode 100644 index 0000000..fbce2d6 --- /dev/null +++ b/test/Transpiler/Monomorphize/RegistryVarianceEdgeDiagnosticTest.php @@ -0,0 +1,248 @@ +registry($collector)->recordInstantiation( + 'App\\Producer', + [new TypeRef('App\\Book')], + $loc, + ); + + self::assertFalse($collector->hasErrors(), 'a warning must not fail the gate'); + self::assertCount(1, $collector->all()); + $d = $collector->all()[0]; + self::assertSame(Severity::Warning, $d->severity); + self::assertSame(Registry::CODE_VARIANCE_EDGE_UNPROVABLE, $d->code); + self::assertSame($loc, $d->location); + } + + public function testWarningMessageHasExactText(): void + { + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('App\\Producer', [new TypeRef('App\\Book')]); + + $expected = <<<'TXT' + Variance edge cannot be proven while instantiating App\Producer. + type parameter +T is covariant, but App\Book is not in the source set the hierarchy was built from (and is not a recognized PHP built-in), + so the compiler cannot prove its subtype edges — this specialization is not linked to related ones and the covariant relationship silently does not apply at runtime. + + Add App\Book to the source set the hierarchy is built from to enable the edge. + TXT; + + self::assertSame($expected, $collector->all()[0]->message); + } + + public function testContravariantOverUnprovableLeafIsWarnedWithMinusMarker(): void + { + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('App\\Consumer', [new TypeRef('App\\Book')]); + + self::assertCount(1, $collector->all()); + self::assertStringContainsString('type parameter -T is contravariant', $collector->all()[0]->message); + } + + public function testProvableDeclaredLeafIsSilent(): void + { + // `App\Banana` is in the hierarchy → provable verdict, no warning. + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('App\\Producer', [new TypeRef('App\\Banana')]); + + self::assertSame([], $collector->all()); + } + + public function testInvariantPositionIsSilent(): void + { + // Invariant positions form no edge, so an unprovable type there loses nothing. + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('App\\Box', [new TypeRef('App\\Book')]); + + self::assertSame([], $collector->all()); + } + + public function testScalarArgIsSilent(): void + { + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('App\\Producer', [new TypeRef('int', isScalar: true)]); + + self::assertSame([], $collector->all()); + } + + public function testTypeParamArgIsSilent(): void + { + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('App\\Producer', [new TypeRef('T', isTypeParam: true)]); + + self::assertSame([], $collector->all()); + } + + public function testGenericArgIsSilentLeafOnly(): void + { + // A generic arg is leaf-only-deferred: its inner leaves are checked when its own + // instantiation is recorded, not at the outer level — even when its template name + // (`App\External`) is itself not in the source set. + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation( + 'App\\Producer', + [new TypeRef('App\\External', [new TypeRef('App\\Banana')])], + ); + + self::assertSame([], $collector->all()); + } + + public function testTwoVariantPositionsEachUnprovableWarnTwice(): void + { + // `Pair<+A, +B>` over two unprovable leaves → one warning per covariant position. + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation( + 'App\\Pair', + [new TypeRef('App\\Book'), new TypeRef('App\\Author')], + ); + + self::assertCount(2, $collector->all()); + foreach ($collector->all() as $d) { + self::assertSame(Registry::CODE_VARIANCE_EDGE_UNPROVABLE, $d->code); + } + } + + public function testLeadingBackslashTemplateNameResolvesAndIsNormalisedInMessage(): void + { + // The template FQN may arrive with a leading backslash; it must still resolve to the + // recorded definition (so the warning fires) and the message must name it normalised + // (no leading backslash), matching the bounds path. + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('\\App\\Producer', [new TypeRef('App\\Book')]); + + self::assertCount(1, $collector->all()); + self::assertStringContainsString('instantiating App\\Producer', $collector->all()[0]->message); + } + + public function testEarlierInvariantPositionDoesNotShortCircuitLaterVariant(): void + { + // `Mixed`: the invariant A is skipped, but the walk must continue to the + // covariant B and still warn (pins `continue`, not `break`, on the invariant skip). + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation( + 'App\\Mixed', + [new TypeRef('App\\Book'), new TypeRef('App\\Author')], + ); + + self::assertCount(1, $collector->all()); + self::assertStringContainsString('App\\Author', $collector->all()[0]->message); + } + + public function testEarlierScalarPositionDoesNotShortCircuitLaterVariant(): void + { + // `Pair<+A, +B>` with a scalar A: A is skipped, B still warns (pins `continue` on + // the scalar/type-param/generic skip). + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation( + 'App\\Pair', + [new TypeRef('int', isScalar: true), new TypeRef('App\\Book')], + ); + + self::assertCount(1, $collector->all()); + self::assertStringContainsString('App\\Book', $collector->all()[0]->message); + } + + public function testEarlierDeclaredPositionDoesNotShortCircuitLaterVariant(): void + { + // `Pair<+A, +B>` with a declared A: A is skipped (provable), B still warns (pins + // `continue` on the isDeclared skip). + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation( + 'App\\Pair', + [new TypeRef('App\\Banana'), new TypeRef('App\\Book')], + ); + + self::assertCount(1, $collector->all()); + self::assertStringContainsString('App\\Book', $collector->all()[0]->message); + } + + public function testCompileModeNeverThrowsAndEmitsNothing(): void + { + // No collector (compile): the warning has no sink, so recording completes silently. + $registry = $this->registry(null); + $registry->recordInstantiation('App\\Producer', [new TypeRef('App\\Book')]); + + self::assertCount(1, $registry->instantiations()); + } + + private function registry(?DiagnosticCollector $collector): Registry + { + $hierarchy = new TypeHierarchy([ + 'App\\Fruit' => [], + 'App\\Banana' => ['App\\Fruit'], + ]); + $registry = new Registry(hierarchy: $hierarchy, diagnostics: $collector); + $registry->recordDefinition( + 'App\\Producer', + 'Producer', + [new TypeParam('T', null, null, Variance::Covariant)], + new Class_(new Identifier('Producer')), + '/App/Producer.xphp', + ); + $registry->recordDefinition( + 'App\\Consumer', + 'Consumer', + [new TypeParam('T', null, null, Variance::Contravariant)], + new Class_(new Identifier('Consumer')), + '/App/Consumer.xphp', + ); + $registry->recordDefinition( + 'App\\Box', + 'Box', + [new TypeParam('T', null, null, Variance::Invariant)], + new Class_(new Identifier('Box')), + '/App/Box.xphp', + ); + $registry->recordDefinition( + 'App\\Pair', + 'Pair', + [ + new TypeParam('A', null, null, Variance::Covariant), + new TypeParam('B', null, null, Variance::Covariant), + ], + new Class_(new Identifier('Pair')), + '/App/Pair.xphp', + ); + // Invariant first, covariant second — to prove an earlier skipped position + // doesn't short-circuit the walk before a later variant position. + $registry->recordDefinition( + 'App\\Mixed', + 'Mixed', + [ + new TypeParam('A', null, null, Variance::Invariant), + new TypeParam('B', null, null, Variance::Covariant), + ], + new Class_(new Identifier('Mixed')), + '/App/Mixed.xphp', + ); + + return $registry; + } +} diff --git a/test/Transpiler/Monomorphize/SuspectUndeclaredTypeTagTest.php b/test/Transpiler/Monomorphize/SuspectUndeclaredTypeTagTest.php new file mode 100644 index 0000000..004eecf --- /dev/null +++ b/test/Transpiler/Monomorphize/SuspectUndeclaredTypeTagTest.php @@ -0,0 +1,144 @@ +taggedNames(<<<'PHP' + + { + public function add(T $x): void; + public function get(): Z; + } + PHP); + + // `T` (undeclared) is tagged with its namespace-resolved FQN; `Z` is a + // declared param (never qualified, never tagged). + self::assertSame(['T' => 'App\\T'], $tagged); + } + + public function testStrayNamesInPropertyAndReturnPositionsAreTagged(): void + { + $tagged = $this->taggedNames(<<<'PHP' + + { + public Stray $item; + public function get(): Other {} + } + PHP); + + // Locks the contract that WI-02 relies on: property + return-type positions + // (not just params) are tagged. + self::assertSame(['Stray' => 'App\\Stray', 'Other' => 'App\\Other'], $tagged); + } + + public function testImportedNameIsNotTagged(): void + { + $tagged = $this->taggedNames(<<<'PHP' + + { + public function set(Real $x): void {} + } + PHP); + + self::assertSame([], $tagged); + } + + public function testFullyQualifiedAndScalarAreNotTagged(): void + { + $tagged = $this->taggedNames(<<<'PHP' + + { + public function a(\App\Thing $x): void {} + public function b(int $x): int { return $x; } + } + PHP); + + self::assertSame([], $tagged); + } + + public function testBareNameInNonGenericClassIsNotTagged(): void + { + $tagged = $this->taggedNames(<<<'PHP' + taggedNames(<<<'PHP' + (B $x): void {} + } + PHP); + + // The method declares , so we're in a generic context; the stray `B` is tagged. + self::assertSame(['B' => 'App\\B'], $tagged); + } + + /** + * @return array tagged short-name => resolved FQN + */ + private function taggedNames(string $source): array + { + $ast = (new XphpSourceParser((new ParserFactory())->createForHostVersion()))->parse($source); + + $visitor = new class extends NodeVisitorAbstract { + /** @var array */ + public array $tagged = []; + + public function enterNode(Node $node): null + { + if ($node instanceof Name) { + $fqn = $node->getAttribute(XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE); + if (is_string($fqn)) { + $this->tagged[$node->toString()] = $fqn; + } + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + return $visitor->tagged; + } +} diff --git a/test/Transpiler/Monomorphize/TypeHierarchyInheritedArgsTest.php b/test/Transpiler/Monomorphize/TypeHierarchyInheritedArgsTest.php new file mode 100644 index 0000000..00354b4 --- /dev/null +++ b/test/Transpiler/Monomorphize/TypeHierarchyInheritedArgsTest.php @@ -0,0 +1,304 @@ + $args */ + private static function ref(string $name, array $args = []): TypeRef + { + return new TypeRef($name, $args); + } + + /** + * @param array> $superTypeArgs + * @param array> $typeParamNames + */ + private static function hierarchy(array $superTypeArgs, array $typeParamNames): TypeHierarchy + { + return new TypeHierarchy([], $superTypeArgs, $typeParamNames); + } + + /** + * @param list|null $args + * @return list|null + */ + private static function canon(?array $args): ?array + { + return $args === null ? null : array_map(static fn (TypeRef $a): string => $a->canonical(), $args); + } + + public function testDirectHitReturnsSubArgsUnchanged(): void + { + $h = self::hierarchy([], ['App\\Box' => ['E']]); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('App\\Box', [self::ref('App\\Product')], 'App\\Box')), + ); + } + + public function testSingleHopGroundsTheClauseArg(): void + { + // ArrayList implements Collection + $h = self::hierarchy( + ['App\\ArrayList' => [self::ref('App\\Collection', [self::tp('E')])], 'App\\Collection' => []], + ['App\\ArrayList' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('App\\ArrayList', [self::ref('App\\Product')], 'App\\Collection')), + ); + } + + public function testMultiHopChainThreadsThrough(): void + { + // ArrayList -> OrderedCollection -> Collection + $h = self::hierarchy( + [ + 'App\\ArrayList' => [self::ref('App\\OrderedCollection', [self::tp('E')])], + 'App\\OrderedCollection' => [self::ref('App\\Collection', [self::tp('E')])], + 'App\\Collection' => [], + ], + ['App\\ArrayList' => ['E'], 'App\\OrderedCollection' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('App\\ArrayList', [self::ref('App\\Product')], 'App\\Collection')), + ); + } + + public function testReorderedMultiArgClauseIsPositionallyZipped(): void + { + // Rev implements Pair — params must zip positionally, swapped at the clause. + $h = self::hierarchy( + ['App\\Rev' => [self::ref('App\\Pair', [self::tp('B'), self::tp('A')])], 'App\\Pair' => []], + ['App\\Rev' => ['A', 'B'], 'App\\Pair' => ['K', 'V']], + ); + + self::assertSame( + ['App\\Y', 'App\\X'], + self::canon($h->resolveInheritedArgs('App\\Rev', [self::ref('App\\X'), self::ref('App\\Y')], 'App\\Pair')), + ); + } + + public function testNestedClauseArgGroundsThroughRecursion(): void + { + // Wrap implements Holder> — the type-param is nested inside the clause arg. + $h = self::hierarchy( + ['App\\Wrap' => [self::ref('App\\Holder', [self::ref('App\\Box', [self::tp('E')])])], 'App\\Holder' => []], + ['App\\Wrap' => ['E'], 'App\\Holder' => ['T']], + ); + + self::assertSame( + ['App\\Box'], + self::canon($h->resolveInheritedArgs('App\\Wrap', [self::ref('App\\Product')], 'App\\Holder')), + ); + } + + public function testTypeParamSubArgsPropagateUnchanged(): void + { + // The `$this`-receiver shape: subArgs are identity type-params, so the grounding stays a + // type-param (WI-4 then drops the bound leniently). + $h = self::hierarchy( + ['App\\ArrayList' => [self::ref('App\\Collection', [self::tp('E')])], 'App\\Collection' => []], + ['App\\ArrayList' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['E'], + self::canon($h->resolveInheritedArgs('App\\ArrayList', [self::tp('E')], 'App\\Collection')), + ); + } + + public function testDiamondWithAgreeingArgsResolvesOnce(): void + { + // A -> B, C; B -> D, C -> D. Both paths agree → single grounding. + $h = self::hierarchy( + [ + 'App\\A' => [self::ref('App\\B', [self::tp('E')]), self::ref('App\\C', [self::tp('E')])], + 'App\\B' => [self::ref('App\\D', [self::tp('E')])], + 'App\\C' => [self::ref('App\\D', [self::tp('E')])], + 'App\\D' => [], + ], + ['App\\A' => ['E'], 'App\\B' => ['E'], 'App\\C' => ['E'], 'App\\D' => ['E']], + ); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('App\\A', [self::ref('App\\Product')], 'App\\D')), + ); + } + + public function testDiamondWithConflictingArgsIsAmbiguousAndReturnsNull(): void + { + // A -> B, C>; B -> D, C -> D. The two paths ground D to Product vs + // Box — a conflict the resolver must report as null (not pick one arbitrarily). + $h = self::hierarchy( + [ + 'App\\A' => [ + self::ref('App\\B', [self::tp('E')]), + self::ref('App\\C', [self::ref('App\\Box', [self::tp('E')])]), + ], + 'App\\B' => [self::ref('App\\D', [self::tp('T')])], + 'App\\C' => [self::ref('App\\D', [self::tp('T')])], + 'App\\D' => [], + ], + ['App\\A' => ['E'], 'App\\B' => ['T'], 'App\\C' => ['T'], 'App\\D' => ['T']], + ); + + self::assertNull($h->resolveInheritedArgs('App\\A', [self::ref('App\\Product')], 'App\\D')); + } + + public function testMultiArgClauseUsingOnlyOneParamThreadsTheRightArg(): void + { + // ImmutableMap implements Collection — only the index-1 param flows up (the + // `Map::containsValue` shape). The dropped K must not leak into the grounding. + $h = self::hierarchy( + ['App\\ImmutableMap' => [self::ref('App\\Collection', [self::tp('V')])], 'App\\Collection' => []], + ['App\\ImmutableMap' => ['K', 'V'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('App\\ImmutableMap', [self::ref('App\\Str'), self::ref('App\\Product')], 'App\\Collection')), + ); + } + + public function testClauseReferencingAnOutOfScopeParamPropagatesItUnchanged(): void + { + // A clause arg naming a type-param absent from the class's own params is left untouched by + // substitution, so it grounds to a stray type-param (which WI-4 then drops leniently). + $h = self::hierarchy( + ['App\\Odd' => [self::ref('App\\Collection', [self::tp('F')])], 'App\\Collection' => []], + ['App\\Odd' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['F'], + self::canon($h->resolveInheritedArgs('App\\Odd', [self::ref('App\\Product')], 'App\\Collection')), + ); + } + + public function testUnreachableTargetReturnsNull(): void + { + $h = self::hierarchy( + ['App\\ArrayList' => [self::ref('App\\Collection', [self::tp('E')])], 'App\\Collection' => []], + ['App\\ArrayList' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertNull($h->resolveInheritedArgs('App\\ArrayList', [self::ref('App\\Product')], 'App\\Stringable')); + } + + public function testArityMismatchAtAHopReturnsNull(): void + { + // ArrayList declares one param but the caller supplies two args — an unbridgeable gap. + $h = self::hierarchy( + ['App\\ArrayList' => [self::ref('App\\Collection', [self::tp('E')])], 'App\\Collection' => []], + ['App\\ArrayList' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertNull( + $h->resolveInheritedArgs('App\\ArrayList', [self::ref('App\\Product'), self::ref('App\\Extra')], 'App\\Collection'), + ); + } + + public function testRegularCycleTerminatesWithNull(): void + { + // A implements B, B implements A — the per-path visited set breaks the cycle. + $h = self::hierarchy( + [ + 'App\\A' => [self::ref('App\\B', [self::tp('E')])], + 'App\\B' => [self::ref('App\\A', [self::tp('E')])], + ], + ['App\\A' => ['E'], 'App\\B' => ['E']], + ); + + self::assertNull($h->resolveInheritedArgs('App\\A', [self::ref('App\\Product')], 'App\\Unreachable')); + } + + public function testExpansiveRecursionTerminatesWithNull(): void + { + // Loop implements Loop> — a non-regular (ever-growing) recursion. The on-path guard + // stops it on the second visit to Loop, so it returns promptly rather than looping forever. + $h = self::hierarchy( + ['App\\Loop' => [self::ref('App\\Loop', [self::ref('App\\Box', [self::tp('T')])])]], + ['App\\Loop' => ['T']], + ); + + self::assertNull($h->resolveInheritedArgs('App\\Loop', [self::ref('int')], 'App\\Unreachable')); + } + + public function testLeadingBackslashOnSubAndSuperFqnIsNormalized(): void + { + // Callers may pass fully-qualified (leading-`\`) names; the resolver normalizes both ends. + $h = self::hierarchy( + ['App\\ArrayList' => [self::ref('App\\Collection', [self::tp('E')])], 'App\\Collection' => []], + ['App\\ArrayList' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('\\App\\ArrayList', [self::ref('App\\Product')], '\\App\\Collection')), + ); + } + + public function testLeadingBackslashOnAClauseNameIsNormalized(): void + { + // A supertype clause whose head is fully-qualified still keys onto the target. + $h = self::hierarchy( + ['App\\ArrayList' => [self::ref('\\App\\Collection', [self::tp('E')])], 'App\\Collection' => []], + ['App\\ArrayList' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('App\\ArrayList', [self::ref('App\\Product')], 'App\\Collection')), + ); + } + + public function testCaptureFromRealParserGroundsAnAliasedSupertypeArg(): void + { + // Real xphp parse: the head FQN (App\Collection, via collectFromAst::resolveName) and the + // clause arg FQN (App\Models\Product, resolved by the parser from the `use` alias) come from + // two independent resolvers; a successful grounding proves they agree. + $src = <<<'PHP' + {} + class ArrayList implements Collection {} + PHP; + + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($src); + $h = TypeHierarchy::fromAstPerFile(['/x.xphp' => $ast]); + + // ArrayList passes the concrete `Product` (not its own E) to Collection, so the grounding is + // Product regardless of the receiver's E argument. + self::assertSame( + ['App\\Models\\Product'], + self::canon($h->resolveInheritedArgs('App\\ArrayList', [self::ref('App\\Whatever')], 'App\\Collection')), + ); + } +} diff --git a/test/Transpiler/Monomorphize/TypeHierarchyTest.php b/test/Transpiler/Monomorphize/TypeHierarchyTest.php index 8d62efe..37fa461 100644 --- a/test/Transpiler/Monomorphize/TypeHierarchyTest.php +++ b/test/Transpiler/Monomorphize/TypeHierarchyTest.php @@ -192,4 +192,77 @@ class Tag implements Stringable self::assertTrue($hierarchy->isSubtype('App\\Tag', 'Stringable')); } + + public function testIsDeclaredForClassLikeInTheSourceSet(): void + { + $hierarchy = new TypeHierarchy(['App\\Foo' => [], 'App\\Bar' => ['App\\Foo']]); + + self::assertTrue($hierarchy->isDeclared('App\\Foo')); + self::assertTrue($hierarchy->isDeclared('App\\Bar')); + self::assertTrue($hierarchy->isDeclared('\\App\\Foo')); // leading backslash normalised + } + + public function testIsDeclaredForBuiltinType(): void + { + $hierarchy = new TypeHierarchy([]); + + self::assertTrue($hierarchy->isDeclared('Stringable')); + self::assertTrue($hierarchy->isDeclared('Countable')); + } + + public function testIsNotDeclaredForUnknownName(): void + { + $hierarchy = new TypeHierarchy(['App\\Foo' => []]); + + self::assertFalse($hierarchy->isDeclared('App\\T')); + self::assertFalse($hierarchy->isDeclared('App\\Nonexistent')); + } + + public function testAncestorChainReturnsNearestFirst(): void + { + $hierarchy = new TypeHierarchy([ + 'App\\Leaf' => ['App\\Mid'], + 'App\\Mid' => ['App\\Base'], + 'App\\Base' => [], + ]); + + self::assertSame(['App\\Mid', 'App\\Base'], $hierarchy->ancestorChain('App\\Leaf')); + self::assertSame(['App\\Base'], $hierarchy->ancestorChain('App\\Mid')); + self::assertSame([], $hierarchy->ancestorChain('App\\Base')); + } + + public function testAncestorChainDedupesDiamondNearestFirst(): void + { + // Diamond: D -> {B, C}; B -> A; C -> {A, E}. A is reachable via two + // paths but appears exactly once. E sits in the queue *after* the + // duplicate A, so a `break`-instead-of-`continue` on the dedup hit + // would wrongly drop E -- the assertion on E's presence pins that. + $hierarchy = new TypeHierarchy([ + 'D' => ['B', 'C'], + 'B' => ['A'], + 'C' => ['A', 'E'], + 'A' => [], + 'E' => [], + ]); + + self::assertSame(['B', 'C', 'A', 'E'], $hierarchy->ancestorChain('D')); + } + + public function testAncestorChainUnknownFqnIsEmpty(): void + { + $hierarchy = new TypeHierarchy([]); + + self::assertSame([], $hierarchy->ancestorChain('App\\Nope')); + } + + public function testAncestorChainNormalizesLeadingBackslash(): void + { + $hierarchy = new TypeHierarchy([ + 'App\\Sub' => ['App\\Sup'], + 'App\\Sup' => [], + ]); + + self::assertSame(['App\\Sup'], $hierarchy->ancestorChain('\\App\\Sub')); + } + } diff --git a/test/Transpiler/Monomorphize/TypeRefTest.php b/test/Transpiler/Monomorphize/TypeRefTest.php index b7f57d9..5a1656b 100644 --- a/test/Transpiler/Monomorphize/TypeRefTest.php +++ b/test/Transpiler/Monomorphize/TypeRefTest.php @@ -48,10 +48,10 @@ public function testCanonicalFormatsMultiArgGenericWithCommaSeparator(): void public function testCanonicalFormatsNestedGenericRecursively(): void { - $inner = new TypeRef('App\\Containers\\Lst', [new TypeRef('App\\Models\\Plastic')]); + $inner = new TypeRef('App\\Containers\\Collection', [new TypeRef('App\\Models\\Plastic')]); $outer = new TypeRef('App\\Containers\\Box', [$inner]); self::assertSame( - 'App\\Containers\\Box>', + 'App\\Containers\\Box>', $outer->canonical(), ); } @@ -89,10 +89,10 @@ public function testToDisplayStringMultiArgUsesCommaSpaceSeparator(): void public function testToDisplayStringNestedGeneric(): void { - $inner = new TypeRef('App\\Containers\\Lst', [new TypeRef('App\\Models\\Plastic')]); + $inner = new TypeRef('App\\Containers\\Collection', [new TypeRef('App\\Models\\Plastic')]); $outer = new TypeRef('App\\Containers\\Box', [$inner]); self::assertSame( - 'App\\Containers\\Box>', + 'App\\Containers\\Box>', $outer->toDisplayString(), ); } @@ -120,7 +120,7 @@ public function testIsConcreteRecursesIntoArgsAndDetectsUnresolvedTypeParam(): v public function testIsConcreteIsTrueWhenAllNestedArgsAreConcrete(): void { - $inner = new TypeRef('App\\Lst', [new TypeRef('App\\Plastic')]); + $inner = new TypeRef('App\\Collection', [new TypeRef('App\\Plastic')]); $outer = new TypeRef('App\\Box', [$inner]); self::assertTrue($outer->isConcrete()); } diff --git a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php index 9cdd309..e887b14 100644 --- a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php +++ b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php @@ -6,11 +6,13 @@ use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard as StandardPrinter; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use RuntimeException; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; +use XPHP\TestSupport\CompiledFixture; use XPHP\TestSupport\SnapshotHash; final class VarianceEdgeIntegrationTest extends TestCase @@ -34,6 +36,248 @@ protected function tearDown(): void } } + #[RunInSeparateProcess] + public function testCovariantImmutableCollectionTakesTypedConstructorInput(): void + { + // A covariant immutable collection `ImmutableList<+T>` with a `T`-typed + // constructor. The constructor param keeps its REAL element type on each + // specialization (`Fruit ...` / `Banana ...`) — PHP exempts `__construct` + // from LSP, so `ImmutableList` extends `ImmutableList` with + // NO autoload fatal, a Banana list is usable where a Fruit list is + // expected, and construction is runtime-type-checked. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_covariant_immutable_constructor/source', + 'variance-covariant-constructor', + ); + try { + $specializationDir = $fixture->cacheDir . '/Generated/App/CovariantConstructor/ImmutableList'; + $files = glob($specializationDir . '/T_*.php') ?: []; + self::assertCount(2, $files, 'two ImmutableList specializations (Fruit, Banana)'); + + $combined = ''; + $extendsEdges = 0; + foreach ($files as $file) { + $content = file_get_contents($file); + self::assertIsString($content); + $combined .= $content; + if (str_contains($content, 'extends \\XPHP\\Generated\\App\\CovariantConstructor\\ImmutableList\\T_')) { + $extendsEdges++; + } + } + // Each specialization keeps its REAL element type in the constructor. + self::assertSame( + 1, + preg_match_all('/function __construct\(\\\\App\\\\CovariantConstructor\\\\Fruit \.\.\.\$items\)/', $combined), + 'Fruit specialization constructor keeps `Fruit ...$items`', + ); + self::assertSame( + 1, + preg_match_all('/function __construct\(\\\\App\\\\CovariantConstructor\\\\Banana \.\.\.\$items\)/', $combined), + 'Banana specialization constructor keeps `Banana ...$items`', + ); + self::assertStringNotContainsString('mixed ...$items', $combined, 'nothing is erased to mixed'); + // Exactly one specialization extends the other — the covariant edge. + self::assertSame(1, $extendsEdges, 'ImmutableList extends ImmutableList'); + // A variant class can't be `final` (rejected at compile time), so no + // specialization is `final` — the edge's parent isn't a final class + // (which would PHP-fatal at autoload). + self::assertStringNotContainsString('final class', $combined); + + $fixture->registerAutoload('App\\CovariantConstructor'); + require __DIR__ . '/../../fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testCovariantPrivatePropertyStoresRealTypeAndIsRuntimeChecked(): void + { + // A covariant container `Box<+T>` that stores its element in a PRIVATE + // promoted property of type `T`. Each specialization keeps the REAL slot + // type (`private Banana $item` / `private Fruit $item`), the variance edge + // `Box extends Box` autoloads with NO fatal (PHP doesn't + // type-check private property types across the chain), a Banana box is + // usable where a Fruit box is expected, and construction is runtime-checked. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_covariant_private_property/source', + 'variance-covariant-private-property', + ); + try { + $specializationDir = $fixture->cacheDir . '/Generated/App/CovariantPrivateProperty/Box'; + $files = glob($specializationDir . '/T_*.php') ?: []; + self::assertCount(2, $files, 'two Box specializations (Fruit, Banana)'); + + $combined = ''; + $extendsEdges = 0; + foreach ($files as $file) { + $content = file_get_contents($file); + self::assertIsString($content); + $combined .= $content; + if (str_contains($content, 'extends \\XPHP\\Generated\\App\\CovariantPrivateProperty\\Box\\T_')) { + $extendsEdges++; + } + } + // Each specialization keeps its REAL private slot type — nothing erased. + self::assertSame( + 1, + preg_match_all('/private \\\\App\\\\CovariantPrivateProperty\\\\Fruit \$item/', $combined), + 'Fruit specialization stores `private Fruit $item`', + ); + self::assertSame( + 1, + preg_match_all('/private \\\\App\\\\CovariantPrivateProperty\\\\Banana \$item/', $combined), + 'Banana specialization stores `private Banana $item`', + ); + self::assertStringNotContainsString('private mixed $item', $combined, 'nothing is erased to mixed'); + // Exactly one specialization extends the other — the covariant edge. + self::assertSame(1, $extendsEdges, 'Box extends Box'); + self::assertStringNotContainsString('final class', $combined); + + $fixture->registerAutoload('App\\CovariantPrivateProperty'); + require __DIR__ . '/../../fixture/compile/generic_covariant_private_property/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testCrossTemplateGenericArgUpcastEmitsEdgeAndRunsAtRuntime(): void + { + // A covariant `Couple<+A, +B> implements Tuple` holding a covariant container + // `ImmutableList` as its first type-argument is upcast to `Tuple, + // Tag>`. That requires the covariant edge `Tuple, Tag> ⊑ + // Tuple, Tag>`, whose per-argument check must recognize `ImmutableList + // ⊑ Collection` ACROSS DIFFERENT TEMPLATES (ImmutableList implements Collection, both + // covariant). Before the cross-template case in `isNestedSubtype`, the edge was silently + // omitted: `xphp check` passed, then the upcast fatal'd at runtime. This proves the edge is now + // emitted AND the covariance holds when the program actually runs. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/cross_template_generic_arg_upcast/source', + 'cross-template-arg', + ); + try { + // The interface `Tuple` specializations carry the cross-template covariant edge: the + // `Tuple, Tag>` spec must `extends` the `Tuple, Tag>` + // spec (an interface-to-interface variance edge). Pre-fix there is no such edge at all. + $tupleDir = $fixture->cacheDir . '/Generated/App/Tuple'; + $tupleFiles = glob($tupleDir . '/T_*.php') ?: []; + self::assertGreaterThanOrEqual(2, count($tupleFiles), 'two Tuple specializations exist'); + $crossEdges = 0; + foreach ($tupleFiles as $file) { + $content = file_get_contents($file); + self::assertIsString($content); + // A Tuple spec whose `extends` clause references ANOTHER generated Tuple spec is the + // cross-template covariant edge (`Tuple,Tag> extends + // Tuple,Tag>`). The supertype spec extends only `\App\Tuple`. + if (str_contains($content, '\\XPHP\\Generated\\App\\Tuple\\T_')) { + $crossEdges++; + } + } + self::assertGreaterThanOrEqual( + 1, + $crossEdges, + 'the cross-template covariant edge between the two Tuple specializations is emitted', + ); + + $fixture->registerAutoload('App\\'); + require __DIR__ . '/../../fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testComparatorParamOnCovariantClassCompilesAndRunsUnderUpcast(): void + { + // A covariant `Box<+E>` with a `pick(Comparator $c): ?E` consuming method — the sound shape + // where `E` sits in a contravariant slot (Comparator<-T>) inside a contravariant parameter + // (contra ∘ contra = covariant). Previously rejected `xphp.variance_position` even though sound; + // the composing variance pass now accepts it. A `Box` is upcast to `Box` and + // `pick` is called with a `ById` (a Comparator, hence by contravariance a + // Comparator): it loads, runs, and returns the max Book — proving the acceptance is sound. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/comparator_param_covariant_upcast/source', + 'comparator-param-covariant', + ); + try { + $fixture->registerAutoload('App\\'); + require __DIR__ . '/../../fixture/compile/comparator_param_covariant_upcast/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + public function testBoundedCovariantConstructorKeepsConcreteType(): void + { + // A bounded covariant constructor param keeps its REAL substituted type (the + // concrete arg, not the bound and not `mixed`) — constructors are LSP-exempt. + $generated = $this->compileFixtureAndReadGenerated('compile/generic_covariant_bounded_constructor/source'); + self::assertStringContainsString('__construct(\\App\\BoundConstructor\\Tag ...$items)', $generated); + self::assertStringNotContainsString('__construct(mixed', $generated); + self::assertStringNotContainsString('__construct(\\Stringable', $generated); + } + + public function testMixedVarianceConstructorKeepsConcreteTypes(): void + { + // `Pair<+A, B>`: the covariant `A` constructor param keeps its concrete type, the + // invariant `B` param keeps its concrete substituted type, and a plain + // scalar param (`int $tag`) is left untouched (it isn't a type-param). + $generated = $this->compileFixtureAndReadGenerated('compile/generic_mixed_variance_constructor/source'); + self::assertMatchesRegularExpression('/__construct\(\\\\App\\\\MixedConstructor\\\\Apple \$a, \\\\App\\\\MixedConstructor\\\\Apple \$b, int \$tag\)/', $generated); + self::assertStringNotContainsString('mixed $a', $generated); + } + + public function testContravariantConstructorParamKeepsConcreteType(): void + { + // Symmetry with the covariant case: a `-T` constructor param keeps its real type + // too, and the contravariant edge (Consumer extends Consumer) + // stays valid because constructors are LSP-exempt. + $generated = $this->compileFixtureAndReadGenerated('compile/generic_contravariant_constructor/source'); + self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContravariantConstructor\\\\Banana \.\.\.\$items\)/', $generated)); + self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContravariantConstructor\\\\Fruit \.\.\.\$items\)/', $generated)); + self::assertStringNotContainsString('mixed ...$items', $generated); + self::assertStringContainsString('extends \\XPHP\\Generated\\App\\ContravariantConstructor\\Consumer\\T_', $generated); + } + + public function testNonBareVariantConstructorParamShapesAreRejected(): void + { + // Only a *bare* variance-marked type-param is currently supported in a + // constructor parameter. Richer shapes are still rejected by the + // inner-variance check (not yet supported in constructor position): + // - `?T` — nullable, not a bare Name + // - `Box` — T through another generic's invariant slot + // - `(T $a, ?T $b)` — the allowed leading `T` must not stop the walk from + // reaching the bad trailing `?T` + $fixtures = [ + 'check/variance_constructor_nullable/source', + 'check/variance_constructor_nested_generic/source', + 'check/variance_constructor_mixed_params/source', + ]; + foreach ($fixtures as $fixture) { + $this->compileFixtureExpectingVarianceViolation($fixture); + } + } + + public function testTwoCovariantParamsBothKeepConcreteTypes(): void + { + // Two covariant params: BOTH `T`-typed constructor params keep their real + // substituted types (pins that nothing is erased for any variant constructor param). + $generated = $this->compileFixtureAndReadGenerated('compile/generic_two_covariant_constructor/source'); + self::assertSame(1, preg_match_all('/__construct\(\\\\App\\\\TwoConstructor\\\\Apple \$a, \\\\App\\\\TwoConstructor\\\\Apple \$b\)/', $generated)); + self::assertStringNotContainsString('mixed $a', $generated); + } + + public function testInvariantClassConstructorIsNotErasedAndKeepsFinal(): void + { + // An invariant class is not variance-erased (constructor param keeps its concrete + // type) and its `final` modifier is preserved (no edges → no LSP hazard). + $generated = $this->compileFixtureAndReadGenerated('compile/generic_invariant_constructor/source'); + self::assertStringContainsString('final class', $generated); + self::assertStringContainsString('App\\InvariantConstructor\\Apple $item', $generated); + self::assertStringNotContainsString('mixed $item', $generated); + } + public function testCovariantSubtypeEdgeIsEmittedAsExtendsForClassSpecializations(): void { // Fixture: `variance_covariant_happy/`. Producer<+T>, Banana <: Fruit. @@ -140,6 +384,50 @@ public function testEmittedSubtypeChainAutoloadsWithoutPhpFatal(): void self::assertContains('OK', $output); } + public function testContravariantConstructorChainAutoloadsAndConstructsWithoutPhpFatal(): void + { + // The CONTRAVARIANT counterpart of the autoload proof, with a real-typed + // constructor. The edge flips: `Consumer` extends `Consumer`, + // so the child constructor (`Fruit ...$items`) WIDENS the parent's (`Banana ...`). + // PHP exempts `__construct` from LSP, so the chain must both autoload AND + // construct instances of each specialization without a fatal — empirically + // confirming the same exemption holds in the contravariant direction. + $src = realpath(__DIR__ . '/../../fixture/compile/generic_contravariant_constructor/source') + ?: throw new RuntimeException('Fixture missing'); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $src, $this->targetDir, $this->cacheDir); + + $bananaFqn = Registry::generatedFqn('App\\ContravariantConstructor\\Consumer', [new TypeRef('App\\ContravariantConstructor\\Banana')]); + $fruitFqn = Registry::generatedFqn('App\\ContravariantConstructor\\Consumer', [new TypeRef('App\\ContravariantConstructor\\Fruit')]); + $prefixes = [ + 'XPHP\\Generated\\' => $this->cacheDir . '/Generated', + 'App\\ContravariantConstructor\\' => $this->targetDir, + ]; + + $loader = $this->workDir . '/contra-load.php'; + $script = " \$base) {\n" + . " if (str_starts_with(\$c, \$p)) {\n" + . " \$f = \$base . '/' . str_replace('\\\\', '/', substr(\$c, strlen(\$p))) . '.php';\n" + . " if (is_file(\$f)) { require_once \$f; }\n" + . " }\n" + . " }\n" + . "});\n" + . "new (" . var_export($bananaFqn, true) . ")(new \\App\\ContravariantConstructor\\Banana());\n" + . "new (" . var_export($fruitFqn, true) . ")(new \\App\\ContravariantConstructor\\Fruit());\n" + . "echo \"OK\\n\";\n"; + file_put_contents($loader, $script); + + $output = []; + $exitCode = 0; + exec('php ' . escapeshellarg($loader) . ' 2>&1', $output, $exitCode); + self::assertSame(0, $exitCode, "Contravariant constructor chain fataled:\n" . implode("\n", $output)); + self::assertContains('OK', $output); + } + public function testNoEdgeBetweenUnrelatedSpecializations(): void { // Banana and Apple both extend Fruit but not each other. The edge @@ -345,7 +633,7 @@ public function testInterfaceSpecializationsGetMultiExtendsButFilterTransitives( // extends [Apple] only, NOT [Apple, Fruit]. The filter-direct-supers // pass is exercised here on a multi-extends path that single-extends // Class_ tests don't cover. - $sourceDir = $this->workDir . '/src-iface-transitive'; + $sourceDir = $this->workDir . '/src-interface-transitive'; mkdir($sourceDir, 0o755, true); file_put_contents($sourceDir . '/IProducer.xphp', <<<'PHP' buildCompiler(); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $src, $this->targetDir, $this->cacheDir); + + $generatedDir = $this->cacheDir . '/Generated'; + if (!is_dir($generatedDir)) { + return ''; + } + $out = ''; + $iter = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($generatedDir)); + foreach ($iter as $file) { + if ($file->isFile() && str_ends_with($file->getFilename(), '.php')) { + $out .= file_get_contents($file->getPathname()) . "\n"; + } + } + return $out; + } + + /** + * Compile a tracked fixture's `source/` dir and assert it raises a variance + * violation (compile-mode, fail-fast). + * + * @param string $relFixtureDir path under `test/fixture/`, e.g. `check/foo/source` + */ + private function compileFixtureExpectingVarianceViolation(string $relFixtureDir): void + { + $src = realpath(__DIR__ . '/../../fixture/' . $relFixtureDir) + ?: throw new RuntimeException("missing fixture: {$relFixtureDir}"); + $dir = sys_get_temp_dir() . '/xphp-iv-' . uniqid('', true); + mkdir($dir, 0o755, true); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + try { + $compiler->compile($sources, $src, $dir . '/dist', $dir . '/cache'); + self::fail('expected a variance violation, none thrown'); + } catch (RuntimeException $e) { + self::assertStringContainsString('Variance violation', $e->getMessage()); + } finally { + self::rrmdir($dir); + } + } + private function fqnToPath(string $fqn): string { $prefix = Registry::GENERATED_NAMESPACE_PREFIX . '\\'; diff --git a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php new file mode 100644 index 0000000..38a249f --- /dev/null +++ b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php @@ -0,0 +1,418 @@ +}> + */ + public static function rejectedSources(): iterable + { + yield 'covariant in method parameter' => [ + "\n{\n public function set(T \$x): void {}\n}\n", + ['+T', 'method parameter'], + ]; + yield 'contravariant in method return' => [ + "\n{\n public function get(): T { throw new \\LogicException; }\n}\n", + ['-T', 'method return'], + ]; + yield 'covariant in mutable property' => [ + "\n{\n public T \$item;\n}\n", + ['mutable property'], + ]; + yield 'covariant in readonly property' => [ + "\n{\n public readonly T \$item;\n public function get(): T { return \$this->item; }\n}\n", + ['readonly property'], + ]; + // NOTE: a NESTED type-param in a bound (`+T : Box`) is owned by the composing + // inner-variance pass, not this direct-position pass — see + // testNestedTypeParamIsRejectedByTheComposingPass. + // A covariant param as the BARE leaf of a sibling class param's bound is a bound position + // too, flagged consistently with the inner-arg `Box` case above. (Distinct from the + // supported method-level `contains` shape, where U is a *method* type parameter.) + yield 'covariant in sibling bare bound' => [ + "\n{\n public function get(): T { throw new \\LogicException; }\n}\n", + ['bound'], + ]; + // NOTE: `+T` in a *non-promoted* constructor parameter of a variant class is + // ALLOWED — a constructor parameter is variance-exempt (constructors aren't + // called through upcast references, and PHP exempts `__construct` from LSP), so + // the real type is emitted there. See VarianceEdgeIntegrationTest's covariant + // immutable-collection test. A *promoted* constructor param is a PROPERTY, which stays + // strictly invariant (a `T`-typed property would PHP-fatal across the chain): + yield 'covariant in promoted constructor property' => [ + "\n{\n public function __construct(public T \$item) {}\n}\n", + ['constructor parameter'], + ]; + // A *protected* property/promoted property is also a visible property (PHP + // enforces invariant types across the chain for it), so it stays rejected — + // only PRIVATE is exempt. These pin the PRIVATE-bit detection against a + // mutant that swaps the visibility bit for PROTECTED. + yield 'covariant in protected promoted constructor property' => [ + "\n{\n public function __construct(protected T \$item) {}\n}\n", + ['constructor parameter'], + ]; + yield 'covariant in protected mutable property' => [ + "\n{\n protected T \$item;\n}\n", + ['mutable property'], + ]; + // Asymmetric visibility (PHP 8.4): a `public private(set)` property is + // externally *readable* through an upcast reference (PRIVATE_SET sets a + // separate bit, not PRIVATE), so it's on the visible variance surface and + // stays strictly invariant. Only a truly *private* slot is exempt. These + // pin the PRIVATE-bit detection against a mutant that swaps it for PRIVATE_SET. + yield 'covariant in public private(set) promoted constructor property' => [ + "\n{\n public function __construct(public private(set) T \$item) {}\n}\n", + ['constructor parameter'], + ]; + yield 'covariant in public private(set) declared property' => [ + "\n{\n public private(set) T \$item;\n}\n", + ['mutable property'], + ]; + yield 'covariant in nested closure parameter' => [ + "\n{\n public function emit(): array\n {\n \$f = function (T \$x) {};\n return [];\n }\n}\n", + ['nested closure/arrow parameter'], + ]; + yield 'contravariant in nested arrow return' => [ + "\n{\n public function pipe(): array\n {\n \$f = fn (): T => null;\n return [];\n }\n}\n", + ['nested closure/arrow return'], + ]; + // NOTE: a NESTED type-param in a method parameter/return (`Box`) is owned by the + // composing inner-variance pass — see testNestedTypeParamIsRejectedByTheComposingPass. + yield 'interface method signature' => [ + "\n{\n public function feed(T \$x): void;\n}\n", + ['+T'], + ]; + // A by-reference parameter is read AND written back, so it is an + // invariant position — neither +T nor -T is allowed there. + yield 'contravariant in by-reference parameter' => [ + "\n{\n public function swap(T &\$x): void {}\n}\n", + ['-T', 'by-reference parameter'], + ]; + yield 'covariant in by-reference parameter' => [ + "\n{\n public function swap(T &\$x): void {}\n}\n", + ['+T', 'by-reference parameter'], + ]; + yield 'contravariant in nested closure by-reference parameter' => [ + "\n{\n public function pipe(): array\n {\n \$f = function (T &\$x) {};\n return [];\n }\n}\n", + ['by-reference parameter'], + ]; + // A variant class can't be `final`: its specializations are linked by + // real `extends` edges, which a `final` class can't anchor. + yield 'final variant class' => [ + "\n{\n public function get(): T { throw new \\LogicException; }\n}\n", + ['cannot be declared `final`'], + ]; + } + + /** + * Sources that must pass variance-position validation. A PRIVATE property + * (declared or promoted; mutable or readonly; bare or inner-generic) is exempt + * from the property-invariance rule: PHP doesn't type-check private slots across + * the `extends` chain, and a private slot is invisible to the variance surface. + * + * @return iterable + */ + public static function allowedSources(): iterable + { + yield 'covariant in private promoted constructor property' => [ + "\n{\n public function __construct(private T \$item) {}\n public function get(): T { return \$this->item; }\n}\n", + ]; + yield 'covariant in private declared property' => [ + "\n{\n private T \$item;\n public function get(): T { return \$this->item; }\n}\n", + ]; + yield 'covariant in private readonly declared property' => [ + "\n{\n private readonly T \$item;\n public function get(): T { return \$this->item; }\n}\n", + ]; + yield 'covariant in private readonly promoted property' => [ + "\n{\n public function __construct(private readonly T \$item) {}\n public function get(): T { return \$this->item; }\n}\n", + ]; + yield 'contravariant in private promoted constructor property' => [ + "\n{\n public function __construct(private T \$item) {}\n public function accept(T \$x): void {}\n}\n", + ]; + // Inner-generic private members are exempt too — the inner-variance walk + // skips them, so `private Container` doesn't trip composition even though + // a *visible* `Container` property would (Container's slot is invariant). + yield 'covariant in private inner-generic declared property' => [ + "\n{\n private Box \$item;\n public function get(): T { throw new \\LogicException; }\n}\n", + ]; + yield 'covariant in private inner-generic promoted property' => [ + "\n{\n public function __construct(private Box \$item) {}\n public function get(): T { throw new \\LogicException; }\n}\n", + ]; + } + + /** + * @param list $fragments + */ + #[DataProvider('rejectedSources')] + public function testVariancePositionIsRejectedInCompileMode(string $source, array $fragments): void + { + $registry = $this->registryFor($source); + + try { + $registry->validateVariancePositions(); + self::fail('expected a variance-position violation'); + } catch (RuntimeException $e) { + foreach ($fragments as $fragment) { + self::assertStringContainsString($fragment, $e->getMessage()); + } + } + } + + /** + * A type-param NESTED inside a type constructor (`Box` in a parameter, return, or bound) is + * judged by the COMPOSING inner-variance pass, not the direct-position pass: its effective variance + * is the composition of the outer position with the inner slot's variance (here Box's invariant + * slot ⇒ invariant ⇒ `+T`/`-T` rejected). The direct-position pass deliberately does NOT descend + * into type-constructor args, so these are reported by `validateInnerVariance` with the composing + * "via slot N of …" message — never double-reported by both passes. + * + * @param list $fragments + */ + #[DataProvider('nestedComposingRejections')] + public function testNestedTypeParamIsRejectedByTheComposingPass(string $source, array $fragments): void + { + $registry = $this->registryFor($source); + // The direct-position pass must stay SILENT on a purely-nested violation (no double-report). + $registry->validateVariancePositions(); + + try { + $registry->validateInnerVariance(); + self::fail('expected an inner-variance composition violation'); + } catch (RuntimeException $e) { + foreach ($fragments as $fragment) { + self::assertStringContainsString($fragment, $e->getMessage()); + } + } + } + + /** + * @return iterable}> + */ + public static function nestedComposingRejections(): iterable + { + yield 'covariant nested in method parameter' => [ + "\n{\n public function set(Box \$x): void {}\n}\n", + ['+T', 'invariant-only position', 'via slot 0 of'], + ]; + yield 'contravariant nested in method return' => [ + "\n{\n public function fetch(): Box { throw new \\LogicException; }\n}\n", + ['-T', 'invariant-only position', 'via slot 0 of'], + ]; + yield 'covariant nested in bound' => [ + ">\n{\n public function get(): T { throw new \\LogicException; }\n}\n", + ['+T', 'invariant-only position', 'via slot 0 of'], + ]; + // A NESTED type-param in a VISIBLE (non-promoted) declared property — `public Box $item` on + // `+T`. The property's outer position is invariant; Box's invariant slot composes to invariant, + // so `+T` is rejected by the composing pass via the declared-property walk (a private property + // would be exempt — only visible ones are walked). + yield 'covariant nested in visible declared property' => [ + "\n{\n public Box \$item;\n}\n", + ['+T', 'invariant-only position', 'via slot 0 of'], + ]; + // Composition through a NON-invariant inner slot. `Producer<+X>` as a method PARAMETER: + // compose(contravariant param, covariant slot) = contravariant → a covariant `+E` is rejected. + yield 'covariant Producer param composes to contravariant' => [ + " { public function get(): X; }\nclass Box<+E>\n{\n public function take(Producer \$p): void {}\n}\n", + ['+E', 'contravariant-only position', 'via slot 0 of'], + ]; + // The mirror that confirms the composing pass owns the unsound direction too: `Sink<-E>` with a + // `Comparator<-T>` parameter — compose(contravariant param, contravariant slot) = covariant → a + // contravariant `-E` is rejected. (The sound covariant case is the accept test below.) + yield 'contravariant Sink with Comparator composes to covariant' => [ + " { public function compare(T \$a, T \$b): int; }\nclass Sink<-E>\n{\n public function pick(Comparator \$c): void {}\n}\n", + ['-E', 'covariant-only position', 'via slot 0 of'], + ]; + } + + /** + * The sound nested compositions that the buggy direct-position descent used to reject: a type-param + * nested in a contravariant slot inside a same-variance outer position composes back to that + * variance, which the class's marker may occupy. Neither pass may flag these. + * + * @param string $source + */ + #[DataProvider('soundNestedCompositions')] + public function testSoundNestedCompositionIsAccepted(string $source): void + { + $registry = $this->registryFor($source); + $registry->validateVariancePositions(); // must NOT throw + $registry->validateInnerVariance(); // must NOT throw + $this->addToAssertionCount(1); + } + + /** + * @return iterable + */ + public static function soundNestedCompositions(): iterable + { + // The headline sound case: a `Comparator<-T>` parameter on a covariant `+E` — compose(contra + // param, contra slot) = covariant, which `+E` may occupy. Sound; must be accepted. + yield 'Comparator param on covariant class' => [ + " { public function compare(T \$a, T \$b): int; }\nclass Box<+E>\n{\n public function pick(Comparator \$c): void {}\n}\n", + ]; + // A `Producer<+X>` RETURN on a covariant `+E` — compose(covariant return, covariant slot) = + // covariant. Sound; must be accepted. + yield 'Producer return on covariant class' => [ + " { public function get(): X; }\nclass Box<+E>\n{\n public function make(): Producer { throw new \\LogicException; }\n}\n", + ]; + } + + /** + * A private property carrying a variance marker must pass BOTH the position + * check and the inner-variance composition check (the latter for the + * inner-generic cases). Neither phase may throw. + */ + #[DataProvider('allowedSources')] + public function testPrivatePropertyVarianceIsAllowed(string $source): void + { + $registry = $this->registryFor($source); + + $registry->validateVariancePositions(); // must NOT throw + $registry->validateInnerVariance(); // must NOT throw + + self::assertTrue(true); + } + + public function testPrivatePromotedDoesNotShortCircuitLaterConstructorParam(): void + { + // A private promoted constructor property is skipped in the inner-variance + // walk — but the skip must `continue`, not `break`: a LATER constructor + // param that DOES violate (an inner-generic `Box`, T through an invariant + // slot) must still be reached and rejected. The position phase passes both + // params (a bare/inner-generic ctor param is position-allowed in a variant + // class), so inner-variance is the phase that must catch the trailing one. + $source = "\n{\n public function __construct(private T \$first, Box \$second) {}\n}\n"; + $registry = $this->registryFor($source); + + $registry->validateVariancePositions(); // must NOT throw — both params position-allowed + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Variance violation'); + $registry->validateInnerVariance(); + } + + public function testVisiblePromotedConstructorPropertyIsReportedExactlyOnce(): void + { + // A VISIBLE promoted constructor property (`public T $item`) is a direct occurrence the + // position pass owns (it reports it as a 'constructor parameter'). The composing pass must NOT + // also report it — it cedes the direct leaf of a PROMOTED constructor param (only a NON-promoted + // constructor param, which the position pass exempts, stays owned by the composing pass). In + // check-mode (both passes run) this must yield EXACTLY ONE diagnostic, not two. + $source = "\n{\n public function __construct(public T \$item) {}\n}\n"; + $collector = new DiagnosticCollector(); + $registry = $this->registryFor($source, $collector); + + $registry->validateVariancePositions(); + $registry->validateInnerVariance(); + + self::assertCount(1, $collector->all()); + self::assertSame(VariancePositionValidator::CODE_VARIANCE_POSITION, $collector->all()[0]->code); + } + + public function testNonPromotedNonBareConstructorParamIsOwnedByTheComposingPassOnce(): void + { + // The companion: a NON-promoted, non-bare constructor param (`?T $x`) is exempt from the + // position pass, so the composing pass keeps ownership of its direct leaf — exactly one + // `inner_variance` diagnostic, no double-report. (The bare-`T` immutable shape stays exempt by + // both; a promoted `public T $item` is owned by the position pass — see the test above.) + $source = "\n{\n public function __construct(?T \$x) {}\n}\n"; + $collector = new DiagnosticCollector(); + $registry = $this->registryFor($source, $collector); + + $registry->validateVariancePositions(); + $registry->validateInnerVariance(); + + self::assertCount(1, $collector->all()); + self::assertSame(InnerVarianceValidator::CODE_INNER_VARIANCE, $collector->all()[0]->code); + } + + public function testViolationIsCollectedWithMemberLineInCheckMode(): void + { + // Line 5 holds `public function set(T $x)`. + $source = "\n{\n public function set(T \$x): void {}\n}\n"; + $collector = new DiagnosticCollector(); + $registry = $this->registryFor($source, $collector); + + $registry->validateVariancePositions(); // must NOT throw + + self::assertCount(1, $collector->all()); + $d = $collector->all()[0]; + self::assertSame(VariancePositionValidator::CODE_VARIANCE_POSITION, $d->code); + self::assertNotNull($d->location); + self::assertSame('/T.xphp', $d->location->file); + self::assertSame(5, $d->location->line); + self::assertStringContainsString('method parameter', $d->message); + } + + public function testAllViolationsAcrossDefinitionsCollectedInOneRun(): void + { + // Two distinct templates, each with a variance violation — both reported. + $source = "\n{\n public function set(T \$x): void {}\n}\n" + . "class Consumer<-T>\n{\n public function get(): T { throw new \\LogicException; }\n}\n"; + $collector = new DiagnosticCollector(); + $registry = $this->registryFor($source, $collector); + + $registry->validateVariancePositions(); + + self::assertCount(2, $collector->all()); + foreach ($collector->all() as $d) { + self::assertSame(VariancePositionValidator::CODE_VARIANCE_POSITION, $d->code); + } + } + + public function testByReferenceParamWithoutVarianceMarkersIsAllowed(): void + { + // An invariant class (no +T/-T) with a by-ref T parameter is fine — the + // by-ref invariance rule only constrains variance-marked type params. + $source = "\n{\n public function swap(T &\$x): void {}\n}\n"; + $registry = $this->registryFor($source); + + $registry->validateVariancePositions(); // must NOT throw + self::assertTrue(true); + } + + public function testByReferenceParamDoesNotShortCircuitLaterParams(): void + { + // A by-ref param violation must not stop the walk: a *later* violating + // param in the same signature is still reported (pins `continue`, not + // `break`, after the by-ref check). + $source = "\n{\n public function f(T &\$a, T \$b): void {}\n}\n"; + $collector = new DiagnosticCollector(); + $registry = $this->registryFor($source, $collector); + + $registry->validateVariancePositions(); + + $messages = array_map(static fn ($d): string => $d->message, $collector->all()); + self::assertCount(2, $messages); + self::assertStringContainsString('by-reference parameter', implode("\n", $messages)); + self::assertStringContainsString('method parameter', implode("\n", $messages)); + } + + private function registryFor(string $source, ?DiagnosticCollector $collector = null): Registry + { + $ast = (new XphpSourceParser((new ParserFactory())->createForHostVersion()))->parse($source); + $registry = new Registry(diagnostics: $collector); + (new RegistryCollector($registry))->collectDefinitions($ast, '/T.xphp'); + + return $registry; + } +} diff --git a/test/Transpiler/Monomorphize/VarianceSubtypingTest.php b/test/Transpiler/Monomorphize/VarianceSubtypingTest.php new file mode 100644 index 0000000..af65f34 --- /dev/null +++ b/test/Transpiler/Monomorphize/VarianceSubtypingTest.php @@ -0,0 +1,334 @@ + defined so the nested-generic recursion has a template to read. */ + private function subtyping(): VarianceSubtyping + { + return new VarianceSubtyping($this->hierarchy()); + } + + private function hierarchy(): TypeHierarchy + { + return new TypeHierarchy([self::BANANA => [self::FRUIT]]); + } + + private function registry(): Registry + { + $registry = new Registry(Registry::DEFAULT_HASH_HEX_LENGTH, $this->hierarchy()); + $registry->recordDefinition( + self::BOX, + 'Box', + [new TypeParam('T', variance: Variance::Covariant)], + new Class_('Box'), + 'test', + ); + return $registry; + } + + private static function covariant(): array + { + return [new TypeParam('T', variance: Variance::Covariant)]; + } + + public function testCovariantStrictSubtypeIsASubtype(): void + { + // Banana <: Fruit, covariant T: Producer <: Producer. Kills the `continue` + // mutant — without it the covariant pass would fall through to the contravariant check + // (Fruit <: Banana = false) and wrongly reject. + self::assertTrue($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BANANA)], + [new TypeRef(self::FRUIT)], + self::covariant(), + $this->registry(), + )); + } + + public function testCovariantSupertypeToSubtypeIsNotASubtype(): void + { + // The reverse direction: Producer is NOT <: Producer. Fruit is not a + // subtype of Banana, so no edge. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::FRUIT)], + [new TypeRef(self::BANANA)], + self::covariant(), + $this->registry(), + )); + } + + public function testContravariantReversesTheDirection(): void + { + // Contravariant T: Consumer <: Consumer (a Fruit-consumer can stand in for a + // Banana-consumer). Kills the contravariant-branch direction. + self::assertTrue($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::FRUIT)], + [new TypeRef(self::BANANA)], + [new TypeParam('T', variance: Variance::Contravariant)], + $this->registry(), + )); + } + + public function testIdenticalArgsAreNotAProperSubtype(): void + { + // Reflexive pair: not a proper subtype edge. Kills the `$sawNonIdentity = false` init mutant — + // initialised true, identical args would be reported as a subtype. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::FRUIT)], + [new TypeRef(self::FRUIT)], + self::covariant(), + $this->registry(), + )); + } + + public function testCovariantRejectsWhenALaterArgIsNotASubtype(): void + { + // Two covariant params where the FIRST agrees but the SECOND does not: Fruit is not a subtype + // of Banana. The loop must keep checking after the first arg — kills the `continue` → `break` + // mutant, which would stop at arg 0 and wrongly accept on the (true) sawNonIdentity flag. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BANANA), new TypeRef(self::FRUIT)], + [new TypeRef(self::FRUIT), new TypeRef(self::BANANA)], + [ + new TypeParam('T', variance: Variance::Covariant), + new TypeParam('U', variance: Variance::Covariant), + ], + $this->registry(), + )); + } + + public function testInvariantParamRequiresEqualArgs(): void + { + // Invariant T: Cell is NOT <: Cell even though Banana <: Fruit. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BANANA)], + [new TypeRef(self::FRUIT)], + [new TypeParam('T', variance: Variance::Invariant)], + $this->registry(), + )); + } + + public function testArityMismatchBetweenArgsAndParamsIsNotASubtype(): void + { + // count(args) != count(params): the arity guard must reject. Kills the `||` → `&&` mutant, + // which would skip the guard and walk past the params array. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BANANA)], + [new TypeRef(self::FRUIT)], + [], + $this->registry(), + )); + } + + public function testMismatchedArgCountsAreNotASubtype(): void + { + // count(args1) != count(args2): the other half of the arity guard. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BANANA)], + [new TypeRef(self::BANANA), new TypeRef(self::FRUIT)], + self::covariant(), + $this->registry(), + )); + } + + public function testNestedSameTemplateGenericRecursesThroughInnerVariance(): void + { + // Producer> <: Producer> because Box has covariant T. Exercises the + // nested-generic branch (both generic, same template) and its recursion into Box's variance — + // kills the `&&` mutants in the same-template guard. + self::assertTrue($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BOX, [new TypeRef(self::BANANA)])], + [new TypeRef(self::BOX, [new TypeRef(self::FRUIT)])], + self::covariant(), + $this->registry(), + )); + } + + public function testNestedSameTemplateGenericRejectsWhenInnerArgsAreNotSubtype(): void + { + // Producer> is NOT <: Producer> — the inner recursion (Fruit <: Banana + // = false) rejects, which is the whole point of recursing instead of flattening to + // isSubtype('Box','Box'). + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BOX, [new TypeRef(self::FRUIT)])], + [new TypeRef(self::BOX, [new TypeRef(self::BANANA)])], + self::covariant(), + $this->registry(), + )); + } + + public function testDifferentInnerTemplatesAreNotSubtype(): void + { + // A nested generic of a template with no recorded definition can't be proven a subtype — the + // conservative false (empty inner params) path. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef('App\\Other', [new TypeRef(self::BANANA)])], + [new TypeRef('App\\Other', [new TypeRef(self::FRUIT)])], + self::covariant(), + $this->registry(), + )); + } + + // ---- Cross-template generic type-argument subtyping ---- + // + // `ImmutableList<+E> implements Collection<+E>`, `Book <: Product`. The hierarchy carries the + // PARAMETERISED supertype edge so `resolveInheritedArgs` can thread `ImmutableList` up to + // `Collection`. `Mid implements Collection` is the MALFORMED case — a 2-arg + // parameterised super against a 1-param target — exercising the load-bearing count() arity guard. + + private const COLLECTION = 'App\\Collection'; + private const IMMUTABLE_LIST = 'App\\ImmutableList'; + private const BOOK = 'App\\Book'; + private const PRODUCT = 'App\\Product'; + + private function crossHierarchy(): TypeHierarchy + { + return new TypeHierarchy( + ancestors: [ + self::IMMUTABLE_LIST => [self::COLLECTION], + self::BOOK => [self::PRODUCT], + 'App\\Mid' => [self::COLLECTION], + 'App\\Foo' => [], + 'App\\Bar' => [], + ], + superTypeArgs: [ + self::IMMUTABLE_LIST => [new TypeRef(self::COLLECTION, [new TypeRef('E', isTypeParam: true)])], + // Malformed: declares two args for a one-param Collection. + 'App\\Mid' => [new TypeRef(self::COLLECTION, [ + new TypeRef('X', isTypeParam: true), + new TypeRef('X', isTypeParam: true), + ])], + ], + typeParamNames: [ + self::IMMUTABLE_LIST => ['E'], + self::COLLECTION => ['E'], + 'App\\Mid' => ['X'], + ], + ); + } + + private function crossRegistry(): Registry + { + $hierarchy = $this->crossHierarchy(); + $registry = new Registry(Registry::DEFAULT_HASH_HEX_LENGTH, $hierarchy); + // Only the PARENT template's definition is read (for its slot variance); Collection<+E>. + $registry->recordDefinition( + self::COLLECTION, + 'Collection', + [new TypeParam('E', variance: Variance::Covariant)], + new Class_('Collection'), + 'test', + ); + return $registry; + } + + private function crossSubtyping(): VarianceSubtyping + { + return new VarianceSubtyping($this->crossHierarchy()); + } + + public function testCrossTemplateGenericArgRelatesThroughThreadedSupertype(): void + { + // The headline: a covariant outer slot holding `ImmutableList` vs `Collection`. + // isNestedSubtype's different-template branch threads ImmutableList → Collection, + // then compares Book ⊑ Product under Collection's covariant E → true. Kills the new branch's + // `isSubtype(...) === true` guard (a mutant dropping it would mis-handle this) and proves the + // threading produces the right arity. + self::assertTrue($this->crossSubtyping()->isVarianceSubtype( + [new TypeRef(self::IMMUTABLE_LIST, [new TypeRef(self::BOOK)])], + [new TypeRef(self::COLLECTION, [new TypeRef(self::PRODUCT)])], + self::covariant(), + $this->crossRegistry(), + )); + } + + public function testCrossTemplateUnrelatedTemplatesAreNotSubtype(): void + { + // Foo ⋢ Bar (no hierarchy edge): isSubtype short-circuits → no edge. A wrong "yes" here would + // emit a bogus `implements` → autoload fatal, so this is the expensive direction to keep false. + self::assertFalse($this->crossSubtyping()->isVarianceSubtype( + [new TypeRef('App\\Foo', [new TypeRef(self::BOOK)])], + [new TypeRef('App\\Bar', [new TypeRef(self::PRODUCT)])], + self::covariant(), + $this->crossRegistry(), + )); + } + + public function testCrossTemplateReverseDirectionIsNotSubtype(): void + { + // Operand order: the SUPER template in the subtype slot. Collection ⋢ ImmutableList, so + // isSubtype(Collection, ImmutableList) is false → no edge. Kills an operand-swap mutant on the + // `isSubtype($childName, $parentName)` call. + self::assertFalse($this->crossSubtyping()->isVarianceSubtype( + [new TypeRef(self::COLLECTION, [new TypeRef(self::BOOK)])], + [new TypeRef(self::IMMUTABLE_LIST, [new TypeRef(self::PRODUCT)])], + self::covariant(), + $this->crossRegistry(), + )); + } + + public function testContravariantSlotThreadsCrossTemplateArgInTheFlippedDirection(): void + { + // A contravariant outer slot flips the operands (isNestedSubtype($a2, $a1)): a subtype needs the + // SUPERTYPE-spec's arg to be a subtype of the SUBTYPE-spec's arg. With a cross-template inner + // pair, the branch must thread `ImmutableList` (the flipped child, from args2) up to + // `Collection` (args1) — proving the cross-template case composes symmetrically across + // variance, not just for the covariant direction. + self::assertTrue($this->crossSubtyping()->isVarianceSubtype( + [new TypeRef(self::COLLECTION, [new TypeRef(self::PRODUCT)])], + [new TypeRef(self::IMMUTABLE_LIST, [new TypeRef(self::BOOK)])], + [new TypeParam('X', variance: Variance::Contravariant)], + $this->crossRegistry(), + )); + } + + public function testContravariantSlotRejectsTheWrongCrossTemplateDirection(): void + { + // The flipped reject: with the args the other way round a contravariant slot needs + // `Collection ⊑ ImmutableList`, which is false (Collection ⋢ ImmutableList). + self::assertFalse($this->crossSubtyping()->isVarianceSubtype( + [new TypeRef(self::IMMUTABLE_LIST, [new TypeRef(self::BOOK)])], + [new TypeRef(self::COLLECTION, [new TypeRef(self::PRODUCT)])], + [new TypeParam('X', variance: Variance::Contravariant)], + $this->crossRegistry(), + )); + } + + public function testCrossTemplateMalformedGroundingIsNotSubtype(): void + { + // The load-bearing guard: `Mid implements Collection` grounds + // Mid to a NON-null but wrong-arity tuple [Book, Book] against the one-param Collection. + // resolveInheritedArgs returns it non-null; only isVarianceSubtype's count() arity guard rejects + // it. Without that guard a bogus edge would be emitted → autoload fatal. + self::assertFalse($this->crossSubtyping()->isVarianceSubtype( + [new TypeRef('App\\Mid', [new TypeRef(self::BOOK)])], + [new TypeRef(self::COLLECTION, [new TypeRef(self::PRODUCT)])], + self::covariant(), + $this->crossRegistry(), + )); + } +} diff --git a/test/Transpiler/Monomorphize/VisitorGuardsTest.php b/test/Transpiler/Monomorphize/VisitorGuardsTest.php index 8d02232..7bca360 100644 --- a/test/Transpiler/Monomorphize/VisitorGuardsTest.php +++ b/test/Transpiler/Monomorphize/VisitorGuardsTest.php @@ -427,7 +427,7 @@ public function testSpecializerNormalizesLeadingBackslashForGenericSubstitution( 'stmts' => [new Property(0, [new PropertyItem('a')], type: new Name('T'))], ]); - $genericSubst = new TypeRef('\\App\\Containers\\Lst', [new TypeRef('App\\Models\\Plastic')]); + $genericSubst = new TypeRef('\\App\\Containers\\Collection', [new TypeRef('App\\Models\\Plastic')]); $specialized = (new Specializer())->specialize($template, [ 'T' => $genericSubst, ]); @@ -435,9 +435,9 @@ public function testSpecializerNormalizesLeadingBackslashForGenericSubstitution( $nameNode = $specialized->stmts[0]->type; self::assertInstanceOf(Name::class, $nameNode); self::assertNotInstanceOf(FullyQualified::class, $nameNode); - self::assertSame('App\\Containers\\Lst', $nameNode->toString(), 'Name() ctor must receive ltrim-normalized name (line 80)'); + self::assertSame('App\\Containers\\Collection', $nameNode->toString(), 'Name() constructor must receive ltrim-normalized name (line 80)'); self::assertSame( - 'App\\Containers\\Lst', + 'App\\Containers\\Collection', $nameNode->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN), 'ATTR_TEMPLATE_FQN attribute must be ltrim-normalized (line 82)', ); diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index d3848d2..ec1a38e 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -47,11 +47,11 @@ public function get(): T; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $ast = $parser->parse($source); - $iface = self::findFirstClassLike($ast, \PhpParser\Node\Stmt\Interface_::class); - self::assertNotNull($iface); - self::assertSame('Container', $iface->name?->toString()); - self::assertSame(['T'], self::paramNames($iface)); - self::assertSame('App\\Container', $iface->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN)); + $interface = self::findFirstClassLike($ast, \PhpParser\Node\Stmt\Interface_::class); + self::assertNotNull($interface); + self::assertSame('Container', $interface->name?->toString()); + self::assertSame(['T'], self::paramNames($interface)); + self::assertSame('App\\Container', $interface->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN)); } public function testGenericTraitTemplateIsDroppedFromOutputWithoutBecomingAMarker(): void @@ -635,14 +635,14 @@ public function testHandlesNestedGenericArgs(): void namespace App; use App\Containers\Box; -use App\Containers\Lst; +use App\Containers\Collection; use App\Models\Plastic; -$x = new Box::>(); +$x = new Box::>(); PHP; $args = self::parseAndGetArgs($source, 'Box'); self::assertCount(1, $args); - self::assertSame('App\\Containers\\Lst', $args[0]->name); + self::assertSame('App\\Containers\\Collection', $args[0]->name); self::assertTrue($args[0]->isGeneric()); self::assertCount(1, $args[0]->args); self::assertSame('App\\Models\\Plastic', $args[0]->args[0]->name); @@ -1019,19 +1019,19 @@ public function testFullyQualifiedTemplateNameIsRecognized(): void public function testFullyQualifiedNamesInNestedGenericArg(): void { - // `new Box<\Vendor\Lst<\Vendor\Plastic>>()` — nested generic where BOTH the + // `new Box<\Vendor\Collection<\Vendor\Plastic>>()` — nested generic where BOTH the // outer-arg template and the inner-arg type use the fully-qualified form. // Exercises the $isFq branch on line 218 (the recursive-into-generic return). $source = <<<'PHP' >(); +$x = new Box::<\Vendor\Collection<\Vendor\Plastic>>(); PHP; $args = self::parseAndGetArgs($source, 'Box'); self::assertCount(1, $args); self::assertTrue($args[0]->isGeneric()); - self::assertSame('Vendor\\Lst', $args[0]->name); + self::assertSame('Vendor\\Collection', $args[0]->name); self::assertSame('Vendor\\Plastic', $args[0]->args[0]->name); } @@ -2198,8 +2198,8 @@ public function current(): V; PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $ast = $parser->parse($source); - $iface = self::findFirstClassLike($ast, \PhpParser\Node\Stmt\Interface_::class); - $params = $iface?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + $interface = self::findFirstClassLike($ast, \PhpParser\Node\Stmt\Interface_::class); + $params = $interface?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); self::assertSame(Variance::Invariant, $params[0]->variance); self::assertSame(Variance::Covariant, $params[1]->variance); } @@ -2216,7 +2216,7 @@ public function id<+T>(T $x): T { return $x; } PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Variance markers `+T` / `-T` are not yet supported on methods, functions, closures, or arrow functions'); + $this->expectExceptionMessage('Variance markers `+T` / `-T` are not supported on methods, functions, closures, or arrow functions'); $parser->parse($source); } @@ -2234,116 +2234,6 @@ function id<-T>(T $x): T { return $x; } $parser->parse($source); } - public function testCovariantInInputPositionIsRejected(): void - { - $source = <<<'PHP' - -{ - public function set(T $x): void {} -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('+T'); - $this->expectExceptionMessage('method parameter'); - $parser->parse($source); - } - - public function testContravariantInOutputPositionIsRejected(): void - { - $source = <<<'PHP' - -{ - public function get(): T { throw new \LogicException; } -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('-T'); - $this->expectExceptionMessage('method return'); - $parser->parse($source); - } - - public function testCovariantInMutablePropertyIsRejected(): void - { - $source = <<<'PHP' - -{ - public T $item; -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('mutable property'); - $parser->parse($source); - } - - public function testCovariantInReadonlyPropertyIsAlsoRejected(): void - { - // PHP enforces invariant property types across `extends` chains - // regardless of `readonly`. Even though a readonly property is - // semantically "output-only", PHP's static type system rejects - // covariance on the property declaration -- so we reject it at - // declaration time to avoid an autoload-time fatal. - $source = <<<'PHP' - -{ - public readonly T $item; - public function get(): T { return $this->item; } -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('readonly property'); - $parser->parse($source); - } - - public function testCovariantInBoundIsRejected(): void - { - // F-bounded with variance: `+T : Box` rejected because T appears - // inside its own bound (an invariant position). - $source = <<<'PHP' -> -{ - public function get(): T { throw new \LogicException; } -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('bound'); - $parser->parse($source); - } - - public function testCovariantInConstructorParamIsRejected(): void - { - // xphp deviates from RFC: constructor params are invariant because - // PHP's autoload-time signature compatibility check applies to - // __construct on `Producer_Banana implements Producer_Fruit`. - $source = <<<'PHP' - -{ - public function __construct(T $item) {} - public function get(): T { throw new \LogicException; } -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('constructor parameter'); - $parser->parse($source); - } - public function testContravariantInInputPositionIsAccepted(): void { $source = <<<'PHP' @@ -2652,105 +2542,17 @@ public function testGenericClosureVarianceIsRejected(): void $parser->parse($source); } - public function testCovariantInNestedClosureParameterIsRejected(): void - { - // `+T` of the OUTER class appears in the parameter type of a nested - // CLOSURE -- the variance validator must recurse into method bodies. - // Without the recursion the inner closure's param `T $x` slips - // through, and at PHP autoload time the variance edge produces a - // signature-compat fatal. - $source = <<<'PHP' - -{ - public function emit(): array - { - $f = function (T $x) {}; - return []; - } -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('nested closure/arrow parameter'); - $parser->parse($source); - } - - public function testContravariantInNestedArrowReturnIsRejected(): void + public function testArrowFunctionVarianceIsRejected(): void { - // `-T` in the return position of a nested ARROW FUNCTION inside a - // contravariant Consumer's method body. $source = <<<'PHP' -{ - public function pipe(): array - { - $f = fn (): T => null; - return []; - } -} +$f = fn<+T>(T $x): T => $x; PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('nested closure/arrow return'); - $parser->parse($source); - } - - public function testCovariantInNestedGenericInputPositionIsRejected(): void - { - // `+T` inside `Box` in a method parameter position. The validator - // walks into the inner generic args attached via xphp:genericArgs; - // the +T leaf is rejected just as if it appeared directly. - $source = <<<'PHP' - -{ - public function set(Box $x): void {} -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('+T'); - $this->expectExceptionMessage('method parameter'); - $parser->parse($source); - } - - public function testContravariantInNestedGenericReturnPositionIsRejected(): void - { - // Symmetric case: `-T` inside `Box` in a method return type. - $source = <<<'PHP' - -{ - public function fetch(): Box { throw new \LogicException; } -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('-T'); - $this->expectExceptionMessage('method return'); - $parser->parse($source); - } - - public function testInterfaceMethodSignatureIsValidatedForVariance(): void - { - // Variance rules apply to interface methods too. - $source = <<<'PHP' - -{ - public function feed(T $x): void; -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('+T'); + $this->expectExceptionMessage('Variance markers'); + $this->expectExceptionMessage('closures, or arrow functions'); $parser->parse($source); } diff --git a/test/fixture/check/body_type_error/source/Box.xphp b/test/fixture/check/body_type_error/source/Box.xphp new file mode 100644 index 0000000..3cde25b --- /dev/null +++ b/test/fixture/check/body_type_error/source/Box.xphp @@ -0,0 +1,26 @@ + +{ + public function __construct(public T $item) + { + } + + public function get(): T + { + return $this->item; + } + + public function broken(): T + { + // The generics are valid, so `xphp check`'s own phase passes and `xphp + // compile` succeeds. But after specialization (T -> int) this method + // returns a string where an int is declared — a body-level type error + // only PHPStan, run over the concrete output, can see. + return 'not a valid value'; + } +} diff --git a/test/fixture/check/body_type_error/source/Use.xphp b/test/fixture/check/body_type_error/source/Use.xphp new file mode 100644 index 0000000..a892bf9 --- /dev/null +++ b/test/fixture/check/body_type_error/source/Use.xphp @@ -0,0 +1,9 @@ +(1); +$box->get(); +$box->broken(); diff --git a/test/fixture/check/clean/source/Box.xphp b/test/fixture/check/clean/source/Box.xphp new file mode 100644 index 0000000..ae32895 --- /dev/null +++ b/test/fixture/check/clean/source/Box.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public T $value) + { + } +} diff --git a/test/fixture/check/clean/source/Use.xphp b/test/fixture/check/clean/source/Use.xphp new file mode 100644 index 0000000..7269797 --- /dev/null +++ b/test/fixture/check/clean/source/Use.xphp @@ -0,0 +1,7 @@ +(1); diff --git a/test/fixture/check/closure_static/source/Use.xphp b/test/fixture/check/closure_static/source/Use.xphp new file mode 100644 index 0000000..ac05560 --- /dev/null +++ b/test/fixture/check/closure_static/source/Use.xphp @@ -0,0 +1,12 @@ +(T $x): T { + return $x; +}; + +$r = $f::(1); diff --git a/test/fixture/check/closure_this_capture/source/C.xphp b/test/fixture/check/closure_this_capture/source/C.xphp new file mode 100644 index 0000000..d01714e --- /dev/null +++ b/test/fixture/check/closure_this_capture/source/C.xphp @@ -0,0 +1,19 @@ +(T $x): string { + return $this->name; + }; + + return $f::(1); + } +} diff --git a/test/fixture/check/default_violation/source/Box.xphp b/test/fixture/check/default_violation/source/Box.xphp new file mode 100644 index 0000000..675d221 --- /dev/null +++ b/test/fixture/check/default_violation/source/Box.xphp @@ -0,0 +1,14 @@ + +{ + public function __construct(public T $value) + { + } +} diff --git a/test/fixture/check/duplicate_generic_function/source/a.xphp b/test/fixture/check/duplicate_generic_function/source/a.xphp new file mode 100644 index 0000000..930d23b --- /dev/null +++ b/test/fixture/check/duplicate_generic_function/source/a.xphp @@ -0,0 +1,15 @@ +(T $x): T +{ + return $x; +} + +function dup2(T $x): T +{ + return $x; +} diff --git a/test/fixture/check/duplicate_generic_function/source/b.xphp b/test/fixture/check/duplicate_generic_function/source/b.xphp new file mode 100644 index 0000000..930d23b --- /dev/null +++ b/test/fixture/check/duplicate_generic_function/source/b.xphp @@ -0,0 +1,15 @@ +(T $x): T +{ + return $x; +} + +function dup2(T $x): T +{ + return $x; +} diff --git a/test/fixture/check/generic_function_bound/source/Use.xphp b/test/fixture/check/generic_function_bound/source/Use.xphp new file mode 100644 index 0000000..738aae8 --- /dev/null +++ b/test/fixture/check/generic_function_bound/source/Use.xphp @@ -0,0 +1,8 @@ +(1); diff --git a/test/fixture/check/generic_function_bound/source/funcs.xphp b/test/fixture/check/generic_function_bound/source/funcs.xphp new file mode 100644 index 0000000..bb72049 --- /dev/null +++ b/test/fixture/check/generic_function_bound/source/funcs.xphp @@ -0,0 +1,10 @@ +(T $x): T +{ + return $x; +} diff --git a/test/fixture/check/generic_method_missing_arg/source/Use.xphp b/test/fixture/check/generic_method_missing_arg/source/Use.xphp new file mode 100644 index 0000000..cdf2ae8 --- /dev/null +++ b/test/fixture/check/generic_method_missing_arg/source/Use.xphp @@ -0,0 +1,9 @@ +pair::(1, 'x'); diff --git a/test/fixture/check/generic_method_missing_arg/source/Util.xphp b/test/fixture/check/generic_method_missing_arg/source/Util.xphp new file mode 100644 index 0000000..b498d80 --- /dev/null +++ b/test/fixture/check/generic_method_missing_arg/source/Util.xphp @@ -0,0 +1,12 @@ +(A $a, B $b): void + { + } +} diff --git a/test/fixture/check/inner_variance/source/Container.xphp b/test/fixture/check/inner_variance/source/Container.xphp new file mode 100644 index 0000000..886c9c3 --- /dev/null +++ b/test/fixture/check/inner_variance/source/Container.xphp @@ -0,0 +1,13 @@ + +{ + public function __construct(public T $value) + { + } +} diff --git a/test/fixture/check/inner_variance/source/P.xphp b/test/fixture/check/inner_variance/source/P.xphp new file mode 100644 index 0000000..74b4a61 --- /dev/null +++ b/test/fixture/check/inner_variance/source/P.xphp @@ -0,0 +1,16 @@ + +{ + public function f(): Container + { + throw new \LogicException(); + } +} diff --git a/test/fixture/check/method_and_class_errors/source/Producer.xphp b/test/fixture/check/method_and_class_errors/source/Producer.xphp new file mode 100644 index 0000000..8276ea2 --- /dev/null +++ b/test/fixture/check/method_and_class_errors/source/Producer.xphp @@ -0,0 +1,13 @@ + +{ + public function set(T $value): void + { + } +} diff --git a/test/fixture/check/method_and_class_errors/source/Use.xphp b/test/fixture/check/method_and_class_errors/source/Use.xphp new file mode 100644 index 0000000..4c54820 --- /dev/null +++ b/test/fixture/check/method_and_class_errors/source/Use.xphp @@ -0,0 +1,8 @@ +(1); diff --git a/test/fixture/check/method_and_class_errors/source/funcs.xphp b/test/fixture/check/method_and_class_errors/source/funcs.xphp new file mode 100644 index 0000000..73cafa3 --- /dev/null +++ b/test/fixture/check/method_and_class_errors/source/funcs.xphp @@ -0,0 +1,10 @@ +(T $x): T +{ + return $x; +} diff --git a/test/fixture/check/missing_arg/source/Pair.xphp b/test/fixture/check/missing_arg/source/Pair.xphp new file mode 100644 index 0000000..17248ed --- /dev/null +++ b/test/fixture/check/missing_arg/source/Pair.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public A $a, public B $b) + { + } +} diff --git a/test/fixture/check/missing_arg/source/Use.xphp b/test/fixture/check/missing_arg/source/Use.xphp new file mode 100644 index 0000000..b48dc66 --- /dev/null +++ b/test/fixture/check/missing_arg/source/Use.xphp @@ -0,0 +1,8 @@ +(1, 2); diff --git a/test/fixture/check/multi_error/source/Box.xphp b/test/fixture/check/multi_error/source/Box.xphp new file mode 100644 index 0000000..bac8e95 --- /dev/null +++ b/test/fixture/check/multi_error/source/Box.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public T $value) + { + } +} diff --git a/test/fixture/check/multi_error/source/Use.xphp b/test/fixture/check/multi_error/source/Use.xphp new file mode 100644 index 0000000..44bcd6e --- /dev/null +++ b/test/fixture/check/multi_error/source/Use.xphp @@ -0,0 +1,10 @@ +(1); +$b = new Box::(2.0); diff --git a/test/fixture/check/parse_error/source/Bag.xphp b/test/fixture/check/parse_error/source/Bag.xphp new file mode 100644 index 0000000..c50ddb6 --- /dev/null +++ b/test/fixture/check/parse_error/source/Bag.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public T $value) + { + } +} diff --git a/test/fixture/check/parse_error/source/Broken.xphp b/test/fixture/check/parse_error/source/Broken.xphp new file mode 100644 index 0000000..43ddf26 --- /dev/null +++ b/test/fixture/check/parse_error/source/Broken.xphp @@ -0,0 +1,11 @@ +(T $x): T { + return $x; +}; diff --git a/test/fixture/check/parse_error/source/Use.xphp b/test/fixture/check/parse_error/source/Use.xphp new file mode 100644 index 0000000..1534b1b --- /dev/null +++ b/test/fixture/check/parse_error/source/Use.xphp @@ -0,0 +1,8 @@ +(1); diff --git a/test/fixture/check/phpstan-level5.neon b/test/fixture/check/phpstan-level5.neon new file mode 100644 index 0000000..a2b3ad8 --- /dev/null +++ b/test/fixture/check/phpstan-level5.neon @@ -0,0 +1,2 @@ +parameters: + level: 5 diff --git a/test/fixture/check/too_many_type_args/source/Box.xphp b/test/fixture/check/too_many_type_args/source/Box.xphp new file mode 100644 index 0000000..a097b0d --- /dev/null +++ b/test/fixture/check/too_many_type_args/source/Box.xphp @@ -0,0 +1,10 @@ + +{ + public T $item; +} diff --git a/test/fixture/check/too_many_type_args/source/Use.xphp b/test/fixture/check/too_many_type_args/source/Use.xphp new file mode 100644 index 0000000..4e1c289 --- /dev/null +++ b/test/fixture/check/too_many_type_args/source/Use.xphp @@ -0,0 +1,9 @@ +(); +$b = new Box::(); diff --git a/test/fixture/check/undeclared_bound/source/Box.xphp b/test/fixture/check/undeclared_bound/source/Box.xphp new file mode 100644 index 0000000..89d1d95 --- /dev/null +++ b/test/fixture/check/undeclared_bound/source/Box.xphp @@ -0,0 +1,10 @@ + +{ +} diff --git a/test/fixture/check/undeclared_bound_clean/source/Ok.xphp b/test/fixture/check/undeclared_bound_clean/source/Ok.xphp new file mode 100644 index 0000000..8c78c59 --- /dev/null +++ b/test/fixture/check/undeclared_bound_clean/source/Ok.xphp @@ -0,0 +1,15 @@ + +{ +} diff --git a/test/fixture/check/undeclared_bound_dedup/source/Dup.xphp b/test/fixture/check/undeclared_bound_dedup/source/Dup.xphp new file mode 100644 index 0000000..ecefc35 --- /dev/null +++ b/test/fixture/check/undeclared_bound_dedup/source/Dup.xphp @@ -0,0 +1,10 @@ + +{ +} diff --git a/test/fixture/check/undeclared_bound_nested/source/Box.xphp b/test/fixture/check/undeclared_bound_nested/source/Box.xphp new file mode 100644 index 0000000..ce26ff1 --- /dev/null +++ b/test/fixture/check/undeclared_bound_nested/source/Box.xphp @@ -0,0 +1,15 @@ + +{ +} + +// Intersection bound with a stray leaf (`Bad1`) and a declared generic whose +// type ARGUMENT is stray (`Bad2`). Both are reported. +class Box> +{ +} diff --git a/test/fixture/check/undeclared_default/source/Pair.xphp b/test/fixture/check/undeclared_default/source/Pair.xphp new file mode 100644 index 0000000..035296f --- /dev/null +++ b/test/fixture/check/undeclared_default/source/Pair.xphp @@ -0,0 +1,10 @@ + +{ +} diff --git a/test/fixture/check/undeclared_type_param/source/Box.xphp b/test/fixture/check/undeclared_type_param/source/Box.xphp new file mode 100644 index 0000000..853ccfc --- /dev/null +++ b/test/fixture/check/undeclared_type_param/source/Box.xphp @@ -0,0 +1,9 @@ + +{ + // `T` and `U` are not declared type parameters (only `Z` is) and name no real + // type — each is a stray/typo'd parameter. Both are reported in one run. + public function add(T $element): void; + + public function wrap(U $value): void; + + // Clean: `Z` is the declared parameter, `int` is a scalar, and `Box` is a + // class declared in this source set — none are flagged. + public function get(int $index): Z; + + public function store(Box $box): void; +} diff --git a/test/fixture/check/undeclared_type_param_escape/source/Foo.xphp b/test/fixture/check/undeclared_type_param_escape/source/Foo.xphp new file mode 100644 index 0000000..d859e5d --- /dev/null +++ b/test/fixture/check/undeclared_type_param_escape/source/Foo.xphp @@ -0,0 +1,20 @@ + +{ + // The escape hatches: an imported name and a fully-qualified name are + // deliberate references to types elsewhere, so neither is flagged even though + // neither is declared in this source set. + public function a(Real $x): void; + + public function b(\App\Models\Other $y): void; + + // Scalar + declared parameter: also clean. + public function c(int $n): Z; +} diff --git a/test/fixture/check/undeclared_type_param_method/source/Util.xphp b/test/fixture/check/undeclared_type_param_method/source/Util.xphp new file mode 100644 index 0000000..5a8dcaa --- /dev/null +++ b/test/fixture/check/undeclared_type_param_method/source/Util.xphp @@ -0,0 +1,28 @@ +(B $x): A + { + return $x; + } +} + +// Free generic function: `C` is a stray parameter. +function wrap(C $x): A +{ + return $x; +} + +// Generic closure and arrow: `D` and `E` are stray parameters. +$closure = function(D $x): A { + return $x; +}; + +$arrow = fn(E $x): A => $x; diff --git a/test/fixture/check/undeclared_type_param_nested_method/source/Box.xphp b/test/fixture/check/undeclared_type_param_nested_method/source/Box.xphp new file mode 100644 index 0000000..23b8218 --- /dev/null +++ b/test/fixture/check/undeclared_type_param_nested_method/source/Box.xphp @@ -0,0 +1,14 @@ + +{ + // A generic method nested in a generic template. Its stray `Stray` must be + // reported exactly once (by the template's member walk), not twice. + public function map(Stray $x): K + { + } +} diff --git a/test/fixture/check/undeclared_type_param_positions/source/Foo.xphp b/test/fixture/check/undeclared_type_param_positions/source/Foo.xphp new file mode 100644 index 0000000..fb2cc9b --- /dev/null +++ b/test/fixture/check/undeclared_type_param_positions/source/Foo.xphp @@ -0,0 +1,37 @@ + +{ + public Prop $field; + + public function __construct(public Promo $promoted) + { + } + + public function ret(): Ret + { + } + + public function nullable(?Nul $x, $untyped): void + { + } + + public function union(): UnA|UnB + { + } + + public function intersection(InA&InB $x): void + { + } + + public function closure(): void + { + // Stray names in a nested closure signature (param + return) are caught too; + // the untyped param exercises the "param has no type" branch. + $f = function (Clo $x, $untyped): CloRet {}; + } +} diff --git a/test/fixture/check/undefined_template/source/Use.xphp b/test/fixture/check/undefined_template/source/Use.xphp new file mode 100644 index 0000000..cad0f20 --- /dev/null +++ b/test/fixture/check/undefined_template/source/Use.xphp @@ -0,0 +1,8 @@ +(1); diff --git a/test/fixture/check/unresolved_generic_method/source/Use.xphp b/test/fixture/check/unresolved_generic_method/source/Use.xphp new file mode 100644 index 0000000..005a46b --- /dev/null +++ b/test/fixture/check/unresolved_generic_method/source/Use.xphp @@ -0,0 +1,16 @@ +(T $x): T + { + return $x; + } +} + +$b = new Box(); +$r = $b->nope::(1); diff --git a/test/fixture/check/variance_constructor_mixed_params/source/P.xphp b/test/fixture/check/variance_constructor_mixed_params/source/P.xphp new file mode 100644 index 0000000..4813f25 --- /dev/null +++ b/test/fixture/check/variance_constructor_mixed_params/source/P.xphp @@ -0,0 +1,14 @@ + +{ + public function __construct(T $a, ?T $b) + { + } +} diff --git a/test/fixture/check/variance_constructor_nested_generic/source/Box.xphp b/test/fixture/check/variance_constructor_nested_generic/source/Box.xphp new file mode 100644 index 0000000..80a450e --- /dev/null +++ b/test/fixture/check/variance_constructor_nested_generic/source/Box.xphp @@ -0,0 +1,9 @@ + +{ +} diff --git a/test/fixture/check/variance_constructor_nested_generic/source/P.xphp b/test/fixture/check/variance_constructor_nested_generic/source/P.xphp new file mode 100644 index 0000000..805408b --- /dev/null +++ b/test/fixture/check/variance_constructor_nested_generic/source/P.xphp @@ -0,0 +1,14 @@ +`), which is not a bare type-param — the inner-variance check rejects it. +class P<+T> +{ + public function __construct(Box $b) + { + } +} diff --git a/test/fixture/check/variance_constructor_nullable/source/P.xphp b/test/fixture/check/variance_constructor_nullable/source/P.xphp new file mode 100644 index 0000000..ac6f505 --- /dev/null +++ b/test/fixture/check/variance_constructor_nullable/source/P.xphp @@ -0,0 +1,14 @@ + +{ + public function __construct(?T $x) + { + } +} diff --git a/test/fixture/check/variance_edge_provable/source/Fruit.xphp b/test/fixture/check/variance_edge_provable/source/Fruit.xphp new file mode 100644 index 0000000..d9c8deb --- /dev/null +++ b/test/fixture/check/variance_edge_provable/source/Fruit.xphp @@ -0,0 +1,11 @@ + +{ + public function get(): T + { + throw new \LogicException(); + } +} diff --git a/test/fixture/check/variance_edge_provable/source/Use.xphp b/test/fixture/check/variance_edge_provable/source/Use.xphp new file mode 100644 index 0000000..04b515a --- /dev/null +++ b/test/fixture/check/variance_edge_provable/source/Use.xphp @@ -0,0 +1,8 @@ +(); diff --git a/test/fixture/check/variance_edge_unprovable/source/Producer.xphp b/test/fixture/check/variance_edge_unprovable/source/Producer.xphp new file mode 100644 index 0000000..ebca57e --- /dev/null +++ b/test/fixture/check/variance_edge_unprovable/source/Producer.xphp @@ -0,0 +1,15 @@ + +{ + public function get(): T + { + throw new \LogicException(); + } +} diff --git a/test/fixture/check/variance_edge_unprovable/source/Use.xphp b/test/fixture/check/variance_edge_unprovable/source/Use.xphp new file mode 100644 index 0000000..52643a1 --- /dev/null +++ b/test/fixture/check/variance_edge_unprovable/source/Use.xphp @@ -0,0 +1,10 @@ +(); diff --git a/test/fixture/check/variance_violation/source/Producer.xphp b/test/fixture/check/variance_violation/source/Producer.xphp new file mode 100644 index 0000000..fa98c77 --- /dev/null +++ b/test/fixture/check/variance_violation/source/Producer.xphp @@ -0,0 +1,13 @@ + +{ + public function set(T $value): void + { + } +} diff --git a/test/fixture/compile/comparator_param_covariant_upcast/source/Book.xphp b/test/fixture/compile/comparator_param_covariant_upcast/source/Book.xphp new file mode 100644 index 0000000..d120302 --- /dev/null +++ b/test/fixture/compile/comparator_param_covariant_upcast/source/Book.xphp @@ -0,0 +1,7 @@ +` CONSUMING method — the sound shape where `E` sits in a +// contravariant slot (Comparator<-T>) inside a contravariant parameter position, so +// contra ∘ contra = covariant, which a covariant `+E` may occupy. +class Box<+E> +{ + /** @var list */ + private array $items; + + public function __construct(E ...$items) + { + $this->items = $items; + } + + public function pick(Comparator $c): ?E + { + $best = $this->items[0] ?? null; + foreach ($this->items as $item) { + if ($best !== null && $c->compare($item, $best) > 0) { + $best = $item; + } + } + return $best; + } +} diff --git a/test/fixture/compile/comparator_param_covariant_upcast/source/ById.xphp b/test/fixture/compile/comparator_param_covariant_upcast/source/ById.xphp new file mode 100644 index 0000000..01ff20b --- /dev/null +++ b/test/fixture/compile/comparator_param_covariant_upcast/source/ById.xphp @@ -0,0 +1,15 @@ +. By contravariance (Comparator<-T>), a Comparator is also a +// Comparator, so it can compare the Book elements of a Box upcast to Box. +class ById implements Comparator +{ + public function compare(Product $a, Product $b): int + { + return $a->id <=> $b->id; + } +} diff --git a/test/fixture/compile/comparator_param_covariant_upcast/source/Comparator.xphp b/test/fixture/compile/comparator_param_covariant_upcast/source/Comparator.xphp new file mode 100644 index 0000000..46c34bb --- /dev/null +++ b/test/fixture/compile/comparator_param_covariant_upcast/source/Comparator.xphp @@ -0,0 +1,10 @@ + +{ + public function compare(T $a, T $b): int; +} diff --git a/test/fixture/compile/comparator_param_covariant_upcast/source/Product.xphp b/test/fixture/compile/comparator_param_covariant_upcast/source/Product.xphp new file mode 100644 index 0000000..e8f9d87 --- /dev/null +++ b/test/fixture/compile/comparator_param_covariant_upcast/source/Product.xphp @@ -0,0 +1,10 @@ +` and a `Comparator`, but is called with a `Box` and a `ById` +// (a Comparator, hence by contravariance a Comparator). The Box's `pick` +// compares its Book elements through the Product comparator — sound under the covariant upcast. +function choose(Box $b, Comparator $c): ?Product +{ + return $b->pick($c); +} + +$books = new Box::(new Book(1), new Book(3), new Book(2)); +$best = choose($books, new ById()); diff --git a/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php b/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php new file mode 100644 index 0000000..4141f30 --- /dev/null +++ b/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php @@ -0,0 +1,27 @@ +` is upcast to `Box` and its `pick(Comparator $c)` is called with a + * `ById` (a `Comparator`, hence by contravariance a `Comparator`). For the program to + * LOAD and run, the covariant `Box ⊑ Box` edge and the contravariant + * `Comparator ⊑ Comparator` edge must both be emitted, and `Box::pick` must accept + * the comparator (its parameter widens to `Comparator` — sound contravariant param widening). + * + * The whole shape was previously REJECTED at compile time (`xphp.variance_position`) even though it is + * sound; the fix routes the nested `Comparator` verdict through the composing variance pass, which + * accepts it. That the program runs and `pick` returns the max Book proves the acceptance is sound. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertInstanceOf(\App\Book::class, $best, 'pick returned a Book element through the upcast'); +Assert::assertSame(3, $best->id, 'pick selected the max-id Book via the Product comparator'); +echo "OK\n"; diff --git a/test/fixture/compile/cross_template_generic_arg_upcast/source/Book.xphp b/test/fixture/compile/cross_template_generic_arg_upcast/source/Book.xphp new file mode 100644 index 0000000..d120302 --- /dev/null +++ b/test/fixture/compile/cross_template_generic_arg_upcast/source/Book.xphp @@ -0,0 +1,7 @@ + +{ + public function first(): ?E; +} diff --git a/test/fixture/compile/cross_template_generic_arg_upcast/source/Couple.xphp b/test/fixture/compile/cross_template_generic_arg_upcast/source/Couple.xphp new file mode 100644 index 0000000..ee55a0f --- /dev/null +++ b/test/fixture/compile/cross_template_generic_arg_upcast/source/Couple.xphp @@ -0,0 +1,20 @@ + implements Tuple +{ + public function __construct(private A $a, private B $b) {} + + public function left(): A + { + return $this->a; + } + + public function right(): B + { + return $this->b; + } +} diff --git a/test/fixture/compile/cross_template_generic_arg_upcast/source/ImmutableList.xphp b/test/fixture/compile/cross_template_generic_arg_upcast/source/ImmutableList.xphp new file mode 100644 index 0000000..8cb0712 --- /dev/null +++ b/test/fixture/compile/cross_template_generic_arg_upcast/source/ImmutableList.xphp @@ -0,0 +1,21 @@ + implements Collection +{ + /** @var list */ + private array $items; + + public function __construct(E ...$items) + { + $this->items = $items; + } + + public function first(): ?E + { + return $this->items[0] ?? null; + } +} diff --git a/test/fixture/compile/cross_template_generic_arg_upcast/source/Product.xphp b/test/fixture/compile/cross_template_generic_arg_upcast/source/Product.xphp new file mode 100644 index 0000000..f94c89a --- /dev/null +++ b/test/fixture/compile/cross_template_generic_arg_upcast/source/Product.xphp @@ -0,0 +1,7 @@ + +{ + public function left(): A; + + public function right(): B; +} diff --git a/test/fixture/compile/cross_template_generic_arg_upcast/source/Use.xphp b/test/fixture/compile/cross_template_generic_arg_upcast/source/Use.xphp new file mode 100644 index 0000000..58c765b --- /dev/null +++ b/test/fixture/compile/cross_template_generic_arg_upcast/source/Use.xphp @@ -0,0 +1,20 @@ +, Tag>`, but is +// called with a `Couple, Tag>`. For that to load and run, the covariant edge +// Tuple, Tag> ⊑ Tuple, Tag> +// must be emitted — which requires recognizing the ARGUMENT subtype `ImmutableList ⊑ +// Collection` across DIFFERENT templates (`ImmutableList implements Collection`, both +// covariant). It reads the left element list through the interface and pulls its first element. +function useTuple(Tuple, Tag> $t): ?Product +{ + return $t->left()->first(); +} + +$books = new ImmutableList::(new Book()); +$couple = new Couple::, Tag>($books, new Tag()); +$first = useTuple($couple); diff --git a/test/fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php b/test/fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php new file mode 100644 index 0000000..ce57339 --- /dev/null +++ b/test/fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php @@ -0,0 +1,27 @@ +, Tag>` is upcast to `Tuple, Tag>` and used through + * the interface. For the program to LOAD, the covariant edge + * Tuple, Tag> ⊑ Tuple, Tag> + * must have been emitted — and that requires the per-argument subtype `ImmutableList ⊑ + * Collection` to be recognized across DIFFERENT templates. Before the fix the edge is + * silently omitted, `xphp check` passes, and this `require` fatals with a `TypeError` (the Couple + * specialization never implements the `Tuple, Tag>` marker). + * + * That the program loads, the call resolves, and `first()` returns the Book proves the cross-template + * edge was emitted and the covariance holds at runtime — not just at `check`. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertInstanceOf(\App\Book::class, $first, 'the upcast tuple resolved and yielded its Book element'); +echo "OK\n"; diff --git a/test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Banana.xphp b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool + { + return $value instanceof Fruit; + } +} diff --git a/test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Food.xphp b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Food.xphp new file mode 100644 index 0000000..2e9414a --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Food.xphp @@ -0,0 +1,9 @@ + $b): bool +{ + // Mangles on the receiver's E (Fruit) → contains_. + return $b->contains::(new Banana()); +} + +$food = new Box::(); +$fruit = new Box::(); +$banana = new Box::(); + +// A Box passed where Box is expected (covariant edge): the inherited +// contains_ dispatches on it. +$viaCovariance = check($banana); +$direct = $fruit->contains::(new Banana()); +$isCovariant = $banana instanceof Box; diff --git a/test/fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php new file mode 100644 index 0000000..b1246d7 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php @@ -0,0 +1,23 @@ + specializes into a covariant `extends` chain (Box extends Box extends + * Box). Each specialization carries its own E-mangled `contains_` and inherits its + * ancestors'; the distinct names mean no parameter-narrowing LSP fatal across the chain. A + * Box used where a Box is expected dispatches the inherited `contains_`. + * That this loads and runs proves erasure is variance-safe through the real pipeline. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($viaCovariance, 'a Box via the Box view runs the inherited contains_'); +Assert::assertTrue($direct); +Assert::assertTrue($isCovariant, 'Box must be an instanceof the Box marker'); diff --git a/test/fixture/compile/enclosing_bound_erasure_forwarding/source/Banana.xphp b/test/fixture/compile/enclosing_bound_erasure_forwarding/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_forwarding/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool + { + return $value instanceof Fruit; + } + + public function probe(U $value): bool + { + // Forwards U to a fellow erasable method; the Specializer rewrites this to contains_. + return $this->contains::($value); + } +} diff --git a/test/fixture/compile/enclosing_bound_erasure_forwarding/source/Fruit.xphp b/test/fixture/compile/enclosing_bound_erasure_forwarding/source/Fruit.xphp new file mode 100644 index 0000000..0db3331 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_forwarding/source/Fruit.xphp @@ -0,0 +1,9 @@ +(); +$viaForward = $box->probe::(new Banana()); +$viaDirect = $box->contains::(new Banana()); diff --git a/test/fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php new file mode 100644 index 0000000..aca0661 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php @@ -0,0 +1,22 @@ +(U)` forwards its parameter to `$this->contains::()`. Both are erasable, so the + * Specializer lowers them to E-mangled members (`contains_`, `probe_`) on Box + * and rewrites the forward to call `contains_`. If the forward were left as a bare + * `$this->contains(...)` (the old silent break), this would fatal with "undefined method" the + * moment `probe` ran. That it runs and returns the contained-element verdict proves the lowering. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($viaForward, 'forwarded self-call must resolve to the emitted contains_ method'); +Assert::assertTrue($viaDirect, 'a direct erasable call must run too'); diff --git a/test/fixture/compile/enclosing_bound_erasure_inherited/source/ArrayList.xphp b/test/fixture/compile/enclosing_bound_erasure_inherited/source/ArrayList.xphp new file mode 100644 index 0000000..600d38c --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_inherited/source/ArrayList.xphp @@ -0,0 +1,9 @@ + extends Base +{ +} diff --git a/test/fixture/compile/enclosing_bound_erasure_inherited/source/Banana.xphp b/test/fixture/compile/enclosing_bound_erasure_inherited/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_inherited/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool + { + return $value instanceof Fruit; + } +} diff --git a/test/fixture/compile/enclosing_bound_erasure_inherited/source/Fruit.xphp b/test/fixture/compile/enclosing_bound_erasure_inherited/source/Fruit.xphp new file mode 100644 index 0000000..0db3331 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_inherited/source/Fruit.xphp @@ -0,0 +1,9 @@ +` is declared on the generic BASE; the erased member is emitted on Base and +// inherited by ArrayList. The call must resolve to that inherited member. +$list = new ArrayList::(); +$inherited = $list->contains::(new Banana()); diff --git a/test/fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php new file mode 100644 index 0000000..4fffa58 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php @@ -0,0 +1,21 @@ +` declared on a generic base is emitted as `contains_` on the + * Base specialization; ArrayList extends it and inherits the member. The call site, + * keyed on the receiver's E threaded to the DECLARING class, must produce the same mangled name — + * this is the cross-cutting mangling invariant where call-site and Specializer name computation could + * silently drift. That the call resolves and runs proves they agree. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($inherited, 'inherited erasable member must resolve via the same E-mangled name'); diff --git a/test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Banana.xphp b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + public function describe(K $key): string + { + return (string) $key; + } + + // Bound references the SECOND class parameter V — the erased member must mangle on V, not K. + public function containsValue(U $value): bool + { + return $value instanceof Fruit; + } +} diff --git a/test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Use.xphp b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Use.xphp new file mode 100644 index 0000000..c6270f7 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Use.xphp @@ -0,0 +1,9 @@ +(); +$found = $map->containsValue::(new Banana()); +$label = $map->describe('k'); diff --git a/test/fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php new file mode 100644 index 0000000..6ea0515 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php @@ -0,0 +1,21 @@ +` on `Map` is bounded by the SECOND class parameter V. The erased member + * must mangle on V's concrete value (Fruit), not K (string) — the call site (keyed on the receiver's + * V) and the Specializer must agree on that key. That the call resolves and runs proves the + * multi-class-param mangle keys on the bound's referent. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($found, 'containsValue mangled on V (Fruit) must resolve and run'); +Assert::assertSame('k', $label); diff --git a/test/fixture/compile/enclosing_bound_erasure_param_widening/source/Banana.xphp b/test/fixture/compile/enclosing_bound_erasure_param_widening/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_param_widening/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool + { + return $value instanceof Fruit; + } +} diff --git a/test/fixture/compile/enclosing_bound_erasure_param_widening/source/Cherry.xphp b/test/fixture/compile/enclosing_bound_erasure_param_widening/source/Cherry.xphp new file mode 100644 index 0000000..466d19e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_param_widening/source/Cherry.xphp @@ -0,0 +1,9 @@ + collapse to ONE erased member +// contains_(Fruit): the parameter is widened to the bound E, so it accepts both subtypes. +$box = new Box::(); +$banana = $box->contains::(new Banana()); +$cherry = $box->contains::(new Cherry()); diff --git a/test/fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php new file mode 100644 index 0000000..bbb51c8 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php @@ -0,0 +1,21 @@ +` lowers to ONE `contains_(Fruit)` member per Box, and + * the parameter is widened to the bound E (Fruit). So two distinct call-site turbofish types + * (Banana, Cherry) both lower to that single member, which accepts each as a Fruit. Both calls + * running proves the per-E collapse and the param widening. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($banana, 'contains:: runs on the widened Fruit-typed member'); +Assert::assertTrue($cherry, 'contains:: runs on the SAME widened member'); diff --git a/test/fixture/compile/enclosing_bound_erasure_two_params/source/Banana.xphp b/test/fixture/compile/enclosing_bound_erasure_two_params/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_two_params/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + // Two enclosing-bounded method params: both erase to E; the member mangles on [E, E]. + public function bothAreFruit(U $a, V $b): bool + { + return $a instanceof Fruit && $b instanceof Fruit; + } +} diff --git a/test/fixture/compile/enclosing_bound_erasure_two_params/source/Cherry.xphp b/test/fixture/compile/enclosing_bound_erasure_two_params/source/Cherry.xphp new file mode 100644 index 0000000..466d19e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_two_params/source/Cherry.xphp @@ -0,0 +1,9 @@ +(); +$both = $box->bothAreFruit::(new Banana(), new Cherry()); diff --git a/test/fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php new file mode 100644 index 0000000..1b3f650 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php @@ -0,0 +1,19 @@ +`) erases both to E and mangles on + * `[E, E]`. Both parameters widen to the bound (Fruit), so `bothAreFruit::` resolves + * to the one emitted member and runs. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($both, 'a two-bounded-param erasable method must resolve and run'); diff --git a/test/fixture/compile/enclosing_bound_interface_upcast/source/AbstractColl.xphp b/test/fixture/compile/enclosing_bound_interface_upcast/source/AbstractColl.xphp new file mode 100644 index 0000000..d1b9ee2 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast/source/AbstractColl.xphp @@ -0,0 +1,21 @@ + implements Collection +{ + /** @var list */ + protected array $items; + + public function __construct(E ...$items) + { + $this->items = $items; + } + + public function contains(E2 $value): bool + { + return \in_array($value, $this->items, true); + } +} diff --git a/test/fixture/compile/enclosing_bound_interface_upcast/source/Book.xphp b/test/fixture/compile/enclosing_bound_interface_upcast/source/Book.xphp new file mode 100644 index 0000000..ad1d8fc --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast/source/Book.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(E2 $value): bool; +} diff --git a/test/fixture/compile/enclosing_bound_interface_upcast/source/ListColl.xphp b/test/fixture/compile/enclosing_bound_interface_upcast/source/ListColl.xphp new file mode 100644 index 0000000..a011bae --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast/source/ListColl.xphp @@ -0,0 +1,9 @@ + extends AbstractColl +{ +} diff --git a/test/fixture/compile/enclosing_bound_interface_upcast/source/Product.xphp b/test/fixture/compile/enclosing_bound_interface_upcast/source/Product.xphp new file mode 100644 index 0000000..298054a --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast/source/Product.xphp @@ -0,0 +1,9 @@ +`, but is called with a +// `ListColl` (Book extends Product). The erased `contains_` declared (abstract) on +// Collection must be implemented for ListColl — through the covariant chain, via an +// AbstractColl specialization that is NEVER instantiated explicitly here. +function probe(Collection $c): bool +{ + return $c->contains::(new Product()); +} + +$books = new ListColl::(new Book()); +$found = probe($books); diff --git a/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php b/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php new file mode 100644 index 0000000..5c06878 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php @@ -0,0 +1,26 @@ +` is upcast to `Collection` and an erased element-method + * (`contains`) is called through the interface. The interface specialization + * `Collection` declares the abstract `contains_`; for the program to load, the + * concrete supertype specialization that implements it (`AbstractColl`) must have been + * scheduled automatically — there is NO `new ListColl::()` anywhere in the source. + * + * That the program loads and `probe` returns proves the closer scheduled the implementer and the + * covariant chain inherited it. `probe` looks for a fresh Product in a list holding one Book, so the + * expected answer is false — the point is that the call resolves and runs at all. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertFalse($found, 'the upcast contains-call must resolve, run, and report the Product absent'); +echo "OK\n"; diff --git a/test/fixture/compile/enclosing_bound_interface_upcast_map/source/AbstractMap.xphp b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/AbstractMap.xphp new file mode 100644 index 0000000..3de3b59 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/AbstractMap.xphp @@ -0,0 +1,21 @@ + implements MMap +{ + /** @var list */ + protected array $values; + + public function __construct(V ...$values) + { + $this->values = $values; + } + + public function containsValue(U $value): bool + { + return \in_array($value, $this->values, true); + } +} diff --git a/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Book.xphp b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Book.xphp new file mode 100644 index 0000000..ad1d8fc --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Book.xphp @@ -0,0 +1,9 @@ + extends AbstractMap +{ +} diff --git a/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Id.xphp b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Id.xphp new file mode 100644 index 0000000..2d2c2e8 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Id.xphp @@ -0,0 +1,9 @@ + +{ + public function containsValue(U $value): bool; +} diff --git a/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Product.xphp b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Product.xphp new file mode 100644 index 0000000..298054a --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Product.xphp @@ -0,0 +1,9 @@ + upcast to +// MMap must schedule the implementer AbstractMap — K kept (Id), V raised +// to the supertype arg (Product). No HashMap is instantiated explicitly. +function probe(MMap $m): bool +{ + return $m->containsValue::(new Product()); +} + +$map = new HashMap::(new Book()); +$found = probe($map); diff --git a/test/fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php b/test/fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php new file mode 100644 index 0000000..8746940 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php @@ -0,0 +1,21 @@ +` (K invariant, +V covariant) is upcast to `MMap`. + * The erased `containsValue` mangles on V, so the closer must schedule `AbstractMap` + * — varying only the covariant V to the supertype arg while keeping the invariant K = Id — with NO + * explicit `HashMap` anywhere. Executing the output proves the threading is correct. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertFalse($found, 'the multi-param upcast containsValue-call must resolve and run'); +echo "OK\n"; diff --git a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/AbstractColl.xphp b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/AbstractColl.xphp new file mode 100644 index 0000000..74d8b56 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/AbstractColl.xphp @@ -0,0 +1,21 @@ + implements Collection +{ + /** @var list */ + protected array $items; + + public function __construct(E ...$items) + { + $this->items = $items; + } + + public function contains(U $value): bool + { + return \in_array($value, $this->items, true); + } +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Book.xphp b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Book.xphp new file mode 100644 index 0000000..ad1d8fc --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Book.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool; +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/ListColl.xphp b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/ListColl.xphp new file mode 100644 index 0000000..2c49b48 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/ListColl.xphp @@ -0,0 +1,18 @@ + extends AbstractColl implements OrderedCollection +{ + public function indexOf(U $value): int + { + foreach ($this->items as $i => $item) { + if ($item === $value) { + return $i; + } + } + return -1; + } +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/OrderedCollection.xphp b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/OrderedCollection.xphp new file mode 100644 index 0000000..0f66e86 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/OrderedCollection.xphp @@ -0,0 +1,10 @@ + extends Collection +{ + public function indexOf(U $value): int; +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Product.xphp b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Product.xphp new file mode 100644 index 0000000..298054a --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Product.xphp @@ -0,0 +1,9 @@ + through a covariant edge, so it's emitted +// directly onto ListColl. contains_ still comes via inheritance from AbstractColl. +function firstProduct(OrderedCollection $c): int +{ + return $c->indexOf::(new Product()); +} + +function hasProduct(Collection $c): bool +{ + return $c->contains::(new Product()); +} + +$books = new ListColl::(new Book()); +$idx = firstProduct($books); // indexOf via direct emission +$has = hasProduct($books); // contains via inheritance diff --git a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php new file mode 100644 index 0000000..c0368eb --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php @@ -0,0 +1,23 @@ +` is upcast to `OrderedCollection` and its erased `indexOf` is called. The + * body lives on `ListColl`, which has a parent (`AbstractColl`), so the member can't be inherited through + * a covariant edge — it must be emitted directly onto `ListColl`, with the parameter widened to + * `Product` and the body reading the inherited `Book`-typed `$items` (Book <: Product). `contains` still + * resolves via the inheritance path. That both calls run proves direct emission and inheritance coexist. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame(-1, $idx, 'indexOf via direct emission must resolve, run, and report the fresh Product absent'); +Assert::assertFalse($has, 'contains via inheritance must still resolve'); +echo "OK\n"; diff --git a/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/AbstractColl.xphp b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/AbstractColl.xphp new file mode 100644 index 0000000..74d8b56 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/AbstractColl.xphp @@ -0,0 +1,21 @@ + implements Collection +{ + /** @var list */ + protected array $items; + + public function __construct(E ...$items) + { + $this->items = $items; + } + + public function contains(U $value): bool + { + return \in_array($value, $this->items, true); + } +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Book.xphp b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Book.xphp new file mode 100644 index 0000000..ad1d8fc --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Book.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool; +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/ListColl.xphp b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/ListColl.xphp new file mode 100644 index 0000000..c122553 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/ListColl.xphp @@ -0,0 +1,16 @@ + extends AbstractColl implements OrderedCollection +{ + // Body references the CLASS parameter E structurally (`instanceof E`). Under the upcast this member + // is emitted directly onto ListColl; E must resolve to the upcast-source's concrete (Book), + // NOT the supertype (Product). So `$value instanceof E` asks "is the argument a Book?". + public function firstKind(U $value): bool + { + return $value instanceof E; + } +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/OrderedCollection.xphp b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/OrderedCollection.xphp new file mode 100644 index 0000000..7d13d98 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/OrderedCollection.xphp @@ -0,0 +1,10 @@ + extends Collection +{ + public function firstKind(U $value): bool; +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Product.xphp b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Product.xphp new file mode 100644 index 0000000..298054a --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Product.xphp @@ -0,0 +1,9 @@ + $c): bool +{ + return $c->firstKind::(new Product()); +} + +$books = new ListColl::(new Book()); +$result = isBook($books); diff --git a/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/verify/runtime.php b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/verify/runtime.php new file mode 100644 index 0000000..16e5a08 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/verify/runtime.php @@ -0,0 +1,22 @@ +` under + * the upcast, with the parameter widened to `Product` but the class `E` resolving to the upcast-source's + * `Book`. So at runtime the call (with a fresh `Product`) evaluates `$value instanceof Book` → false. If the + * split substitution were wrong and `E` resolved to `Product`, it would be `$value instanceof Product` → + * true. The false answer proves the class `E` was substituted with `Book`, not `Product`. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertFalse($result, 'the body class parameter E must substitute to the upcast-source concrete (Book), not the supertype (Product)'); +echo "OK\n"; diff --git a/test/fixture/compile/enclosing_param_bound_compound_drop/source/Box.xphp b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Box.xphp new file mode 100644 index 0000000..019da97 --- /dev/null +++ b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Box.xphp @@ -0,0 +1,13 @@ + +{ + // U must be BOTH `\Stringable` (have a `__toString`) AND a subtype of the element type E. The + // `\Stringable` half is checkable with no knowledge of E; only the `E` half needs the receiver's + // element type. + public function register(U $value): void {} +} diff --git a/test/fixture/compile/enclosing_param_bound_compound_drop/source/Models.xphp b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Models.xphp new file mode 100644 index 0000000..c026038 --- /dev/null +++ b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Models.xphp @@ -0,0 +1,11 @@ +, so the merge keeps the +// element type and $box is determined to be Box. The whole `\Stringable & E` bound grounds to +// `\Stringable & Fruit`. `Banana` is a valid element (Banana <: Fruit) but is not `\Stringable`, so +// it is rejected on the `\Stringable` operand. +// +// Expected behaviour: COMPILE ERROR (Banana does not extend/implement \Stringable). Grounding the +// whole bound preserves every operand — the checkable `\Stringable` constraint is never lost. +function store(bool $cond): void +{ + if ($cond) { + $box = new Box::(); + } else { + $box = new Box::(); + } + + $box->register::(new Banana()); +} diff --git a/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Box.xphp b/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Box.xphp new file mode 100644 index 0000000..d7fdb33 --- /dev/null +++ b/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Box.xphp @@ -0,0 +1,15 @@ + +{ + // U must be a subtype of the element type E (the sound, element-typed shape on a + // covariant collection). The bound is checked only when E can be grounded. + public function contains(U $value): bool + { + return true; + } +} diff --git a/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Models.xphp b/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Models.xphp new file mode 100644 index 0000000..c39cc48 --- /dev/null +++ b/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Models.xphp @@ -0,0 +1,11 @@ +, so the merge keeps the element type +// (the arms agree on Box) and $box is determined to be Box. The bound `` +// grounds to ``, so `Rock` — plainly not a Fruit — is rejected. +// +// Expected behaviour: COMPILE ERROR (Rock does not extend/implement App\Fruit). A knowable element +// type is never dropped, so a determinate violation is never silently accepted. +function pick(bool $cond): bool +{ + if ($cond) { + $box = new Box::(); + } else { + $box = new Box::(); + } + + return $box->contains::(new Rock()); +} diff --git a/test/fixture/compile/generic_contravariant_constructor/source/Banana.xphp b/test/fixture/compile/generic_contravariant_constructor/source/Banana.xphp new file mode 100644 index 0000000..77358bd --- /dev/null +++ b/test/fixture/compile/generic_contravariant_constructor/source/Banana.xphp @@ -0,0 +1,9 @@ +` extends `Consumer` — and the chain still loads +// because PHP exempts `__construct` from LSP. +class Consumer<-T> +{ + private array $items; + + public function __construct(T ...$items) + { + $this->items = $items; + } + + public function accept(T $x): void + { + $this->items[] = $x; + } +} diff --git a/test/fixture/compile/generic_contravariant_constructor/source/Fruit.xphp b/test/fixture/compile/generic_contravariant_constructor/source/Fruit.xphp new file mode 100644 index 0000000..4948512 --- /dev/null +++ b/test/fixture/compile/generic_contravariant_constructor/source/Fruit.xphp @@ -0,0 +1,9 @@ +(new Banana()); +$b = new Consumer::(new Fruit()); diff --git a/test/fixture/compile/generic_covariant_bounded_constructor/source/Box.xphp b/test/fixture/compile/generic_covariant_bounded_constructor/source/Box.xphp new file mode 100644 index 0000000..ecb8ed0 --- /dev/null +++ b/test/fixture/compile/generic_covariant_bounded_constructor/source/Box.xphp @@ -0,0 +1,22 @@ + +{ + private array $items; + + public function __construct(T ...$items) + { + $this->items = $items; + } + + public function get(int $i): T + { + return $this->items[$i]; + } +} diff --git a/test/fixture/compile/generic_covariant_bounded_constructor/source/Tag.xphp b/test/fixture/compile/generic_covariant_bounded_constructor/source/Tag.xphp new file mode 100644 index 0000000..d2b8240 --- /dev/null +++ b/test/fixture/compile/generic_covariant_bounded_constructor/source/Tag.xphp @@ -0,0 +1,13 @@ +(new Tag()); diff --git a/test/fixture/compile/generic_covariant_immutable_constructor/source/Banana.xphp b/test/fixture/compile/generic_covariant_immutable_constructor/source/Banana.xphp new file mode 100644 index 0000000..e2f0582 --- /dev/null +++ b/test/fixture/compile/generic_covariant_immutable_constructor/source/Banana.xphp @@ -0,0 +1,13 @@ + extends ImmutableList) is +// valid and construction is runtime-type-checked. Not `final` — a variant class +// can't be (its specializations are linked by `extends` edges). +class ImmutableList<+T> +{ + private array $items; + + public function __construct(T ...$items) + { + $this->items = $items; + } + + public function get(int $i): T + { + return $this->items[$i]; + } + + public function count(): int + { + return \count($this->items); + } +} diff --git a/test/fixture/compile/generic_covariant_immutable_constructor/source/Use.xphp b/test/fixture/compile/generic_covariant_immutable_constructor/source/Use.xphp new file mode 100644 index 0000000..f21d7b6 --- /dev/null +++ b/test/fixture/compile/generic_covariant_immutable_constructor/source/Use.xphp @@ -0,0 +1,16 @@ + is accepted where ImmutableList +// is expected, because Banana extends Fruit and T is covariant. +function firstName(ImmutableList $list): string +{ + return $list->get(0)->name; +} + +$bananas = new ImmutableList::(new Banana(), new Banana()); +$cnt = $bananas->count(); +$name = firstName($bananas); diff --git a/test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php b/test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php new file mode 100644 index 0000000..9388b0c --- /dev/null +++ b/test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php @@ -0,0 +1,43 @@ +` with a `T`-typed + * constructor. `ImmutableList` is usable where `ImmutableList` + * is expected (covariant `extends` edge), the constructor keeps its REAL element + * type on each specialization (no erasure), and that real type is enforced by + * PHP at construction — passing a non-Banana to `ImmutableList` throws. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use App\CovariantConstructor\Banana; +use App\CovariantConstructor\Fruit; +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame(2, $cnt); +Assert::assertSame('banana', $name); + +// The constructor element type is REAL (not erased to `mixed`) and runtime-checked: +// an `ImmutableList` rejects a plain `Fruit` at construction. +$bananaListFqn = Registry::generatedFqn( + 'App\\CovariantConstructor\\ImmutableList', + [new TypeRef('App\\CovariantConstructor\\Banana')], +); +$threw = false; +try { + new $bananaListFqn(new Fruit('apple')); +} catch (\TypeError) { + $threw = true; +} +Assert::assertTrue($threw, 'ImmutableList must reject a non-Banana element at construction'); + +// And it accepts a real Banana. +$ok = new $bananaListFqn(new Banana()); +Assert::assertSame('banana', $ok->get(0)->name); diff --git a/test/fixture/compile/generic_covariant_private_property/source/Banana.xphp b/test/fixture/compile/generic_covariant_private_property/source/Banana.xphp new file mode 100644 index 0000000..c08dfd7 --- /dev/null +++ b/test/fixture/compile/generic_covariant_private_property/source/Banana.xphp @@ -0,0 +1,13 @@ +` shape — and it is sound: +// PHP does NOT type-check private property types across an `extends` chain (a +// private slot is per-declaring-scope, never inherited), so the variance edge +// `Box extends Box` lands with NO autoload fatal even though the +// specializations declare `private Banana $item` and `private Fruit $item`. +// The constructor keeps its REAL element type on each specialization (PHP +// exempts `__construct` from LSP), so construction is runtime-type-checked. +// Not `final` — a variant class can't be (its specializations are linked by +// `extends` edges). +class Box<+T> +{ + public function __construct(private T $item) + { + } + + public function get(): T + { + return $this->item; + } +} diff --git a/test/fixture/compile/generic_covariant_private_property/source/Fruit.xphp b/test/fixture/compile/generic_covariant_private_property/source/Fruit.xphp new file mode 100644 index 0000000..b0a7dd5 --- /dev/null +++ b/test/fixture/compile/generic_covariant_private_property/source/Fruit.xphp @@ -0,0 +1,12 @@ + is accepted where a Box is expected, because +// Banana extends Fruit and T is covariant — the element is read back through a +// covariant `get(): T`. +function nameOf(Box $box): string +{ + return $box->get()->name; +} + +$bananaBox = new Box::(new Banana()); +$name = nameOf($bananaBox); diff --git a/test/fixture/compile/generic_covariant_private_property/verify/runtime.php b/test/fixture/compile/generic_covariant_private_property/verify/runtime.php new file mode 100644 index 0000000..21f0903 --- /dev/null +++ b/test/fixture/compile/generic_covariant_private_property/verify/runtime.php @@ -0,0 +1,54 @@ +` that stores its element in a PRIVATE promoted + * property of type `T`. The variance edge `Box extends Box` + * autoloads with no PHP fatal even though each specialization declares a + * divergent-typed private slot (PHP doesn't type-check private property types + * across the chain), a `Box` is usable where a `Box` is expected + * (covariant `get(): T`), and the constructor keeps its REAL element type so + * construction is runtime-type-checked. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use App\CovariantPrivateProperty\Banana; +use App\CovariantPrivateProperty\Fruit; +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; + +require $fixture->targetDir . '/Use.php'; + +// Covariant use worked: a Box flowed into a Box parameter and the +// element read back through `get(): T`. +Assert::assertSame('banana', $name); + +$bananaBoxFqn = Registry::generatedFqn( + 'App\\CovariantPrivateProperty\\Box', + [new TypeRef('App\\CovariantPrivateProperty\\Banana')], +); +$fruitBoxFqn = Registry::generatedFqn( + 'App\\CovariantPrivateProperty\\Box', + [new TypeRef('App\\CovariantPrivateProperty\\Fruit')], +); + +// The private slot type is REAL (not erased to `mixed`) and runtime-checked at +// construction: a `Box` rejects a plain `Fruit`. +$threw = false; +try { + new $bananaBoxFqn(new Fruit('apple')); +} catch (\TypeError) { + $threw = true; +} +Assert::assertTrue($threw, 'Box must reject a non-Banana element at construction'); + +// And it accepts a real Banana, exposing it through the covariant getter. +$ok = new $bananaBoxFqn(new Banana()); +Assert::assertSame('banana', $ok->get()->name); + +// The covariant edge is real: a Box IS a Box at the type level. +Assert::assertInstanceOf($fruitBoxFqn, $ok); diff --git a/test/fixture/compile/generic_interface/verify/specialized_interface_runtime.php b/test/fixture/compile/generic_interface/verify/specialized_interface_runtime.php index d8626f3..08875ba 100644 --- a/test/fixture/compile/generic_interface/verify/specialized_interface_runtime.php +++ b/test/fixture/compile/generic_interface/verify/specialized_interface_runtime.php @@ -15,7 +15,7 @@ use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; -$ifaceFqn = Registry::generatedFqn( +$interfaceFqn = Registry::generatedFqn( 'App\\GenericInterface\\Containers\\Container', [new TypeRef('App\\GenericInterface\\Models\\Plastic')], ); @@ -26,11 +26,11 @@ $box = new $boxFqn(new \App\GenericInterface\Models\Plastic('red')); -Assert::assertInstanceOf($ifaceFqn, $box); +Assert::assertInstanceOf($interfaceFqn, $box); Assert::assertSame('red', $box->get()->color); // Reflection: the interface's get() return type must be the // concrete substituted class, not the unspecialized `T`. -$returnType = (new \ReflectionMethod($ifaceFqn, 'get'))->getReturnType(); +$returnType = (new \ReflectionMethod($interfaceFqn, 'get'))->getReturnType(); Assert::assertInstanceOf(\ReflectionNamedType::class, $returnType); Assert::assertSame('App\\GenericInterface\\Models\\Plastic', $returnType->getName()); diff --git a/test/fixture/compile/generic_invariant_constructor/source/Apple.xphp b/test/fixture/compile/generic_invariant_constructor/source/Apple.xphp new file mode 100644 index 0000000..e7c3f9f --- /dev/null +++ b/test/fixture/compile/generic_invariant_constructor/source/Apple.xphp @@ -0,0 +1,9 @@ + +{ + public function __construct(public T $item) + { + } +} diff --git a/test/fixture/compile/generic_invariant_constructor/source/Use.xphp b/test/fixture/compile/generic_invariant_constructor/source/Use.xphp new file mode 100644 index 0000000..725a84d --- /dev/null +++ b/test/fixture/compile/generic_invariant_constructor/source/Use.xphp @@ -0,0 +1,7 @@ +(new Apple()); diff --git a/test/fixture/compile/generic_method_through_inheritance/source/Base.xphp b/test/fixture/compile/generic_method_through_inheritance/source/Base.xphp new file mode 100644 index 0000000..0466271 --- /dev/null +++ b/test/fixture/compile/generic_method_through_inheritance/source/Base.xphp @@ -0,0 +1,13 @@ + +{ + public function identity(U $x): U + { + return $x; + } +} diff --git a/test/fixture/compile/generic_method_through_inheritance/source/Derived.xphp b/test/fixture/compile/generic_method_through_inheritance/source/Derived.xphp new file mode 100644 index 0000000..d27da57 --- /dev/null +++ b/test/fixture/compile/generic_method_through_inheritance/source/Derived.xphp @@ -0,0 +1,9 @@ + extends Base +{ +} diff --git a/test/fixture/compile/generic_method_through_inheritance/source/Use.xphp b/test/fixture/compile/generic_method_through_inheritance/source/Use.xphp new file mode 100644 index 0000000..148c427 --- /dev/null +++ b/test/fixture/compile/generic_method_through_inheritance/source/Use.xphp @@ -0,0 +1,9 @@ +(); +$s = $d->identity::('hi'); +$n = $d->identity::(7); diff --git a/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php b/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php new file mode 100644 index 0000000..a275936 --- /dev/null +++ b/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php @@ -0,0 +1,20 @@ +`) declared on the base class `Base` + * resolves and runs when called via turbofish on a `Derived` subclass + * receiver. The specialization is emitted onto the declaring base and + * inherited through the class-level `extends` edge. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame('hi', $s); +Assert::assertSame(7, $n); diff --git a/test/fixture/compile/generic_mixed_variance_constructor/source/Apple.xphp b/test/fixture/compile/generic_mixed_variance_constructor/source/Apple.xphp new file mode 100644 index 0000000..a071849 --- /dev/null +++ b/test/fixture/compile/generic_mixed_variance_constructor/source/Apple.xphp @@ -0,0 +1,9 @@ +`: the covariant `A` and the invariant `B` constructor params both +// keep their concrete substituted types; the scalar `int $tag` is untouched. +class Pair<+A, B> +{ + private array $slots; + + public function __construct(A $a, B $b, int $tag) + { + $this->slots = [$a, $b, $tag]; + } + + public function first(): A + { + return $this->slots[0]; + } +} diff --git a/test/fixture/compile/generic_mixed_variance_constructor/source/Use.xphp b/test/fixture/compile/generic_mixed_variance_constructor/source/Use.xphp new file mode 100644 index 0000000..c795802 --- /dev/null +++ b/test/fixture/compile/generic_mixed_variance_constructor/source/Use.xphp @@ -0,0 +1,7 @@ +(new Apple(), new Apple(), 5); diff --git a/test/fixture/compile/generic_static_method_through_inheritance/source/Base.xphp b/test/fixture/compile/generic_static_method_through_inheritance/source/Base.xphp new file mode 100644 index 0000000..2328a6e --- /dev/null +++ b/test/fixture/compile/generic_static_method_through_inheritance/source/Base.xphp @@ -0,0 +1,13 @@ +(U $x): U + { + return $x; + } +} diff --git a/test/fixture/compile/generic_static_method_through_inheritance/source/Derived.xphp b/test/fixture/compile/generic_static_method_through_inheritance/source/Derived.xphp new file mode 100644 index 0000000..f365885 --- /dev/null +++ b/test/fixture/compile/generic_static_method_through_inheritance/source/Derived.xphp @@ -0,0 +1,9 @@ +('hi'); +$n = Derived::make::(7); diff --git a/test/fixture/compile/generic_static_method_through_inheritance/verify/runtime.php b/test/fixture/compile/generic_static_method_through_inheritance/verify/runtime.php new file mode 100644 index 0000000..6c71aee --- /dev/null +++ b/test/fixture/compile/generic_static_method_through_inheritance/verify/runtime.php @@ -0,0 +1,21 @@ +`) declared on `Base` resolves and runs when + * called as `Derived::make::<...>()` on a subclass. The specialization is emitted + * onto Base and reached through PHP's static-method inheritance. + * + * Driver contract: `$fixture` (CompiledFixture) in scope. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Base.php'; +require $fixture->targetDir . '/Derived.php'; +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame('hi', $s); +Assert::assertSame(7, $n); diff --git a/test/fixture/compile/generic_two_covariant_constructor/source/Apple.xphp b/test/fixture/compile/generic_two_covariant_constructor/source/Apple.xphp new file mode 100644 index 0000000..6793f22 --- /dev/null +++ b/test/fixture/compile/generic_two_covariant_constructor/source/Apple.xphp @@ -0,0 +1,9 @@ + +{ + private array $slots; + + public function __construct(A $a, B $b) + { + $this->slots = [$a, $b]; + } + + public function getA(): A + { + return $this->slots[0]; + } + + public function getB(): B + { + return $this->slots[1]; + } +} diff --git a/test/fixture/compile/generic_two_covariant_constructor/source/Use.xphp b/test/fixture/compile/generic_two_covariant_constructor/source/Use.xphp new file mode 100644 index 0000000..d2ee521 --- /dev/null +++ b/test/fixture/compile/generic_two_covariant_constructor/source/Use.xphp @@ -0,0 +1,7 @@ +(new Apple(), new Apple()); diff --git a/test/fixture/compile/nested_instantiation/source/Containers/Lst.xphp b/test/fixture/compile/nested_instantiation/source/Containers/Collection.xphp similarity index 93% rename from test/fixture/compile/nested_instantiation/source/Containers/Lst.xphp rename to test/fixture/compile/nested_instantiation/source/Containers/Collection.xphp index 8d350db..0790c20 100644 --- a/test/fixture/compile/nested_instantiation/source/Containers/Lst.xphp +++ b/test/fixture/compile/nested_instantiation/source/Containers/Collection.xphp @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\NestedInstantiation\Containers; -class Lst +class Collection { /** @var array */ public array $items = []; diff --git a/test/fixture/compile/nested_instantiation/source/Use.xphp b/test/fixture/compile/nested_instantiation/source/Use.xphp index 9659557..ffbfacf 100644 --- a/test/fixture/compile/nested_instantiation/source/Use.xphp +++ b/test/fixture/compile/nested_instantiation/source/Use.xphp @@ -5,12 +5,12 @@ declare(strict_types=1); namespace App\NestedInstantiation; use App\NestedInstantiation\Containers\Box; -use App\NestedInstantiation\Containers\Lst; +use App\NestedInstantiation\Containers\Collection; use App\NestedInstantiation\Models\Plastic; -$boxOfList = new Box::>(); +$boxOfList = new Box::>(); -$inner = new Lst::(); +$inner = new Collection::(); $inner->push(new Plastic('red')); $boxOfList->set($inner); diff --git a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Collection_Plastic.expected.php b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Collection_Plastic.expected.php new file mode 100644 index 0000000..76bf633 --- /dev/null +++ b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Collection_Plastic.expected.php @@ -0,0 +1,17 @@ +item = $val; + } + public function get(): \XPHP\Generated\App\NestedInstantiation\Containers\Collection\T_72cd9917ee1636a336e65540a284a68071f850278ebd78f2f765b25f9e969418 + { + return $this->item; + } +} \ No newline at end of file diff --git a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Lst_Plastic.expected.php b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Lst_Plastic.expected.php deleted file mode 100644 index dee8ad6..0000000 --- a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Lst_Plastic.expected.php +++ /dev/null @@ -1,17 +0,0 @@ -item = $val; - } - public function get(): \XPHP\Generated\App\NestedInstantiation\Containers\Lst\T_72cd9917ee1636a336e65540a284a68071f850278ebd78f2f765b25f9e969418 - { - return $this->item; - } -} diff --git a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Lst_Plastic.expected.php b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Collection_Plastic.expected.php similarity index 73% rename from test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Lst_Plastic.expected.php rename to test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Collection_Plastic.expected.php index 563165b..0229880 100644 --- a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Lst_Plastic.expected.php +++ b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Collection_Plastic.expected.php @@ -1,9 +1,9 @@ */ public array $items = []; @@ -15,4 +15,4 @@ public function first(): \App\NestedInstantiation\Models\Plastic { return $this->items[0]; } -} +} \ No newline at end of file diff --git a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Use.expected.php b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Use.expected.php index c6cff17..c89aee1 100644 --- a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Use.expected.php +++ b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Use.expected.php @@ -4,9 +4,9 @@ namespace App\NestedInstantiation; use App\NestedInstantiation\Containers\Box; -use App\NestedInstantiation\Containers\Lst; +use App\NestedInstantiation\Containers\Collection; use App\NestedInstantiation\Models\Plastic; -$boxOfList = new \XPHP\Generated\App\NestedInstantiation\Containers\Box\T_97c6bd3a59f7a82e44580814227a9520bc2d6551991c8263366d2d9c44f7627f(); -$inner = new \XPHP\Generated\App\NestedInstantiation\Containers\Lst\T_72cd9917ee1636a336e65540a284a68071f850278ebd78f2f765b25f9e969418(); +$boxOfList = new \XPHP\Generated\App\NestedInstantiation\Containers\Box\T_b161c2856b3c6debba523ecb74e8cefe279a8707898b647ece965cdeb420deb2(); +$inner = new \XPHP\Generated\App\NestedInstantiation\Containers\Collection\T_72cd9917ee1636a336e65540a284a68071f850278ebd78f2f765b25f9e969418(); $inner->push(new Plastic('red')); -$boxOfList->set($inner); +$boxOfList->set($inner); \ No newline at end of file diff --git a/test/fixture/compile/variance_edge_preserves_source_parent/source/Banana.xphp b/test/fixture/compile/variance_edge_preserves_source_parent/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/variance_edge_preserves_source_parent/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool + { + return $value instanceof Fruit; + } +} diff --git a/test/fixture/compile/variance_edge_preserves_source_parent/source/Fruit.xphp b/test/fixture/compile/variance_edge_preserves_source_parent/source/Fruit.xphp new file mode 100644 index 0000000..0db3331 --- /dev/null +++ b/test/fixture/compile/variance_edge_preserves_source_parent/source/Fruit.xphp @@ -0,0 +1,9 @@ + extends Base +{ +} diff --git a/test/fixture/compile/variance_edge_preserves_source_parent/source/Use.xphp b/test/fixture/compile/variance_edge_preserves_source_parent/source/Use.xphp new file mode 100644 index 0000000..00aa10b --- /dev/null +++ b/test/fixture/compile/variance_edge_preserves_source_parent/source/Use.xphp @@ -0,0 +1,17 @@ + extends Base`. Because `ListColl <: ListColl` (covariant E, +// Banana extends Fruit), the variance edge emitter would, if it overwrote the source `extends`, +// replace `ListColl extends Base` with `extends ListColl` — severing the +// inherited `contains_` member (its body lives on Base). The direct call below +// would then fatal "undefined method". The source parent must be preserved. +$fruits = new ListColl::(); +$bananas = new ListColl::(); + +$fruitHit = $fruits->contains::(new Fruit()); +$bananaHit = $bananas->contains::(new Banana()); diff --git a/test/fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php b/test/fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php new file mode 100644 index 0000000..2a36e9f --- /dev/null +++ b/test/fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php @@ -0,0 +1,25 @@ + extends Base` is instantiated at two covariant args (Fruit, Banana). The variance + * edge emitter must NOT overwrite each specialization's source `extends Base` with the same-template + * covariant super (`ListColl extends ListColl`): single inheritance allows one parent, + * and the source parent carries the inherited `contains_` member. Overwriting would drop + * `ListColl`'s path to `contains_` and fatal "undefined method" on the call below. + * + * That both calls resolve and run proves each specialization kept its source parent and its inherited + * erased member. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($fruitHit, 'ListColl must inherit contains_ from its source parent Base'); +Assert::assertTrue($bananaHit, 'ListColl must keep its source parent (not be overwritten) so contains_ resolves'); diff --git a/test/smoke/check.sh b/test/smoke/check.sh new file mode 100755 index 0000000..5ed6d36 --- /dev/null +++ b/test/smoke/check.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env sh +# +# End-to-end self-test of the `xphp check` gate. +# +# Runs the REAL binary (not the in-process CommandTester) against the check +# fixtures and asserts the 0/1/2 exit contract plus that every renderer emits: +# 0 = clean 1 = at least one error 2 = operational failure +# +# Parameterized by the binary under test so the same assertions cover both the +# dev entrypoint and the released PHAR: +# XPHP_BIN="php bin/xphp" (default, CI self-test) +# XPHP_BIN="php dist/xphp.phar" (release.yml, post-build) +set -eu + +XPHP="${XPHP_BIN:-php bin/xphp}" +FIX="test/fixture/check" + +# check_exit +# Captures stdout+stderr into the global OUT; fails loudly on a code mismatch. +check_exit() { + set +e + # --no-phpstan keeps this a deterministic check of the binary's exit/render + # contract: it must not depend on a phpstan install (the PHAR bundles none) or + # on a consumer config. The PHPStan pass is covered by `make test/phpstan`. + OUT=$($XPHP check "$2" --format="$3" --no-phpstan 2>&1) + code=$? + set -e + if [ "$code" != "$1" ]; then + echo "FAIL: '$XPHP check $2 --format=$3' expected exit $1, got $code" + echo "$OUT" + exit 1 + fi +} + +# Assert the captured OUT is well-formed JSON (php is always present; avoids a jq dep). +valid_json() { + printf '%s' "$OUT" | php -r 'json_decode(stream_get_contents(STDIN), false, 512, JSON_THROW_ON_ERROR);' \ + || { echo "FAIL: output was not valid JSON"; echo "$OUT"; exit 1; } +} + +# clean sources → exit 0, and all three renderers emit without error. +check_exit 0 "$FIX/clean/source" text +check_exit 0 "$FIX/clean/source" github +check_exit 0 "$FIX/clean/source" json; valid_json +echo "[ok] clean passes (exit 0) in text/json/github" + +# known-bad sources → exit 1, a GitHub annotation is emitted, json stays well-formed. +check_exit 1 "$FIX/multi_error/source" github +printf '%s' "$OUT" | grep -q '^::error file=' || { echo "FAIL: no ::error annotation"; echo "$OUT"; exit 1; } +check_exit 1 "$FIX/multi_error/source" json; valid_json +echo "[ok] multi_error fails (exit 1) with ::error annotation + valid json" + +# operational failure: a missing source dir is exit 2, distinct from a found-errors exit 1. +check_exit 2 "$FIX/does-not-exist" text +echo "[ok] missing source dir is operational failure (exit 2)" + +echo "check smoke: PASS ($XPHP)"