diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 00000000..046cef6b --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,42 @@ +#!/bin/sh +# Commit-message lint: the SAME tool CI runs — commitlint over +# commitlint.config.mjs — executed through the docker compose `node` service, +# so contributors need docker but no host Node toolchain. +# +# Installed by `composer install` via `git config core.hooksPath .githooks`. +# The message is piped on stdin (comment lines stripped, as git would on +# commit), so no path mapping into the container is needed and the hook works +# from worktrees too. node_modules is (re)installed from the lockfile whenever +# it's missing or stale, so the local commitlint always matches the pinned one. + +msg_file="$1" + +if ! command -v docker > /dev/null 2>&1; then + echo "commit-msg: docker is required to lint the commit message (compose service 'node')." >&2 + echo " rules: commitlint.config.mjs (Conventional Commits) — CI enforces the same check." >&2 + exit 1 +fi + +# `command -v docker` isn't enough: this hook runs `docker compose run`, which +# needs the Compose v2 plugin. Without it, Docker prints its own cryptic +# "'compose' is not a docker command" — surface a useful message instead. +if ! docker compose version > /dev/null 2>&1; then + echo "commit-msg: the Docker Compose v2 plugin is required ('docker compose' is unavailable)." >&2 + echo " rules: commitlint.config.mjs (Conventional Commits) — CI enforces the same check." >&2 + exit 1 +fi + +top=$(git rev-parse --show-toplevel) + +# Reinstall when node_modules is absent OR the lockfile is newer than the +# installed binary — a version bump a collaborator pulls in updates +# package-lock.json but leaves node_modules untouched, and a stale local +# commitlint would silently disagree with CI. `npm ci` is otherwise skipped so +# the common case (already installed) stays fast. +sed -e 's/\r$//' -e '/^#/d' "$msg_file" \ + | docker compose --project-directory "$top" run --rm -T --no-deps node sh -c ' + if [ ! -x node_modules/.bin/commitlint ] || [ package-lock.json -nt node_modules/.bin/commitlint ]; then + npm ci --no-audit --no-fund --loglevel=error + fi + exec node_modules/.bin/commitlint + ' diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 18dc601f..53090b7e 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -21,6 +21,27 @@ env: SHARD_TOTAL: 10 jobs: + # Detect whether this PR/push touches code that mutation testing covers. + # Infection is expensive, so the `infection` job below runs only when src/ + # or test/ changed — a docs-only or config-only change skips it. + changes: + name: Detect code changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + code: + - 'src/**' + - 'test/**' + phpunit: # 8.4 is the supported/default runtime and runs the full suite minus the # `php85` group; a dedicated 8.5 container runs only that group, which @@ -133,7 +154,10 @@ jobs: mutation-coverage: name: Mutation coverage (generate once) runs-on: ubuntu-latest - needs: phpunit + # Only after unit tests pass, and only when src/ or test/ changed + # (see the `changes` job) — mutation is skipped for docs/config-only work. + needs: [phpunit, changes] + if: needs.changes.outputs.code == 'true' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 00000000..f20e1960 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,38 @@ +name: Commitlint + +# Gate every PR on Conventional Commit messages. Same tool, same version, same +# config as the local .githooks/commit-msg hook: commitlint pinned by +# package-lock.json, rules in commitlint.config.mjs. + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + commitlint: + name: Conventional commit messages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # commitlint needs the full PR range, not a shallow tip. + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + # Keep in step with the compose `node` service image. + node-version: 24 + cache: npm + - name: Install commitlint (lockfile-pinned) + run: npm ci --no-audit --no-fund + - name: Lint PR commit messages + run: > + npx --no-install commitlint + --from ${{ github.event.pull_request.base.sha }} + --to ${{ github.event.pull_request.head.sha }} + --verbose diff --git a/.gitignore b/.gitignore index 8ad0cf23..258caed6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ /core/.xphp-cache/ docker-compose.override.yml .phpunit.result.cache +node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b5ec8495..b596807e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,75 @@ 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.4.0] + +### Added + +- **Type aliases.** Give a type a reusable name, in two forms: + `type Name = Body;` (generic) and `type Name = Body;` (non-generic). An + alias is a compile-time substitution — expanded into its body before + specialization, with no runtime existence, so the emitted PHP never mentions the + alias. Bodies may be a single + (possibly-generic) head, a **union** (`int|string`), or a **nullable** (`?Box`): + a single head expands in every type position (incl. as a generic argument, + `Bag`), while a union/nullable expands as the whole type of a parameter, + property, return, or class-constant slot. Aliases compose (nested and + concrete-instantiation, `type UserMap = Pair`); parameters carry + **defaults** (`type P` — a use may omit trailing defaulted arguments) + and **bounds** (`type B` — an argument that violates the bound is a + compile error; the bound may itself name an alias), like a generic class. An alias + is **file-local** — visible only in the file that declares it, like a `use` alias. + A cyclic (`xphp.alias_cycle`), arity-mismatched (`xphp.alias_arity`), + class-colliding (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), + unsupported-body (`xphp.alias_unsupported_body` — intersection / DNF / closure), + compound-in-non-slot (`xphp.alias_compound_in_non_slot`), or bound-violating + (`xphp.bound_violation`) alias is a loud error in both `xphp compile` and + `xphp check`. See [type aliases](docs/syntax/type-aliases.md). +- **Type-argument inference (optional turbofish).** A generic call or `new` whose + type parameters are determined by the argument values no longer needs the `::<>` + turbofish: `identity(5)` infers `identity::`, `new Box($product)` infers + `new Box::`, `$factory->make($p)` and `$box->put($this->item)` infer + from the argument's type. Works for free functions, static and instance methods, + and class instantiation. An inferred call compiles to exactly the specialization + the turbofish would have selected — the type arguments are unified from the + arguments' static types and dispatched through the identical path, so bounds, + variance, mangling, and check/compile parity are unchanged. Argument types are + read conservatively (literals, `new`, `$this` properties, and non-reassigned + typed parameters); where they don't determine the type — a type parameter only in + the return type, an unknown argument type, or conflicting arguments — the explicit + turbofish is still required and omitting it remains the same `xphp.missing_type_argument` + error. Generic closure calls (`$f($x)`) and `T[]`-typed parameters are not yet + inference sources. See [turbofish → inference](docs/syntax/turbofish.md#type-argument-inference). +- **Method-generic turbofish grounded by an enclosing type parameter.** A turbofish + whose type argument is supplied by the enclosing generic scope now grounds **per + specialization** and runs, instead of being rejected by the emitted-marker backstop: + a named free-function forward (`identity::($v)` inside `wrap`), a static call + (`self::gen::()`, `Maker::wrap::()` inside `Box` — the idiomatic "delegate + to a shared static generic helper" shape), and an instance call (`$this->dup::()`, + `$m->dup::()` on a non-generic receiver, including a target declared on a generic + base class). Freshly specialized bodies are re-grounded transitively, so multi-hop + forwards and mutually recursive generics converge; a member grounded onto the + program's classes is deduplicated per unique argument tuple, and an instantiation + that first appears inside a grounded body is discovered like any other. A bound that + only becomes provable after specialization (`gen` receiving the + class's `T`) is checked per instantiation, and a strictly-growing forward chain + (`grow` calling `grow::>`) is rejected as non-convergent + (`xphp.unconverged_method_specialization`) instead of specializing forever. See + [turbofish](docs/syntax/turbofish.md) and the + [remaining caveats](docs/caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter) + (closure turbofish in generic function bodies, cross-template targets, and the + late-bound `static::`/`parent::` spellings still fail loudly). + +### Fixed + +- **`xphp check` / `xphp compile` parity on enclosing-parameter turbofish.** `check` + previously reported nothing for the shapes only the compile-time emitted-marker + backstop rejected — a pipeline gating on `check` alone saw green on code `compile` + refused. The validate-only pass now grounds each specialization the same way + `compile` does and collects the same diagnostics (a violated grounded bound, a + non-convergent chain, a surviving marker), located at the template's real source + line. + ## [0.3.0] ### Added @@ -593,6 +662,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). +[0.4.0]: https://github.com/xphp-lang/xphp/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/xphp-lang/xphp/compare/v0.2.1...v0.3.0 [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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5ced8be..53c851ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,30 @@ # Contributing +## Commit messages + +Commits follow [Conventional Commits](https://www.conventionalcommits.org): + +``` +type(scope): lowercase subject +``` + +Types: `build` `chore` `ci` `docs` `feat` `fix` `perf` `refactor` `revert` +`style` `test`. The scope is the component the change lives in +(`monomorphize`, `specializer`, `parser`, `cli`, ...); bare `type:` is fine +for cross-cutting changes. Examples from the history: + +``` +feat(monomorphize): ground enclosing-param method turbofish per class specialization +fix(cli): locate Composer autoloader when installed as a dependency +docs: refresh docs/ tree with syntax tour, caveats, errors +``` + +CI rejects a PR whose commits don't conform: `commitlint` (pinned by +`package-lock.json`) over `commitlint.config.mjs`. Locally, `composer +install` wires up a `commit-msg` hook (`.githooks/`) that runs the **same +tool** through the docker compose `node` service at commit time — docker is +required, a host Node toolchain is not. + ## Test ```bash diff --git a/README.md b/README.md index e778b135..4dfc9323 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,10 @@ genuinely [hard work](https://thephp.foundation/blog/2024/08/19/state-of-generic The object model that's served the ecosystem for two decades doesn't bend easily. -Supporting generics proves that the compile-to-vanilla model handles non-trivial -type-system additions. The remaining features are on +Supporting generics — and now type aliases — proves that the compile-to-vanilla +model handles non-trivial type-system additions. Further features are on the [roadmap](docs/roadmap.md): -type aliases, literal types, mapped and conditional types to name a few. +literal types, mapped and conditional types to name a few. ## Quick start diff --git a/commitlint.config.mjs b/commitlint.config.mjs new file mode 100644 index 00000000..290fa12e --- /dev/null +++ b/commitlint.config.mjs @@ -0,0 +1,15 @@ +// Commit-message rules: Conventional Commits (https://www.conventionalcommits.org). +// One rule set, one tool, two entry points: +// - locally: .githooks/commit-msg runs commitlint through the docker compose +// `node` service (installed by `composer install`) +// - in CI: .github/workflows/commitlint.yml runs the same lockfile-pinned +// commitlint over every PR commit +// +// The stock preset already matches this repo's history: +// type(scope): lowercase subject +// with types build/chore/ci/docs/feat/fix/perf/refactor/revert/style/test, an +// optional free-form scope (monomorphize, specializer, parser, cli, ...), a +// 100-char header cap, and merge/revert/fixup subjects ignored. +export default { + extends: ['@commitlint/config-conventional'], +}; diff --git a/composer.json b/composer.json index 553e08ae..f629e199 100644 --- a/composer.json +++ b/composer.json @@ -51,5 +51,13 @@ "allow-plugins": { "infection/extension-installer": true } + }, + "scripts": { + "post-install-cmd": "@git:hooks", + "post-update-cmd": "@git:hooks", + "git:hooks": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath .githooks || true" + }, + "scripts-descriptions": { + "git:hooks": "Point git at the tracked .githooks/ directory (commit-msg lint); no-op outside a git checkout." } } diff --git a/docker-compose.yml b/docker-compose.yml index 31851af5..36a29892 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,5 +22,20 @@ services: service: php entrypoint: [ "/opt/app/bin/xphp" ] + # Node runtime for commit-message linting: the .githooks/commit-msg hook runs + # commitlint through this service, so contributors need docker but no host + # Node toolchain — and local linting uses the exact tool CI runs. One-shot: + # docker compose run --rm node npx commitlint --help + # Runs commitlint for the commit-msg hook and CI. Behind a profile so a bare + # `docker compose up` doesn't start it; the hook targets it explicitly with + # `docker compose run node`, which starts a profiled service on demand and + # supplies its own command (no keepalive needed). + node: + image: node:24-alpine + working_dir: /opt/app + volumes: + - ./:/opt/app + profiles: [ "commitlint" ] + volumes: composer_cache: ~ diff --git a/docs/adr/0023-type-alias-declaration-syntax.md b/docs/adr/0023-type-alias-declaration-syntax.md new file mode 100644 index 00000000..66479adc --- /dev/null +++ b/docs/adr/0023-type-alias-declaration-syntax.md @@ -0,0 +1,115 @@ +# 23. Type-alias syntax is the declaration form `type Name<…> = Body` + +- Status: Accepted — 2026-07 + +## Context and Problem Statement + +xphp adds type aliases — a name for a type, expanded at compile time (see +[type aliases](../syntax/type-aliases.md)). A first-class goal is that an alias may be +**generic** (`type Pair = Map>`), not only a name for a fixed type. + +PHP itself has a live but unsettled proposal, [PHP RFC: Type +Aliases](https://wiki.php.net/rfc/typed-aliases), which uses an *import* form +(`use type int|float as Number;`) and explicitly lists parameterized (generic) aliases +under "Future Scope" — so there is no PHP-blessed syntax for the generic case xphp needs. +xphp must therefore choose a surface, ideally one that stays forward-compatible with where +PHP is most likely to land. + +## Decision Drivers + +- **Must express generic aliases**, since that is a primary goal. +- Forward-compatibility with a plausible future PHP syntax. +- Fit xphp's existing angle-bracket surface (`Foo`, the `::<>` turbofish). +- Correctness first: no silent miscompile; an alias must lower to exactly what its body + would have. + +## Considered Options + +- **A — declaration form `type Name<…> = Body;`** (with the non-generic case being the + zero-parameter `type Name = Body;`). The form used by TypeScript, Rust, Scala, and — most + relevantly — **Hack**, PHP's closest relative. +- **B — import form `use type Body as Name;`** (PHP's current RFC). +- **C — a distinct keyword** (`typedef` / `typealias`). +- **D — a runtime, autoloadable alias symbol** (an alias that exists at runtime and via + reflection), rather than a pure compile-time substitution. + +## Decision Outcome + +Chosen: **A — the declaration form `type Name<…> = Body`, resolved as a compile-time +substitution.** + +The import form (B) is eliminated by the generic requirement: `use type Body as Name` has +no place to put parameters on `Name` (`use type Map> as Pair` is +ambiguous), which is almost certainly why PHP deferred generic aliases. The declaration +form is the *only* one of the two that expresses both cases with a single rule, and it is +what every language that supports generic aliases uses. Hack — the closest precedent to +xphp's situation — spells it exactly `type Name = …;`. It also fits xphp's own +angle-bracket surface. A distinct keyword (C) buys nothing over `type` and is further from +that precedent. + +Aliases are a **compile-time substitution** with no runtime existence (not option D). The +long-standing blocker for PHP here — how to autoload/define a runtime alias symbol — simply +does not arise for xphp: it is a whole-program, build-time transpiler +([ADR-0002](0002-build-time-transpiler.md)), so an alias is expanded before specialization +and needs no runtime identity. + +### Consequences + +- Good: one grammar covers generic and non-generic aliases; it matches the cross-language + and Hack consensus and xphp's existing syntax; expansion reuses the monomorphizer with no + new emission path or runtime cost. +- Trade-off: for the *generic* case xphp defines surface ahead of PHP (which deferred it), + a bet on the declaration-form consensus. The non-generic import form (`use type … as`) + could be added later as a parity synonym without disturbing this decision. +- Trade-off: the delivered scope is single-head / union / nullable bodies, **file-local** (an + alias is scoped to its file like a `use` alias, by design — see option D below); + intersection / DNF / closure bodies and compound-in-non-slot positions are still + rejected (see the [caveat](../caveats.md#type-alias-body-and-position-limits)) — a safe + subset, with the richer bodies as later work. + +### Confirmation + +The scanner recognizes `type Name[<…>] = SingleHead;` and strips it; expansion is exercised +end to end by `test/fixture/compile/type_aliases/` (a runtime fixture that executes the +compiled output and asserts no alias name survives) and the `TypeAliasIntegrationTest` +cases. Every rejection carries a stable code (`xphp.alias_cycle`, `xphp.alias_arity`, +`xphp.alias_class_collision`, `xphp.alias_duplicate`, `xphp.alias_unsupported_body`) and is +verified in both `compile` and `check`. + +## Pros and Cons of the Options + +### A — declaration form `type Name<…> = Body` + +- Good: expresses generic and non-generic aliases with one rule; matches Hack + TS + Rust + + Scala; fits xphp's angle-bracket surface. +- Bad: leads PHP for the generic case (PHP has only the import form, and only for + non-generic aliases so far). + +### B — import form `use type Body as Name` + +- Good: matches PHP's current RFC for the non-generic case; forward-compatible there. +- Bad: cannot carry type parameters, so it cannot express generic aliases — the primary + goal. + +### C — distinct keyword (`typedef` / `typealias`) + +- Good: unambiguous keyword. +- Bad: no advantage over `type`; further from the Hack precedent and the cross-language norm. + +### D — runtime / autoloadable alias symbol + +- Good: reflection and cross-file use "for free". +- Bad: imports PHP's unsolved autoloading/definition problem for no benefit — xphp expands + aliases at build time and needs no runtime symbol. + +## More Information + +- [Type aliases](../syntax/type-aliases.md) and the + [file-local / single-head caveat](../caveats.md#type-alias-body-and-position-limits). +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — monomorphization; + [ADR-0002](0002-build-time-transpiler.md) — build-time transpiler (why a runtime alias + symbol is unnecessary). +- [PHP RFC: Type Aliases](https://wiki.php.net/rfc/typed-aliases) (import form; generic + aliases in Future Scope); [PHP RFC: Bound-erased generic + types](https://wiki.php.net/rfc/bound_erased_generic_types) (the `Foo` surface xphp + tracks). Hack spells the declaration form `type Name = …;` (and `newtype`). diff --git a/docs/adr/README.md b/docs/adr/README.md index c0d5bcdb..b4216ff8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -40,3 +40,4 @@ should be added here as a new numbered file; copy | [0020](0020-diagnose-and-restructure-self-reintroducing-specialization.md) | Diagnose and restructure self-reintroducing specialization (erased seam deferred) | Accepted | | [0021](0021-compile-runs-the-check-gate-by-default.md) | `xphp compile` runs the check gate by default | Accepted | | [0022](0022-bounds-are-upper-only.md) | Bounds are upper-only (no supertype/lower bounds) | Accepted | +| [0023](0023-type-alias-declaration-syntax.md) | Type-alias syntax is the declaration form `type Name<…> = Body` | Accepted | diff --git a/docs/caveats.md b/docs/caveats.md index e3cd7b41..b44268a6 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -7,6 +7,157 @@ each with the underlying reason and the workaround. Pages in the [syntax tour](syntax/) link back to specific sections here using anchor links — search this page for the same heading text. +## Type-argument inference is partial + +xphp infers a generic call's or `new`'s type arguments from the values you +pass, so the `::<>` turbofish is optional where the arguments determine the +type. But inference reads argument types conservatively — deliberately, so it +never emits a specialization the runtime value can't match — and where it +can't see a concrete type, you still write the turbofish. + +### ❌ What isn't inferred + +```php +function first(): T { /* ... */ } // T is only in the return type +$x = first(); // ✗ nothing to infer from — needs first::() + +function pair(T $a, T $b): array { /* ... */ } +$p = pair(1, 'x'); // ✗ int vs string disagree — needs pair::<...>() + +$val = $repo->find(); // a scalar-returning call assigned to a local +$b = new Box($val); // ✗ local-from-call isn't tracked — needs new Box::() + +function f(Fruit $x): void { + $x = pickAnother(); // $x reassigned... + $b = new Box($x); // ✗ reassigned param isn't trusted — needs the turbofish +} + +$g = function(T $x): T { return $x; }; +$g(5); // ✗ generic *closure* calls aren't inferred (deferred) +``` + +### ✅ What is inferred + +```php +identity(5); // ✓ T = int, from the literal +wrap(new Plastic()); // ✓ T = Plastic, from the `new` +Factory::make($p); // ✓ from $p's declared (class) type +$box->put($this->item); // ✓ from the declared property type +wrap($factory->make()); // ✓ from make()'s class return type (call path) +new Box(5); // ✓ T = int +new Pair($a, new Plastic()); // ✓ from a typed parameter + a `new` +``` + +Inference sources differ slightly between the two paths, because a **call** +reuses the monomorphizer's receiver/flow tracking while **`new`** runs a +lighter standalone pass: + +- **Calls** infer from: literals, `new X(...)`, `$this->prop` (declared type), + a plain parameter, a local whose type is statically tracked (assigned from a + `new` or a class-returning call), and a call whose declared return type is a + determinable class. +- **`new`** infers from the conservative set only: literals, `new X(...)`, + `$this->prop`, and a non-reassigned typed parameter — not locals or call + returns. + +In both, a reassigned parameter, a *scalar*-returning-call value held in a +local, a union-typed value, or a value typed by a still-abstract type parameter +yields no inference. + +One conservative edge: inference is skipped when an argument's *simple* type +name coincides with an in-scope type parameter — e.g. a class imported as +`use Other\U as U` (or a same-named `U` in the current namespace) passed inside +`f(...)`. The name is treated as the type parameter (which shadows it), so +the call falls back. It never mis-infers — write the explicit turbofish there. + +### Why + +Monomorphization needs the *concrete* type to pick a specialization, and an +inferred call must compile to exactly what the turbofish would have. So +inference only fires when it can prove the concrete type from the argument +itself: it derives the type arguments by unifying each parameter's declared +type against the argument's static type, then dispatches through the identical +path an explicit turbofish uses (same bounds, variance, and mangling). A value +whose type it can't prove statically — or can't prove *soundly*, like a +reassigned parameter — is left alone rather than guessed, because a wrong guess +would emit a specialization the runtime value fails to satisfy. + +### ✅ Workaround + +Write the explicit turbofish (`identity::(5)`, `new Box::($val)`) +wherever inference can't see the type. It's always accepted, and an inferred +call is identical to the turbofished one — so adding a turbofish never changes +behavior, only makes the type explicit. + +## Type-alias body and position limits + +[Type aliases](syntax/type-aliases.md) are a compile-time substitution, and are +**file-local by design** — an alias is visible only in the file that declares it, +like a PHP `use` alias. A single head (`Ident`, `Box`), a union (`int|string`), +and a nullable (`?Box`) body are all supported; parameters may carry defaults and +bounds. Two limits remain, both on the body shape and its position. + +### ❌ What doesn't work + +```php +type Both = A & B; // ✗ xphp.alias_unsupported_body — intersection +type Dnf = (A & B) | C; // ✗ xphp.alias_unsupported_body — DNF +type Fn = Closure(int): int; // ✗ xphp.alias_unsupported_body — closure signature + +// A union / nullable alias is only usable as the WHOLE type of a slot: +type Num = int|string; +function f(Num $n): void {} // ✓ whole param slot +function g(Bag $x): void {} // ✗ xphp.alias_compound_in_non_slot — generic argument +function h(Num&Extra $x): void {} // ✗ nested in another intersection/union +$b = new Num(); // ✗ compound alias in `new` / extends / a bound +``` + +### 🔒 File-local (by design) + +An alias is scoped to its file, like a `use` alias — not visible in another file: + +```php +// File Types.xphp +type UserId = Ident; +type Pair = Dict; +// File Other.xphp — a DIFFERENT file +function f(): UserId { … } // UserId is a plain unknown type here — not expanded +function g(): Pair { … } // ✗ Pair is not visible — an undefined template +``` + +To share a vocabulary, **declare the alias in each file that uses it** (a zero-cost +substitution) or reference the underlying type directly. Because scoping is +per-file there is no cross-file duplicate or collision to detect — two files each +with `type Id = …` are simply independent local aliases. (Same-file duplicate / +class-collision *are* caught — `xphp.alias_duplicate` / `xphp.alias_class_collision`.) + +An alias's body, bounds, and defaults resolve in the namespace that **uses** it. +Under one `namespace {}` per file (the PSR norm) that is always the declaring +namespace; in a file with multiple namespace blocks a bare name can mis-resolve — +keep one namespace per file, or fully-qualify. + +### Why + +The body is limited to a single head, a flat union, or a nullable because those +lower cleanly into a PHP type node. An intersection or DNF pulls in *distribution* +(`(A|B)&C → (A&C)|(B&C)`), and a union/nullable has no single identity to hash or +anchor, so it is representable only as the whole type of a param / property / +return / class-constant slot — anywhere else it is rejected loudly rather than +mis-compiled. These are "make the safe subset solid first" trades, candidates to +lift later. File-locality, by contrast, is a deliberate choice — an alias is a +local naming convenience, like `use`, not a whole-program symbol — not a limit. + +### ✅ Workaround + +- For an intersection / DNF / closure body, write the type directly, or wrap it in + a named class or interface and alias *that*. +- Use a union/nullable alias as the whole type of a slot; write the union directly + where you need it as a generic argument or nested in another compound type. +- Declare an alias in each file that uses it (a zero-cost substitution), or + reference the underlying type directly across files. + +--- + ## `$this`-capturing arrows and closures rejected ### ❌ What doesn't work @@ -762,32 +913,53 @@ so the growing type is never reached through an unbounded chain. ## Generic turbofish grounded by an enclosing type parameter A turbofish whose type argument is supplied by an **enclosing** generic scope — a -function type parameter or a class type parameter — cannot yet be specialized. Every -such shape is rejected with a loud compile error rather than emitted as runtime-fatal -code; the representative cases below are not exhaustive (a named free-function forward -grounded by an enclosing parameter, `return identity::($v)` inside `wrap`, is the -same class of shape and rejected the same way). Each may be lifted in a future version. +function type parameter or a class type parameter — is grounded **per specialization**: +the call is abstract inside the template, and once the enclosing generic specializes +(`wrap::`, `new Box::`) the now-concrete call is dispatched to a real +specialized member or function. The shapes below compile and run: -### ❌ What doesn't work +```php +function identity(U $x): U { return $x; } +function wrap(T $v): T { return identity::($v); } // ✅ named forward +wrap::(3); -A generic **closure** grounded by an enclosing function type parameter: +final class Maker +{ + public static function wrap(X $v): array { return [$v]; } +} -```php -function relay(S $v): S +class Box { - $inner = fn(I $x): I => $x; - return $inner::($v); // ❌ `S` is not concrete here + public function make(T $v): T { return self::gen::($v); } // ✅ own static + public function viaMaker(T $v): array { return Maker::wrap::($v); } // ✅ external static + public function twice(T $v): array { return $this->dup::($v); } // ✅ own instance + public static function gen(U $x): U { return $x; } + public function dup(V $x): array { return [$x, $x]; } } ``` -``` -Generic closure call `$inner::(...)` cannot be specialized: its type argument(s) -are grounded only by an enclosing generic scope and are not concrete here … -``` +Instance calls also ground on a receiver with a **non-generic** declared type +(`$maker->wrap::($v)` for a `Maker $maker` parameter), and both call shapes ground +a target declared on a generic **base** class (`$this->dup::` / `self::gen::` +where the target lives on `Base` — the member lands on the calling class's +specialization). Both `xphp check` and `xphp compile` +agree on every accept and reject below: a bound that only becomes provable after +specialization (`gen` called with the class's `T`) is checked per +instantiation in both modes. + +### ❌ What still doesn't work -A **concrete** inner closure turbofish, but written **inside a generic function body**: +A generic **closure** grounded by an enclosing function type parameter — and a +**concrete** inner closure turbofish written inside a generic function body. Closure +dispatch is not re-entered per specialization: ```php +function relay(S $v): S +{ + $inner = fn(I $x): I => $x; + return $inner::($v); // ❌ xphp.unspecialized_generic_closure +} + function outer(T $seed): int { $f = fn(U $x): U => $x; @@ -795,55 +967,47 @@ function outer(T $seed): int } ``` -A **method/static turbofish grounded by an enclosing class type parameter**: +A target declared on a **different generic template** — its specialized member belongs +on that template's own specializations, which the grounding pass must not touch: ```php -class Box +class Other { public static function gen(U $x): U { return $x; } } +class Holder { - public function make(T $v): T { return self::gen::($v); } // ❌ `T` from the class - public static function gen(U $x): U { return $x; } + public function m(T $v): T { return Other::gen::($v); } // ❌ cross-template } ``` -``` -A generic turbofish/closure marker survived specialization into the emitted output … -[xphp.unspecialized_generic_leak] -``` +The late-bound `static::` / `parent::` spellings (resolving them statically could +silently re-route a subclass or parent dispatch — rejecting loudly is the contract), +a forward to a **bare top-level** (namespace-less) generic function from inside a +generic class, and a **method-level** parameter forwarded to any generic method +(`$this->dup::` inside `probe`). A method-level parameter can't be forwarded +because a generic method is specialized before its class, so `W` has no concrete +value where the forward would be grounded — a non-erasable target reports +`xphp.unspecializable_self_call`, an erasable one `xphp.unspecialized_generic_leak`, +but neither is supported. -### Why +A **strictly-growing** forward chain is rejected as non-convergent rather than +compiled forever: -Variable-turbofish and method-turbofish dispatch is **call-site-driven**: a site is -specialized only when its type arguments are concrete *at that site*. When the argument -comes from an enclosing type parameter it is still abstract when the inner site is -visited, so no concrete dispatch can be built; and the concrete-inner case (`outer`) -only fails because the closure sits inside a *generic function* body, which the current -dispatch pass does not re-enter per specialization. Left un-grounded, each would emit -PHP that names a non-existent type-parameter class (`App\I`, `App\U`, or a stripped -`gen()` method) and fatal on first use. Rather than emit that, xphp fails the build: the -closure form is caught at the source seam in both `xphp check` and `xphp compile` -(`xphp.unspecialized_generic_closure`); the two shapes that reach code generation are -caught by a compile-time backstop over the emitted output -(`xphp.unspecialized_generic_leak`). Grounding these shapes so they *run* is tracked -for a later release; today the guarantee is only that they never miscompile silently. - -**`xphp check` catches only the closure form.** The two shapes that surface at code -generation (`outer`, `Box::make`, and the named free-function forward above) are caught -by the emit-time backstop, which `xphp check` does not run — it validates without -emitting. So `check` reports **zero** diagnostics for those, while `compile` rejects -them loudly. A CI pipeline that gates on `xphp compile` (or runs it after `check`) is -fully covered; one that gates on `xphp check` alone will see green on code that -`compile` will reject. This is a completeness gap in `check`, never a runtime-safety -hole: no fatal-able code is ever emitted. +```php +function grow(T $v): int +{ + return grow::>(new Box::($v)); // ❌ xphp.unconverged_method_specialization +} +``` -### ✅ Workaround +Every rejected shape fails **loudly** — with the diagnostic named above or the +`xphp.unspecialized_generic_leak` backstop — in both `check` and `compile`; none is +ever emitted as runtime-fatal PHP. -Call the inner generic with an **explicit concrete** turbofish at a scope where the type -is known, or lift it out of the enclosing generic scope: +### ✅ Workaround (for the still-rejected shapes) + +Call the inner generic with an **explicit concrete** turbofish at a scope where the +type is known, or lift it out of the enclosing generic scope: ```php $inner = fn(I $x): I => $x; echo $inner::(41); // works at file / plain-function scope - -function gen(U $x): U { return $x; } -Box::useGen(gen::(5)); // ground the generic where the type is concrete ``` diff --git a/docs/errors.md b/docs/errors.md index 2e7bd232..4e2d3141 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -37,7 +37,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: |------|---------| | `xphp.bound_violation` | a concrete type argument doesn't satisfy its parameter's bound | | `xphp.default_bound_violation` | a parameter's default doesn't satisfy its own bound | -| `xphp.missing_type_argument` | a required type argument was omitted and has no default — including a **turbofish-less call** to a generic method, function, or closure (`$x->pick('a')` instead of `$x->pick::('a')`), and a **bare `new` of a generic without all-defaults** (`new Box(...)` where `Box` has a required parameter, instead of `new Box::(...)`): the type argument takes no inference, so it must be supplied explicitly | +| `xphp.missing_type_argument` | a required type argument was omitted, has no default, and **could not be inferred from the call/constructor arguments** — e.g. a type parameter used only in the return type, an argument whose static type isn't known, or arguments that disagree. A turbofish-less call (`$x->pick('a')`) or bare `new` (`new Box(...)`) is fine when the arguments determine the type; when they don't, supply an explicit turbofish (`$x->pick::('a')`, `new Box::(...)`). See [turbofish → inference](syntax/turbofish.md#type-argument-inference) | | `xphp.too_many_type_arguments` | more type arguments were supplied than the template declares (e.g. `Box::` for a one-parameter `Box`) | | `xphp.variance_position` | an `out T` / `in T` parameter appears in a position its variance forbids | | `xphp.inner_variance` | variance is violated through another generic's slot (composition) | @@ -47,14 +47,21 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) | | `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) | | `xphp.unspecialized_generic_closure` | a generic closure/arrow is declared but no in-scope `$var::<...>(...)` call grounds its type parameters — the emitted value would keep raw hints naming non-existent classes (`App\T`) and fatal on first invocation, even when only handed away as a callable (which cannot ground it). Call it with a turbofish in the scope that declares it, or remove the `<...>` clause (also flagged when the parameters are never referenced — the clause is dead syntax) | -| `xphp.unspecialized_generic_leak` | a last-resort compile-time safety net: a generic turbofish or closure marker survived specialization into the emitted output, meaning a call site could not be grounded to a concrete type and its type-parameter hints would otherwise reach the generated PHP as references to non-existent classes (a runtime `TypeError`/`Error`). Raised by a small set of enclosing-parameter / generic-function-scope turbofish shapes xphp cannot yet ground — a *concrete* inner closure turbofish inside a generic function body (`$f::` in `outer`), or a method/static turbofish grounded by an enclosing class type parameter (`self::gen::()` in `Box`). Call it with an explicit concrete turbofish, or move it out of the enclosing generic scope. Compile-only; the closure-grounded-by-enclosing-parameter form is caught earlier (in both `check` and `compile`) as `xphp.unspecialized_generic_closure` | +| `xphp.unspecialized_generic_leak` | a last-resort safety net: a generic turbofish or closure marker survived specialization into the emitted output, meaning a call site could not be grounded to a concrete type and its type-parameter hints would otherwise reach the generated PHP as references to non-existent classes (a runtime `TypeError`/`Error`). The common enclosing-parameter shapes ground and run (a named free-function forward `identity::` inside `wrap`; a static/instance method turbofish grounded by the enclosing class parameter, `self::gen::()` / `$this->dup::()` / `Maker::wrap::()` in `Box`); what still reaches this backstop is a *concrete* inner closure turbofish inside a generic function body (`$f::` in `outer`), a target declared on a *different* generic template (`Other::gen::`), the late-bound `static::`/`parent::` spellings, and a forward to a bare top-level generic function from a generic class. Call the site with an explicit concrete turbofish, or move it out of the enclosing generic scope. `compile` throws; `check` collects the same diagnostic from its grounding pass. The closure-grounded-by-enclosing-parameter form is caught earlier (in both modes) as `xphp.unspecialized_generic_closure` | | `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.unspecializable_self_call` | a `$this`-rooted self-call forwards a **method-level** type parameter to a non-erasable generic method (`$this->dup::()` inside `probe`): no specialization ever grounds `W`, so the call is rejected at its precise site. Forwarding the enclosing **class** parameter (`$this->dup::()` inside `Box`) is grounded per specialization and runs, as does forwarding to an *erasable* target (parameter used only as a direct input) | +| `xphp.unconverged_method_specialization` | a generic method/function forward chain mints a strictly deeper type argument every hop (`grow` calling `grow::>`), so specialization can never converge; the chain is cut off at a fixed hop depth. Break the growth by forwarding a concrete turbofish | | `xphp.unschedulable_covariant_upcast` | a value is upcast to a covariant *interface* whose element-consuming method (`contains`) needs a concrete implementation at the supertype argument that can neither be inherited through the covariant chain nor emitted directly onto the upcast source. Direct emission already covers the cases where inheritance can't carry it (the implementing class has another `extends` parent, implements only a parent of the interface, or reorders the clause); the upcast fails only when **no** emittable class body exists (a truly abstract or trait-only method), the method's **return type** names the element parameter (the widened argument would escape through a narrower return), or its parameters are bounded by **different** enclosing parameters (no single member can be derived). Provide a concrete implementation on a class — move a trait body onto the covariant base, or give the method a non-element return type | | `xphp.closure_conformance` | a closure literal returned against a `Closure(...)` type doesn't conform to it — its parameters aren't wide enough, its return isn't narrow enough, its by-reference-ness differs, or its arity is incompatible | | `xphp.parse_error` | the source can't be parsed — either a PHP syntax error after the generic strip pass, or a parse-time xphp rejection (a variance marker on a method/closure, a malformed generic default, a generic clause on a `use` import, a `Closure(...)` signature with a defaulted or untyped parameter, or a `Closure(...)` signature type in an unsupported position such as a generic argument or bound), reported at the offending line | +| `xphp.alias_cycle` | a [type alias](syntax/type-aliases.md) defined, directly or transitively, in terms of itself — through its body (`type A = B; type B = A;`) or a parameter bound (`type A`) | +| `xphp.alias_arity` | a type-alias use whose type-argument count is outside the alias's accepted range — fewer than the required (default-less) parameters or more than it declares (`type P = …;` used as `P`; a default widens the range) | +| `xphp.alias_class_collision` | a type-alias name collides with a class, interface, or trait of the same name in the same file (no silent shadowing) | +| `xphp.alias_duplicate` | the same type-alias name is declared more than once in a file | +| `xphp.alias_unsupported_body` | a type-alias body that is not a single head, a flat union, or a nullable — an intersection (`A & B`), a DNF (`(A & B) \| C`), or a closure signature (`Closure(int): int`) | +| `xphp.alias_compound_in_non_slot` | a union / nullable type alias used somewhere other than the whole type of a parameter, property, return, or class-constant slot (e.g. as a generic argument or nested in another compound type) | | `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) | diff --git a/docs/guides/comparison.md b/docs/guides/comparison.md index 668ad6f0..de325cbe 100644 --- a/docs/guides/comparison.md +++ b/docs/guides/comparison.md @@ -25,24 +25,25 @@ than erasure can. | Feature | xphp | RFC | TS | Kotlin | Rust | |------------------------------------------|-------------------------|------------------|------------------|---------------|-----------------------| | Generic classes / interfaces / traits | ✅ | ✅ | ✅ | ✅ | ✅ | -| Generic functions / methods | ✅ | ✅ | ✅ | ✅ | ✅ | -| Generic closures + arrow functions | ✅ | ✅ | ✅ | ✅ | ✅ | -| Typed closure signatures (`Closure(int): bool`) | ✅ (erases to `\Closure`; literal conformance checked at compile time) | ✅ (runtime-lenient) | ✅ (function types) | ✅ (`(Int) -> Bool`) | ✅ (`Fn(i32) -> bool`) | +| Generic functions / methods | ⚠️ (can't forward a method-level param, target another generic template, or use `static::`/`parent::`) | ✅ | ✅ | ✅ | ✅ | +| Generic closures + arrow functions | ⚠️ (no `$this` capture or `static function` closures; reflection/serializers see the dispatcher rewrite) | ✅ | ✅ | ✅ | ✅ | +| Typed closure signatures (`Closure(int): bool`) | ⚠️ (param/return/property only — not a generic arg or bound; erases to `\Closure`, literal conformance checked) | ❌ (only untyped `callable` / `\Closure`; noted as future work) | ✅ (function types) | ✅ (`(Int) -> Bool`) | ✅ (`Fn(i32) -> bool`) | +| Type-argument inference (call without `::<>`) | ⚠️ (inferred from the arguments for calls and `new` where they determine the type; otherwise the explicit turbofish is still required) | ❌ (turbofish optional; omitting runs unvalidated) | ✅ | ✅ | ✅ (turbofish is the fallback) | | Upper bounds | ✅ | ✅ | ✅ | ✅ | ✅ | | Multiple bounds (intersection) | ✅ | ✅ | ✅ | ✅ | ✅ | | Union bounds + DNF | ✅ | ✅ | ✅ | ❌ (intersection only via `where`) | n/a | | F-bounded recursion (`T : Box`) | ✅ | ✅ | ✅ | ✅ | ✅ | | Default type parameters | ✅ | ✅ | ✅ | ✅ | ✅ | -| Declaration-site variance (`out T` / `in T`) | ✅ | ✅ | ✅ | ✅ | ⚠️ inferred (lifetime-driven; PhantomData for unused type params) | +| Declaration-site variance (`out T` / `in T`) | ⚠️ (class-level only; violations inside trait-`use`d methods go unchecked) | ✅ | ✅ | ✅ | ⚠️ inferred (lifetime-driven; PhantomData for unused type params) | | Inner-template variance composition | ✅ | ✅ | ✅ | ✅ | ✅ | -| Reified T at runtime | ✅ (via AOT) | ❌ (erased) | ❌ | ✅ (inline) | ✅ (monomorphic) | +| Reified T at runtime | ✅ (via AOT) | ❌ (erased) | ❌ | ⚠️ (`inline fun` only — can't reify a class type parameter) | ✅ (monomorphic) | | `instanceof OriginalFqn` works | ✅ | ✅ (trivially: only one class exists at runtime) | n/a | n/a | n/a | -| Real subtype edges between specializations | ✅ | ❌ (erased) | n/a | n/a | n/a | -| Generic type aliases | ❌ | ❌ | ✅ | ✅ | ✅ | +| Real subtype edges between specializations | ⚠️ (common case works; some covariant upcasts are unschedulable or may not converge) | ❌ (erased) | n/a | n/a | n/a | +| Generic type aliases | ⚠️ (compile-time substitution; single-head / union / nullable bodies, parameter defaults + bounds, aliases usable as bounds; aliases are file-local, and intersection / DNF / closure-signature bodies aren't supported) | ❌ | ✅ | ✅ | ✅ | | Wildcard / `*` (use-site existential) | ⚠️ partial (via marker) | n/a (erased) | ⚠️ via `any` (bivariant escape hatch — loses type discipline) | ✅ (`Box<*>`) | n/a | | Use-site variance | ❌ | ❌ | ❌ | ✅ | n/a | | Variadic generics | ❌ | ❌ | ✅ | ❌ | ⚠️ tuples | -| Generic enums / sum types | ❌ | ❌ | ✅ | ✅ | ✅ | +| Generic enums / sum types | ❌ | ❌ | ✅ (via discriminated unions; `enum` can't be generic) | ✅ (via `sealed` classes; `enum class` can't be generic) | ✅ | | Per-arg specialization | ❌ | ❌ (erasure) | ❌ | ❌ | ⚠️ nightly | | Associated types | ❌ | n/a | ❌ | ❌ | ✅ | | `T[]` array sugar | ✅ | ❌ | ✅ | ❌ | ❌ | @@ -53,6 +54,30 @@ mark features that simply can't exist under bound erasure: there are no specialized classes at runtime, so subtype edges, reified-T operations, and a wildcard sigil all lose their meaning. +**Type-argument inference.** xphp infers the type arguments from the +values passed when they determine the type, so the turbofish is +optional there: `identity(5)` infers `identity::`, `new Box($product)` +infers `new Box::`, `$factory->make($p)` infers from `$p`'s +type. An inferred call compiles to exactly the specialization the +turbofish would have selected — inference only writes the turbofish for +you, so bounds, variance, and mangling are unchanged. When the arguments +*don't* determine the type — a type parameter used only in the return +type, an argument whose static type isn't known, or two arguments that +disagree — you still write the turbofish, and omitting it is the same +compile error as before (`xphp.missing_type_argument`). Argument types are +read conservatively (literals, `new`, `$this` properties, and non-reassigned +typed parameters); generic *closure* calls (`$f($x)`) and `T[]`-typed +parameters are not yet inference sources and keep the explicit turbofish. +See [caveats](../caveats.md#type-argument-inference-is-partial). + +This is the same `::<>` turbofish Rust uses, and xphp now works like Rust +in spirit: infer by default, reach for the turbofish to disambiguate or +where inference can't see the type. TypeScript and Kotlin infer too. The +bound-erasure RFC has no inference, and diverges in the other direction — +there the turbofish is *optional* in a different sense: omit it and the +call runs unvalidated with erased-to-`mixed` semantics rather than +inferring or failing to compile. + ## Where the monomorphic and erasure paths diverge Three features fall out of the monomorphization model that the @@ -96,6 +121,12 @@ runtime check. Erasure-based runtimes can't do this because their specializations don't exist as distinct classes. +> ⚠️ Not universal — see [supported with caveats](#supported-with-caveats). +> A covariant upcast to an interface with an *erased* element-consuming +> method can be unschedulable (a loud `xphp.unschedulable_covariant_upcast`, +> never wrong code), and a self-reintroducing derivation can fail to +> converge. + ### `instanceof OriginalFqn` works Every generic template emits a marker interface at the original FQN. @@ -104,6 +135,51 @@ and any other specialization, even though they're physically unrelated classes. You get the "polymorphic over T" mental model without losing instance checks. +## Supported with caveats + +The features marked ⚠️ for xphp in the grid work, but with limits worth +knowing before you lean on them. Each links to the full write-up (with a +reproduction and workaround) in [caveats](../caveats.md). + +- **Generic closures + arrow functions.** A generic closure/arrow can't + capture `$this` ([caveat](../caveats.md#this-capturing-arrows-and-closures-rejected)), + the `static function` closure form isn't supported + ([caveat](../caveats.md#static-closures-not-supported)), and — because + each call site is rewritten to a dispatcher closure — reflection and + closure serializers see the dispatcher's shape rather than your original + body ([caveat](../caveats.md#reflection-on-rewritten-generic-closures)). + Plain (non-`static`, non-`$this`) generic closures and arrows work. +- **Typed closure signatures.** Accepted only in parameter, return, and + property positions. A signature as a generic argument + (`Box`) or a bound is a compile error, and a signature + parameter can't be defaulted or untyped + ([caveat](../caveats.md#closure-signature-types-only-in-parameter-return-and-property-slots)). +- **Generic functions / methods.** The base feature is solid; *composition* + is where the gaps are. Type arguments are inferred where the call arguments + determine them, and otherwise the turbofish is required (see the grid row). + And a turbofish grounded by an enclosing type parameter can't forward a + *method-level* parameter, target a *different* generic template, or use the + `static::`/`parent::` spellings + ([caveat](../caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter)). + Receiver-type tracking also gives up across branches that disagree on the + type, and on a local assigned from a free function + ([caveat](../caveats.md#branching-narrowing-precision-loss)). +- **Declaration-site variance.** Variance is enforced on methods declared + directly on the class, but a violation inside a **trait-`use`d** method + slips through unchecked + ([caveat](../caveats.md#variance-validator-and-trait-use)) — audit traits + on variant classes. Variance is class-level only; there's no + method/function-level variance. +- **Real subtype edges.** Emitted for the common case (and a genuine + strength — see above), but not universal: some covariant upcasts to an + interface with an erased element-consuming method are **unschedulable** + and fail loudly (`xphp.unschedulable_covariant_upcast`), a + self-reintroducing list-↔-map derivation can fail to converge + ([caveat](../caveats.md#self-reintroducing-specialization-list--map-derivations)), and a + covariant `array`-backed collection trips the optional PHPStan pass at + level 6+ + ([caveat](../caveats.md#covariant-array-backed-collections-trip-the-xphp-check-phpstan-pass)). + ## What's missing today The features marked ❌ in the grid above are conscious deferrals, not diff --git a/docs/index.md b/docs/index.md index 1897cf3b..0ff8390d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -57,6 +57,6 @@ and the gap is explicit in [comparison](guides/comparison.md) and Generics are the first substantial chunk of work in xphp, but the roadmap is much broader. See [roadmap](roadmap.md) for what's -shipped and for the discovery items under exploration (type aliases, -mapped types, variadic generics, generic enums, source maps, AST -macros, and more). +shipped — generics and, now, [type aliases](syntax/type-aliases.md) — +and for the discovery items under exploration (mapped types, variadic +generics, generic enums, source maps, AST macros, and more). diff --git a/docs/roadmap.md b/docs/roadmap.md index b5e32956..55ea8eef 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -50,9 +50,14 @@ timeline Reified T : runtime instanceof T : marker interface per template + Type aliases + : compile-time substitution, file-local + : single-head union and nullable bodies + : parameter defaults and bounds Developer experience : RFC-aligned call-site syntax : empty turbofish for all-defaults templates + : optional turbofish via type-argument inference Validation and diagnostics : xphp check validate-only gate : collect-all diagnostics with text json github renderers @@ -61,7 +66,6 @@ timeline : PHPStan over the compiled output section Discovery Generic surface - : Generic type aliases : Variance edges on trait-owned templates : Branching narrowing precision Generic completeness @@ -143,6 +147,15 @@ upcoming one. 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`). +- Enclosing-parameter turbofish grounding: a turbofish whose type argument is + supplied by the enclosing generic scope (`identity::($v)` inside `wrap`, + `self::gen::()` / `Maker::wrap::()`, `$this->dup::()`) grounds **per + specialization** and runs, instead of being rejected by the emitted-marker + backstop. Freshly specialized bodies are re-grounded transitively so + multi-hop and mutually recursive forwards converge; a strictly-growing chain + (`grow` calling `grow::>`) is rejected as non-convergent + (`xphp.unconverged_method_specialization`). `compile` and `check` report this + identically. ### Anonymous templates @@ -219,6 +232,27 @@ upcoming one. - Marker interface per template so `$x instanceof App\Box` works across every `Box<...>` specialization. +### Type aliases + +- `type Name = Body;` and `type Name = Body;` — a compile-time + substitution expanded into its body before specialization, with no + runtime existence (the emitted PHP never mentions the alias). +- Single-head, **union** (`int|string`), and **nullable** (`?Box`) bodies. + A single head expands in every type position (incl. as a generic + argument, `Bag`); a union/nullable expands as the whole type of a + slot. Composes with nested and concrete-instantiation aliases. +- Parameters carry **defaults** (`type P` — a use may omit + trailing defaulted arguments) and **bounds** (`type B` — an + argument that violates the bound is a compile error), like a generic class. +- **File-local by design**: an alias is visible only in the file that + declares it (like a `use` alias); declare it per file to share it. +- Cyclic, arity-mismatched, class-colliding, duplicate, unsupported-body + (intersection / DNF / closure), compound-in-non-slot, and + bound-violating uses are loud compile errors in both `compile` and + `check`, each with a stable code. +- See the [type aliases](syntax/type-aliases.md) tour and the + [body / position limits caveat](caveats.md#type-alias-body-and-position-limits). + ### Naming and collisions - SHA-256-based generated FQCN; namespace mirrors the template. @@ -230,6 +264,16 @@ upcoming one. - RFC-aligned call-site syntax (`Name::<...>` turbofish). - Empty turbofish (`Name::<>`) for all-defaults templates. +- **Type-argument inference (optional turbofish)**: a generic call or `new` + whose type parameters are fixed by the argument values drops the turbofish + — `identity(5)` infers `identity::`, `new Box($product)` infers + `new Box::` — compiling to the exact specialization the turbofish + would have selected. Free functions, static/instance methods, and `new`. + Where the arguments don't determine the type (a parameter only in the return + type, an unknown or conflicting argument type), the explicit turbofish is + still required. See the + [turbofish → inference](syntax/turbofish.md#type-argument-inference) tour and + [caveats](caveats.md#type-argument-inference-is-partial). ### Validation and diagnostics @@ -264,7 +308,6 @@ to ship. ### Generic surface -- Generic type aliases (e.g. `type Pair = ...`). - Variance edges on trait-owned templates. - Branching narrowing precision: today a turbofish call on a receiver whose branch arms disagree is a compile error; could track unions with diff --git a/docs/syntax/index.md b/docs/syntax/index.md index cfc65451..ed8c1b06 100644 --- a/docs/syntax/index.md +++ b/docs/syntax/index.md @@ -22,6 +22,7 @@ first. | [Pseudo-types](pseudo-types.md) | `self` / `static` / `parent` and the `new self::(...)` form | | [Turbofish](turbofish.md) | All four call-site shapes plus variable and empty turbofish | | [Array sugar](array-sugar.md) | `T[]` shorthand | +| [Type aliases](type-aliases.md) | `type Pair = …;`, compile-time substitution, file-local; union/nullable bodies, parameter defaults + bounds | | [Exceptions](exceptions.md) | Generic exceptions, `catch (HttpError $e)`, bare and union catch | ## Quick reference card diff --git a/docs/syntax/methods-and-functions.md b/docs/syntax/methods-and-functions.md index 730dd5a6..5da8b69b 100644 --- a/docs/syntax/methods-and-functions.md +++ b/docs/syntax/methods-and-functions.md @@ -126,6 +126,20 @@ build time instead of fataling at runtime with "Call to undefined method". isn't tracked — give such a local a typed parameter/property hop, or the turbofish call fails as an undetermined receiver. +- > ⚠️ Forwarding an **enclosing class type parameter** + (`$this->dup::` / `self::gen::` / `Maker::wrap::` inside + `Box`, or `identity::` inside a generic function `wrap`) + grounds per specialization and runs — the class parameter becomes + concrete when the class specializes. Forwarding a **method-level** + parameter (`$this->dup::` inside `probe`) does **not**: a generic + method is specialized before its class, so `W` has no concrete value + where the forward would be grounded. It is a compile error regardless + of the target — `xphp.unspecializable_self_call` for a non-erasable + target, `xphp.unspecialized_generic_leak` for an erasable one. + Targets on a *different* generic template and the `static::`/`parent::` + spellings are rejected too. See + [caveats](../caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter). + ## See also - Test fixture: `test/fixture/compile/generic_method/` diff --git a/docs/syntax/turbofish.md b/docs/syntax/turbofish.md index 36f4b6b2..f80cf951 100644 --- a/docs/syntax/turbofish.md +++ b/docs/syntax/turbofish.md @@ -83,16 +83,51 @@ $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.) +- **The turbofish is optional where the arguments determine the type.** + A bare call or `new` whose type parameters are fixed by the argument + values is inferred — `$x->pick('a')` infers `$x->pick::('a')`, + `new Box(5)` infers `new Box::(5)` — and compiles to exactly the + specialization the turbofish would have selected (see + [inference](#type-argument-inference), below). When the arguments *don't* + determine the type — a type parameter only in the return type, an argument + whose static type isn't known, or arguments that disagree — a turbofish-less + call is a compile error (`xphp.missing_type_argument`), not a silent skip: + `xphp check` catches it 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 generic **closure** call (`$f($x)`) is not inferred + and always needs an explicit turbofish. (A first-class callable `pick(...)` + creates a closure rather than calling, and is left alone.) + +## Type-argument inference + +Where the arguments determine the type parameters, you can omit the turbofish +and xphp infers it: + +```php +$r = identity(5); // identity:: +$b = new Box(new Plastic()); // new Box:: +$m = Factory::make($product); // from $product's declared type +$d = $bag->put($this->item); // from the declared property type +``` + +Inference derives the type arguments by matching each parameter's declared +type against the argument's static type, then dispatches through the identical +path an explicit turbofish uses — so an inferred call is byte-for-byte the same +specialization, with the same bound and variance checks. It never *weakens* +anything: adding a turbofish to an inferred call can only make the type +explicit, never change behavior. + +Argument types are read from: literals, `new X(...)`, `$this->prop` (declared +type), and a plain parameter with a concrete declared type that isn't reassigned. +A **call** additionally infers from a statically-tracked local (assigned from a +`new` or a class-returning call) and from a call whose declared return type is a +determinable class, because the call path reuses the monomorphizer's receiver/flow +tracking; **`new`** inference is limited to the conservative set (no locals or call +returns). A reassigned parameter, a scalar-returning-call value, a union-typed +value, or a value typed by a still-abstract type parameter is never an inference +source, and such a site keeps the explicit turbofish. Generic **closure** calls +(`$f($x)`) and `T[]`-typed parameters are not yet inferred either. See +[caveats](../caveats.md#type-argument-inference-is-partial). ## Receiver-type analysis (instance methods) @@ -135,6 +170,14 @@ than a silent pass-through that fatals at runtime. error (`xphp.undetermined_receiver`), not a silent de-specialization. See [caveats](../caveats.md#branching-narrowing-precision-loss). +- > ⚠️ **Enclosing type parameters as turbofish arguments** — a turbofish + grounded by an enclosing generic scope (`identity::` inside `wrap`, + `self::gen::` / `$this->dup::` / `Maker::wrap::` inside `Box`) + is grounded per specialization and runs. Still rejected loudly: closure + turbofish inside generic function bodies, targets on a *different* + generic template, and `static::`/`parent::` spellings. See + [caveats](../caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter). + ## See also - Test fixture: `test/fixture/compile/generic_method/` diff --git a/docs/syntax/type-aliases.md b/docs/syntax/type-aliases.md new file mode 100644 index 00000000..5ba346ed --- /dev/null +++ b/docs/syntax/type-aliases.md @@ -0,0 +1,128 @@ +# Type aliases + +A type alias gives a name to a type — generic or not — so you can write +it once and reuse it. It's a **compile-time substitution**: the alias is +expanded into its body before specialization and has no runtime +existence, so the emitted PHP never mentions the alias name. + +```php +type Pair = Dict>; // generic alias +type UserId = Ident; // non-generic alias (a plain class) +type UserMap = Pair; // a concrete instantiation of another alias +type Num = int|string; // union body +type MaybeUser = ?User; // nullable body +``` + +## Example + +```php + = Dict>; +type UserId = Ident; + +class Service { + public function pair(): Pair { + return new Pair::(1, new Bag::(new User())); + } + + public function id(): UserId { + return new UserId(); + } +} +``` + +## What gets emitted + +Each use is replaced by its expanded body, then monomorphized exactly as +if you had written the body by hand. `Pair` expands to +`Dict>` (a real specialization); `UserId` expands to the +plain class `Ident`. The `type …` declarations themselves vanish. + +```php +namespace App; + +class Service { + public function pair(): \XPHP\Generated\App\Dict\T_ { + return new \XPHP\Generated\App\Dict\T_(1, new \XPHP\Generated\App\Bag\T_(new User())); + } + public function id(): \App\Ident { + return new \App\Ident(); + } +} +``` + +Because expansion happens before specialization, an aliased generic +records and specializes the same class an explicit type would — there is +no separate code path and no runtime cost. + +## Rules + +- **Declaration forms**: `type Name = Body;` (generic) and + `type Name = Body;` (non-generic). The parameter list is optional; the + separator is `=`. +- **Bodies**: a single (possibly-generic) head (`Ident`, `Dict`), a + **union** (`int|string`), or a **nullable** (`?Box`). A single-head or + generic body expands in **every** type position, including as a generic + argument (`Bag`), `new`, `extends`, and a bound. A **union / + nullable** body expands only as the *whole* type of a parameter, + property, return, or class-constant slot (see caveats). +- **Parameters** may carry **defaults** and **bounds**, like a generic + class: `type P = Dict;` (a use may omit trailing + defaulted arguments — `P` fills `B = A = int`), and + `type B = Bag;` (a use whose argument does not satisfy the + bound is a compile error, the same `xphp.bound_violation` a class + instantiation raises). A bound may itself name an alias — `type Named = + Face; type B` checks against `Face`. +- **File-local**: an alias is visible only in the file that declares it, + like a PHP `use` alias. To share a vocabulary, declare the alias in each + file that uses it (a zero-cost substitution), or reference the underlying + type directly (see caveats). +- Aliases compose: an alias body may reference another alias + (`type UserMap = Pair`), and an alias may take type + parameters used inside its body (`type Pair = Dict>`). +- A non-alias name of the same shape is untouched — only a declared alias + is expanded. +- The following are compile errors (each with a stable code, reported by + both `xphp compile` and `xphp check`): + - `xphp.alias_cycle` — an alias defined, directly or transitively, in + terms of itself (`type A = B; type B = A;`). + - `xphp.alias_arity` — a use whose type-argument count is outside the + alias's accepted range (`type P = …;` used as `P`; with a + default the range widens — `type P` accepts one or two). + - `xphp.bound_violation` — a use whose argument does not satisfy a + parameter's bound (`type B = …;` used as `B`). + - `xphp.alias_class_collision` — an alias whose name collides with a + class, interface, or trait of the same name (no silent shadowing). + - `xphp.alias_duplicate` — the same alias name declared twice. + - `xphp.alias_unsupported_body` — an intersection / DNF / closure-signature + body (see caveats below). + - `xphp.alias_compound_in_non_slot` — a union / nullable alias used + outside a whole slot (see caveats below). + +## Caveats + +An alias is **file-local by design** (like a `use` alias). The remaining +limits are the body shape and the positions a compound alias can take. See +[caveats → type-alias body and position limits](../caveats.md#type-alias-body-and-position-limits) +for the details and the reasons: + +- **File-local.** An alias is visible only in its own file — declare it in + each file that uses it, or reference the underlying type directly. (Because + scoping is per-file there is no cross-file collision/duplicate to detect; + same-file ones *are* caught.) +- **Intersection / DNF / closure bodies** (`A&B`, `(A&B)|C`, + `Closure(int): int`) are rejected with `xphp.alias_unsupported_body` — + write the type directly or wrap it in a named class/interface. +- **A union / nullable alias is a whole-slot type only.** As a generic + argument, in `new` / `extends` / a bound, or nested inside another + union/intersection, it is `xphp.alias_compound_in_non_slot`. + +## See also + +- Test fixture: `test/fixture/compile/type_aliases/` +- Related: [classes and interfaces](classes-and-interfaces.md), + [turbofish](turbofish.md) diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index b4ff77aa..36502860 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -70,7 +70,9 @@ once it sees `public int $value`. ## Rules -- A bound can be any valid PHP class or interface name. +- A bound can be any valid PHP class or interface name, or a + [type alias](type-aliases.md) that resolves to one (`type Named = Face; + T : Named` checks against `Face`; a union alias becomes a union bound). - Intersection: `T : A & B` — concrete must satisfy both. - Union: `T : A | B` — any operand suffices. - DNF: `T : (A & B) | C` — outer OR of inner ANDs. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..b6b7d3b6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1638 @@ +{ + "name": "xphp-dev-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xphp-dev-tools", + "devDependencies": { + "@commitlint/cli": "^19.8.1", + "@commitlint/config-conventional": "^19.8.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@commitlint/cli": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.8.1.tgz", + "integrity": "sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/format": "^19.8.1", + "@commitlint/lint": "^19.8.1", + "@commitlint/load": "^19.8.1", + "@commitlint/read": "^19.8.1", + "@commitlint/types": "^19.8.1", + "tinyexec": "^1.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.8.1.tgz", + "integrity": "sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "conventional-changelog-conventionalcommits": "^7.0.2" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-19.8.1.tgz", + "integrity": "sha512-0jvJ4u+eqGPBIzzSdqKNX1rvdbSU1lPNYlfQQRIFnBgLy26BtC0cFnr7c/AyuzExMxWsMOte6MkTi9I3SQ3iGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/ensure": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.8.1.tgz", + "integrity": "sha512-mXDnlJdvDzSObafjYrOSvZBwkD01cqB4gbnnFuVyNpGUM5ijwU/r/6uqUmBXAAOKRfyEjpkGVZxaDsCVnHAgyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-19.8.1.tgz", + "integrity": "sha512-YfJyIqIKWI64Mgvn/sE7FXvVMQER/Cd+s3hZke6cI1xgNT/f6ZAz5heND0QtffH+KbcqAwXDEE1/5niYayYaQA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/format": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-19.8.1.tgz", + "integrity": "sha512-kSJj34Rp10ItP+Eh9oCItiuN/HwGQMXBnIRk69jdOwEW9llW9FlyqcWYbHPSGofmjsqeoxa38UaEA5tsbm2JWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.8.1.tgz", + "integrity": "sha512-AceOhEhekBUQ5dzrVhDDsbMaY5LqtN8s1mqSnT2Kz1ERvVZkNihrs3Sfk1Je/rxRNbXYFzKZSHaPsEJJDJV8dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/lint": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-19.8.1.tgz", + "integrity": "sha512-52PFbsl+1EvMuokZXLRlOsdcLHf10isTPlWwoY1FQIidTsTvjKXVXYb7AvtpWkDzRO2ZsqIgPK7bI98x8LRUEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/is-ignored": "^19.8.1", + "@commitlint/parse": "^19.8.1", + "@commitlint/rules": "^19.8.1", + "@commitlint/types": "^19.8.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/load": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-19.8.1.tgz", + "integrity": "sha512-9V99EKG3u7z+FEoe4ikgq7YGRCSukAcvmKQuTtUyiYPnOd9a2/H9Ak1J9nJA1HChRQp9OA/sIKPugGS+FK/k1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^19.8.1", + "@commitlint/execute-rule": "^19.8.1", + "@commitlint/resolve-extends": "^19.8.1", + "@commitlint/types": "^19.8.1", + "chalk": "^5.3.0", + "cosmiconfig": "^9.0.0", + "cosmiconfig-typescript-loader": "^6.1.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "lodash.uniq": "^4.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/message": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.8.1.tgz", + "integrity": "sha512-+PMLQvjRXiU+Ae0Wc+p99EoGEutzSXFVwQfa3jRNUZLNW5odZAyseb92OSBTKCu+9gGZiJASt76Cj3dLTtcTdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-19.8.1.tgz", + "integrity": "sha512-mmAHYcMBmAgJDKWdkjIGq50X4yB0pSGpxyOODwYmoexxxiUCy5JJT99t1+PEMK7KtsCtzuWYIAXYAiKR+k+/Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-parser": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/read": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.8.1.tgz", + "integrity": "sha512-03Jbjb1MqluaVXKHKRuGhcKWtSgh3Jizqy2lJCRbRrnWpcM06MYm8th59Xcns8EqBYvo0Xqb+2DoZFlga97uXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/top-level": "^19.8.1", + "@commitlint/types": "^19.8.1", + "git-raw-commits": "^4.0.0", + "minimist": "^1.2.8", + "tinyexec": "^1.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-19.8.1.tgz", + "integrity": "sha512-GM0mAhFk49I+T/5UCYns5ayGStkTt4XFFrjjf0L4S26xoMTSkdCf9ZRO8en1kuopC4isDFuEm7ZOm/WRVeElVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^19.8.1", + "@commitlint/types": "^19.8.1", + "global-directory": "^4.0.1", + "import-meta-resolve": "^4.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/rules": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.8.1.tgz", + "integrity": "sha512-Hnlhd9DyvGiGwjfjfToMi1dsnw1EXKGJNLTcsuGORHz6SS9swRgkBsou33MQ2n51/boIDrbsg4tIBbRpEWK2kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/ensure": "^19.8.1", + "@commitlint/message": "^19.8.1", + "@commitlint/to-lines": "^19.8.1", + "@commitlint/types": "^19.8.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-19.8.1.tgz", + "integrity": "sha512-98Mm5inzbWTKuZQr2aW4SReY6WUukdWXuZhrqf1QdKPZBCCsXuG87c+iP0bwtD6DBnmVVQjgp4whoHRVixyPBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/top-level": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-19.8.1.tgz", + "integrity": "sha512-Ph8IN1IOHPSDhURCSXBz44+CIu+60duFwRsg6HqaISFHQHbmBtxVw4ZrFNIYUzEP7WwrNPxa2/5qJ//NK1FGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^7.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/types": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.8.1.tgz", + "integrity": "sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/conventional-commits-parser": "^5.0.0", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@types/conventional-commits-parser": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.2.tgz", + "integrity": "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", + "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", + "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jiti": "2.6.1" + }, + "engines": { + "node": ">=v18" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" + } + }, + "node_modules/dargs": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", + "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/git-raw-commits": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", + "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", + "deprecated": "Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "dargs": "^8.0.0", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "git-raw-commits": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-extensions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", + "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..372e35e3 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "xphp-dev-tools", + "private": true, + "description": "Dev-only Node toolchain: commit-message linting (commitlint). The PHP package proper is defined in composer.json.", + "devDependencies": { + "@commitlint/cli": "^19.8.1", + "@commitlint/config-conventional": "^19.8.1" + } +} diff --git a/src/Transpiler/Monomorphize/AliasBoundObligation.php b/src/Transpiler/Monomorphize/AliasBoundObligation.php new file mode 100644 index 00000000..ec5d91ee --- /dev/null +++ b/src/Transpiler/Monomorphize/AliasBoundObligation.php @@ -0,0 +1,30 @@ + $typeParams the alias's resolved parameters (name + bound), in order + * @param list $args the concrete, padded type arguments supplied at the use site + */ + public function __construct( + public array $typeParams, + public array $args, + public string $label, + public SourceLocation $location, + ) { + } +} diff --git a/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php b/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php new file mode 100644 index 00000000..ed6f2a55 --- /dev/null +++ b/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php @@ -0,0 +1,47 @@ + */ + private array $obligations = []; + + /** + * @param list $typeParams + * @param list $args + */ + public function add(array $typeParams, array $args, string $label, SourceLocation $location): void + { + $this->obligations[] = new AliasBoundObligation($typeParams, $args, $label, $location); + } + + /** + * Commit another (per-file) collector's obligations into this one. Used so a file's obligations + * are absorbed only after that file has parsed successfully: a file that aborts mid-parse has its + * AST dropped from the hierarchy, so its obligations — which may reference now-absent types — + * must be dropped with it rather than checked against a hierarchy that no longer contains them. + */ + public function absorb(self $other): void + { + foreach ($other->obligations as $obligation) { + $this->obligations[] = $obligation; + } + } + + /** @return list */ + public function all(): array + { + return $this->obligations; + } +} diff --git a/src/Transpiler/Monomorphize/AliasBoundValidator.php b/src/Transpiler/Monomorphize/AliasBoundValidator.php new file mode 100644 index 00000000..584f6711 --- /dev/null +++ b/src/Transpiler/Monomorphize/AliasBoundValidator.php @@ -0,0 +1,33 @@ +all() as $obligation) { + Registry::checkAliasBounds( + $obligation->typeParams, + $obligation->args, + $hierarchy, + $obligation->label, + $diagnostics, + $obligation->location, + ); + } + } +} diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 3934ede5..7b040f0a 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -67,7 +67,8 @@ public function compile( // 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 = $this->parseAll($sources); + $aliasBoundObligations = new AliasBoundObligationCollector(); + $astPerFile = $this->parseAll($sources, $aliasBoundObligations); $hierarchy = TypeHierarchy::fromAstPerFile($astPerFile); $registry = new Registry($this->hashLength, $hierarchy); @@ -86,17 +87,28 @@ public function compile( // AST in compile-mode, so this must precede it). UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy); - $methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy); - $methodCompiler->process($astPerFile); - // Phase 1b.i: collect class definitions across every source file. Splitting // definitions ahead of instantiations gives bare-`new Foo;` synthesis (added // in 1b.ii) a complete template registry so it can recognize Foo as an - // all-defaulted template regardless of the file-walk order. + // all-defaulted template regardless of the file-walk order. Collected BEFORE the + // method compiler runs so the type-argument inference pass below has the full + // template registry AND sees the original (un-stripped, un-appended) user ASTs — + // the same shape `check()` runs it against, keeping the two modes in parity. foreach ($astPerFile as $filepath => $ast) { $collector->collectDefinitions($ast, $filepath); } + // Optional turbofish on `new`: infer a bare `new Box($x)`'s type arguments from its + // constructor arguments and annotate it, so the instantiation collector + call-site + // rewriter treat it as an explicit turbofish. A `new` it can't resolve is left bare + // (all-defaults synthesis / missing-type-argument error). Runs before `process` so it + // never sees appended specializations (which `check` can't), and before + // collectInstantiations so the annotation is picked up. + (new NewInferencePass($registry, $hierarchy))->run($astPerFile); + + $methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy); + $methodCompiler->process($astPerFile); + // Phase 1b.ii: validate defaults-against-bounds at the source level (so a // bad declaration like `class Box` fails BEFORE any // padded instantiation is recorded), then collect instantiations -- including @@ -113,6 +125,9 @@ public function compile( // reaches emission as broken PHP. $registry->validateUndeclaredTypeParameters(); $registry->validateDefaultsAgainstBounds(); + // Enforce type-alias parameter bounds now that the hierarchy exists (the obligations were + // captured during parse, before it did) — compile mode has no collector, so a violation throws. + AliasBoundValidator::validate($aliasBoundObligations, $hierarchy); // Inner-template variance composition: every template's variance // markers are known by now, so cases the parse-time validator // couldn't catch (e.g. `class P { f(): Container }` where @@ -135,8 +150,10 @@ public function compile( // Phase 2: fixed-point specialization loop. Fail-fast (an undefined template // or an exceeded depth throws) — the emit path must not proceed on a set it - // couldn't fully build. - $specializedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: false); + // couldn't fully build. The method compiler rides along: each fresh + // specialization is grounded (enclosing-param method-generic turbofish + // dispatched against the retained Phase-1a template index) before collection. + $specializedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: false, methodCompiler: $methodCompiler); // Phase 2.3: re-qualify free-function calls and const fetches in every specialization // produced by the fixed-point loop. Each body was relocated out of its origin namespace @@ -202,12 +219,12 @@ public function compile( $specializedAsts[$generatedFqn] = $first; } - // Note for future-proofing (review F9): method-level specialization runs in Phase 1a - // against the raw user-file ASTs, NOT against the specialized cache classes. That's - // safe under the current MVP limit ("generic methods on non-generic classes only" — - // see GenericMethodCompiler's docblock). If that limit ever relaxes, the specialized - // class ASTs would need to be fed back through the method compiler with their - // enclosing namespace preserved so FQN keying still works. + // Method-level specialization runs twice-shaped: Phase 1a against the raw + // user-file ASTs, then per-specialization inside the Phase-2 loop + // (GenericMethodCompiler::groundSpecializedClass, fed the retained template + // index with the spec's identity threaded — the F9 wiring note this replaces). + // Anything neither pass could ground still carries its marker and is rejected + // by the backstop below. foreach ($specializedAsts as $generatedFqn => $classAst) { // Last-resort safety net: no generic marker may survive into emitted output. A // surviving turbofish/closure marker is a site the pipeline could not ground — @@ -305,6 +322,7 @@ private function specializeToFixedPoint( RegistryCollector $collector, TypeHierarchy $hierarchy, bool $resilient, + ?GenericMethodCompiler $methodCompiler = null, ): array { /** @var array $specializedAsts keyed by generated FQCN */ $specializedAsts = []; @@ -365,6 +383,44 @@ private function specializeToFixedPoint( } $specializedAsts[$generatedFqn] = $specialized; + + // Ground method-generic turbofish markers the class substitution just + // made concrete (`self::gen::` → `::`) BEFORE collecting: an + // own-template member appended onto the spec is then swept by the + // collect below, and externally-appended members (onto a non-generic + // user class or a function namespace — invisible to spec collection) + // are collected explicitly, so nested instantiation needs discovered by + // grounding converge through this same fixed point. + if ($methodCompiler !== null) { + if ($resilient) { + try { + $externalAppends = $methodCompiler->groundSpecializedClass( + $specialized, + $generatedFqn, + $instantiation->templateFqn, + $instantiation->concreteTypes, + emit: false, + ); + } catch (RuntimeException) { + // Grounding failures surface as collected diagnostics in + // check mode; a residual throw must not abort the resilient + // pass over the remaining instantiations. + $externalAppends = []; + } + } else { + $externalAppends = $methodCompiler->groundSpecializedClass( + $specialized, + $generatedFqn, + $instantiation->templateFqn, + $instantiation->concreteTypes, + emit: true, + ); + } + if ($externalAppends !== []) { + $collector->collect($externalAppends, ""); + } + } + $collector->collect([$specialized], ""); } @@ -381,6 +437,10 @@ private function specializeToFixedPoint( } if ($countAfter === $countBefore) { + // @infection-ignore-all Continue_ -- break vs continue reconverges: an + // unchanged count means this pass recorded no new instantiations, so the + // next iteration processes nothing new and exits via the !newlyProcessed + // break; the mutant merely skips that no-op pass. continue; } @@ -399,13 +459,23 @@ private function specializeToFixedPoint( public function check(FilepathArray $sources): DiagnosticCollector { $diagnostics = new DiagnosticCollector(); - $astPerFile = []; + $aliasBoundObligations = new AliasBoundObligationCollector(); + // Read every source up front — 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. + $contents = []; 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); + $contents[$filepath] = $this->fileReader->read($filepath); + } + $astPerFile = []; + foreach ($contents as $filepath => $content) { + // Buffer this file's alias-bound obligations and commit them to the shared collector only + // once the file has parsed cleanly — a file that aborts mid-parse is dropped from the + // hierarchy, so its obligations must not be checked against it (they would reference + // now-absent types and mis-report a valid use as a bound violation). + $fileObligations = new AliasBoundObligationCollector(); try { - $astPerFile[$filepath] = $this->sourceParser->parse($content); + $astPerFile[$filepath] = $this->sourceParser->parse($content, $filepath, $fileObligations); + $aliasBoundObligations->absorb($fileObligations); } catch (PhpParserError $e) { $line = $e->getStartLine(); $diagnostics->add(new Diagnostic( @@ -422,11 +492,12 @@ public function check(FilepathArray $sources): DiagnosticCollector } catch (XphpParseException $e) { // xphp-specific parse-time rejections from the scanner (e.g. variance markers // on methods, malformed generic defaults) — these carry the offending token's - // original-source line so the diagnostic points at the real site. + // original-source line so the diagnostic points at the real site, and optionally a + // stable diagnostic code (e.g. a type-alias rejection) in place of the generic one. $line = $e->sourceLine(); $diagnostics->add(new Diagnostic( Severity::Error, - self::CODE_PARSE_ERROR, + $e->diagnosticCode() ?? self::CODE_PARSE_ERROR, $e->getMessage(), // @infection-ignore-all GreaterThan/IncrementInteger/DecrementInteger -- every // current throw site supplies a real token line (>= 1), so this `> 0` guard is @@ -456,10 +527,17 @@ public function check(FilepathArray $sources): DiagnosticCollector foreach ($astPerFile as $filepath => $ast) { $collector->collectDefinitions($ast, $filepath); } + // Optional turbofish on `new` (see compile()): infer bare `new` type arguments before + // instantiations are collected. Same pipeline position as compile — after definitions, + // before collectInstantiations — so check and compile infer identically. + (new NewInferencePass($registry, $hierarchy))->run($astPerFile); $registry->validateVariancePositions(); $registry->validateUndeclaredTypeParameters(); UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy, $diagnostics); $registry->validateDefaultsAgainstBounds(); + // Enforce type-alias parameter bounds (obligations captured during parse) now the hierarchy + // exists; check mode collects each violation as an xphp.bound_violation and continues. + AliasBoundValidator::validate($aliasBoundObligations, $hierarchy, $diagnostics); $registry->validateInnerVariance(); // Closure-signature conformance at the statically-visible literal site // (a `Closure(...)` return handing back a closure literal). In @@ -481,8 +559,12 @@ public function check(FilepathArray $sources): DiagnosticCollector // 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); + // correct (no-wasted-work, no-mutation) choice. The instance is held: the resilient + // specialization pass below feeds each spec back through it (groundSpecializedClass) so + // enclosing-param turbofish diagnostics only provable after substitution are collected — + // keeping check's verdicts aligned with compile's. + $methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy, $diagnostics); + $methodCompiler->process($astPerFile, emit: false); // Grounded closure-signature conformance. A `Closure(T $x)` target whose // type parameter is still abstract above is gradually accepted; grounding it @@ -497,7 +579,7 @@ public function check(FilepathArray $sources): DiagnosticCollector // by-ref) mismatches were already collected by the abstract pre-loop above, so // the grounded pass skips them to avoid a duplicate report at the specialized // location. - $groundedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: true); + $groundedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: true, methodCompiler: $methodCompiler); foreach ($groundedAsts as $generatedFqn => $classAst) { $closureValidator->validateFile([$classAst], "", $diagnostics, groundedTypesOnly: true); } @@ -510,11 +592,11 @@ public function check(FilepathArray $sources): DiagnosticCollector * * @return array> */ - private function parseAll(FilepathArray $sources): array + private function parseAll(FilepathArray $sources, ?AliasBoundObligationCollector $obligations = null): array { $astPerFile = []; foreach ($sources->filepaths as $filepath) { - $astPerFile[$filepath] = $this->sourceParser->parse($this->fileReader->read($filepath)); + $astPerFile[$filepath] = $this->sourceParser->parse($this->fileReader->read($filepath), $filepath, $obligations); } return $astPerFile; diff --git a/src/Transpiler/Monomorphize/ExpressionTyper.php b/src/Transpiler/Monomorphize/ExpressionTyper.php new file mode 100644 index 00000000..80095a9d --- /dev/null +++ b/src/Transpiler/Monomorphize/ExpressionTyper.php @@ -0,0 +1,29 @@ +prop`, or a call return — which only the + * monomorphizer's receiver/scope tracker can answer, and only inside a method body. + * + * Returning null means "cannot determine a concrete type here"; the inference driver then leaves + * that argument's parameter unconstrained (which, if it was the only witness for a required type + * parameter, makes the whole inference fall back to today's explicit-turbofish requirement). An + * implementation must never invent a type it cannot prove — an unknown expression is null, never a + * guess. + */ +interface ExpressionTyper +{ + /** The concrete static type of `$expr`, or null when it cannot be determined. */ + public function typeOf(Expr $expr): ?TypeRef; +} diff --git a/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php b/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php index 9d259833..f55b0197 100644 --- a/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php +++ b/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php @@ -11,7 +11,13 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\NullsafeMethodCall; use PhpParser\Node\Expr\StaticCall; +use PhpParser\Node\Name; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Function_; use PhpParser\NodeFinder; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor; +use PhpParser\NodeVisitorAbstract; use RuntimeException; /** @@ -45,39 +51,119 @@ final class GenericMarkerLeakGuard public const CODE = 'xphp.unspecialized_generic_leak'; /** - * Throw if any generic marker survives into a specialized AST subtree. + * Find the first surviving generic marker in a specialized AST subtree, or null when + * the subtree is clean. The scan is the guard's single source of truth — `assertNoLeak` + * throws on it, and `check`-mode callers degrade it to a collected diagnostic so the + * validate-only pass reports the same shapes the compile-time backstop rejects. + * + * `$includeClosureTemplates` toggles the defense-in-depth arm. The compile-time + * assert keeps it on. The check-mode drain turns it off: an un-specialized closure + * template inside a drained body always accompanies either a source-seam diagnostic + * on its call site (a different line — the template node's own line would dodge the + * caller's already-reported dedupe) or an orphan diagnostic from the + * declared-but-never-specialized check, so re-flagging the template node itself only + * double-reports; the call-site marker arm is what carries new information there. + * + * `$includeVariableTurbofish` toggles variable-turbofish FuncCalls (`$f::`). + * The check-mode CLASS-spec backstop turns it off: check's validate-only walk never + * materializes closure dispatchers, so a class-spec clone legitimately carries the + * variable marker that compile's dispatcher pass grounds — flagging it would reject + * code compile accepts. Every genuinely-broken variable-turbofish shape is caught + * elsewhere (the source seam in both modes, or the append-drain backstop, whose + * check side keeps this arm on because compile's drain rejects the same body). + * + * `$skipUnspecializedTemplates` skips the SUBTREES of function/method/closure + * declarations still carrying their generic-template marker. Same check-mode + * class-spec backstop rationale: check never strips templates, so a spec clone + * retains e.g. `a` whose body legitimately holds a `self::b::` marker — the + * template as a whole is dispatch machinery, not emitted output, and flagging its + * interior would reject code compile accepts. Compile-mode specs never contain + * such declarations, so the assert path is unaffected. * * @param Node|list $specialized the emitted specialized node(s) - * @param string $label the specialization's identity, for the error message */ - public static function assertNoLeak(Node|array $specialized, string $label): void - { + public static function findLeak( + Node|array $specialized, + bool $includeClosureTemplates = true, + bool $includeVariableTurbofish = true, + bool $skipUnspecializedTemplates = false, + ): ?Node { $nodes = is_array($specialized) ? $specialized : [$specialized]; - $finder = new NodeFinder(); - $leak = $finder->findFirst($nodes, static function (Node $n): bool { + $matcher = static function (Node $n) use ($includeClosureTemplates, $includeVariableTurbofish): bool { if ($n instanceof FuncCall || $n instanceof MethodCall || $n instanceof StaticCall || $n instanceof NullsafeMethodCall ) { + if (!$includeVariableTurbofish && $n instanceof FuncCall && !$n->name instanceof Name) { + return false; + } return $n->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) !== null; } - if ($n instanceof Closure || $n instanceof ArrowFunction) { + if ($includeClosureTemplates && ($n instanceof Closure || $n instanceof ArrowFunction)) { return is_array($n->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)); } return false; - }); + }; - if ($leak === null) { - return; + if (!$skipUnspecializedTemplates) { + return (new NodeFinder())->findFirst($nodes, $matcher); } + // Subtree-skipping scan: NodeFinder can't prune, so walk with a traverser that + // refuses to descend into declarations still carrying the template marker. + // Preorder like findFirst, so both paths report the same first leak. + $visitor = new class($matcher) extends NodeVisitorAbstract { + public ?Node $leak = null; + + /** @param \Closure(Node): bool $matcher */ + public function __construct(private readonly \Closure $matcher) + { + } + + public function enterNode(Node $node): ?int + { + if ($this->leak !== null) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + if (($node instanceof ClassMethod || $node instanceof Function_ + || $node instanceof Closure || $node instanceof ArrowFunction) + && is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)) + ) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + if (($this->matcher)($node)) { + $this->leak = $node; + // @infection-ignore-all ReturnRemoval — descending into the found + // leak's children is a no-op: the leak-set early-exit above prunes + // every subsequent node before the matcher can overwrite. The + // prune is an optimization; first-leak-wins is pinned by + // GenericMarkerLeakGuardTest's document-order test. + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($nodes); + + return $visitor->leak; + } + + /** + * Build the guard's diagnostic message for a found leak. Shared verbatim between the + * compile-time throw and the check-mode collected diagnostic so both modes name the + * same site the same way. + */ + public static function leakMessage(Node $leak, string $label): string + { // @infection-ignore-all Concat ConcatOperandRemoval — the diagnostic wording is not // behavior: the tests pin that a leak throws and that the message names the label, the // line, and the code; reordering or dropping a prose clause changes none of those. - throw new RuntimeException(sprintf( + return sprintf( 'A generic turbofish/closure marker survived specialization into the emitted output for %s ' . '(near line %d). This site could not be grounded to a concrete type, so its type-parameter ' . 'hints would reach the emitted PHP as references to non-existent classes — a runtime TypeError. ' @@ -87,6 +173,23 @@ public static function assertNoLeak(Node|array $specialized, string $label): voi $label, $leak->getStartLine(), self::CODE, - )); + ); + } + + /** + * Throw if any generic marker survives into a specialized AST subtree. + * + * @param Node|list $specialized the emitted specialized node(s) + * @param string $label the specialization's identity, for the error message + */ + public static function assertNoLeak(Node|array $specialized, string $label): void + { + $leak = self::findLeak($specialized); + + if ($leak === null) { + return; + } + + throw new RuntimeException(self::leakMessage($leak, $label)); } } diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 1cbb6adf..3f210594 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -45,6 +45,7 @@ use PhpParser\Node\Stmt\While_; use PhpParser\Node\UseItem; use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor; use PhpParser\NodeVisitorAbstract; use RuntimeException; use XPHP\Diagnostics\Diagnostic; @@ -56,14 +57,22 @@ * Specializes method-scoped generics: `function NAME(...)` inside a class body, called via * `ClassFqn::NAME(...)`. * - * The pass runs after the class-level pipeline has settled. It walks the per-file AST set - * (the rewritten user code AND the specialized cache classes) twice: + * The pass runs in two stages. `process()` walks the raw per-file user ASTs (Phase 1a, + * before class specialization): * 1. Collect every generic-method template — keyed by "classFqn::methodName". * 2. Collect every StaticCall carrying ATTR_METHOD_GENERIC_ARGS — derive (classFqn, * methodName, args), generate a mangled method (cloning the template, substituting - * the type-param, renaming), append it to the owning class AST. + * the type-param, renaming), append it to the owning class AST — then drain the + * appends, re-walking each freshly specialized body so a forward that substitution + * just made concrete dispatches too. * 3. Strip the original generic-method ClassMethod from each class. * 4. Rewrite each StaticCall's Identifier name to the mangled form. + * Then `groundSpecializedClass()` runs per fresh class specialization inside the + * fixed-point loop, against the retained template index: a turbofish grounded by an + * ENCLOSING class type parameter (`self::gen::` inside `Box`) is abstract during + * Phase 1a and only becomes dispatchable once `Box`'s substitution rewrites the + * marker — own-template members land on the specialization itself, external targets on + * the retained user ASTs. * * Supported call shapes: static (`ClassFqn::method::(...)`), instance and nullsafe * (`$obj->method::(...)`, `$obj?->method::(...)`) via receiver-type analysis, and @@ -94,6 +103,33 @@ final class GenericMethodCompiler public const CODE_UNDETERMINED_RECEIVER = 'xphp.undetermined_receiver'; public const CODE_UNSPECIALIZABLE_SELF_CALL = 'xphp.unspecializable_self_call'; public const CODE_UNSPECIALIZED_GENERIC_CLOSURE = 'xphp.unspecialized_generic_closure'; + public const CODE_UNCONVERGED_METHOD_SPECIALIZATION = 'xphp.unconverged_method_specialization'; + + /** + * Cap on the append-drain's specialization chain depth. A freshly specialized + * function/method body may itself carry a now-concrete turbofish that mints a further + * specialization (`wrap` forwarding to `mid::` forwarding to `identity::`); + * same-args cycles terminate via the alreadyGenerated dedup, but a strictly-growing + * chain (`grow` calling `grow::>`) mints a new mangled name every hop and + * would never converge. Sixteen mirrors Compiler::MAX_SPECIALIZATION_DEPTH (kept as a + * separate constant — this pass must stay independent of the class-level pipeline). + */ + private const MAX_METHOD_SPECIALIZATION_HOPS = 16; + + /** + * Phase-1a state retained for the post-specialization grounding pass + * ({@see groundSpecializedClass}). `process()` strips generic templates from the + * user ASTs at the end of its run, but the index keeps referencing the detached + * template nodes — retaining it is what lets a marker that only became concrete + * under class specialization still find its method/function template. The dedup + * map is shared too, so a member a Phase-1a call already appended (or another + * specialization already grounded) is never appended twice. + */ + private ?TemplateIndex $retainedIndex = null; + /** @var array shared specialization-dedup keys (see rewriteStaticCall) */ + private array $alreadyGenerated = []; + /** @var array class template FQN => source ast key (filepath), for grounding-time diagnostics */ + private array $classSourceByFqn = []; /** * @param ?DiagnosticCollector $diagnostics When null (the default — `xphp compile`), every @@ -173,6 +209,7 @@ public function process(array &$astSet, bool $emit = true): void } foreach ($perFileClasses as $k => $v) { $classByFqn[$k] = $v; + $this->classSourceByFqn[$k] = (string) $astKey; } foreach ($perFileFns as $k => $v) { $functionTemplates[$k] = $v; @@ -214,9 +251,11 @@ public function process(array &$astSet, bool $emit = true): void $functionNamespaceByFqn, $allFunctionsByFqn, ); + // Retain for the post-specialization grounding pass; the template nodes stay + // reachable through the index even after the strip loops below detach them. + $this->retainedIndex = $index; - /** @var array $alreadyGenerated */ - $alreadyGenerated = []; + $alreadyGenerated = &$this->alreadyGenerated; foreach ($astSet as $astKey => &$ast) { // For top-level (null-namespace) functions: the visitor's pendingAppends // mechanism mutates a container's ->stmts; the top-level AST is a plain @@ -280,6 +319,109 @@ public function process(array &$astSet, bool $emit = true): void } } + /** + * Ground + dispatch the method-generic turbofish markers inside one freshly + * specialized class. + * + * Runs from the fixed-point specialization loop, right after the class substitution + * and BEFORE the spec is collected: a marker like `self::gen::` or + * `Maker::wrap::` is abstract at the Phase-1a walk (the enclosing `T` has no + * value in the template) and only becomes dispatchable here, once the substitution + * has rewritten it to `::`. Re-uses the Phase-1a rewrite machinery against the + * retained template index, in the markers-only mode the append-drain introduced, + * plus a {@see GroundingContext} that redirects own-template member appends onto the + * spec itself (the template class lowers to a marker interface in output) and + * records externally-appended members so the caller can collect their nested + * instantiation needs into the same fixed point. + * + * Shapes deliberately NOT grounded here keep their marker and fall to the emit + * backstop exactly as before: instance-call markers, static calls whose declaring + * class is a *different* generic template, `static::`/`parent::` spellings (their + * default resolution would silently mis-ground, not fail), and forwards to a bare + * top-level function template (no container to append to from a detached walk). + * + * In `check` mode (`$emit = false`) nothing is attached; diagnostics only provable + * after substitution (a violated bound, a surviving marker) are collected so check + * and compile agree. + * + * @param list $classArgs the instantiation's concrete type arguments, + * parallel to the template's declared parameters + * @return list members appended onto containers other than the spec + * (user classes / namespaces) — the caller must collect + * these for nested instantiation discovery + */ + public function groundSpecializedClass( + ClassLike $specialized, + string $generatedFqn, + string $templateFqn, + array $classArgs, + bool $emit, + ): array { + $index = $this->retainedIndex; + if ($index === null) { + // process() never built an index (no templates anywhere) — with no method + // or function templates in the program there is nothing a marker could + // dispatch to; any survivor is the emit backstop's to report. + return []; + } + // Cheap pre-scan: most specs carry no marker; skip the visitor entirely then. + // skipUnspecializedTemplates matches the check-mode backstop below: in check + // mode a spec clone retains its (unstripped) generic-method templates, whose + // bodies carry call markers that the grounding walk deliberately skips — without + // this flag the pre-scan would see those and never take the cheap exit for any + // spec that declares a generic method. + if (GenericMarkerLeakGuard::findLeak( + $specialized, + includeClosureTemplates: false, + skipUnspecializedTemplates: true, + ) === null) { + return []; + } + + $currentFile = $this->classSourceByFqn[$templateFqn] ?? ""; + $grounding = new GroundingContext($specialized, $generatedFqn, $templateFqn, $classArgs); + /** @var list $topLevelAppends never grows in grounding mode (bare-template forwards keep their marker) */ + $topLevelAppends = []; + $this->rewriteCallSites( + [$specialized], + $index, + $this->alreadyGenerated, + $topLevelAppends, + $currentFile, + $emit, + $grounding, + ); + + // Check-mode parity backstop: compile rejects a still-marked spec at the emit + // loop's assertNoLeak; check has no emit loop, so collect the equivalent + // diagnostic here (call-marker arm only; sites the Phase-1a walk already + // reported — e.g. an unspecializable `$this` self-call — dedupe by position). + if (!$emit && $this->diagnostics !== null) { + // Variable-turbofish markers and the interiors of retained generic-method + // templates are excluded: check never materializes closure dispatchers nor + // strips templates, so a class-spec clone legitimately carries a `$f::` + // marker (compile's dispatcher pass grounds it) and template bodies with + // method-param markers (compile clones stripped classes) — flagging either + // would reject code compile accepts. + $leak = GenericMarkerLeakGuard::findLeak( + $specialized, + includeClosureTemplates: false, + includeVariableTurbofish: false, + skipUnspecializedTemplates: true, + ); + if ($leak !== null && !$this->alreadyReportedAt($currentFile, $leak->getStartLine())) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMarkerLeakGuard::CODE, + GenericMarkerLeakGuard::leakMessage($leak, $generatedFqn), + new SourceLocation($currentFile, $leak->getStartLine()), + )); + } + } + + return $grounding->externalAppends; + } + /** * @param list $ast * @param array $methodTemplates out-param @@ -386,6 +528,7 @@ private function rewriteCallSites( array &$topLevelAppends, string $currentFile, bool $emit, + ?GroundingContext $grounding = null, ): void { $hashLength = $this->hashLength; $hierarchy = $this->hierarchy; @@ -397,7 +540,7 @@ private function rewriteCallSites( // @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($index, $alreadyGenerated, $hashLength, $hierarchy, $topLevelAppends, $diagnostics, $currentFile, $closureValidator) extends NodeVisitorAbstract { + $visitor = new class($index, $alreadyGenerated, $hashLength, $hierarchy, $topLevelAppends, $diagnostics, $currentFile, $closureValidator) extends NodeVisitorAbstract implements ExpressionTyper { private string $currentNamespace = ''; private ?Namespace_ $currentNamespaceNode = null; /** @var array alias => fqn */ @@ -410,9 +553,40 @@ private function rewriteCallSites( */ private NamespaceContext $nsContext; - /** @var list */ + /** + * Buffered specialized-member appends, flushed (and drained for freshly + * grounded markers) after the traversal. Slot 2 is the drain context: the + * declaring class FQN (methods; null for functions) and the namespace the + * specialized body resolves against — a drained stmt is traversed DETACHED, + * so the visitor's namespace/class state must be primed per item rather + * than inherited from whatever file the main walk last visited. + * + * @var list + */ public array $pendingAppends = []; + /** + * Markers-only mode for drain re-traversals of freshly specialized bodies. + * When true the rewrite pass touches ONLY named-call turbofish markers that + * substitution has made concrete; everything else — plain-call closure-arg + * sweeps, closure-dispatcher tracking (finalize has already run), orphan + * re-checks, static/instance marker rewrites (their name resolution is not + * drain-safe yet; a kept marker falls to the leak guard exactly as before) — + * is skipped so a drained body can neither duplicate diagnostics already + * reported against the template nor mis-ground through stale file state. + */ + public bool $markersOnly = false; + + /** + * Set (together with markersOnly) when this walk grounds a freshly + * specialized CLASS ({@see GenericMethodCompiler::groundSpecializedClass}). + * Widens the markers-only pass to static-call markers — their resolution + * is drain-safe here because names are attribute-resolved and the spec's + * own identity is threaded — and drives the append-target rule: an + * own-template member lands on the spec, deduped per specialization. + */ + public ?GroundingContext $grounding = null; + /** Receiver-type analysis state. Pushed on entering ClassLike, popped on leave. */ private ?string $currentClassFqn = null; /** @@ -472,6 +646,18 @@ private function rewriteCallSites( * @var array */ private array $currentScopeClosureTemplates = []; + /** + * In-scope generic type-parameter names of the enclosing function/method/closure scopes, + * accumulated through nesting. A call/`new` argument whose declared type IS one of these + * is abstract here (it's a type parameter, not a concrete class), so it must NOT seed + * inference — otherwise a bare `identity($x)` inside `outer(U $x)` would infer + * `identity::` and emit a specialization referencing the non-existent class `U`. + * Class-level type parameters are read dynamically from {@see $currentClassFqn} in + * {@see typeOf}. Mirrors {@see NewInferencePass::enclosingTypeParamNames}. + * + * @var array + */ + private array $currentScopeTypeParamNames = []; /** * Parallel to `currentScopeClosureTemplates`: the Assign node and * lexical-scope info that introduced each generic anonymous template. @@ -522,7 +708,7 @@ private function rewriteCallSites( * 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, paramArgs: array>, localArgs: array>, branches: list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, perBranchArgs: list>>, armIndex: int}>, closureTemplates: array, closureContexts: array}> + * @var list, locals: array, paramArgs: array>, localArgs: array>, typeParamNames: array, branches: list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, perBranchArgs: list>>, armIndex: int}>, closureTemplates: array, closureContexts: array}> */ private array $scopeSnapshots = []; /** @@ -580,10 +766,57 @@ public function __construct( private readonly ?ClosureConformanceValidator $closureValidator, ) { $this->nsContext = new NamespaceContext(); + $this->literalTyper = new LiteralTyper(); } - public function enterNode(Node $node): null + /** + * Types literals and `new` for {@see typeOf} — the context-free half of argument typing; + * the flow-dependent half (variables, `$this->prop`, call returns) is answered by this + * visitor's own receiver/scope resolvers. + */ + private readonly LiteralTyper $literalTyper; + + /** + * Reset the visitor's lexical state for one drained (detached) specialized + * stmt. The stmt is traversed outside any Namespace_/Use_/ClassLike parent, + * so enterNode never primes this state — left stale it would resolve names + * against whatever file the main traversal last walked. The use-alias map is + * cleared rather than reconstructed: names the drain needs are attribute- + * resolved (ATTR_TEMPLATE_FQN / ATTR_RESOLVED_FQN at parse time), so aliases + * are never consulted on the markers-only path. + */ + public function primeDrainScope(?string $classFqn, string $namespace): void { + $this->currentClassFqn = $classFqn; + $this->currentNamespace = $namespace; + $this->currentNamespaceNode = null; + $this->useMap = []; + $this->nsContext = new NamespaceContext(); + $this->nsContext->enterNamespace($namespace !== '' ? $namespace : null); + $this->currentScopeParamTypes = []; + $this->currentScopeLocalTypes = []; + $this->currentScopeParamTypeArgs = []; + $this->currentScopeLocalTypeArgs = []; + $this->currentScopeTypeParamNames = []; + $this->branchSnapshots = []; + $this->scopeSnapshots = []; + $this->currentScopeClosureTemplates = []; + $this->currentScopeClosureContexts = []; + $this->callReturnCache = []; + } + + public function enterNode(Node $node): null|int + { + // A markers-only walk skips generic declarations still carrying their + // template marker wholesale: in check mode nothing is stripped, so a + // spec clone contains the generic-method templates themselves, whose + // method-param-leaf markers the Phase-1a walk already validated — + // re-walking them would duplicate diagnostics (or false-flag closure + // templates the template walk already handled). leaveNode mirrors the + // test so it never pops a scope this skip never pushed. + if ($this->markersOnly && self::isUnspecializedTemplateDeclaration($node)) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } if ($node instanceof Namespace_) { $this->currentNamespace = $node->name?->toString() ?? ''; $this->currentNamespaceNode = $node; @@ -625,11 +858,13 @@ public function enterNode(Node $node): null $parentLocals = $this->currentScopeLocalTypes; $parentParamArgs = $this->currentScopeParamTypeArgs; $parentLocalArgs = $this->currentScopeLocalTypeArgs; + $parentTypeParamNames = $this->currentScopeTypeParamNames; $this->scopeSnapshots[] = [ 'params' => $parentParams, 'locals' => $parentLocals, 'paramArgs' => $parentParamArgs, 'localArgs' => $parentLocalArgs, + 'typeParamNames' => $parentTypeParamNames, '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 @@ -645,6 +880,18 @@ public function enterNode(Node $node): null $this->currentScopeClosureTemplates = []; $this->currentScopeClosureContexts = []; + // Type parameters accumulate through nesting: this scope sees the enclosing + // scopes' type params plus its own. Used to keep a type-param-typed argument + // from seeding inference (it's abstract here, not a concrete class). + $this->currentScopeTypeParamNames = $parentTypeParamNames; + $ownTypeParams = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (is_array($ownTypeParams)) { + /** @var list $ownTypeParams */ + foreach ($ownTypeParams as $ownTypeParam) { + $this->currentScopeTypeParamNames[$ownTypeParam->name] = true; + } + } + // For closures: `use ($x)` explicitly imports outer variables. // Copy each imported name's type from the parent scope so the // closure body can specialize `$x->m::(...)` correctly. @@ -855,18 +1102,73 @@ public function enterNode(Node $node): null public function leaveNode(Node $node): ?Node { + // Markers-only re-walks must not re-diagnose a site the Phase-1a walk + // already reported (one collector message per source position): skip + // the rewrite wholesale — the kept marker falls to the backstops, which + // dedupe by the same position and stay silent. + if ($this->markersOnly + && ($node instanceof StaticCall || $node instanceof FuncCall + || $node instanceof MethodCall || $node instanceof NullsafeMethodCall) + && $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) !== null + && $this->siteAlreadyReported($node->getStartLine()) + ) { + return null; + } if ($node instanceof StaticCall) { + if ($this->markersOnly) { + // Phase-1a drain (no grounding context): static resolution is + // not drain-safe there; a kept marker falls to the leak guard + // exactly as it did before the drain existed. Grounding a + // specialized class DOES process static markers — but only + // marker-bearing ones, and never the `static::`/`parent::` + // spellings: `resolveClassName` maps both to the current class, + // which would silently mis-ground a late-bound or parent-side + // dispatch; keeping the marker fails loudly instead. + if ($this->grounding === null + || $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) === null + || $this->isLateBoundPseudoName($node->class) + ) { + return null; + } + } return $this->rewriteStaticCall($node); } if ($node instanceof FuncCall) { + // Drain traversals rewrite ONLY named-call turbofish markers: dispatch + // is ATTR_TEMPLATE_FQN-driven (no lexical resolution), so a detached + // body grounds safely. Bare calls and variable turbofish (`$f::<...>`) + // are skipped — dispatcher finalize has already run, and a surviving + // variable marker stays for the leak guard's closure arm. + if ($this->markersOnly + && (!$node->name instanceof Name + || $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) === null) + ) { + return null; + } return $this->rewriteFuncCall($node); } if ($node instanceof MethodCall || $node instanceof NullsafeMethodCall) { + // Same rule as StaticCall: the Phase-1a drain leaves instance + // markers for the leak guard, but grounding a specialized class + // processes them — receiver identity/args come from the threaded + // context (`$this` = the spec) or the spec's already-substituted + // declared types. + if ($this->markersOnly + && ($this->grounding === null + || $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) === null) + ) { + return null; + } return $this->rewriteInstanceMethodCall($node); } if ($node instanceof ClassLike) { $this->currentClassFqn = null; } + // Mirror of enterNode's markers-only template skip: the enter never + // pushed a scope for this declaration, so the pop below must not run. + if ($this->markersOnly && self::isUnspecializedTemplateDeclaration($node)) { + return null; + } if ($node instanceof Function_ || $node instanceof ClassMethod || $node instanceof Closure @@ -878,6 +1180,7 @@ public function leaveNode(Node $node): ?Node $this->currentScopeLocalTypes = $snapshot['locals']; $this->currentScopeParamTypeArgs = $snapshot['paramArgs']; $this->currentScopeLocalTypeArgs = $snapshot['localArgs']; + $this->currentScopeTypeParamNames = $snapshot['typeParamNames']; $this->branchSnapshots = $snapshot['branches']; $this->currentScopeClosureTemplates = $snapshot['closureTemplates']; $this->currentScopeClosureContexts = $snapshot['closureContexts']; @@ -890,6 +1193,7 @@ public function leaveNode(Node $node): ?Node $this->currentScopeLocalTypes = []; $this->currentScopeParamTypeArgs = []; $this->currentScopeLocalTypeArgs = []; + $this->currentScopeTypeParamNames = []; $this->branchSnapshots = []; $this->currentScopeClosureTemplates = []; $this->currentScopeClosureContexts = []; @@ -1197,6 +1501,29 @@ private function rewriteStaticCall(StaticCall $node): ?Node return $this->reportUnresolvedTurbofishOrSkip($classFqn, $methodName, $node); } [$template, $declaringFqn] = $resolved; + // Grounding mode, generic declaring template: groundable ONLY when the + // call site lexically lives inside the spec being grounded (a drained + // body appended onto ANOTHER class must not dispatch through this + // spec's `self::` — that emits a call to a member the other class + // doesn't have), AND the declaring template's parameters are threadable + // from the spec's own concrete arguments — its own template, or a + // generic ancestor through the extends chain. Anything else (a + // different generic template, an unthreadable chain, a foreign drained + // body) keeps its marker for the emit backstop. + $groundingClassSubst = null; + if ($this->grounding !== null && $this->isGenericTemplateClass($declaringFqn)) { + if ($this->currentClassFqn !== $this->grounding->templateFqn) { + return null; + } + $groundingClassSubst = $this->classSubstitutionFor( + $this->grounding->templateFqn, + $this->grounding->classArgs, + $declaringFqn, + ); + if ($groundingClassSubst->isEmpty()) { + return null; + } + } $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); if (!is_array($params)) { return null; @@ -1212,14 +1539,13 @@ private function rewriteStaticCall(StaticCall $node): ?Node 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 = []; + // Optional turbofish: infer the method's type arguments from the call arguments. + // A miss yields [] and falls through to padArgsWithDefaults, which pads an + // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise — + // never a silent skip that emits a call to the stripped method and fatals at runtime. + $args = $this->inferCallTypeArgs($params, $template->params, $node->args) ?? []; } - /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or empty after the all-defaults branch above). */ + /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or inferred/empty above). */ $location = new SourceLocation($this->currentFile, $node->getStartLine()); $padded = Registry::padArgsWithDefaults($params, $args, $key, $this->diagnostics, $location); if (!self::allConcrete($padded) || count($params) !== count($padded)) { @@ -1273,6 +1599,52 @@ private function rewriteStaticCall(StaticCall $node): ?Node } $mangled = self::mangleName($methodName, $args, $this->hashLength); + + // Grounding a target declared on the spec's own template (or a generic + // ancestor — $groundingClassSubst threads the spec's arguments through + // the extends chain): the member lands on the spec itself — the + // template class lowers to a marker interface in output, so an append + // there would vanish (and mutating a shared template mid-loop would be + // order-dependent). Dedup per specialization, but consult the global + // key first: a member a concrete Phase-1a call already appended onto + // the template was cloned INTO this spec, and appending again would + // redeclare the method (load-time fatal). + if ($this->grounding !== null && $groundingClassSubst !== null) { + $templateKey = $declaringFqn . '::' . $mangled; + $specKey = $this->grounding->generatedFqn . '::' . $mangled; + if (!isset($this->alreadyGenerated[$templateKey]) && !isset($this->alreadyGenerated[$specKey])) { + // Compose the declaring class's substitution under the method's + // own overlay: the detached template's body may reference class + // type parameters, which are concrete for THIS spec only. + $overlay = []; + foreach ($params as $i => $param) { + $overlay[$param->name] = $args[$i]; + } + $specialized = (new Specializer())->specializeMethod( + $template, + $groundingClassSubst->withOverrides(Substitution::of($overlay)), + $mangled, + ); + // Drain context = the SPEC's template, not the declaring class: + // the member now lives on the spec, so `$this`/`self` inside its + // drained body are the spec — an ancestor-declared member whose + // body forwards again (`self::genB::` on Base) must pass the + // in-spec site guard, or a groundable chain leaks spuriously. + $this->pendingAppends[] = [$this->grounding->spec, $specialized, [ + 'classFqn' => $this->grounding->templateFqn, + 'namespace' => self::namespaceOf($this->grounding->templateFqn), + ]]; + $this->alreadyGenerated[$specKey] = true; + } + $node->name = new Identifier($mangled, $node->name->getAttributes()); + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); + // Dispatch through `self`: the member lives on the emitted spec, and + // the template FQN spelling (`Box::gen` / `\App\Box::gen`) would + // resolve to the stripped marker interface. + $node->class = new Name('self', $node->class->getAttributes()); + return $node; + } + // Emit onto the declaring class (see the instance path) so subclasses // inherit the single specialization; dedup by the declaring FQN. $generatedKey = $declaringFqn . '::' . $mangled; @@ -1285,7 +1657,10 @@ private function rewriteStaticCall(StaticCall $node): ?Node $owner = $this->index->classLike($declaringFqn); if ($owner !== null) { // Buffer the append (see rewriteFuncCall for the rationale). - $this->pendingAppends[] = [$owner, $specialized]; + $this->pendingAppends[] = [$owner, $specialized, [ + 'classFqn' => $declaringFqn, + 'namespace' => self::namespaceOf($declaringFqn), + ]]; $this->alreadyGenerated[$generatedKey] = true; } } @@ -1357,14 +1732,13 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): 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 = []; + // Optional turbofish: infer the method's type arguments from the call arguments. + // A miss yields [] and falls through to padArgsWithDefaults, which pads an + // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise — + // never a silent skip that emits a call to the stripped method and fatals at runtime. + $args = $this->inferCallTypeArgs($params, $template->params, $node->args) ?? []; } - /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or empty after the all-defaults branch above). */ + /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or inferred/empty above). */ $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 @@ -1375,15 +1749,21 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): 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. + // A non-concrete turbofish arg on a `$this`-rooted receiver can't be + // specialized at the template — the arg is concrete only per + // instantiation. Three shapes: + // - ERASABLE target: the Specializer rewrites the self-call to the + // E-mangled member per instantiation; leave it for that pass. + // - Every abstract leaf is an ENCLOSING CLASS parameter + // (`$this->dup::(...)` inside `Box`): defer — the + // per-specialization grounding pass dispatches it once the class + // substitution makes it concrete. + // - A METHOD-level parameter leaf (`probe{ $this->dup::(...) }` + // on a non-erasable target): nothing downstream ever grounds it — + // report here, at the precise site, instead of a vaguer late leak. if ($this->receiverRootedAtThis($node->var) && !$this->isErasableTarget($template, $params, $declaringFqn) + && !$this->abstractLeavesAreEnclosingClassParams($padded) ) { return $this->reportUnspecializableSelfCall($methodName, $location); } @@ -1468,6 +1848,54 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): } $mangled = self::mangleName($methodName, $args, $this->hashLength); + + // Grounding mode, generic declaring class: only a `$this`-rooted call + // FROM INSIDE THE SPEC BEING GROUNDED is groundable — there the + // receiver IS this spec, so the declaring template's substitution + // threads through the inheritance chain from the spec's own concrete + // args, and the member lands on the spec itself (the template lowers + // to a marker interface; mutating it mid-loop would be + // order-dependent). An OBJECT receiver of another generic template, or + // a `$this` inside a DRAINED body appended onto some other class + // (where `$this` is that class, not this spec), keeps its marker for + // the emit backstop. + if ($this->grounding !== null && $this->isGenericTemplateClass($declaringFqn)) { + if (!$this->receiverRootedAtThis($node->var) + || $this->currentClassFqn !== $this->grounding->templateFqn + ) { + return null; + } + $templateKey = $declaringFqn . '::' . $mangled; + $specKey = $this->grounding->generatedFqn . '::' . $mangled; + if (!isset($this->alreadyGenerated[$templateKey]) && !isset($this->alreadyGenerated[$specKey])) { + $classSubst = $this->classSubstitutionFor( + $classFqn, + $this->resolveReceiverTypeArgs($node->var), + $declaringFqn, + ); + $overlay = []; + foreach ($params as $i => $param) { + $overlay[$param->name] = $args[$i]; + } + $specialized = (new Specializer())->specializeMethod( + $template, + $classSubst->withOverrides(Substitution::of($overlay)), + $mangled, + ); + // Drain context = the SPEC's template (see the static arm): the + // member lives on the spec, so its drained body's `$this`/`self` + // are the spec and further own-chain forwards keep grounding. + $this->pendingAppends[] = [$this->grounding->spec, $specialized, [ + 'classFqn' => $this->grounding->templateFqn, + 'namespace' => self::namespaceOf($this->grounding->templateFqn), + ]]; + $this->alreadyGenerated[$specKey] = true; + } + $node->name = new Identifier($mangled, $node->name->getAttributes()); + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); + return $node; + } + // 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. @@ -1480,7 +1908,10 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): $specialized = (new Specializer())->specializeMethod($template, Substitution::of($overlay), $mangled); $owner = $this->index->classLike($declaringFqn); if ($owner !== null) { - $this->pendingAppends[] = [$owner, $specialized]; + $this->pendingAppends[] = [$owner, $specialized, [ + 'classFqn' => $declaringFqn, + 'namespace' => self::namespaceOf($declaringFqn), + ]]; $this->alreadyGenerated[$generatedKey] = true; } } @@ -1662,6 +2093,12 @@ private function reportUnresolvedTurbofishOrSkip(string $receiverFqn, string $me // Plain (non-turbofish) call -- not a generic-resolution failure. return null; } + // Markers-only walks: the Phase-1a walk already diagnosed this site (the + // clone keeps its source position) — skip; a survivor falls to the emit + // backstop / check-mode leak diagnostic, which dedupe by position. + if ($this->markersOnly) { + return null; + } $message = self::unresolvedGenericCallMessage($receiverFqn, $methodName); if ($this->diagnostics !== null) { $this->diagnostics->add(new Diagnostic( @@ -1702,6 +2139,13 @@ private function reportUndeterminedReceiverOrSkip(string $methodName, Node $node if (!is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS))) { return null; } + // Markers-only walks re-visit call sites the Phase-1a walk already + // diagnosed (the clone keeps the same source position): skip instead of + // double-reporting; a genuinely un-grounded survivor still falls to the + // emit backstop / check-mode leak diagnostic, which dedupe by position. + if ($this->markersOnly) { + 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 ' @@ -1757,8 +2201,8 @@ private function reportUnspecializableSelfCall(string $methodName, SourceLocatio 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::<...>(...)`.', + 'Generic call `%s(...)` is missing its type arguments: they could not be inferred ' + . 'from the call arguments, so call it with an explicit turbofish `%s::<...>(...)`.', $label, $label, ); @@ -1891,6 +2335,15 @@ private function resolveReceiverTypeArgs(Node $receiver): array { if ($receiver instanceof Variable && is_string($receiver->name)) { if ($receiver->name === 'this') { + // Grounding a spec: `$this` IS the specialization — but ONLY + // for sites inside the spec's own body. In a drained body + // appended onto another class, `$this` is that class; fall + // through to the ordinary (lenient) resolution there. + if ($this->grounding !== null + && $this->currentClassFqn === $this->grounding->templateFqn + ) { + return $this->grounding->classArgs; + } $owner = $this->currentClassFqn !== null ? ($this->index->classLike($this->currentClassFqn)) : null; @@ -2221,6 +2674,197 @@ private static function boundHasUngroundedLeaf(BoundExpr $bound): bool return false; } + /** + * Infer a bare generic call's type arguments from its ordinary arguments' static types, + * so the turbofish is optional wherever the arguments determine it. Returns the inferred + * concrete arguments (ready to dispatch exactly as an explicit turbofish would), or null + * to fall back to today's missing-turbofish handling. Inference is skipped without a type + * hierarchy (nothing to ground subtypes or run the identical bound checks against), + * matching how the seams below guard their bound checks. + * + * @param list $typeParams the callee's generic parameters + * @param array $valueParams the callee's value parameters (template AST) + * @param array $args the call-site arguments + * @return list|null + */ + private function inferCallTypeArgs(array $typeParams, array $valueParams, array $args): ?array + { + if ($this->hierarchy === null) { + return null; + } + return (new TypeInference($this->hierarchy))->infer($typeParams, $valueParams, $args, $this); + } + + /** + * The concrete static type of an argument expression, for {@see TypeInference}. Literals + * and `new` are delegated to the context-free {@see LiteralTyper}; a variable, `$this` + * property, or call return is answered from this visitor's own receiver/scope tracking. + * Anything not statically determinable — an untyped local, a scalar flow value, an + * abstract (still-templated) type — is null, so its parameter is left unconstrained. + */ + public function typeOf(Node\Expr $expr): ?TypeRef + { + $literal = $this->literalTyper->typeOf($expr); + if ($literal !== null) { + return $literal; + } + if ($expr instanceof Variable && is_string($expr->name)) { + $fqn = $this->currentScopeParamTypes[$expr->name] + ?? $this->currentScopeLocalTypes[$expr->name] + ?? null; + if ($fqn === null || $this->isInScopeTypeParam($fqn)) { + // Unknown, or the variable's declared type is an enclosing type parameter — + // abstract here, so not a concrete inference source. + return null; + } + $args = $this->currentScopeParamTypeArgs[$expr->name] + ?? $this->currentScopeLocalTypeArgs[$expr->name] + ?? []; + return self::concreteOrNull(new TypeRef($fqn, $args)); + } + if ($expr instanceof PropertyFetch + && $expr->var instanceof Variable + && $expr->var->name === 'this' + && $expr->name instanceof Identifier + ) { + return $this->typeOfThisProperty($expr->name->toString()); + } + if ($expr instanceof MethodCall || $expr instanceof NullsafeMethodCall || $expr instanceof StaticCall) { + $return = $this->resolveCallReturn($expr); + if ($return === null || $this->isInScopeTypeParam($return[0])) { + return null; + } + return self::concreteOrNull(new TypeRef($return[0], $return[1])); + } + return null; + } + + /** + * Whether a resolved type name refers to a generic type parameter in scope — an enclosing + * function/method/closure parameter ({@see $currentScopeTypeParamNames}) or an enclosing + * class parameter. Such a name is abstract at this site (a type parameter shadows a + * same-named class), so a value of that type must not seed inference — otherwise a bare + * `identity($x)` inside `outer(U $x)` would infer `identity::` and emit a call to a + * non-existent class. Mirrors {@see NewInferencePass}'s use of paramTypeRef's type-param set. + */ + private function isInScopeTypeParam(string $fqn): bool + { + $short = self::lastSegment(ltrim($fqn, '\\')); + if (isset($this->currentScopeTypeParamNames[$short])) { + return true; + } + if ($this->currentClassFqn === null) { + return false; + } + $classParams = $this->index->classLike($this->currentClassFqn) + ?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($classParams)) { + return false; + } + /** @var list $classParams */ + foreach ($classParams as $classParam) { + if ($classParam->name === $short) { + return true; + } + } + return false; + } + + /** + * The declared type of `$this->$propName` as a concrete TypeRef, or null when the class, + * property, or its type cannot be determined (an unknown class, a promoted-constructor or + * union-typed property, or a type that is not yet concrete in this template). + */ + private function typeOfThisProperty(string $propName): ?TypeRef + { + if ($this->currentClassFqn === null) { + return null; + } + $owner = $this->index->classLike($this->currentClassFqn); + if ($owner === null) { + return null; + } + 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) { + return null; + } + $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + /** @var list $argRefs */ + $argRefs = is_array($args) ? $args : []; + $fqn = $this->resolveClassName($type); + if ($this->isInScopeTypeParam($fqn)) { + // A property typed by the class's own type parameter (`private T $value` + // in `Box`) is abstract here — not a concrete inference source. + return null; + } + return self::concreteOrNull(new TypeRef($fqn, $argRefs)); + } + } + return null; + } + + /** A type is a basis for inference only when fully concrete; an abstract one is null. */ + private static function concreteOrNull(TypeRef $ref): ?TypeRef + { + return $ref->isConcrete() ? $ref : null; + } + + /** + * Try to infer a bare free-function call's type arguments; on success, annotate the node + * as if the turbofish had been written — so {@see rewriteFuncCall} re-dispatches it down + * the identical explicit-turbofish path — and return true. Only free-function calls (a + * Name callee) are inferred; a bare generic *closure* call ($var) keeps the explicit- + * turbofish requirement (deferred). The inferred prefix is padded to full arity with the + * template's defaults so the annotation carries the exact tuple a turbofish would. + * + * @param list $typeParams + */ + private function tryInferFuncCall(FuncCall $node, array $typeParams): bool + { + if (!$node->name instanceof Name) { + return false; + } + $fqn = $this->resolveGenericFunctionFqn($node->name); + if ($fqn === null) { + return false; + } + $template = $this->index->functionTemplate($fqn); + if ($template === null) { + return false; + } + $inferred = $this->inferCallTypeArgs($typeParams, $template->params, $node->args); + if ($inferred === null) { + return false; + } + // Free-function dispatch requires an exact-arity tuple (it does not pad), so fill any + // defaulted tail here. Inference only ever leaves a defaultable tail unbound, so this + // never reports — it yields the same complete tuple an explicit turbofish would. + $padded = Registry::padArgsWithDefaults( + $typeParams, + $inferred, + $fqn, + $this->diagnostics, + new SourceLocation($this->currentFile, $node->getStartLine()), + ); + if (count($padded) !== count($typeParams) || !self::allConcrete($padded)) { + return false; + } + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, $padded); + $node->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, $fqn); + return true; + } + private function rewriteFuncCall(FuncCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); @@ -2235,6 +2879,12 @@ private function rewriteFuncCall(FuncCall $node): ?Node if (!$node->isFirstClassCallable()) { $bare = $this->resolveBareGenericCall($node); if ($bare !== null) { + // Optional turbofish: infer the type arguments from the call arguments + // and re-dispatch as if they had been written. Only when that fails is a + // bare generic call the missing-type-arguments error it is today. + if ($this->tryInferFuncCall($node, $bare[0])) { + return $this->rewriteFuncCall($node); + } $this->reportMissingTurbofishArguments( $bare[1], new SourceLocation($this->currentFile, $node->getStartLine()), @@ -2358,6 +3008,14 @@ private function rewriteFuncCall(FuncCall $node): ?Node $generatedKey = 'fn::' . $mangledFqn; if (!isset($this->alreadyGenerated[$generatedKey])) { + // Grounding a specialized class: a BARE top-level function template + // has no container node, and the top-level bag routes into whatever + // file the walk was invoked for — which, for a detached spec, is no + // file at all (the append would be dropped silently). Keep the + // marker instead; the emit backstop rejects it loudly. + if ($this->grounding !== null && $this->index->functionNamespaceNode($fqn) === null) { + return null; + } $overlay = []; foreach ($params as $i => $param) { $overlay[$param->name] = $args[$i]; @@ -2368,7 +3026,10 @@ private function rewriteFuncCall(FuncCall $node): ?Node // Buffer the append — modifying $namespaceNode->stmts mid-traversal // doesn't reliably propagate through nikic's NodeTraverser. The // outer process() loop flushes pendingAppends after the walk. - $this->pendingAppends[] = [$namespaceNode, $specialized]; + $this->pendingAppends[] = [$namespaceNode, $specialized, [ + 'classFqn' => null, + 'namespace' => $namespace, + ]]; } else { // Bare top-level template (no enclosing `namespace { }` block): // there's no container to append to, so route the specialized @@ -2589,6 +3250,15 @@ private function buildForwardingFccClosure(string $varName, string $tag, FuncCal private function resolveClassName(Name $name): string { + // Markers-only walks run over DETACHED bodies with no use-alias state + // (primeDrainScope clears the map): the parse-time resolution attribute + // is authoritative there. Pseudo-names (`self`/`static`/`parent`) never + // carry it and keep the enclosing-class mapping below; the normal + // Phase-1a walk keeps its lexical resolution byte-identical. + $resolvedAttr = $name->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + if ($this->markersOnly && is_string($resolvedAttr)) { + return $resolvedAttr; + } $raw = $name->toString(); // Pseudo-types short-circuit to the enclosing class FQN. Without this, // a parameter typed `self` would resolve to `App\…\self` (a phantom @@ -2737,41 +3407,340 @@ private static function lastSegment(string $name): string $pos = strrpos($name, '\\'); return $pos === false ? $name : substr($name, $pos + 1); } + + /** The namespace part of an FQN ('' for a global-namespace symbol). */ + private static function namespaceOf(string $fqn): string + { + $pos = strrpos($fqn, '\\'); + return $pos === false ? '' : substr($fqn, 0, $pos); + } + + /** + * Whether the collector already holds a diagnostic at this source position + * (clones keep the template's line numbers). Markers-only walks re-visit + * call sites the Phase-1a walk already validated: re-running the rewrite on + * a site that already drew a diagnostic (an arity error, a bound failure) + * would re-fire the same collector message once per specialization. + */ + private function siteAlreadyReported(int $line): bool + { + if ($this->diagnostics === null) { + return false; + } + foreach ($this->diagnostics->all() as $diagnostic) { + // Errors only: a same-line WARNING must not suppress grounding — + // that would mask a real error the grounded walk would surface + // (check-green on code compile rejects, the dangerous direction). + if ($diagnostic->severity === Severity::Error + && $diagnostic->location !== null + && $diagnostic->location->file === $this->currentFile + && $diagnostic->location->line === $line + ) { + return true; + } + } + return false; + } + + /** + * A function/method/closure declaration still carrying its generic-template + * marker — never present in a compile-mode spec (templates are stripped or + * lowered before cloning), but present in check-mode clones; markers-only + * walks skip them wholesale (see enterNode). + */ + private static function isUnspecializedTemplateDeclaration(Node $node): bool + { + return ($node instanceof ClassMethod + || $node instanceof Function_ + || $node instanceof Closure + || $node instanceof ArrowFunction) + && is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)); + } + + /** + * A `static::` / `parent::` class spelling — late-bound (or parent-side) + * dispatch that `resolveClassName`'s currentClassFqn mapping would silently + * mis-ground in a detached grounding walk. + */ + private function isLateBoundPseudoName(Node $class): bool + { + if (!$class instanceof Name) { + return false; + } + $first = strtolower($class->getParts()[0]); + return $first === 'static' || $first === 'parent'; + } + + /** + * Whether every abstract (type-param) leaf across the given TypeRef trees + * names a type parameter DECLARED BY THE ENCLOSING CLASS — the shape the + * per-specialization grounding pass can dispatch. A method-level parameter + * leaf (or any leaf when the enclosing class isn't generic) fails the test: + * no downstream pass ever grounds those. + * + * @param list $args + */ + private function abstractLeavesAreEnclosingClassParams(array $args): bool + { + if ($this->currentClassFqn === null) { + return false; + } + $classParams = $this->index->classLike($this->currentClassFqn) + ?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($classParams)) { + return false; + } + /** @var list $classParams — set as a list by XphpSourceParser::resolveAndAttach. */ + $names = []; + foreach ($classParams as $classParam) { + $names[$classParam->name] = true; + } + $walk = static function (TypeRef $ref) use (&$walk, $names): bool { + if ($ref->isTypeParam && !isset($names[$ref->name])) { + return false; + } + foreach ($ref->args as $inner) { + if (!$walk($inner)) { + return false; + } + } + return true; + }; + foreach ($args as $arg) { + if (!$walk($arg)) { + return false; + } + } + return true; + } + + /** Whether the FQN names a generic class template (declares type parameters). */ + private function isGenericTemplateClass(string $fqn): bool + { + $params = $this->index->classLike($fqn)?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + return is_array($params) && $params !== []; + } + }; + if ($grounding !== null) { + // Grounding a specialized class: markers-only walk, identity primed from + // the context (the spec clone is nameless and namespace-less — enterNode + // would never see a Namespace_/name to derive them from). + $visitor->markersOnly = true; + $visitor->grounding = $grounding; + $visitor->primeDrainScope( + $grounding->templateFqn, + self::namespacePrefixOf($grounding->templateFqn), + ); + } + $traverser = new NodeTraverser(); $traverser->addVisitor($visitor); $traverser->traverse($ast); // Runs BEFORE the emit gate: check mode must collect the orphan // diagnostics too (this is a validation, not an emission side-effect). - $this->rejectUnspecializedClosureTemplates($ast, $visitor->attemptedClosureTemplates, $currentFile); - - // 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; + // Skipped in grounding mode: a markers-only walk records no attempts, so an + // inner generic closure the template walk already handled would false-flag + // as an orphan on every specialization. + if ($grounding === null) { + $this->rejectUnspecializedClosureTemplates($ast, $visitor->attemptedClosureTemplates, $currentFile); } // 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 - // site to inject the tag arg. - $this->finalizeClosureDispatchers($visitor, $hashLength); - - // Apply buffered appends now that the traversal has finished, so we don't fight - // nikic's NodeTraverser's child-array iteration semantics mid-walk. Each appended - // node is a fully specialized function/method: guard it against a surviving generic - // marker (a site that could not be grounded) before it reaches emitted output — the - // function-shaped counterpart to the specialized-class backstop in Compiler's emit - // loop. Compile-only: the `!$emit` gate above already returned for `check`. - foreach ($visitor->pendingAppends as [$container, $stmt]) { - $container->stmts[] = $stmt; - GenericMarkerLeakGuard::assertNoLeak($stmt, $currentFile . ' (' . $stmt->name->toString() . ')'); + // site to inject the tag arg. Compile-only: it mutates shared Assign + // nodes and call sites, which check's discarded walk must not do. + if ($emit) { + $this->finalizeClosureDispatchers($visitor, $hashLength); } - foreach ($topLevelAppends as $stmt) { - GenericMarkerLeakGuard::assertNoLeak($stmt, $currentFile . ' (' . $stmt->name->toString() . ')'); + + // Drain the buffered appends now that the traversal has finished, so we don't + // fight nikic's NodeTraverser's child-array iteration semantics mid-walk. Runs + // in BOTH modes: compile attaches, grounds, and backstops each appended body; + // check re-traverses the (discarded) bodies validate-only so diagnostics that + // only become provable after substitution are collected — keeping check and + // compile verdicts aligned. + $this->drainSpecializedAppends($visitor, $currentFile, $emit); + } + + /** + * Flush the buffered specialized appends as a grounding worklist. + * + * Each buffered stmt is a freshly specialized function/method whose body may itself + * carry method-generic turbofish markers that substitution has made concrete + * (`identity::` inside `wrap` becomes `identity::` inside the buffered + * `wrap_T_`). A flat flush would emit those bodies ungrounded — the leak-guard + * backstop tripped on exactly that shape. Instead each stmt is attached (compile + * mode), then re-traversed with the same rewrite visitor in markers-only mode so a + * named-forward marker dispatches and may buffer further appends; the loop repeats + * until both queues drain. Same-args cycles (`a` forwarding to `b` forwarding + * back) terminate through the shared alreadyGenerated dedup — the second visit finds + * the key set and only rewrites the call. A strictly-growing chain mints a fresh + * mangled name every hop and is cut off at MAX_METHOD_SPECIALIZATION_HOPS with a + * loud non-convergence error instead of an endless compile. + * + * Check mode traverses without attaching (the walked ASTs are discarded) and + * degrades both the non-convergence error and the leak backstop to collected + * diagnostics, so `xphp check` reports the shapes `compile` rejects. + */ + private function drainSpecializedAppends(object $visitor, string $currentFile, bool $emit): void + { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $visitor->markersOnly = true; + // @infection-ignore-all UnwrapFinally — the reset is defensive hygiene: this drain is + // the visitor's last use (one visitor per rewriteCallSites call), so a leftover + // markersOnly=true is dead state today; the finally guards future reuse, not behavior. + try { + $pendingIdx = 0; + $topLevelIdx = 0; + /** @var array $hopDepth spl_object_id(stmt) => chain depth; absent = 1 (buffered by the user-code walk) */ + $hopDepth = []; + while (true) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + if ($pendingIdx < count($visitor->pendingAppends)) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + [$container, $stmt, $context] = $visitor->pendingAppends[$pendingIdx++]; + if ($emit) { + $container->stmts[] = $stmt; + } + // Grounding mode: a member appended onto anything but the spec + // itself (a non-generic user class, a function namespace) is + // invisible to the fixed-point loop's spec collection — record it + // so the caller can collect its nested instantiation needs. In + // check mode NOTHING is attached, so own-spec members are equally + // invisible to the spec collect and must be recorded too — or a + // bound violation nested in a grounded member's body (`new + // Pair::` inside `gen`) passes check while compile rejects. + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + if ($visitor->grounding !== null && (!$emit || $container !== $visitor->grounding->spec)) { + $visitor->grounding->externalAppends[] = $stmt; + } + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + } elseif ($topLevelIdx < count($visitor->topLevelAppends)) { + // Top-level (null-namespace) functions have no container node here; + // process() flushes them into the top-level AST array after this + // method returns. They are still grounded + leak-checked like any + // other append. + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $stmt = $visitor->topLevelAppends[$topLevelIdx++]; + $context = ['classFqn' => null, 'namespace' => '']; + } else { + break; + } + + // @infection-ignore-all IncrementInteger DecrementInteger — shifting the + // initial depth by one only offsets where the cap lands (16±1 hops); the + // behavior — a growing chain halts loudly with the unconverged code — is + // pinned by the growth fixtures, and the exact allowance is not contract. + $depth = $hopDepth[spl_object_id($stmt)] ?? 1; + if ($depth > self::MAX_METHOD_SPECIALIZATION_HOPS) { + $message = sprintf( + 'Generic method/function specialization did not converge: grounding "%s" ' + . '(in %s) is %d specialization hops deep — each hop mints a new type argument ' + . '(e.g. a generic forwarding to itself with a nested `Box`), so the chain ' + . 'would never terminate. Break the growth by forwarding a concrete turbofish. [%s]', + $stmt->name->toString(), + $currentFile, + $depth, + self::CODE_UNCONVERGED_METHOD_SPECIALIZATION, + ); + if (!$emit && $this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_UNCONVERGED_METHOD_SPECIALIZATION, + $message, + new SourceLocation($currentFile, $stmt->getStartLine()), + )); + continue; + } + throw new RuntimeException($message); + } + + // @phpstan-ignore-next-line method.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $visitor->primeDrainScope($context['classFqn'], $context['namespace']); + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $beforePending = count($visitor->pendingAppends); + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $beforeTopLevel = count($visitor->topLevelAppends); + + $traverser = new NodeTraverser(); + assert($visitor instanceof NodeVisitorAbstract); + $traverser->addVisitor($visitor); + $traverser->traverse([$stmt]); + + // @infection-ignore-all IncrementInteger Plus — a coarser per-hop increment + // only halves/offsets the cap allowance; growth still halts loudly with the + // unconverged code (pinned by the growth fixtures) at the same reported depth. + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + for ($i = $beforePending; $i < count($visitor->pendingAppends); $i++) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $hopDepth[spl_object_id($visitor->pendingAppends[$i][1])] = $depth + 1; + } + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + for ($i = $beforeTopLevel; $i < count($visitor->topLevelAppends); $i++) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $hopDepth[spl_object_id($visitor->topLevelAppends[$i])] = $depth + 1; + } + + $label = $currentFile . ' (' . $stmt->name->toString() . ')'; + if ($emit) { + GenericMarkerLeakGuard::assertNoLeak($stmt, $label); + } elseif ($this->diagnostics !== null) { + // Call-marker arm only: an un-specialized closure template in a + // drained body is always reported elsewhere (seam or orphan check). + $leak = GenericMarkerLeakGuard::findLeak($stmt, includeClosureTemplates: false); + // Suppress the backstop when the site already carries a diagnostic: + // the cloned body preserves the template's line numbers, so a shape + // the source seam rejected with a precise error (e.g. a non-concrete + // variable turbofish, CODE_UNSPECIALIZED_GENERIC_CLOSURE) would + // otherwise double-report here under the vaguer leak code. + if ($leak !== null && !$this->alreadyReportedAt($currentFile, $leak->getStartLine())) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMarkerLeakGuard::CODE, + GenericMarkerLeakGuard::leakMessage($leak, $label), + new SourceLocation($currentFile, $leak->getStartLine()), + )); + } + } + } + } finally { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $visitor->markersOnly = false; + } + } + + /** The namespace part of an FQN ('' for a global-namespace symbol). */ + private static function namespacePrefixOf(string $fqn): string + { + $pos = strrpos($fqn, '\\'); + return $pos === false ? '' : substr($fqn, 0, $pos); + } + + /** + * Whether the collector already holds a diagnostic at this exact source position. + * Used by the drain's check-mode backstop to avoid re-reporting a site the source + * seam rejected with a more precise code. + */ + private function alreadyReportedAt(string $file, int $line): bool + { + if ($this->diagnostics === null) { + return false; } + foreach ($this->diagnostics->all() as $diagnostic) { + // Errors only — a same-line warning must not swallow the leak backstop. + if ($diagnostic->severity === Severity::Error + && $diagnostic->location !== null + && $diagnostic->location->file === $file + && $diagnostic->location->line === $line + ) { + return true; + } + } + return false; } /** @@ -2857,7 +3826,10 @@ private function finalizeClosureDispatchers(object $visitor, int $hashLength): v foreach ($result['declarations'] as $specialized) { if ($entry['namespaceNode'] !== null) { // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. - $visitor->pendingAppends[] = [$entry['namespaceNode'], $specialized]; + $visitor->pendingAppends[] = [$entry['namespaceNode'], $specialized, [ + 'classFqn' => null, + 'namespace' => $entry['namespace'], + ]]; } else { // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. $visitor->topLevelAppends[] = $specialized; diff --git a/src/Transpiler/Monomorphize/GroundingContext.php b/src/Transpiler/Monomorphize/GroundingContext.php new file mode 100644 index 00000000..8b43bd16 --- /dev/null +++ b/src/Transpiler/Monomorphize/GroundingContext.php @@ -0,0 +1,48 @@ +` → `self::gen::`). Grounding them re-uses the Phase-1a + * rewrite machinery, but three decisions differ from a user-file walk and are driven by + * this context: + * + * - **identity**: the walked ClassLike is a nameless clone; `templateFqn` stands in as + * the current class FQN so `self::` resolution and method-template lookup key against + * the retained Phase-1a index, and `classArgs` are the instantiation's concrete type + * arguments (parallel to the template's declared parameters) for composing the class + * substitution into method specialization. + * - **append target**: a member specialized from the spec's own template lands on the + * spec itself (`spec`), deduped per specialization via `generatedFqn` — never on the + * template class, which lowers to a marker interface in emitted output. + * - **external collection**: members appended onto OTHER containers (a non-generic user + * class, a function namespace) are recorded in `externalAppends` so the fixed-point + * loop can collect their nested instantiation needs — user files were collected before + * these members existed. + */ +final class GroundingContext +{ + /** @var list members appended onto containers other than the spec */ + public array $externalAppends = []; + + /** + * @param list $classArgs concrete instantiation args, parallel to the + * template's declared type parameters + */ + public function __construct( + public readonly ClassLike $spec, + public readonly string $generatedFqn, + public readonly string $templateFqn, + public readonly array $classArgs, + ) { + } +} diff --git a/src/Transpiler/Monomorphize/LiteralTyper.php b/src/Transpiler/Monomorphize/LiteralTyper.php new file mode 100644 index 00000000..f25e77f1 --- /dev/null +++ b/src/Transpiler/Monomorphize/LiteralTyper.php @@ -0,0 +1,76 @@ +prop`, a call return — is out of scope here and + * yields null; the monomorphizer's own receiver/scope tracker answers those (and typically composes + * with this typer, delegating literal/`new` shapes to it). A `new` whose type is not fully concrete + * (an un-turbofished generic construction, or an anonymous/dynamic class) also yields null: this + * typer never invents a type it cannot read directly and completely. + */ +final class LiteralTyper implements ExpressionTyper +{ + public function typeOf(Expr $expr): ?TypeRef + { + if ($expr instanceof Int_) { + return new TypeRef('int', isScalar: true); + } + if ($expr instanceof Float_) { + return new TypeRef('float', isScalar: true); + } + if ($expr instanceof String_) { + return new TypeRef('string', isScalar: true); + } + if ($expr instanceof ConstFetch) { + $name = strtolower($expr->name->toString()); + return $name === 'true' || $name === 'false' + ? new TypeRef('bool', isScalar: true) + : null; + } + if ($expr instanceof Array_) { + return new TypeRef('array', isScalar: true); + } + if ($expr instanceof New_) { + return self::typeOfNew($expr); + } + return null; + } + + /** + * The constructed type of a `new X(...)` expression: the class's resolved FQN plus any + * turbofish type arguments the parser attached (`new Box::()` → `Box`). Null for a + * dynamic (`new $class()`) or anonymous class, and for a construction that is not fully concrete + * (`new Box::()` inside a template, or a bare generic `new Box(...)` awaiting its own + * inference) — an abstract or unresolved type is no basis for inferring another. + */ + private static function typeOfNew(New_ $new): ?TypeRef + { + if (!$new->class instanceof Name) { + return null; + } + $resolved = $new->class->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + $args = $new->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + /** @var list $argRefs */ + $argRefs = is_array($args) ? $args : []; + $ref = new TypeRef(is_string($resolved) ? $resolved : $new->class->toString(), $argRefs); + return $ref->isConcrete() ? $ref : null; + } +} diff --git a/src/Transpiler/Monomorphize/NewInferencePass.php b/src/Transpiler/Monomorphize/NewInferencePass.php new file mode 100644 index 00000000..d13007d7 --- /dev/null +++ b/src/Transpiler/Monomorphize/NewInferencePass.php @@ -0,0 +1,332 @@ + the enclosing class stack, for `$this`-property typing */ + private array $classStack = []; + /** @var list> per-function scope: variable name => trustworthy concrete type */ + private array $scopes = []; + + public function __construct( + private readonly Registry $registry, + TypeHierarchy $hierarchy, + ) { + $this->ctx = new NamespaceContext(); + $this->literalTyper = new LiteralTyper(); + $this->inference = new TypeInference($hierarchy); + } + + /** @param array> $astPerFile */ + public function run(array $astPerFile): void + { + foreach ($astPerFile as $ast) { + $this->ctx = new NamespaceContext(); + $this->classStack = []; + $this->scopes = []; + $traverser = new NodeTraverser(); + $traverser->addVisitor($this); + $traverser->traverse($ast); + } + } + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->ctx->enterNamespace($node->name?->toString()); + } + if ($node instanceof Use_) { + $this->ctx->indexUse($node); + } elseif ($node instanceof GroupUse) { + $this->ctx->indexGroupUse($node); + } + if ($node instanceof ClassLike) { + $this->classStack[] = $node; + } + if ($node instanceof FunctionLike) { + $this->scopes[] = $this->scopeForFunction($node); + } + return null; + } + + public function leaveNode(Node $node): null + { + // Infer on leave (bottom-up): a nested `new` argument must be annotated with its own + // inferred type arguments BEFORE its enclosing `new` reads it, or `new Box(new Box(5))` + // would type the inner as the raw `Box` template and infer `Box` instead of + // `Box>` — a specialization the explicit turbofish would never produce. The + // namespace context, class stack, and scope are still in place here: those are popped + // only when the enclosing ClassLike/FunctionLike leaves, which is strictly later. + if ($node instanceof New_ + && $node->class instanceof Name + && $node->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS) === null + ) { + $this->tryInferNew($node->class, $node); + } + if ($node instanceof FunctionLike) { + array_pop($this->scopes); + } + if ($node instanceof ClassLike) { + array_pop($this->classStack); + } + return null; + } + + /** + * Infer and attach the type arguments of a bare `new`, or leave it untouched. The class must + * resolve to a generic template; a non-generic or unknown class is left for PHP / the bare-new + * synthesis to handle. + */ + private function tryInferNew(Name $class, New_ $node): void + { + $fqn = $this->ctx->resolveName($class); + $definition = $this->registry->definition($fqn); + if ($definition === null || $definition->typeParams === []) { + return; + } + $inferred = $this->inference->infer( + $definition->typeParams, + self::constructorParams($definition->templateAst), + $node->args, + $this, + ); + if ($inferred !== null) { + // Mirror synthesizeBareNewIfAllDefaults / an explicit turbofish: the collector records + // the instantiation off these two attributes, padding any defaulted tail itself. + $class->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, $inferred); + $class->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, $fqn); + } + } + + public function typeOf(Node\Expr $expr): ?TypeRef + { + $literal = $this->literalTyper->typeOf($expr); + if ($literal !== null) { + return $literal; + } + if ($expr instanceof Variable && is_string($expr->name)) { + $scope = $this->scopes === [] ? [] : $this->scopes[array_key_last($this->scopes)]; + return $scope[$expr->name] ?? null; + } + $propName = self::thisPropertyName($expr); + if ($propName !== null) { + return $this->typeOfThisProperty($propName); + } + return null; + } + + /** + * The property name of a `$this->prop` fetch, or null for any other expression. A defensive + * AST-shape guard: the `&&` chain narrows to exactly a plain `$this->name` fetch, excluding a + * `$other->prop`, a `$this->expr->prop`, a `$this->$dynamic`, or a method call. + */ + private static function thisPropertyName(Node\Expr $expr): ?string + { + // @infection-ignore-all -- each `&&` guards a distinct AST shape; flipping one to `||` + // either needs a receiver/name form that never appears as a plain property-fetch argument + // (a `$this->a->b` or variable-variable) or is observationally identical here. + if ($expr instanceof PropertyFetch + && $expr->var instanceof Variable + && $expr->var->name === 'this' + && $expr->name instanceof Identifier + ) { + return $expr->name->toString(); + } + return null; + } + + /** + * Build a function's variable-type scope: every parameter with a concrete declared type that is + * never reassigned in the body. A reassigned parameter is dropped — after `$x = …` it no longer + * holds its declared type, so inferring from that type would be unsound. + * + * @return array + */ + private function scopeForFunction(FunctionLike $fn): array + { + $typeParamNames = $this->enclosingTypeParamNames($fn); + $reassigned = self::reassignedNames($fn); + $scope = []; + foreach ($fn->getParams() as $param) { + // @infection-ignore-all LogicalOr -- defensive: a real parameter always has a Variable + // var with a string name (Error vars / expression names only arise on parse failures + // that never reach this pass), so both operands are always false and || vs && is + // observationally identical. + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + continue; + } + $name = $param->var->name; + if (isset($reassigned[$name])) { + continue; + } + $type = TypeInference::paramTypeRef($param->type, $typeParamNames); + if ($type !== null && $type->isConcrete()) { + $scope[$name] = $type; + } + } + return $scope; + } + + /** The declared type of `$this->$propName`, or null when it is not a determinable concrete type. */ + private function typeOfThisProperty(string $propName): ?TypeRef + { + if ($this->classStack === []) { + return null; + } + $class = $this->classStack[array_key_last($this->classStack)]; + $typeParamNames = self::typeParamNamesOf($class); + foreach ($class->stmts as $stmt) { + if (!$stmt instanceof Property) { + continue; + } + foreach ($stmt->props as $prop) { + if ($prop->name->toString() !== $propName) { + continue; + } + $type = TypeInference::paramTypeRef($stmt->type, $typeParamNames); + return $type !== null && $type->isConcrete() ? $type : null; + } + } + return null; + } + + /** + * The type-parameter names in scope for a function: its enclosing class's parameters plus its + * own method/function-level generic parameters. A parameter or property typed by one of these + * is abstract here, so it never becomes a (spuriously concrete) inference source. + * + * @return array + */ + private function enclosingTypeParamNames(FunctionLike $fn): array + { + $names = $this->classStack === [] + ? [] + : self::typeParamNamesOf($this->classStack[array_key_last($this->classStack)]); + $methodParams = $fn->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (is_array($methodParams)) { + /** @var list $methodParams */ + foreach ($methodParams as $param) { + // @infection-ignore-all TrueValue -- $names is a set; membership is tested with + // isset() in paramTypeRef, so the stored value is immaterial. + $names[$param->name] = true; + } + } + return $names; + } + + /** @return array */ + private static function typeParamNamesOf(ClassLike $class): array + { + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + $names = []; + if (is_array($params)) { + /** @var list $params */ + foreach ($params as $param) { + // @infection-ignore-all TrueValue -- $names is a set; membership is tested with + // isset() in TypeInference::paramTypeRef, so the stored value is immaterial. + $names[$param->name] = true; + } + } + return $names; + } + + /** + * The variable names assigned anywhere in a function body — the conservative "do not trust" + * set. Covers `=`, `=&`, compound-assign, and inc/dec; nested closures are included too, so a + * parameter a closure mutates by reference is (safely) not trusted. + * + * @return array + */ + private static function reassignedNames(FunctionLike $fn): array + { + $stmts = $fn->getStmts(); + // @infection-ignore-all ReturnRemoval -- a bodiless method (interface/abstract) has null + // stmts; the guard is for the type checker. Removing it is equivalent at runtime because + // NodeFinder::find(null) returns [] anyway (verified), yielding the same empty result. + if ($stmts === null) { + return []; + } + $targets = (new NodeFinder())->find($stmts, static fn (Node $n): bool => + $n instanceof Assign || $n instanceof AssignRef || $n instanceof AssignOp + || $n instanceof PreInc || $n instanceof PostInc + || $n instanceof PreDec || $n instanceof PostDec); + $names = []; + foreach ($targets as $target) { + /** @var Assign|AssignRef|AssignOp|PreInc|PostInc|PreDec|PostDec $target */ + $var = $target->var; + if ($var instanceof Variable && is_string($var->name)) { + // @infection-ignore-all TrueValue -- $names is a set; membership is tested with + // isset() in scopeForFunction, so the stored value is immaterial. + $names[$var->name] = true; + } + } + return $names; + } + + /** + * The parameters of a template's `__construct`, or an empty list when it declares none — the + * shape inference unifies the constructor arguments against. + * + * @return array + */ + private static function constructorParams(ClassLike $template): array + { + foreach ($template->stmts as $stmt) { + if ($stmt instanceof ClassMethod && strtolower($stmt->name->toString()) === '__construct') { + return $stmt->params; + } + } + return []; + } +} diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 6e55b18b..67e710fc 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -748,6 +748,34 @@ private static function varianceEdgeUnprovableMessage( ); } + /** + * Bound-check a used type alias's parameters against its concrete arguments — the same check + * {@see validateBounds} runs for a class instantiation, grounding any sibling-referencing bound + * (``) against the supplied args first. Exposed statically so the post-hierarchy + * {@see AliasBoundValidator} pass reports through the identical `checkBounds` seam (a violation + * surfaces as the same `xphp.bound_violation`). + * + * @param list $typeParams + * @param list $args + */ + public static function checkAliasBounds( + array $typeParams, + array $args, + TypeHierarchy $hierarchy, + string $label, + ?DiagnosticCollector $diagnostics = null, + ?SourceLocation $callSite = null, + ): void { + self::checkBounds( + self::groundSiblingBounds($typeParams, $args), + $args, + $hierarchy, + $label, + $diagnostics, + $callSite, + ); + } + /** * Reusable bound check for any (typeParams, concreteArgs) pair against a hierarchy. * diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index bc2c28d6..f4301bcd 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -9,6 +9,7 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\NullsafeMethodCall; +use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; use PhpParser\Node\Name; @@ -331,15 +332,27 @@ public function __construct(private Substitution $substitution) public function leaveNode(Node $node): ?Node { - // Ground a variable-turbofish call's type arguments in place. An inner + // Ground a method-generic turbofish call's type arguments in place. An inner // `$inner::(...)` inside a generic template body parses as a FuncCall on - // a Variable whose type args live in ATTR_METHOD_GENERIC_ARGS; when the - // enclosing generic specializes (`S → int`) those args must ground too, so - // the per-specialization closure-grounding pass sees `$inner::` and can - // dispatch it. Left un-substituted the closure keeps a raw `I` hint and - // fatals at runtime. (This substitution is e2e-inert until that grounding - // pass runs — it only rewrites the recorded type args, never emits.) - if ($node instanceof FuncCall) { + // a Variable whose type args live in ATTR_METHOD_GENERIC_ARGS; a static / + // instance / nullsafe method turbofish (`Maker::wrap::`, `$this->m::`) + // carries the same attribute on its call node. When the enclosing generic + // specializes (`T → int`) those recorded args must ground too, so a + // downstream grounding pass sees `::` and can dispatch it. Left + // un-substituted the call keeps a raw `T` ref and is rejected by the emit + // leak guard. (This substitution is e2e-inert until a grounding pass + // consumes it — it only rewrites the recorded type args, never emits.) + // @infection-ignore-all LogicalOrAllSubExprNegation — node classes are + // mutually exclusive, so the all-negated disjunction is a tautology + // (every node passes); the arm is still gated on the marker attribute, + // which only call nodes carry, so the mutant is observationally + // equivalent. The per-kind instanceof checks are pinned positively by + // SpecializerMethodGenericArgsTest's call-kind provider. + if ($node instanceof FuncCall + || $node instanceof StaticCall + || $node instanceof MethodCall + || $node instanceof NullsafeMethodCall + ) { $methodArgs = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); if (is_array($methodArgs) && $methodArgs !== []) { /** @var list $methodArgs — ATTR_METHOD_GENERIC_ARGS is a TypeRef list (set by XphpSourceParser). */ diff --git a/src/Transpiler/Monomorphize/TypeInference.php b/src/Transpiler/Monomorphize/TypeInference.php new file mode 100644 index 00000000..78f8515c --- /dev/null +++ b/src/Transpiler/Monomorphize/TypeInference.php @@ -0,0 +1,302 @@ +` turbofish becomes optional wherever the argument values determine it. + * + * The algorithm is the structural inverse of {@see Specializer::substituteTypeRef}: substitution + * takes a template type (`Box`) plus a binding (`T => int`) and produces a concrete type + * (`Box`); inference takes the template parameter type (`Box`) plus the concrete argument + * type (`Box`) and recovers the binding (`T => int`). {@see unify} walks the two trees in + * lock-step, binding each type-parameter leaf to the corresponding concrete sub-type; {@see infer} + * pairs each argument with its parameter, unifies, and assembles the bindings into a + * declaration-ordered type-argument list. + * + * This class is pure and context-free: everything flow-dependent (what type a `$var` or a call + * return actually has) is delegated to an injected {@see ExpressionTyper}. That keeps the load- + * bearing logic — conflict detection, subtype threading, the prefix/gap rules — in one small + * unit-testable place, deliberately outside the monomorphizer's anonymous NodeVisitor classes + * (Infection's blind spot). + * + * Soundness is by construction: inference only ever produces the exact concrete tuple an explicit + * turbofish would have carried, and downstream (bounds, variance edges, mangling, specialization) + * treats the two identically. Anything it cannot resolve to a complete, unambiguous, concrete tuple + * yields null, and the caller leaves the site bare — falling back to today's exact behaviour. + */ +final class TypeInference +{ + public function __construct(private readonly TypeHierarchy $hierarchy) + { + } + + /** + * Infer the type arguments for a generic callee from its call-site arguments. + * + * Returns the inferred type arguments in declaration order — a *complete* tuple, or a concrete + * prefix whose omitted tail is entirely defaulted (so {@see Registry::padArgsWithDefaults} + * fills it exactly as it would for a partial explicit turbofish). Returns null — meaning "leave + * the site bare, fall back to the explicit-turbofish path" — when inference is: + * + * - impossible: a required (non-defaulted) type parameter has no argument witness, or an + * argument's static type is unknown so its parameter stays unconstrained; + * - ambiguous: an argument is a subtype reaching the parameter's type through conflicting + * supertype paths; + * - conflicting: a type parameter used in two parameters is witnessed as two different types + * (`pair(T $a, T $b)` called `pair(1, 'x')`); + * - a "hole": an inferred parameter follows an un-inferred one (not a clean prefix). + * + * @param list $typeParams the callee's generic parameters, in declaration order + * @param array $params the callee's value parameters, from the template AST + * @param array $args the call-site arguments + * @return list|null + */ + public function infer(array $typeParams, array $params, array $args, ExpressionTyper $typer): ?array + { + // A non-generic callee (empty $typeParams) needs no guard here: assemblePrefix() returns + // null for an empty parameter list anyway. + $nameSet = []; + foreach ($typeParams as $param) { + // @infection-ignore-all TrueValue -- $nameSet is a set: membership is tested with + // isset() in paramTypeRef(), so the stored value is immaterial (same rationale as the + // on-path sentinel in TypeHierarchy::groundPaths). + $nameSet[$param->name] = true; + } + + /** @var array $bindings type-param name => inferred concrete type */ + $bindings = []; + foreach (self::pairArgsToParams($params, $args) as [$param, $value]) { + $paramType = self::paramTypeRef($param->type, $nameSet); + if ($paramType === null || !self::mentionsTypeParam($paramType)) { + // The parameter's declared type constrains no type parameter — it contributes + // nothing to inference (and, crucially, leaves all-defaults templates untouched). + continue; + } + $argType = $typer->typeOf($value); + if ($argType === null) { + // Unknown argument type: leave this parameter's type variables unconstrained. + continue; + } + if (!$this->unify($paramType, $argType, $bindings)) { + return null; + } + } + + return self::assemblePrefix($typeParams, $bindings); + } + + /** + * Bind the type-parameter leaves of `$paramType` from the concrete `$argType`, the inverse of + * {@see Specializer::substituteTypeRef}. Accumulates bindings by reference and returns whether + * the two types are unifiable. Failure modes: + * + * - a type-parameter leaf against a non-concrete argument (nothing concrete to bind); + * - a type parameter already bound to a different type (multi-occurrence conflict); + * - a parametric head the argument neither shares nor reaches as a supertype, or reaches + * ambiguously, or with mismatched arity. + * + * A plain (non-generic, non-type-param) parameter leaf imposes no constraint and unifies + * vacuously — inference derives type arguments; it does not re-check argument assignability + * (that is the compiler's separate job, run identically for inferred and explicit turbofishes). + * + * @param array $bindings + * @param-out array $bindings + */ + public function unify(TypeRef $paramType, TypeRef $argType, array &$bindings): bool + { + if ($paramType->isTypeParam) { + if (!$argType->isConcrete()) { + return false; + } + $existing = $bindings[$paramType->name] ?? null; + if ($existing !== null) { + return $existing->canonical() === $argType->canonical(); + } + $bindings[$paramType->name] = $argType; + return true; + } elseif (!$paramType->isGeneric()) { + // A plain concrete leaf imposes no constraint. Kept as an `elseif` deliberately: were + // it a separate `if`, dropping the type-param branch's `return true` above would fall + // through to this same `true` (an equivalent mutant); the chain routes that fall-through + // into the parametric block below instead, where a type-param head is rejected. + return true; + } + // A parametric head: view the argument as this head (directly, or threaded up its supertype + // chain when the argument is a subtype) and unify the type arguments pairwise. + $argArgs = $this->hierarchy->resolveInheritedArgs($argType->name, $argType->args, $paramType->name); + if ($argArgs === null || count($argArgs) !== count($paramType->args)) { + return false; + } + foreach ($paramType->args as $i => $sub) { + if (!$this->unify($sub, $argArgs[$i], $bindings)) { + return false; + } + } + return true; + } + + /** + * Convert a parameter's declared type AST node into a {@see TypeRef}, marking every leaf whose + * name is one of the callee's type parameters (`$typeParamNames`) as a type-param leaf. A + * generic parameter type (`Box`) reuses the type arguments the parser already resolved and + * attached (with their own leaves flagged), so nesting to any depth is handled by the parser's + * own resolution. Returns null for a shape inference does not model — a union, an intersection, + * or a missing type — so its parameter contributes no constraint. + * + * Public so the `new`-inference pass can reuse the exact same parameter→TypeRef mapping over a + * constructor's parameters. + * + * @param array $typeParamNames + */ + public static function paramTypeRef(?Node $type, array $typeParamNames): ?TypeRef + { + if ($type instanceof NullableType) { + $type = $type->type; + } + if ($type instanceof Identifier) { + // A scalar or keyword type (`int`, `string`, `array`, ...) — concrete, no type params. + return new TypeRef(strtolower($type->name), isScalar: true); + } + if ($type instanceof Name) { + $name = $type->toString(); + if (isset($typeParamNames[$name])) { + return new TypeRef($name, isTypeParam: true); + } + $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + $resolved = $type->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + /** @var list $argRefs */ + $argRefs = is_array($args) ? $args : []; + return new TypeRef(is_string($resolved) ? $resolved : $name, $argRefs); + } + return null; + } + + /** + * Assemble the bindings into a declaration-ordered type-argument list, or null when they do not + * form a "concrete prefix + defaulted tail": every bound parameter must precede every unbound + * one (no hole), and every unbound parameter must be defaultable. An empty result (nothing + * inferred) is null too — the site stays bare. + * + * @param list $typeParams + * @param array $bindings + * @return list|null + */ + private static function assemblePrefix(array $typeParams, array $bindings): ?array + { + $prefix = []; + $sawUnbound = false; + foreach ($typeParams as $param) { + $bound = $bindings[$param->name] ?? null; + if ($bound !== null) { + if ($sawUnbound) { + return null; + } + $prefix[] = $bound; + continue; + } + if ($param->default === null) { + return null; + } + $sawUnbound = true; + } + return $prefix === [] ? null : $prefix; + } + + private static function mentionsTypeParam(TypeRef $ref): bool + { + if ($ref->isTypeParam) { + return true; + } + foreach ($ref->args as $arg) { + if (self::mentionsTypeParam($arg)) { + return true; + } + } + return false; + } + + /** + * Pair each call argument with the callee parameter it binds, mirroring PHP's own rules: + * a named argument binds by parameter name; a positional argument binds by position (a trailing + * variadic absorbs the overflow); a spread (`...$xs`) stops positional pairing, since it + * rebinds every following slot at runtime; and a first-class-callable placeholder carries no + * value and is skipped. Arguments with no matching parameter are dropped. + * + * @param array $params + * @param array $args + * @return list + */ + private static function pairArgsToParams(array $params, array $args): array + { + $pairs = []; + $position = 0; + $sawSpread = false; + foreach ($args as $arg) { + if (!$arg instanceof Arg) { + continue; + } + if ($arg->name instanceof Identifier) { + $param = self::paramByName($params, $arg->name->toString()); + if ($param !== null) { + $pairs[] = [$param, $arg->value]; + } + continue; + } + if ($sawSpread) { + continue; + } + if ($arg->unpack) { + $sawSpread = true; + continue; + } + $param = self::paramForPosition($params, $position); + if ($param !== null) { + $pairs[] = [$param, $arg->value]; + } + $position++; + } + return $pairs; + } + + /** + * The parameter a named argument binds, or null when no parameter has that name. + * + * @param array $params + */ + private static function paramByName(array $params, string $name): ?Param + { + foreach ($params as $param) { + if ($param->var instanceof Variable && $param->var->name === $name) { + return $param; + } + } + return null; + } + + /** + * The parameter a positional argument at `$index` binds: the parameter at that index, or a + * trailing variadic that absorbs everything past the fixed arity, or null when the call + * over-supplies a non-variadic list. + * + * @param array $params + */ + private static function paramForPosition(array $params, int $index): ?Param + { + if (isset($params[$index])) { + return $params[$index]; + } + $last = $params === [] ? null : $params[array_key_last($params)]; + return $last !== null && $last->variadic ? $last : null; + } +} diff --git a/src/Transpiler/Monomorphize/XphpParseException.php b/src/Transpiler/Monomorphize/XphpParseException.php index e22152ea..b743d0fe 100644 --- a/src/Transpiler/Monomorphize/XphpParseException.php +++ b/src/Transpiler/Monomorphize/XphpParseException.php @@ -14,11 +14,18 @@ * `RuntimeException` keep catching it unchanged — only the line is added. Check * mode catches it specifically to report the real line in its diagnostic instead * of the line-1 fallback used for position-less parse failures. + * + * An optional stable diagnostic `code` (e.g. `xphp.alias_cycle`) lets check mode + * report a specific code instead of the generic parse-error code; throw sites that + * omit it keep the generic code. */ final class XphpParseException extends RuntimeException { - public function __construct(string $message, private readonly int $sourceLine) - { + public function __construct( + string $message, + private readonly int $sourceLine, + private readonly ?string $diagnosticCode = null, + ) { parent::__construct($message); } @@ -30,4 +37,10 @@ public function sourceLine(): int { return $this->sourceLine; } + + /** The stable diagnostic code for this rejection, or null to use the generic parse-error code. */ + public function diagnosticCode(): ?string + { + return $this->diagnosticCode; + } } diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index bf5c9e12..2111818f 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -15,6 +15,7 @@ use PhpParser\Parser; use PhpToken; use RuntimeException; +use XPHP\Diagnostics\SourceLocation; /** * Parses .xphp source text into an AST with generic metadata, supporting @@ -110,6 +111,19 @@ final class XphpSourceParser // tagged (the escape hatch). Advisory metadata only — not emitted. public const ATTR_SUSPECT_UNDECLARED_TYPE = 'xphp:suspectUndeclaredType'; + // Set on a type-hint Name that is the WHOLE type of a param / property / return / class-const + // slot (not nested inside a nullable/union/intersection). A compound-body alias may only expand + // here — elsewhere it has no representable form and is rejected. + public const ATTR_ALIAS_WHOLE_SLOT = 'xphp:aliasWholeSlot'; + + /** Stable diagnostic codes for type-alias rejections. */ + public const CODE_ALIAS_CYCLE = 'xphp.alias_cycle'; + public const CODE_ALIAS_ARITY = 'xphp.alias_arity'; + public const CODE_ALIAS_DUPLICATE = 'xphp.alias_duplicate'; + public const CODE_ALIAS_CLASS_COLLISION = 'xphp.alias_class_collision'; + public const CODE_ALIAS_UNSUPPORTED_BODY = 'xphp.alias_unsupported_body'; + public const CODE_ALIAS_COMPOUND_IN_NON_SLOT = 'xphp.alias_compound_in_non_slot'; + /** * The reserved PHP type keywords — names PHP forbids as class names. A bare name in this list is * unambiguously a builtin, so every site that asks "is this name a builtin keyword or a class?" @@ -131,11 +145,18 @@ public function __construct(private readonly Parser $parser) } /** + * A type alias is file-local: only the aliases declared in `$source` are visible to it, mirroring + * PHP's `use`-alias scoping. There is no whole-program alias table. + * + * @param ?string $filepath the source file, threaded only so a captured alias-bound obligation + * can carry an accurate SourceLocation; null on the standalone parse path. + * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations, + * verified after the hierarchy is built; null (inert) on the standalone parse path. * @return list */ - public function parse(string $source): array + public function parse(string $source, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array { - return $this->parseWithMap($source)[0]; + return $this->parseWithMap($source, $filepath, $obligations)[0]; } /** @@ -147,11 +168,12 @@ public function parse(string $source): array * Returns the identity map when no length-changing replacements fired * (the common case for files without `T[]` array-suffix sugar). * + * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations (null = inert) * @return array{0: list, 1: ByteOffsetMap} */ - public function parseWithMap(string $source): array + public function parseWithMap(string $source, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array { - [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers] = $this->scanAndStrip($source); + [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers, $aliasMarkers] = $this->scanAndStrip($source); try { $ast = $this->parser->parse($cleanedSource); @@ -169,7 +191,7 @@ public function parseWithMap(string $source): array } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap); + $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers, $filepath, $obligations); // @infection-ignore-all — defensive backstop, unreachable from valid input by // construction (see unboundDeclarationMarkerMessage): no test can reach a // mutant here. The message builder is pinned by direct unit tests; this @@ -273,7 +295,7 @@ public function parseTolerant(string $source): ?array */ public function parseTolerantWithMap(string $source): ?ParseWithMapResult { - [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers] = $this->scanAndStrip($source); + [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers, $aliasMarkers] = $this->scanAndStrip($source); $errorHandler = new \PhpParser\ErrorHandler\Collecting(); $ast = $this->parser->parse($cleanedSource, $errorHandler); @@ -282,7 +304,7 @@ public function parseTolerantWithMap(string $source): ?ParseWithMapResult } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap); + $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers); return new ParseWithMapResult($ast, $byteOffsetMap); } @@ -307,7 +329,7 @@ public function strip(string $source): string } /** - * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list} + * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:?list, bytePosition:int, line:int}>} */ private function scanAndStrip(string $source): array { @@ -321,6 +343,8 @@ private function scanAndStrip(string $source): array $methodMarkers = []; /** @var list $closureMarkers */ $closureMarkers = []; + /** @var list, body:?list, bytePosition:int, line:int}> $aliasMarkers */ + $aliasMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -328,6 +352,27 @@ private function scanAndStrip(string $source): array while ($i < $n) { $tok = $tokens[$i]; + // Type-alias declaration: `type Name[] = SingleHeadBody;` (WI-01, file-local). + // `type` is a contextual keyword (an ordinary T_STRING), so this arm MUST run first — + // before the bare `Name<…>` arm below, which would otherwise strip the `` off + // `type Pair = …` and leave the statement half-parsed. `tryParseAliasDeclaration` + // gates on statement position (so a `type` used as a constant / function / member name + // is never mistaken for a declaration) and consumes the WHOLE `type … ;` statement, + // blanking it to equal-length whitespace (the alias has no runtime existence). + if ($tok->id === T_STRING && $tok->text === 'type') { + $aliasParsed = self::tryParseAliasDeclaration($tokens, $i); + if ($aliasParsed !== null) { + [$aliasMarker, $semicolonIdx] = $aliasParsed; + $aliasMarkers[] = $aliasMarker; + $startByte = $tok->pos; + $endByte = $tokens[$semicolonIdx]->pos + strlen($tokens[$semicolonIdx]->text); + $length = $endByte - $startByte; + $replacements[] = [$startByte, $length, self::blank(substr($source, $startByte, $length))]; + $i = $semicolonIdx + 1; + continue; + } + } + // Anonymous closure: `function(...){}` / `fn(...)`. // Recognized by T_FUNCTION/T_FN followed immediately by `<` (no // T_STRING name). `static`-prefixed shapes are consumed by the @@ -828,7 +873,7 @@ private function scanAndStrip(string $source): array $cleaned = self::applyReplacements($source, $replacements); $byteOffsetMap = ByteOffsetMap::fromReplacements($replacements); - return [$classMarkers, $nameMarkers, $methodMarkers, $cleaned, $byteOffsetMap, $closureMarkers]; + return [$classMarkers, $nameMarkers, $methodMarkers, $cleaned, $byteOffsetMap, $closureMarkers, $aliasMarkers]; } /** @@ -2385,6 +2430,177 @@ private static function parseTypeArgList(array $tokens, int $openIdx): ?array return null; } + /** + * Recognize a type-alias declaration `type Name [] = SingleHeadBody;` beginning at the + * `type` token index `$typeIdx`, and return `[marker, semicolonIndex]` — or null when the tokens + * are not a well-formed single-head alias declaration, so the `type` token falls through to + * ordinary handling (a genuinely malformed shape then reaches nikic / the validators; nothing is + * silently eaten). The marker carries the alias short name, its (possibly empty) type-parameter + * names, the raw body TypeRef (resolved later against the namespace context), and the `type` + * token's byte position for namespace-span attribution. + * + * v1 (WI-01): file-local; the body must be a single (possibly-generic) head that `parseTypeArg` + * accepts. A union / intersection / nullable / closure body leaves a non-`;` token after the head + * and is declined here — a dedicated `xphp.alias_unsupported_body` diagnostic lands in a later + * change rather than a silent pass-through. + * + * Gated to STATEMENT position: the previous significant token must be a statement boundary + * (`;`, `{`, `}`, or the opening `type`, `new type()`) is never mistaken for a declaration. + * + * @param list $tokens + * @return array{0: array{name:string, params:list, body:?list, bytePosition:int, line:int}, 1: int}|null + */ + private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?array + { + // Statement-position guard. `type` always sits at index >= 1 (index 0 is the open tag), so + // skipWsBack lands on a real token; the `?? null` is a defensive floor only. A `type` used as + // a constant / function / member name (preceded by `->`, `::`, `=`, `(`, …) is declined here. + $prevTok = $tokens[self::skipWsBack($tokens, $typeIdx - 1)] ?? null; + if ($prevTok === null + || !($prevTok->id === T_OPEN_TAG + || $prevTok->text === ';' + || $prevTok->text === '{' + || $prevTok->text === '}') + ) { + return null; + } + + // Alias name. + // @infection-ignore-all IncrementInteger -- `type` is always followed by whitespace (else + // `typeName` would tokenize as one T_STRING), so skipWs(+1) and skipWs(+2) reach the same + // name token: the offset increment is an equivalent mutant. + $nameIdx = self::skipWs($tokens, $typeIdx + 1); + $nameTok = $tokens[$nameIdx] ?? null; + if ($nameTok === null || $nameTok->id !== T_STRING) { + return null; + } + + // Optional `` parameter list. Parsed permissively (defaults + variance allowed, as on + // a class header) so recognition never throws. The full per-param entries — carrying each + // param's optional bound and default — are retained (not just the names): expansion applies + // the defaults (fewer args than params) and enforces the bounds. + $params = []; + $afterName = self::skipWs($tokens, $nameIdx + 1); + $afterNameTok = $tokens[$afterName] ?? null; + if ($afterNameTok === null) { + return null; + } + if ($afterNameTok->text === '<') { + $parsed = self::parseTypeParamList($tokens, $afterName, allowDefaults: true, allowVariance: true); + if ($parsed === null) { + return null; + } + [$params, $paramsEndIdx] = $parsed; + // @infection-ignore-all IncrementInteger -- the `>` closing the param list is followed + // by whitespace-then-`=` in every reachable shape (a no-space `>=` is the comparison + // operator, not this position), so skipWs(+1) and skipWs(+2) reach the same token. + $eqIdx = self::skipWs($tokens, $paramsEndIdx + 1); + } else { + $eqIdx = $afterName; + } + + // `=`. + if (($tokens[$eqIdx] ?? null)?->text !== '=') { + return null; + } + + // The alias statement must be terminated by a `;` before any `{` / `}` / end of input, + // otherwise it is truncated (mid-typing) and we decline so the tolerant path and PHP's own + // parser handle it. + $bodyStart = self::skipWs($tokens, $eqIdx + 1); + $semiIdx = self::aliasTerminator($tokens, $bodyStart); + if ($semiIdx === null) { + return null; + } + + // The body is a single head or a flat union of single heads (`?X` desugars to `X|null`); + // anything else (intersection, DNF, closure signature) yields a null body. The whole + // statement is still stripped here (so `strip()` never produces a PHP parse error); a null + // body is rejected with `xphp.alias_unsupported_body` at parse time by `buildAliasTable`. + $body = self::parseAliasBody($tokens, $bodyStart, $semiIdx); + + return [ + [ + 'name' => $nameTok->text, + 'params' => $params, + 'body' => $body, + 'bytePosition' => $tokens[$typeIdx]->pos, + 'line' => $tokens[$typeIdx]->line, + ], + $semiIdx, + ]; + } + + /** + * Parse a type-alias body (between `=`, starting at $bodyStart, and its terminator $semiIdx) as a + * flat union of single heads. Returns the union members — a single-head body is one member, and + * `?X` desugars to `[X, null]`. Returns null when the body is a shape v1/v2 does not support: an + * intersection (`&`), a parenthesised / DNF form, or a closure signature; `buildAliasTable` then + * rejects the null body with `xphp.alias_unsupported_body`. + * + * @param list $tokens + * @return list|null + */ + private static function parseAliasBody(array $tokens, int $bodyStart, int $semiIdx): ?array + { + // Leading `?` → nullable: `?` desugars to ` | null`. A `?` in front of a + // compound (`?A|B`) is illegal PHP anyway, so only a single head may follow. + // @infection-ignore-all NullSafePropertyCall -- `$bodyStart <= $semiIdx < count`, so the token + // always exists; the `?? null` / `?->` is a defensive floor that never sees null. + if (($tokens[$bodyStart] ?? null)?->text === '?') { + $parsed = self::parseTypeArg($tokens, self::skipWs($tokens, $bodyStart + 1)); + if ($parsed === null || self::skipWs($tokens, $parsed[1]) !== $semiIdx) { + return null; + } + return [$parsed[0], new TypeRef('null')]; + } + + // Otherwise a union of single heads: `Head ( '|' Head )*`. A non-head member (an intersection + // `&`, a `(` DNF group, a closure `(`) leaves a token that is neither the terminator nor `|`, + // so the body is declined as unsupported. + $members = []; + $i = $bodyStart; + while (true) { + $parsed = self::parseTypeArg($tokens, $i); + if ($parsed === null) { + return null; + } + $members[] = $parsed[0]; + $next = self::skipWs($tokens, $parsed[1]); + if ($next === $semiIdx) { + return $members; + } + // @infection-ignore-all NullSafePropertyCall -- `$next <= $semiIdx < count`, so the token + // always exists; the `?? null` / `?->` is a defensive floor that never sees null. + if (($tokens[$next] ?? null)?->text !== '|') { + return null; + } + $i = self::skipWs($tokens, $next + 1); + } + } + + /** + * The index of the `;` that terminates an alias statement whose body starts at $bodyStart, or + * null when a `{` / `}` / end of input is reached first (a truncated, mid-typing declaration). + * A type body never contains `;` / `{` / `}`, so the first such token decides. + * + * @param list $tokens + */ + private static function aliasTerminator(array $tokens, int $bodyStart): ?int + { + for ($i = $bodyStart, $n = count($tokens); $i < $n; $i++) { + $text = $tokens[$i]->text; + if ($text === ';') { + return $i; + } + if ($text === '{' || $text === '}') { + return null; + } + } + return null; + } + /** * Parse a single type arg: `NAME ( < TypeArgList > )?`. * @@ -2676,6 +2892,114 @@ private static function applyReplacements(string $source, array $replacements): return $source; } + /** + * Build the file-local type-alias table, keyed by fully-qualified name. Each alias's declaring + * namespace is found by locating the `Namespace_` node whose (original-source) byte span contains + * the `type` keyword, so a real class sharing an alias's short name in another namespace never + * collides. Bodies stay raw (unresolved) — they resolve lazily at expansion, when the use-site + * namespace context is available. A duplicate FQN is rejected with `xphp.alias_duplicate` (never + * silently overwritten), and a name colliding with a class/interface/trait with + * `xphp.alias_class_collision`. + * + * @param list $ast + * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers + * @return array, body:list}> + */ + private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOffsetMap $byteOffsetMap): array + { + // @infection-ignore-all ReturnRemoval -- optimization only: with no markers the loops below + // produce an empty table anyway; the early return just skips the namespace-span walk for the + // common alias-free file. + if ($aliasMarkers === []) { + return []; + } + /** @var list $spans namespace name + original byte span */ + $spans = []; + foreach ($ast as $stmt) { + if ($stmt instanceof Namespace_) { + $spans[] = [ + $stmt->name?->toString() ?? '', + $byteOffsetMap->toOriginal($stmt->getStartFilePos()), + $byteOffsetMap->toOriginal($stmt->getEndFilePos()), + ]; + } + } + $classFqns = self::collectClassLikeFqns($ast); + $table = []; + foreach ($aliasMarkers as $marker) { + $namespace = ''; + foreach ($spans as [$name, $start, $end]) { + // @infection-ignore-all GreaterThanOrEqualTo LessThanOrEqualTo -- a `type` keyword's + // byte sits strictly inside its namespace span (after the `namespace` keyword, before + // the closing brace / EOF), so the `>=`/`<=` boundary variants never shift attribution; + // the `&&` (a use in an earlier namespace must not match a later one) is exercised. + if ($marker['bytePosition'] >= $start && $marker['bytePosition'] <= $end) { + $namespace = $name; + // @infection-ignore-all Break_ -- namespace spans are disjoint, so no later span + // can also contain this byte; continuing the loop is equivalent. + break; + } + } + $fqn = $namespace === '' ? $marker['name'] : $namespace . '\\' . $marker['name']; + if ($marker['body'] === null) { + throw new XphpParseException( + "Type alias `{$fqn}` has an unsupported body: an alias body must be a single class " + . 'or generic type, a union, or a nullable (intersection, DNF, and closure-signature ' + . 'bodies are not supported). Use a bare type or a named class.', + $marker['line'], + self::CODE_ALIAS_UNSUPPORTED_BODY, + ); + } + if (isset($table[$fqn])) { + throw new XphpParseException( + "Type alias `{$fqn}` is declared more than once in this file.", + $marker['line'], + self::CODE_ALIAS_DUPLICATE, + ); + } + if (isset($classFqns[$fqn])) { + throw new XphpParseException( + "Type alias `{$fqn}` collides with a class, interface, or trait of the same name.", + $marker['line'], + self::CODE_ALIAS_CLASS_COLLISION, + ); + } + $table[$fqn] = ['params' => $marker['params'], 'body' => $marker['body']]; + } + return $table; + } + + /** + * Collect the fully-qualified names of every class / interface / trait / enum declared in the + * file, so a type alias colliding with one can be rejected. Declarations are direct children of a + * namespace (or top-level in the global namespace); this matches the file-local (v1) scope — a + * collision with a class declared in another file is not detected here. + * + * @param list $ast + * @return array + */ + private static function collectClassLikeFqns(array $ast): array + { + $fqns = []; + foreach ($ast as $stmt) { + if ($stmt instanceof Namespace_) { + $ns = $stmt->name?->toString() ?? ''; + foreach ($stmt->stmts as $inner) { + if ($inner instanceof ClassLike && $inner->name !== null) { + $short = $inner->name->toString(); + // @infection-ignore-all TrueValue -- a set membership; the value is only ever + // probed with isset(), which is true for any present key (incl. false). + $fqns[$ns === '' ? $short : $ns . '\\' . $short] = true; + } + } + } elseif ($stmt instanceof ClassLike && $stmt->name !== null) { + // @infection-ignore-all TrueValue -- set membership probed only with isset() (above). + $fqns[$stmt->name->toString()] = true; + } + } + return $fqns; + } + /** * Walk the AST: attach markers to ClassLike and Name nodes by (line, name) + order; resolve TypeRef names. * @@ -2696,15 +3020,21 @@ private static function applyReplacements(string $source, array $replacements): * @param list}> $nameMarkers * @param list}> $methodMarkers * @param list $closureMarkers + * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers + * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations (null = inert) */ - private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap): ?string + private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): ?string { + // buildAliasTable runs the per-file rejections (same-file duplicate / class-collision / + // unsupported body). A type alias is file-local, so this file's own table is the only one + // expansion consults — an alias declared in another file is simply not visible here. + $aliasTable = self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); $traverser = new NodeTraverser(); $visitor = new /** * @phpstan-import-type BoundDict from XphpSourceParser */ - class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap) extends NodeVisitorAbstract { + class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasTable, $filepath, $obligations) extends NodeVisitorAbstract { private NamespaceContext $ctx; /** @var list> stack of enclosing type-param scopes */ private array $typeParamStack = []; @@ -2719,6 +3049,10 @@ class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetM * @param array}> $nameMarkers * @param array}> $methodMarkers * @param array $closureMarkers + * @param array, body:list}> $aliasTable the + * aliases available for expansion (whole-program when injected) keyed by FQN; body is the raw (unresolved) TypeRef. + * @param ?string $filepath the source file, for a captured obligation's SourceLocation; null on the standalone parse path + * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations; null (inert) on the standalone parse path */ public function __construct( private array $classMarkers, @@ -2726,11 +3060,43 @@ public function __construct( private array $methodMarkers, private array $closureMarkers, private ByteOffsetMap $byteOffsetMap, + private array $aliasTable, + private ?string $filepath, + private ?AliasBoundObligationCollector $obligations, ) { $this->ctx = new NamespaceContext(); } - public function enterNode(Node $node): null + /** + * Cache of resolved alias bodies keyed by alias FQN — the raw body is resolved once + * (against the use-site namespace context, with the alias's params in scope) and reused. + * + * @var array> + */ + private array $aliasBodyCache = []; + + /** + * Cache of resolved alias parameters keyed by alias FQN — each raw param entry resolved + * (against the use-site context, with the alias's params in scope) to a TypeParam carrying + * its bound and default. Feeds both default-padding and bound enforcement. Resolved once. + * + * @var array> + */ + private array $aliasParamsCache = []; + + /** + * Alias FQNs whose parameter resolution has begun. Checked only after {@see $aliasParamsCache} + * misses, so a re-entry recorded here (but not yet cached) is an alias resolving through its + * own parameter bound — a self-referential cycle. Not cleared: once cached, the cache check + * short-circuits before this guard, so a lingering flag never yields a false positive. + * + * @var array + */ + private array $aliasParamsInFlight = []; + + // Returns a replacement Node when a type-alias use is expanded in place (the traverser + // swaps it into the parent slot); null in every other case leaves the node untouched. + public function enterNode(Node $node): ?Node { if ($node instanceof Use_ || $node instanceof GroupUse) { // Reject a generic clause on a namespace-import BEFORE the blanket @@ -3055,6 +3421,16 @@ public function enterNode(Node $node): null break; } } + + // Alias expansion (WI-01): if this type-position Name resolves to a declared + // single-head alias, replace it with the recursively-expanded body so nothing + // downstream (registry, specializer, call-site rewriter) ever sees the alias. + // Runs after marker binding (a generic use's args are on the node by now) and + // after the parent slot's markName (a bare use's ATTR_RESOLVED_FQN is set). + $expansion = $this->expandAliasName($node); + if ($expansion !== null) { + return $expansion; + } } // Tag bare class/interface Name references in class-name positions @@ -3065,7 +3441,9 @@ public function enterNode(Node $node): null // Use_ branches (so $ctx is populated) and after the ClassLike/ // method type-param push (so isEnclosingTypeParam sees this scope). if ($node instanceof Node\Stmt\Class_) { - $this->markType($node->extends); + // `extends` needs a single class, so a compound alias there must reject — + // wholeSlot:false (a single-head alias still expands regardless of the flag). + $this->markType($node->extends, false); foreach ($node->implements as $impl) { $this->markName($impl); } @@ -3091,17 +3469,17 @@ public function enterNode(Node $node): null $this->markName($type); } } elseif ($node instanceof Node\Param) { - $this->markType($node->type); + $this->markType($node->type, true); } elseif ($node instanceof Node\Stmt\Property) { - $this->markType($node->type); + $this->markType($node->type, true); } elseif ($node instanceof Node\Stmt\ClassConst) { - $this->markType($node->type); + $this->markType($node->type, true); } elseif ($node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_ || $node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction ) { - $this->markType($node->returnType); + $this->markType($node->returnType, true); } return null; @@ -3112,16 +3490,22 @@ public function enterNode(Node $node): null * through nullable/union/intersection wrappers). Scalar `Identifier` * leaves and non-Name expressions are left untouched. */ - private function markType(?Node $type): void + private function markType(?Node $type, bool $wholeSlot): void { if ($type instanceof Name) { $this->attachClosureSig($type); $this->markName($type); + // A Name that IS the whole slot type may expand to a compound (union) alias; a Name + // reached through the nullable/union/intersection recursion below is nested and may + // not (tagged only at the top level). + if ($wholeSlot) { + $type->setAttribute(XphpSourceParser::ATTR_ALIAS_WHOLE_SLOT, true); + } } elseif ($type instanceof Node\NullableType) { - $this->markType($type->type); + $this->markType($type->type, false); } elseif ($type instanceof Node\UnionType || $type instanceof Node\IntersectionType) { foreach ($type->types as $inner) { - $this->markType($inner); + $this->markType($inner, false); } } } @@ -3569,6 +3953,20 @@ private function buildBoundExprNode(array $node): BoundExpr $fqn = $node['isFq'] ? $node['name'] : $this->resolveNameOnly($node['name']); + // If the bound names a type alias, expand it exactly as a type position would, so + // the check runs against the real type: a single-head alias (`Named = Face`) + // becomes that head, a union / nullable alias (`Num = int|string`) becomes a union + // bound (any-of). Without this the alias name is a phantom class and every argument + // is wrongly rejected. Reaches class-, method-, and alias-parameter bounds alike. + if (isset($this->aliasTable[$fqn])) { + // @infection-ignore-all IncrementInteger -- buildBoundExprNode carries no source + // line; a cycle/arity error while expanding a *bound* alias is reported at the + // check-mode line-1 fallback whether the seed is 0 or 1, so the value is inert. + $members = $this->expandAliasToUnion(new TypeRef($fqn, $resolvedArgs), [], 0); + return count($members) === 1 + ? new BoundLeaf($members[0]) + : new BoundUnion(...array_map(static fn (TypeRef $m): BoundLeaf => new BoundLeaf($m), $members)); + } $suspect = !$node['isFq'] && $this->isSuspectUndeclared($node['name']); return new BoundLeaf(new TypeRef($fqn, $resolvedArgs, suspectUndeclared: $suspect)); @@ -3637,6 +4035,368 @@ private function resolveTypeRef(TypeRef $ref): TypeRef ); } + /** + * If this type-position Name resolves to a declared single-head alias, return the AST + * node for its fully-expanded body; otherwise null (leave the node untouched). The use's + * head + arguments come from the attributes already attached: a generic use carries + * ATTR_GENERIC_ARGS + ATTR_TEMPLATE_FQN, a bare use carries ATTR_RESOLVED_FQN. A Name in a + * non-type position (a plain function call) has neither and is skipped. + */ + private function expandAliasName(Name $node): ?Node + { + // @infection-ignore-all ReturnRemoval -- optimization only: with an empty table the + // `isset($this->aliasTable[$head])` guard below already returns null for every name. + if ($this->aliasTable === []) { + return null; + } + $genericArgs = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + $templateFqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + $resolvedFqn = $node->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + // @infection-ignore-all LogicalAnd -- ATTR_GENERIC_ARGS and ATTR_TEMPLATE_FQN are + // attached together by the generic-marker binding (never one without the other), so + // `&&` and `||` select the same branch here. + if (is_array($genericArgs) && is_string($templateFqn)) { + /** @var list $genericArgs */ + $head = ltrim($templateFqn, '\\'); + $useArgs = $genericArgs; + } elseif (is_string($resolvedFqn)) { + $head = ltrim($resolvedFqn, '\\'); + $useArgs = []; + } else { + return null; + } + // Expand the head AND (recursively) the arguments — an alias can appear as a generic + // argument of a non-alias type (`Bag`), not just as the head. The expansion is a + // union of members: one member is a single head (leave a non-alias untouched, else + // replace); two or more is a compound (union) alias, representable only as the whole + // type of a slot (`ATTR_ALIAS_WHOLE_SLOT`). + $useRef = new TypeRef($head, $useArgs); + $members = $this->expandAliasToUnion($useRef, [], $node->getStartLine()); + // Drop the pre-expansion xphp attributes; the builders re-add the right ones (position + // attributes are preserved so diagnostics still map back). + $attrs = $node->getAttributes(); + unset( + $attrs[XphpSourceParser::ATTR_GENERIC_ARGS], + $attrs[XphpSourceParser::ATTR_TEMPLATE_FQN], + $attrs[XphpSourceParser::ATTR_RESOLVED_FQN], + $attrs[XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE], + $attrs[XphpSourceParser::ATTR_ALIAS_WHOLE_SLOT], + ); + if (count($members) === 1) { + if ($members[0]->canonical() === $useRef->canonical()) { + return null; + } + return Specializer::typeRefToNode($members[0], $attrs); + } + if ($node->getAttribute(XphpSourceParser::ATTR_ALIAS_WHOLE_SLOT) !== true) { + throw new XphpParseException( + "Type alias `{$head}` is a union type, which is only usable as the whole type " + . 'of a parameter, property, return, or class-constant slot.', + $node->getStartLine(), + XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, + ); + } + return self::unionMembersToNode($members, $attrs); + } + + /** + * Build the PHP type node for an expanded union: a `NullableType` when the sole non-null + * member is atomic (`?X` ≡ `X|null`), otherwise a `UnionType` (with a `null` member when + * the union is nullable). Members are single heads, so `?X` never wraps a compound — + * `?(A&B)` would be a fatal PHP parse error. + * + * @param list $members + * @param array $attrs + */ + private static function unionMembersToNode(array $members, array $attrs): Node + { + $hasNull = false; + /** @var list $nonNull */ + $nonNull = []; + foreach ($members as $m) { + // @infection-ignore-all UnwrapStrToLower -- resolveTypeRef already lowercases a + // scalar keyword, so a `null` leaf's name is always lowercase here; strtolower is + // a belt-and-suspenders guard. + if (!$m->isGeneric() && strtolower($m->name) === 'null') { + $hasNull = true; + } else { + $nonNull[] = $m; + } + } + // A single-head member always lowers to an atomic Identifier (scalar) or Name (class), + // never a compound node — so it is valid inside a UnionType and (for the `?X` case) a + // NullableType. + /** @var list $nodes */ + $nodes = array_map(static fn (TypeRef $m): Node => Specializer::typeRefToNode($m, []), $nonNull); + if ($hasNull && count($nodes) === 1) { + return new Node\NullableType($nodes[0], $attrs); + } + if ($hasNull) { + $nodes[] = new Node\Identifier('null'); + } + return new Node\UnionType($nodes, $attrs); + } + + /** + * Recursively expand a type reference against the file-local alias table. A non-alias + * head is returned with its arguments expanded; an alias head is substituted with its + * body (params → arguments) and re-expanded, so nested and concrete-instantiation aliases + * (`type UserMap = Pair`) resolve fully. A head that recurs into itself + * (through its body or a generic argument) is a cycle, and a use whose argument count + * differs from the alias's parameter count is an arity error — both fail loudly with + * `xphp.alias_cycle` / `xphp.alias_arity`. + * + * @param list $visited alias FQNs already entered on this expansion chain + */ + private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef + { + $members = $this->expandAliasToUnion($ref, $visited, $line); + if (count($members) !== 1) { + // A union alias reached where only a single head is representable — a generic + // argument, a `new` / turbofish / `extends` / bound, or a nested type position. + throw new XphpParseException( + "Type alias `{$ref->name}` is a union type, which is only usable as the whole " + . 'type of a parameter, property, return, or class-constant slot.', + $line, + XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, + ); + } + return $members[0]; + } + + /** + * Expand a type reference to its **union members** (a single-head result is one member), + * fully resolving aliases. A non-alias head yields itself with its generic arguments + * expanded (single-head — a union cannot be a generic argument, so an alias argument that + * expands to a union throws via `expandAlias`). An alias head substitutes its body's union + * members (params → arguments) and expands each recursively, concatenating — so a + * single-head alias whose body transitively resolves to a union becomes a union too, and a + * union member that is itself a union alias flattens in. A cycle or arity mismatch throws. + * + * @param list $visited alias FQNs already entered on this expansion chain + * @return list + */ + private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): array + { + // Expand each argument on the SAME visited chain — an argument that refers back to an + // alias already being expanded (`type A = Bag>`) is a cycle through the + // argument path; passing an empty chain here would miss it and recurse without bound. + $expandedArgs = array_map(fn (TypeRef $a): TypeRef => $this->expandAlias($a, $visited, $line), $ref->args); + $entry = $this->aliasTable[$ref->name] ?? null; + if ($entry === null) { + return [new TypeRef($ref->name, $expandedArgs, $ref->isScalar, $ref->isTypeParam, $ref->suspectUndeclared)]; + } + if (in_array($ref->name, $visited, true)) { + throw new XphpParseException( + "Type alias `{$ref->name}` is defined (directly or transitively) in terms of itself.", + $line, + XphpSourceParser::CODE_ALIAS_CYCLE, + ); + } + $paddedArgs = $this->padAliasArgs($ref->name, $entry, $expandedArgs, $line); + $this->captureAliasBoundObligation($ref->name, $entry, $paddedArgs, $line); + $subst = []; + foreach (array_column($entry['params'], 'name') as $k => $paramName) { + $subst[$paramName] = $paddedArgs[$k]; + } + $members = []; + foreach ($this->resolveAliasBody($ref->name, $entry) as $bodyMember) { + $substituted = self::substituteTypeRef($bodyMember, $subst); + foreach ($this->expandAliasToUnion($substituted, [...$visited, $ref->name], $line) as $m) { + $members[] = $m; + } + } + return $members; + } + + /** + * Record a deferred bound-check for a used alias whose parameters declare bounds, to be + * verified once the whole-program hierarchy exists ({@see AliasBoundValidator}). Captured + * only when a collector is threaded in (the compile/check path — inert for standalone / LSP + * parse) and every supplied argument is top-level ground: a bare type-param argument + * (`B` inside `class C`) is absent from the hierarchy and would be spuriously + * rejected, so it is skipped, whereas a concrete head over a type-param inner (`Box`) IS + * captured (bounds erase generic arguments). + * + * Only a generic alias has parameters, hence bounds; and a generic alias only expands where + * it is FILE-LOCAL (a cross-file generic-alias use is a separate unsupported case that + * hard-errors as an undefined template, never reaching here). So a captured bound always + * resolves in the same namespace it was declared in — no cross-file misresolution. When + * cross-file generic aliases are supported, that resolution context must be revisited. + * + * @param array{params:list, body:list} $entry + * @param list $paddedArgs + */ + private function captureAliasBoundObligation(string $fqn, array $entry, array $paddedArgs, int $line): void + { + if ($this->obligations === null) { + return; + } + // A top-level type-param argument (`B` in `class C`) is absent from the hierarchy + // and would be spuriously rejected; skip the whole obligation. A concrete head over a + // type-param inner (`Coll`) is kept — bounds erase generic arguments. (An alias with + // no bounds is captured harmlessly: checkBounds is a no-op for a param without a bound, + // so gating on "has a bound" would be an unobservable optimization.) + foreach ($paddedArgs as $arg) { + if ($arg->isTypeParam) { + return; + } + } + $this->obligations->add( + $this->resolveAliasParams($fqn, $entry, $line), + $paddedArgs, + "type alias `{$fqn}`", + new SourceLocation($this->filepath ?? '', $line), + ); + } + + /** + * Reconcile the supplied type arguments against an alias's parameters, filling missing + * trailing arguments from the parameters' defaults. A default may reference an earlier + * parameter (`B = A`), so each is substituted with the arguments already positioned. The + * required (default-less) parameters form a prefix (enforced by `parseTypeParamList`), so a + * valid supply count is `required <= given <= total`; anything else is `xphp.alias_arity`. + * + * @param array{params:list, body:list} $entry + * @param list $expandedArgs + * @return list + */ + private function padAliasArgs(string $fqn, array $entry, array $expandedArgs, int $line): array + { + $total = count($entry['params']); + $required = 0; + foreach ($entry['params'] as $param) { + if ($param['default'] === null) { + $required++; + } + } + $given = count($expandedArgs); + if ($given < $required || $given > $total) { + // @infection-ignore-all CastString -- $total is interpolated into the message + // either way; the cast only keeps both ternary branches typed `string`. + $expected = $required === $total + ? (string) $total + : "between {$required} and {$total}"; + throw new XphpParseException( + "Type alias `{$fqn}` expects {$expected} type argument(s), {$given} given.", + $line, + XphpSourceParser::CODE_ALIAS_ARITY, + ); + } + $params = $this->resolveAliasParams($fqn, $entry, $line); + $paramNames = array_column($entry['params'], 'name'); + $padded = $expandedArgs; + for ($i = $given; $i < $total; $i++) { + $subst = []; + foreach ($padded as $k => $arg) { + $subst[$paramNames[$k]] = $arg; + } + // @infection-ignore-all CoalesceRemoval -- indices [$given,$total) are exactly the + // trailing params, every one of which has a default (required params form a prefix), + // so $params[$i]->default is never null here; the coalesce is a defensive floor. + $default = $params[$i]->default ?? throw new \LogicException('padded slot without a default'); + $padded[] = self::substituteTypeRef($default, $subst); + } + return $padded; + } + + /** + * Resolve an alias's raw body against the current namespace context, with the alias's own + * type parameters pushed so `A` / `B` become type-param references rather than qualified + * class names. Cached per alias FQN. + * + * @param array{params:list, body:list} $entry + * @return list + */ + private function resolveAliasBody(string $fqn, array $entry): array + { + // @infection-ignore-all ReturnRemoval -- the cache is an optimization; resolveTypeRef + // is deterministic for a fixed context, so re-resolving on a cache miss is equivalent. + if (isset($this->aliasBodyCache[$fqn])) { + return $this->aliasBodyCache[$fqn]; + } + // Resolve each union member with the alias's own parameters in scope, then restore the + // exact prior scope stack — so the alias's params never leak into later resolution. + // Restore by saved-copy assignment (not a pop) so the restore is exact and unconditional. + $saved = $this->typeParamStack; + $this->typeParamStack[] = array_column($entry['params'], 'name'); + $resolved = array_map(fn (TypeRef $m): TypeRef => $this->resolveTypeRef($m), $entry['body']); + $this->typeParamStack = $saved; + return $this->aliasBodyCache[$fqn] = $resolved; + } + + /** + * Resolve an alias's raw parameter entries against the current namespace context, with the + * alias's own parameters in scope so a bound / default that references a param (`T : A`, + * `B = A`) resolves to a type-param leaf. Returns one TypeParam per parameter, in order, + * carrying the resolved bound and default. Cached per FQN; feeds both default-padding + * (`padAliasArgs`) and bound enforcement (`captureAliasBoundObligation`). + * + * A parameter's bound may itself name an alias (`type B`), which expands here via + * `buildBoundExpr`. If that bound refers (directly or transitively) back to this alias, the + * `$inFlight` guard turns the otherwise-unbounded recursion into a clean `xphp.alias_cycle` + * — the body-cycle `$visited` guard in `expandAliasToUnion` does not cover the bound axis. + * + * @param array{params:list, body:list} $entry + * @return list + */ + private function resolveAliasParams(string $fqn, array $entry, int $line): array + { + // @infection-ignore-all ReturnRemoval -- the cache is an optimization; resolution is + // deterministic for a fixed context, so re-resolving on a cache miss is equivalent. + if (isset($this->aliasParamsCache[$fqn])) { + return $this->aliasParamsCache[$fqn]; + } + if (isset($this->aliasParamsInFlight[$fqn])) { + throw new XphpParseException( + "Type alias `{$fqn}` is defined (directly or transitively) in terms of itself.", + $line, + XphpSourceParser::CODE_ALIAS_CYCLE, + ); + } + // @infection-ignore-all TrueValue -- a presence set: the isset() guard above reads key + // existence, not the value, so true vs false is unobservable. Never cleared, and it + // needn't be: the cache check above short-circuits a COMPLETED alias before this guard, + // so a lingering flag can only ever mark an alias still mid-resolution (a real cycle). + $this->aliasParamsInFlight[$fqn] = true; + $saved = $this->typeParamStack; + $this->typeParamStack[] = array_column($entry['params'], 'name'); + $resolved = array_map( + fn (array $param): TypeParam => new TypeParam( + $param['name'], + $this->buildBoundExpr($param), + $this->buildDefault($param), + $param['variance'], + ), + $entry['params'], + ); + $this->typeParamStack = $saved; + return $this->aliasParamsCache[$fqn] = $resolved; + } + + /** + * Replace type-parameter leaves in a resolved TypeRef tree using a name → concrete map. + * + * @param array $subst + */ + private static function substituteTypeRef(TypeRef $ref, array $subst): TypeRef + { + // @infection-ignore-all LogicalAnd -- a resolved body's type-param leaves are exactly + // the alias's parameters, every one present in $subst; and a class leaf's FQN name + // never equals a bare parameter-name key. So both operands are always true together or + // false together, and `&&`/`||` select the same result. + if ($ref->isTypeParam && isset($subst[$ref->name])) { + return $subst[$ref->name]; + } + return new TypeRef( + $ref->name, + array_map(static fn (TypeRef $a): TypeRef => self::substituteTypeRef($a, $subst), $ref->args), + $ref->isScalar, + $ref->isTypeParam, + $ref->suspectUndeclared, + ); + } + /** * A bare, single-segment, non-imported class name used inside a generic * context — the suspect condition shared by the bound/default TypeRef path diff --git a/test/TestSupport/CompiledFixture.php b/test/TestSupport/CompiledFixture.php index da94f7e2..3c21b423 100644 --- a/test/TestSupport/CompiledFixture.php +++ b/test/TestSupport/CompiledFixture.php @@ -31,16 +31,23 @@ * `#[RunInSeparateProcess]` (or use `setUpBeforeClass` for an entire * `TestCase` that shares one fixture across methods). * - * Failure attribution: when a verify file calls `Assert::*` and it - * fails, the exception's throw site is the verify file (`path:line`), - * and the `require` frame is the calling `testFoo` method — PHPUnit + * Verify contract: a verify file `return`s a + * `function (CompiledFixture $fixture): void` that runs its `Assert::*` + * calls when invoked, so the fixture dependency is an explicit typed + * parameter rather than an implicit in-scope variable. The driver + * loads the closure with `require` and calls it with the fixture. + * + * Failure attribution: when a verify file's `Assert::*` fails, the + * exception's throw site is the verify file (`path:line`), and the + * closure-invocation frame is the calling `testFoo` method — PHPUnit * reports both. * * Usage: * $fixture = CompiledFixture::compile($sourceDir, 'array-sugar'); * $fixture->registerAutoload('App\\ArraySugar\\'); * try { - * require __DIR__ . '/../../fixture/compile/array_sugar/verify/foo.php'; + * $runtime = require __DIR__ . '/../../fixture/compile/array_sugar/verify/foo.php'; + * $runtime($fixture); * } finally { * $fixture->cleanup(); * } diff --git a/test/Transpiler/Monomorphize/ArraySugarIntegrationTest.php b/test/Transpiler/Monomorphize/ArraySugarIntegrationTest.php index 2891da61..62a04f74 100644 --- a/test/Transpiler/Monomorphize/ArraySugarIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ArraySugarIntegrationTest.php @@ -84,7 +84,8 @@ public function testNullableReturnEnforcesConcreteType(): void $fixture = CompiledFixture::compile($this->sourceDir, 'array-sugar-verify'); $fixture->registerAutoload('App\\ArraySugar\\'); try { - require __DIR__ . '/../../fixture/compile/array_sugar/verify/nullable_return.php'; + $runtime = require __DIR__ . '/../../fixture/compile/array_sugar/verify/nullable_return.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -104,7 +105,8 @@ public function testSugarBeforeGenericClosureStillSpecializes(): void ?: throw new RuntimeException('Fixture missing'); $fixture = CompiledFixture::compile($source, 'array-sugar-marker-offset'); try { - require __DIR__ . '/../../fixture/compile/array_sugar_before_generic_closure/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/array_sugar_before_generic_closure/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/ArrowSpecializationTest.php b/test/Transpiler/Monomorphize/ArrowSpecializationTest.php index a556447b..5ac16b78 100644 --- a/test/Transpiler/Monomorphize/ArrowSpecializationTest.php +++ b/test/Transpiler/Monomorphize/ArrowSpecializationTest.php @@ -94,7 +94,8 @@ public function testArrowSpecializationEndToEndSingleCapture(): void 'arrow-capture', ); try { - require __DIR__ . '/../../fixture/compile/arrow_capture_at_declaration/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_capture_at_declaration/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -108,7 +109,8 @@ public function testArrowSpecializationEndToEndMultipleCaptures(): void 'arrow-multi', ); try { - require __DIR__ . '/../../fixture/compile/arrow_multiple_captures/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_multiple_captures/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -122,7 +124,8 @@ public function testArrowSpecializationEndToEndNoCaptures(): void 'arrow-empty', ); try { - require __DIR__ . '/../../fixture/compile/arrow_no_captures/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_no_captures/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -138,7 +141,8 @@ public function testArrowSpecializationCaptureShadowingParamName(): void 'arrow-shadow', ); try { - require __DIR__ . '/../../fixture/compile/arrow_capture_shadowing/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_capture_shadowing/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -164,7 +168,8 @@ public function testArrowSpecializationMultipleArgTuples(): void $out, ); - require __DIR__ . '/../../fixture/compile/arrow_multiple_arg_tuples/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_multiple_arg_tuples/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -218,7 +223,8 @@ public function testArrowSpecializationReservedArgsCaptureAlsoTriggersRename(): $out, ); - require __DIR__ . '/../../fixture/compile/arrow_reserved_args_capture/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_reserved_args_capture/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -250,7 +256,8 @@ public function testArrowSpecializationReservedCaptureAutoRenamesDispatcherParam $out, ); - require __DIR__ . '/../../fixture/compile/arrow_reserved_tag_capture/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_reserved_tag_capture/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php index cd88684c..a5f6dfbe 100644 --- a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php +++ b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php @@ -428,7 +428,8 @@ public function testScalarAliasClassTypeArgumentsResolveAndRunAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/scalar_alias_class_resolves/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/scalar_alias_class_resolves/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php b/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php index 0981453b..df4db297 100644 --- a/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php +++ b/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php @@ -139,7 +139,8 @@ public function testSpecializedClassLoadsAndResolvesBuiltinsAtRuntime(): void $fixture = CompiledFixture::compile($this->sourceDir, 'builtin-via-use-runtime'); $fixture->registerAutoload('App\\BuiltinViaUse\\'); try { - require __DIR__ . '/../../fixture/compile/builtin_interface_via_use/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/builtin_interface_via_use/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index cd46e6a5..58460ada 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\Diagnostics\Severity; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FilepathArray; use XPHP\FileSystem\FileReader\NativeFileReader; @@ -43,6 +44,249 @@ public function testCleanSourcesProduceNoDiagnostics(): void self::assertSame([], $diagnostics->all()); } + public function testNamedForwardGroundedByEnclosingParamIsCleanInCheck(): void + { + // `wrap` forwarding `identity::` is grounded per specialization by the + // append-drain; the validate-only walk must agree with compile and report + // nothing — no duplicate diagnostics from the drain's re-traversal either. + $diagnostics = $this->check('forward_named_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testMethodTurbofishGroundedByEnclosingClassParamIsCleanInCheck(): void + { + // `self::gen::` / `Maker::wrap::` inside `Box` ground per + // specialization; the validate-only pass must agree with compile and report + // nothing — across two instantiations and two forwarding classes. + $diagnostics = $this->check('method_turbofish_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testGroundedStaticBoundViolationIsCollectedByCheck(): void + { + // `gen` grounded with `T = int`: provable only after + // Box specializes, collected by the per-specialization grounding pass. + // The location must point at the template's real source file (the grounding + // pass resolves it through the retained class-source map), not a synthetic + // `` label. + $diagnostics = $this->check('method_turbofish_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); + } + + public function testClassClosureDispatcherIsCleanInCheck(): void + { + // A concrete `$f::` inside a generic CLASS method: compile materializes + // the dispatcher and the program runs, so check must stay silent — the spec + // clone's leftover variable marker (check never finalizes dispatchers) is not + // a leak. + $diagnostics = $this->check('class_closure_dispatcher_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testNestedBoundViolationInsideGroundedMemberIsCollectedByCheck(): void + { + // The violation lives inside the grounded member's body (`new Pair::` with + // `Pair

`, grounded to Pair): own-spec members are collected + // even though check attaches nothing, so check agrees with compile's reject. + $diagnostics = $this->check('method_turbofish_nested_bound'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(Registry::CODE_BOUND_VIOLATION, $codes); + } + + public function testDeferredTurbofishArityErrorIsReportedOncePerSite(): void + { + // An arity error on a deferred enclosing-param turbofish under TWO + // instantiations: one diagnostic at the source site — the per-spec grounding + // walks must not re-fire the same collector message per specialization. + $diagnostics = $this->check('method_turbofish_arity_once'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_TOO_MANY_TYPE_ARGUMENTS, $diagnostics->all()[0]->code); + } + + public function testTwoHopOwnTemplateForwardChainIsCleanInCheck(): void + { + // Compile grounds `go` → `self::a::` → `self::b::` and the program + // runs; check must stay silent — its un-stripped spec clone retains the + // `a`/`b` templates, whose interior method-param markers are dispatch + // machinery, not leaks. + $diagnostics = $this->check('method_turbofish_two_hop_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testSameLineWarningDoesNotMaskAGroundedError(): void + { + // A warning-producing construct shares the source line with the deferred + // turbofish: the position dedupe is severity-aware, so grounding still runs + // and the bound violation nested in the grounded member surfaces — check + // must not go green on code compile rejects. + $diagnostics = $this->check('method_turbofish_warning_same_line'); + + self::assertTrue($diagnostics->hasErrors()); + $errorCodes = array_map( + static fn ($d) => $d->code, + array_filter($diagnostics->all(), static fn ($d) => $d->severity === Severity::Error), + ); + self::assertContains(Registry::CODE_BOUND_VIOLATION, $errorCodes); + } + + public function testTemplateTargetOutsideItsOwnSpecLeakIsCollectedByCheck(): void + { + // The compile-side keep-marker contract for a template-owned target named + // outside the template's own body (see the compile reject fixture) holds in + // check too: exactly one leak diagnostic, at the real source site. + $diagnostics = $this->check('template_target_outside_spec'); + + $leaks = array_values(array_filter( + $diagnostics->all(), + static fn ($d) => $d->code === GenericMarkerLeakGuard::CODE, + )); + self::assertCount(1, $leaks); + self::assertNotNull($leaks[0]->location); + self::assertStringEndsWith('Use.xphp', $leaks[0]->location->file); + } + + public function testCrossTemplateStaticTurbofishLeakIsCollectedByCheck(): void + { + // `Other::gen::` (a static method-generic on a DIFFERENT generic template) + // stays un-grounded by design; compile rejects at the emit backstop, and check + // must collect the same leak diagnostic from the grounding pass — located at + // the template's real source file. + $diagnostics = $this->check('method_turbofish_cross_template'); + + self::assertTrue($diagnostics->hasErrors()); + $leaks = array_values(array_filter( + $diagnostics->all(), + static fn ($d) => $d->code === GenericMarkerLeakGuard::CODE, + )); + self::assertCount(1, $leaks); + self::assertNotNull($leaks[0]->location); + self::assertStringEndsWith('Use.xphp', $leaks[0]->location->file); + } + + public function testClassParamBoundOnStaticIsStillUnprovableInCheck(): void + { + // `gen` on a static method-generic: genuinely unprovable in a static + // context — the pre-existing rejection survives the grounding pass in check + // exactly as in compile. + $diagnostics = $this->check('method_turbofish_unprovable'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(GenericMethodCompiler::CODE_BOUND_UNPROVABLE, $codes); + } + + public function testInstanceTurbofishGroundedByEnclosingClassParamIsCleanInCheck(): void + { + // `$this->dup::` / `$m->dup::` inside `Holder` ground per + // specialization; the validate-only pass must agree with compile and + // report nothing. + $diagnostics = $this->check('instance_turbofish_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testInstanceGroundedBoundViolationIsCollectedByCheck(): void + { + // `need` grounded with `T = int` through `$this`: + // collected by the grounding pass, located at the template's real file. + $diagnostics = $this->check('instance_turbofish_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); + } + + public function testMethodParamLeafSelfCallIsCollectedByCheck(): void + { + // `$this->dup::` inside `probe`: deferral is reserved for + // class-param leaves, so check keeps the precise Phase-1a diagnostic + // (exactly one — the grounding pass must not add a leak duplicate). + $diagnostics = $this->check('instance_turbofish_method_param'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame( + GenericMethodCompiler::CODE_UNSPECIALIZABLE_SELF_CALL, + $diagnostics->all()[0]->code, + ); + } + + public function testNeverInstantiatedDeferredMarkerIsCleanInCheck(): void + { + // A deferred enclosing-param turbofish in a never-instantiated generic + // class: unreachable code, no diagnostic (deliberate surface choice, + // matching compile). + $diagnostics = $this->check('instance_never_instantiated'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testGroundedForwardBoundViolationIsCollectedByCheck(): void + { + // `need` forwarded `T = int`: provable only after `wrap::` + // substitutes, so it surfaces from the drain's validate-only traversal — + // matching the compile-side throw (check/compile parity). + $diagnostics = $this->check('forward_named_bound'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_BOUND_VIOLATION, $diagnostics->all()[0]->code); + } + + public function testUnconvergedForwardChainIsCollectedByCheck(): void + { + // `grow` forwarding `grow::>` never converges; check collects the + // drain's hop-cap diagnostic instead of hanging or throwing. + $diagnostics = $this->check('forward_growth'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION, $codes); + } + + public function testEachUnconvergedChainGetsItsOwnDiagnosticInCheck(): void + { + // Two independent growing chains: hitting the cap on the first stops that + // chain only — the drain keeps going and the second chain reports too. + $diagnostics = $this->check('forward_growth_pair'); + + $unconverged = array_values(array_filter( + $diagnostics->all(), + static fn ($d) => $d->code === GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION, + )); + self::assertCount(2, $unconverged); + } + + public function testInnerClosureTurbofishLeakIsCollectedByCheck(): void + { + // A concrete inner closure turbofish (`$f::` inside `outer`) survives + // the drain (variable turbofish stays out of the markers-only pass) and is + // degraded from the compile-time leak throw to a collected diagnostic here. + $diagnostics = $this->check('forward_inner_closure_leak'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(GenericMarkerLeakGuard::CODE, $codes); + } + public function testDefaultBoundViolationIsCollectedByCheck(): void { // Exercises the validateDefaultsAgainstBounds() step of check(). diff --git a/test/Transpiler/Monomorphize/ClosureArrowDefaultsTest.php b/test/Transpiler/Monomorphize/ClosureArrowDefaultsTest.php index 78c3bccb..a41b97c3 100644 --- a/test/Transpiler/Monomorphize/ClosureArrowDefaultsTest.php +++ b/test/Transpiler/Monomorphize/ClosureArrowDefaultsTest.php @@ -29,7 +29,8 @@ public function testClosureWithSingleDefaultUsesPadding(): void 'cdef-single', ); try { - require __DIR__ . '/../../fixture/compile/closure_defaults_single/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_defaults_single/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -43,7 +44,8 @@ public function testArrowWithSingleDefaultUsesPadding(): void 'adef-single', ); try { - require __DIR__ . '/../../fixture/compile/arrow_defaults_single/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_defaults_single/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -135,7 +137,8 @@ public function testDefaultOnClosureWithUseClause(): void 'cdef-use', ); try { - require __DIR__ . '/../../fixture/compile/closure_defaults_with_use/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_defaults_with_use/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -150,7 +153,8 @@ public function testDefaultOnArrowWithImplicitCapture(): void 'adef-cap', ); try { - require __DIR__ . '/../../fixture/compile/arrow_defaults_with_implicit_capture/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_defaults_with_implicit_capture/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php index 6c563967..a0e44f69 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php @@ -25,7 +25,8 @@ public function testConformingClosuresCompileEraseAndRun(): void 'closure-conformance-run', ); try { - require __DIR__ . '/../../fixture/compile/closure_conformance_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_conformance_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -43,7 +44,8 @@ public function testDnfGroupedSignaturesCompileEraseAndRun(): void 'closure-conformance-dnf', ); try { - require __DIR__ . '/../../fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -61,7 +63,8 @@ public function testUserFunctionNamedClosureInExpressionColonsExecutes(): void 'closure-named-user-fn', ); try { - require __DIR__ . '/../../fixture/compile/closure_named_user_function_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_named_user_function_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -78,7 +81,8 @@ public function testArraySugarSignaturesCompileEraseAndRun(): void 'closure-conformance-sugar', ); try { - require __DIR__ . '/../../fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -96,7 +100,8 @@ public function testExceptionFactoryAgainstBuiltinThrowableTargetCompilesAndRuns ); $fixture->registerAutoload('App\\ClosureBuiltinOk\\'); try { - require __DIR__ . '/../../fixture/compile/closure_conformance_builtin_ok/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_conformance_builtin_ok/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -124,7 +129,8 @@ public function testConformingClosureArgumentCompilesEraseAndRuns(): void ); $fixture->registerAutoload('App\\ClosureArgRun\\'); try { - require __DIR__ . '/../../fixture/compile/closure_arg_instance_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_arg_instance_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -152,7 +158,8 @@ public function testConformingStaticClosureArgumentCompilesEraseAndRuns(): void ); $fixture->registerAutoload('App\\ClosureArgStaticRun\\'); try { - require __DIR__ . '/../../fixture/compile/closure_arg_static_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_arg_static_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -180,7 +187,8 @@ public function testConformingFreeFunctionClosureArgumentCompilesEraseAndRuns(): ); $fixture->registerAutoload('App\\ClosureArgFnRun\\'); try { - require __DIR__ . '/../../fixture/compile/closure_arg_free_fn_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_arg_free_fn_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -208,7 +216,8 @@ public function testConformingPlainMethodClosureArgumentCompilesEraseAndRuns(): ); $fixture->registerAutoload('App\\PlainArgRun\\'); try { - require __DIR__ . '/../../fixture/compile/closure_arg_plain_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_arg_plain_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -236,7 +245,8 @@ public function testConformingPlainFreeFunctionClosureArgumentCompilesEraseAndRu ); $fixture->registerAutoload('App\\PlainFnArgRun\\'); try { - require __DIR__ . '/../../fixture/compile/closure_arg_plain_fn_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_arg_plain_fn_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -265,7 +275,8 @@ public function testBucket3SelfCallClosureArgumentCompilesEraseAndRuns(): void ); $fixture->registerAutoload('App\\Bucket3Run\\'); try { - require __DIR__ . '/../../fixture/compile/closure_arg_bucket3_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_arg_bucket3_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -295,7 +306,8 @@ public function testGroundedGenericClosureConformsWhenTypeParameterResolves(): v ); $fixture->registerAutoload('App\\ClosureGroundRuntime\\'); try { - require __DIR__ . '/../../fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php index 715316fb..6da5eef2 100644 --- a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php @@ -126,7 +126,8 @@ public function testRuntimeRoutingThroughDispatcher(): void 'disp-routing', ); try { - require __DIR__ . '/../../fixture/compile/closure_dispatcher_runtime_routing/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_dispatcher_runtime_routing/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -142,7 +143,8 @@ public function testUnknownTagAtRuntimeThrows(): void 'disp-unknown', ); try { - require __DIR__ . '/../../fixture/compile/closure_dispatcher_unknown_tag/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_dispatcher_unknown_tag/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -291,7 +293,8 @@ public function testFirstClassCallableTurbofishClosureEmitsValidForwardingClosur 'fcc-closure', ); try { - require __DIR__ . '/../../fixture/compile/turbofish_fcc_closure/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/turbofish_fcc_closure/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php b/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php index 49eec68f..fb1a15a3 100644 --- a/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php +++ b/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php @@ -102,7 +102,8 @@ public function testBareNewSelfInNonDefaultsGenericBodyIsNotRejectedAndRuns(): v ); try { $fixture->registerAutoload('App\\BareNewSelfInGenericBody'); - require __DIR__ . '/../../fixture/compile/bare_new_self_in_generic_body/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/bare_new_self_in_generic_body/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/DispatcherFixtureIntegrationTest.php b/test/Transpiler/Monomorphize/DispatcherFixtureIntegrationTest.php index 5d7a7a36..723a12c5 100644 --- a/test/Transpiler/Monomorphize/DispatcherFixtureIntegrationTest.php +++ b/test/Transpiler/Monomorphize/DispatcherFixtureIntegrationTest.php @@ -68,7 +68,8 @@ public function testArrowFixtureSynthesizesUseClauseFromImplicitCapture(): void $out, ); - require __DIR__ . '/../../fixture/compile/closure_dispatcher_arrow/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_dispatcher_arrow/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -100,7 +101,8 @@ public function testUseClauseFixturePropagatesByRefThroughDispatcher(): void preg_match_all('/function closure_f_T_[0-9a-f]+\(/', $out, $matches); self::assertCount(2, $matches[0]); - require __DIR__ . '/../../fixture/compile/closure_dispatcher_use_clause/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_dispatcher_use_clause/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -131,7 +133,8 @@ public function testDefaultsFixturePadsEmptyTurbofish(): void preg_match_all('/function closure_f_T_[0-9a-f]+\(/', $out, $fMatches); self::assertCount(2, $fMatches[0]); - require __DIR__ . '/../../fixture/compile/closure_dispatcher_defaults/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_dispatcher_defaults/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index d2e6104b..ad310ac8 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -732,7 +732,8 @@ public function testErasableForwardingRunsAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -749,7 +750,8 @@ public function testTwoEnclosingBoundedParamsEraseAndRunAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -766,7 +768,8 @@ public function testMultiClassParamErasureMangleKeysOnTheBoundsReferentAtRuntime ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -783,7 +786,8 @@ public function testTwoTurbofishTypesCollapseToOneWidenedMemberAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -801,7 +805,8 @@ public function testInheritedErasableMemberResolvesAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -819,7 +824,8 @@ public function testErasureIsVarianceSafeOnTheCovariantChainAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -839,7 +845,8 @@ public function testCovariantInterfaceUpcastResolvesTheErasedMemberAtRuntime(): ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -859,7 +866,8 @@ public function testSubInterfaceMethodDirectEmittedUnderUpcastRunsAtRuntime(): v ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -878,7 +886,8 @@ public function testDirectEmittedBodyResolvesTheClassParamToTheUpcastSourceAtRun ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_structural_class_param/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_structural_class_param/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -896,7 +905,8 @@ public function testMultiParamCovariantInterfaceUpcastResolvesAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -916,7 +926,8 @@ public function testVarianceEdgeDoesNotOverwriteASourceParentAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1264,7 +1275,8 @@ public function testNestedGenericDiamondCovariantUpcastCompilesAndRuns(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/covariant_upcast_nested_generic_diamond/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/covariant_upcast_nested_generic_diamond/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1287,7 +1299,8 @@ public function testMultiPathDiamondClosureSuppliesEverySiblingAcrossInterfacesA ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/covariant_upcast_multipath_diamond/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/covariant_upcast_multipath_diamond/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1307,7 +1320,8 @@ public function testReturnEnclosingParamOnParentlessBaseSurvivesPlainUpcast(): v ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/covariant_upcast_return_enclosing_inherited/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/covariant_upcast_return_enclosing_inherited/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1700,24 +1714,25 @@ function probe(Pair $p): bool { return $p->contains::(new 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_<…>`. + // A turbofish-less call whose arguments don't determine the type parameter — here `T` appears + // only in the return type — cannot be inferred, so 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", + 'Box.xphp' => "(int \$n): T { throw new \\RuntimeException('x'); } }\n", + 'Use.xphp' => "pick(1);\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. + // The same non-inferable 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", + 'Box.xphp' => "(int \$n): T { throw new \\RuntimeException('x'); } }\n", + 'Use.xphp' => "pick(1);\n", ]); $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); @@ -1746,10 +1761,11 @@ public function testBareCallToANonGenericMethodIsUnaffected(): void public function testBareStaticMethodGenericCallIsReported(): void { - // The static path (`Box::pick('b')`) has the identical silent-skip branch — also reported. + // The static path (`Box::pick(1)`) has the identical silent-skip branch — a non-inferable + // bare call (T only in the return type) is also reported. $collector = $this->check([ - 'Box.xphp' => "(T \$x): T { return \$x; } }\n", - 'Use.xphp' => " "(int \$n): T { throw new \\RuntimeException('x'); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); @@ -1757,20 +1773,21 @@ public function testBareStaticMethodGenericCallIsReported(): void 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_<…>`. + // The free-function path skipped bare calls via a different early return; a non-inferable + // bare call (T only in the return type) 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' => " "(int \$n): T { throw new \\RuntimeException('x'); }\n", + 'Use.xphp' => "check([ - 'fns.xphp' => "(T \$x): T { return \$x; }\n", - 'Use.xphp' => " "(int \$n): T { throw new \\RuntimeException('x'); }\n", + 'Use.xphp' => " $d->code, $collector->all()); self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); diff --git a/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php b/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php index 1953ce66..a0e69dd2 100644 --- a/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php +++ b/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php @@ -31,7 +31,8 @@ public function testBareQualifiedConstAndBuiltinReferencesBindCorrectlyAtRuntime ); try { $fixture->registerAutoload('App\\FreeSym'); - require __DIR__ . '/../../fixture/compile/free_symbol_requalify/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/free_symbol_requalify/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -49,7 +50,8 @@ public function testUseFunctionAndUseConstImportsBindTheImportedNamespaceAtRunti ); try { $fixture->registerAutoload('App', 'Vendor'); - require __DIR__ . '/../../fixture/compile/free_symbol_use_import/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/free_symbol_use_import/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -67,7 +69,8 @@ public function testGroupUseFunctionAndConstImportsBindTheImportedNamespaceAtRun ); try { $fixture->registerAutoload('App', 'Vendor'); - require __DIR__ . '/../../fixture/compile/free_symbol_group_use_import/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/free_symbol_group_use_import/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -86,7 +89,8 @@ public function testGapFilledCovariantMembersReQualifyTheirFreeSymbolsAtRuntime( ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/GenericExceptionCatchIntegrationTest.php b/test/Transpiler/Monomorphize/GenericExceptionCatchIntegrationTest.php index a93e805b..f3048b64 100644 --- a/test/Transpiler/Monomorphize/GenericExceptionCatchIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericExceptionCatchIntegrationTest.php @@ -57,7 +57,8 @@ public function testGenericCatchDiscriminatesBySpecializationAtRuntime(): void $fixture = CompiledFixture::compile($this->sourceDir, 'generic-catch-runtime'); $fixture->registerAutoload('App\\GenericExceptionCatch\\'); try { - require __DIR__ . '/../../fixture/compile/generic_exception_catch/verify/catch_runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_exception_catch/verify/catch_runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php index 56e42176..4cc61d4e 100644 --- a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php @@ -84,7 +84,8 @@ public function testRuntimeExecutionOfSpecializedFunctions(): void { $fixture = CompiledFixture::compile($this->sourceDir, 'genfn-runtime'); try { - require __DIR__ . '/../../fixture/compile/generic_function/verify/runtime_execution.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_function/verify/runtime_execution.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -269,7 +270,8 @@ public function testBareTopLevelStripPreservesAllNonTemplateStatements(): void $funcsOut, ); - require __DIR__ . '/../../fixture/compile/generic_function_bare_top_level/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_function_bare_top_level/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -354,6 +356,120 @@ function bareId(T $x): T } } + public function testNamedForwardGroundedByEnclosingParamSpecializes(): void + { + // `wrap` forwards `identity::($v)` — abstract in the template, concrete + // after `wrap::` / `wrap::` specialize. The append-drain grounds + // each specialized body and dispatches the forward into a real + // `identity_T_` declaration; no marker (and no raw `identity(`) survives. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_named_forward/source', + 'genfn-named-forward', + ); + try { + $content = file_get_contents($fixture->targetDir . '/Use.php'); + self::assertIsString($content); + + // Two instantiations → two wrap + two forwarded identity specializations. + self::assertSame(2, preg_match_all('/function wrap_T_[0-9a-f]+\(/', $content)); + self::assertSame(2, preg_match_all('/function identity_T_[0-9a-f]+\(/', $content)); + // Each specialized wrap body calls the matching identity specialization. + self::assertSame(2, preg_match_all('/return \\\\App\\\\NamedForward\\\\identity_T_[0-9a-f]+\(/', $content)); + // Negative invariants: templates stripped, no un-rewritten forward survives + // (`identity::<` only appears in the carried-over source comment's prose). + self::assertStringNotContainsString('function wrap(', $content); + self::assertStringNotContainsString('function identity(', $content); + self::assertStringNotContainsString('return identity::<', $content); + SnapshotHash::assertMatches( + __DIR__ . '/../../fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php', + $content, + ); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testNamedForwardRuntimeExecution(): void + { + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_named_forward/source', + 'genfn-named-forward-runtime', + ); + try { + $runtime = require __DIR__ . '/../../fixture/compile/generic_function_named_forward/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testForwardChainAndSameArgsCycleRuntimeExecution(): void + { + // 2-hop chain (`wrap` → `mid` → `identity`) and same-args mutual recursion + // (`ping` ↔ `pong`): the drain keeps grounding freshly appended bodies until + // the queue empties, and the specialization dedup terminates the cycle. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_chain/source', + 'genfn-forward-chain', + ); + try { + $runtime = require __DIR__ . '/../../fixture/compile/generic_function_forward_chain/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testGrowingForwardChainIsRejectedAsUnconverged(): void + { + // `grow` forwards `grow::>` — every hop mints a deeper type argument, + // so the chain can never converge. The drain's hop cap rejects it loudly. The + // reported depth pins the cap boundary exactly: sixteen allowed hops, failing + // on the seventeenth. + try { + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_growth_reject/source', + 'genfn-forward-growth', + ); + self::fail('expected the growing chain to be rejected'); + } catch (RuntimeException $e) { + self::assertStringContainsString(GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION, $e->getMessage()); + self::assertStringContainsString('is 17 specialization hops deep', $e->getMessage()); + } + } + + #[RunInSeparateProcess] + public function testGrowingBareTopLevelForwardChainIsRejectedAsUnconverged(): void + { + // Top-level variant: specializations route through the top-level append bag + // (no Namespace_ container), whose queue the hop cap must bound identically — + // an unbounded bare-file chain would otherwise specialize forever. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_growth_bare_reject/source', + 'genfn-forward-growth-bare', + ); + } + + #[RunInSeparateProcess] + public function testGroundedForwardBoundViolationFailsCompilation(): void + { + // `need` forwarded `T = int` — the violation is only provable + // after `wrap::` substitutes, so it must fail at the grounding drain. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated while instantiating App\ForwardBound\need'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_bound_reject/source', + 'genfn-forward-bound', + ); + } + private function compile(): void { $compiler = $this->buildCompiler(); diff --git a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php new file mode 100644 index 00000000..c4a91f95 --- /dev/null +++ b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php @@ -0,0 +1,621 @@ +` turbofish would be. Covers free-function, static-method, and instance-method + * calls; that inference records the same instantiation an explicit turbofish records; that check and + * compile agree; and that a call whose arguments do NOT determine the type still falls back to the + * `xphp.missing_type_argument` error rather than silently emitting a broken call. + */ +final class GenericInferenceIntegrationTest extends TestCase +{ + private string $work; + + protected function setUp(): void + { + $this->work = sys_get_temp_dir() . '/xphp-inference-' . uniqid('', true); + mkdir($this->work, 0o755, true); + } + + protected function tearDown(): void + { + self::rrmdir($this->work); + } + + private const LIB = <<<'PHP' + (T $x): T { return $x; } + final class Factory { public static function make(T $x): T { return $x; } } + final class Bag { public function put(U $x): U { return $x; } } + PHP; + + #[RunInSeparateProcess] + public function testInferredCallArgumentsRunAtRuntime(): void + { + // The non-negotiable gate: execute the emitted output. Every call site is bare; that the + // program runs and returns the right values proves the turbofish-less calls inferred their + // type arguments and dispatched to real specializations. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/inferred_call_arguments/source', + 'inference', + ); + try { + $fixture->registerAutoload('App'); + $runtime = require __DIR__ . '/../../fixture/compile/inferred_call_arguments/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + public function testFreeFunctionInfersFromLiteral(): void + { + $dist = $this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "`), not left as a bare `identity`. + self::assertStringContainsString('identity_T_', self::read($dist, 'Use.php')); + } + + public function testStaticMethodInfersFromLiteral(): void + { + $dist = $this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "put(9);\n", + ]); + self::assertStringContainsString('put_', self::read($dist, 'Use.php')); + } + + public function testInferenceProducesTheSameSpecializationAsATurbofish(): void + { + // The inferred call and the explicit-turbofish call must dispatch to the byte-identical + // mangled specialization — inference just writes the turbofish for you. + $inferred = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "(5);\n", + ]), 'Use.php'); + + self::assertSame(1, preg_match('/identity_T_\w+/', $inferred, $inferredMatch)); + self::assertSame(1, preg_match('/identity_T_\w+/', $explicit, $explicitMatch)); + self::assertSame($explicitMatch[0], $inferredMatch[0]); + } + + public function testCheckAcceptsAnInferableCall(): void + { + $collector = $this->check([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "all(), 'an inferable bare call must not be flagged'); + } + + public function testExplicitTurbofishStillCompiles(): void + { + // No regression: an explicit turbofish is unchanged by inference. + $dist = $this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "(5);\n", + ]); + self::assertStringContainsString('identity_T_', self::read($dist, 'Use.php')); + } + + public function testFirstClassCallableIsNotInferred(): void + { + // `identity(...)` creates a Closure; it must not be inferred or flagged. + $collector = $this->check([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "all(), 'a first-class-callable must not be inferred or flagged'); + } + + public function testConflictingArgumentsFallBackToErrorInCheck(): void + { + // pair(T $a, T $b) called (int, string): T is witnessed as two types → no inference → + // today's missing-type-argument error. + $collector = $this->check([ + 'Lib.xphp' => "(T \$a, T \$b): T { return \$a; }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testConflictingArgumentsFailCompile(): void + { + // Parity: the same conflict throws in compile mode. + $this->expectException(RuntimeException::class); + $this->compile([ + 'Lib.xphp' => "(T \$a, T \$b): T { return \$a; }\n", + 'Use.xphp' => "`, a call to the + // non-existent class `U`. It falls back to the missing-type-argument error instead. + $collector = $this->check([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfunction outer(U \$x): U { \$r = identity(\$x); return \$x; }\n", + 'Use.xphp' => "(5);\n", + ]); + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testCallArgTypedByEnclosingFunctionTypeParamFailsCompile(): void + { + // Parity: compile throws the same error rather than emitting the broken specialization. + $this->expectException(RuntimeException::class); + $this->compile([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfunction outer(U \$x): U { \$r = identity(\$x); return \$x; }\n", + 'Use.xphp' => "(5);\n", + ]); + } + + public function testCallArgTypedByMethodTypeParamFallsBack(): void + { + // Same, but the argument is typed by the enclosing METHOD's type parameter. + $collector = $this->check([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfinal class Foo { public function m(W \$x): W { \$r = identity(\$x); return \$x; } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testCallArgTypedByEnclosingClassTypeParamFallsBack(): void + { + // Same, but the argument is typed by the enclosing CLASS's type parameter. + $collector = $this->check([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfinal class C { public function f(E \$e): void { \$r = identity(\$e); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testInstanceCallArgTypedByClassTypeParamFallsBack(): void + { + // The instance-method seam: `$this->prop`-less bare instance-generic call whose argument is + // typed by the class type parameter must not infer either. + $collector = $this->check([ + 'Lib.xphp' => "(T \$x): T { return \$x; } }\nfinal class C { public function f(E \$e): void { \$s = new Sink(); \$r = \$s->take(\$e); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testExplicitTurbofishGroundedByEnclosingParamStillWorks(): void + { + // The correct alternative — an explicit turbofish grounded by the enclosing type parameter — + // still grounds per specialization (unchanged by inference): `outer::` emits a call to + // a concrete `identity_T_`, never a reference to the abstract `U`. + $dist = $this->compile([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfunction outer(U \$x): U { \$r = identity::(\$x); return \$x; }\n", + 'Use.xphp' => "(5);\n", + ]); + $lib = self::read($dist, 'Lib.php'); + self::assertStringContainsString('identity_T_', $lib); + self::assertStringNotContainsString('\\App\\U', $lib, 'no reference to the abstract type parameter U'); + } + + // --- `new` inference ----------------------------------------------------------------------- + + private const BOX = <<<'PHP' + { public function __construct(private T $value) {} public function get(): T { return $this->value; } } + PHP; + + #[RunInSeparateProcess] + public function testInferredNewArgumentsRunAtRuntime(): void + { + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/inferred_new_arguments/source', + 'new-inference', + ); + try { + $fixture->registerAutoload('App'); + $runtime = require __DIR__ . '/../../fixture/compile/inferred_new_arguments/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + public function testBareNewInfersFromLiteral(): void + { + $dist = $this->compile([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => ">` — the SAME specialization the explicit + // turbofish selects — not `Box` read off an un-annotated inner node. Regression for the + // bottom-up (leaveNode) annotation order. + $box = " { public function __construct(private T \$v) {} public function get(): T { return \$this->v; } }\n"; + $inferred = self::read($this->compile([ + 'Box.xphp' => $box, + 'Use.xphp' => "compile([ + 'Box.xphp' => $box, + 'Use.xphp' => ">(new Box::(5));\n", + ]), 'Use.php'); + self::assertSame(2, preg_match_all('/Box\\\\T_\w+/', $explicit, $e)); + self::assertSame(2, preg_match_all('/Box\\\\T_\w+/', $inferred, $i)); + // The inferred outer + inner specializations are exactly the two the turbofish produces. + self::assertSame($e[0], $i[0], 'inferred nested new selects the same specializations as the turbofish'); + self::assertCount(2, array_unique($i[0]), 'outer and inner are distinct specializations'); + } + + public function testCallInfersFromAClassReturningCallArgument(): void + { + // A call argument that is itself a call returning a determinable class is an inference source + // for the call path (it reuses the receiver flow engine): `identity($f->make())` infers + // T=Plastic. (The `new` pass is more conservative and would not.) + $dist = $this->compile([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfinal class Factory { public function make(): Plastic { return new Plastic(); } }\nfinal class R { public function go(Factory \$f): Plastic { return identity(\$f->make()); } }\n", + 'Use.xphp' => "check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => "all(), 'a `new` whose argument determines T must not be flagged'); + } + + public function testNonInferableBareNewStillErrorsInCheck(): void + { + // T appears only in a method return, so `new Box()` cannot infer it → still an error. + $collector = $this->check([ + 'Box.xphp' => " { public function get(): ?T { return null; } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testNonInferableBareNewThrowsInCompile(): void + { + // Parity with check. + $this->expectException(RuntimeException::class); + $this->compile([ + 'Box.xphp' => " { public function get(): ?T { return null; } }\n", + 'Use.xphp' => "; without the + // pop it would read the closure's scope (which has $wp, not $op) and fail to infer. + $dist = $this->compile([ + 'Lib.xphp' => " { public function __construct(private T \$v) {} public function get(): T { return \$this->v; } }\nfunction outer(Plastic \$op): Plastic { \$f = function (Widget \$wp): Widget { return \$wp; }; \$unused = \$f; \$b = new Box(\$op); return \$b->get(); }\n", + 'Use.xphp' => "ap` must resolve against the OUTER class + // A — i.e. the class stack is popped on leave. `new Box($this->ap)` infers Box; + // without the pop it would scan the anon class (which has no `$ap`) and fail to infer. + $dist = $this->compile([ + 'Lib.xphp' => " { public function __construct(private T \$v) {} public function get(): T { return \$this->v; } }\nfinal class A { private Plastic \$ap; public function __construct() { \$this->ap = new Plastic(); } public function m(): Plastic { \$inner = new class { private ?Widget \$wp = null; public function n(): ?Widget { return \$this->wp; } }; \$b = new Box(\$this->ap); return \$b->get(); } }\n", + 'Use.xphp' => " from the stale declaration (the value is now an int). + // It falls back to requiring an explicit turbofish instead of emitting an unsound type. + $collector = $this->check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testAllDefaultsBareNewStillSynthesizes(): void + { + // No regression: a bare `new` of an all-defaults template still pads from defaults; the + // inference pass leaves it bare (nothing to infer) and the synthesis path handles it. + $dist = $this->compile([ + 'Cache.xphp' => " { public function size(): int { return 0; } }\n", + 'Use.xphp' => "compile([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\n", + 'Use.xphp' => "check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => "all()); + } + + public function testVariableVariableArgumentFallsBack(): void + { + // `new Box($$name)` — the argument is a variable-variable (its name is an expression, not a + // string), so it can't be typed; inference falls back rather than mishandling the name. + $collector = $this->check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testNewInfersFromPropertyDeclaredAfterAMethod(): void + { + // The property scan must skip non-property statements (a method here) and keep looking, + // not stop at the first one. + $collector = $this->check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class H { public function boot(): void {} private Plastic \$p; public function f(): void { \$b = new Box(\$this->p); } }\n", + 'Use.xphp' => "all(), 'new Box($this->p) must infer even when p follows a method'); + } + + public function testNewFromUnionTypedPropertyFallsBack(): void + { + // A union-typed property is a shape paramTypeRef does not model (null), so the `new` falls + // back rather than dereferencing a null type. + $collector = $this->check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class H { private int|string \$u = 1; public function f(): void { \$b = new Box(\$this->u); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testMethodGenericParameterIsNotTrustedForNewInference(): void + { + // Inside a generic method, a parameter typed by the METHOD type parameter is abstract, so + // `new Box($x)` must not infer from it (that would fabricate a bogus class argument). + $collector = $this->check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class M { public function make(U \$x): void { \$b = new Box(\$x); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testArrowFunctionPresentDoesNotBreakInference(): void + { + // An arrow function has no statement body (getStmts() is null); the reassignment scan must + // tolerate that, and a bare `new Box(5)` alongside it still infers. + $dist = $this->compile([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => " \$x;\n\$b = new Box(5);\n", + ]); + self::assertStringContainsString('Generated\\App\\Box\\T_', self::read($dist, 'Use.php')); + } + + public function testEveryReassignmentFormDropsTheParameter(): void + { + // Each reassignment form — `=`, `+=`, `=&`, `++x`, `x++`, `--x`, `x--` — must mark the + // parameter untrusted, so every `new Box($x)` falls back: one missing-type-argument per + // site. Pins the whole reassignment-detection predicate (and that ALL names are dropped, + // not just the first). + $body = '$a = 5; $b += 1; $ref = 1; $c =& $ref; ++$d; $e++; --$f; $g--; ' + . 'new Box($a); new Box($b); new Box($c); new Box($d); new Box($e); new Box($f); new Box($g);'; + $collector = $this->check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => " $d->code, $collector->all()); + self::assertSame(array_fill(0, 7, Registry::CODE_MISSING_TYPE_ARGUMENT), $codes); + } + + public function testReassignedParameterDoesNotBlockLaterTrustedParameter(): void + { + // The first parameter is reassigned (skipped), but the scan must CONTINUE to the second, + // trustworthy parameter — not stop at the first. + $dist = $this->compile([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => "check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class C { public function f(E \$e): void { \$b = new Box(\$e); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testBodilessMethodDoesNotBreakTheReassignmentScan(): void + { + // An interface method has no body (getStmts() is null); the reassignment scan must tolerate + // that. A bare `new Box(5)` in the same program still infers. + $dist = $this->compile([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\n", + 'Use.xphp' => "check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => "all(), 'both new Box(...) infer from their trusted parameters'); + } + + public function testMultipleClassTypeParametersAreAllRecognized(): void + { + // A class with two type parameters: BOTH must be recognized as abstract (the name set isn't + // truncated), so neither `new Box($a)` nor `new Box($b)` infers → two fallbacks. + $collector = $this->check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class P { public function f(A \$a, B \$b): void { \$x = new Box(\$a); \$y = new Box(\$b); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertSame( + [Registry::CODE_MISSING_TYPE_ARGUMENT, Registry::CODE_MISSING_TYPE_ARGUMENT], + $codes, + ); + } + + public function testConstructorNameMatchedCaseInsensitively(): void + { + // PHP method names are case-insensitive; a `__Construct` constructor must still be found. + $collector = $this->check([ + 'Box.xphp' => " { public function __Construct(private T \$v) {} }\n", + 'Use.xphp' => "all(), 'a __Construct constructor is found case-insensitively, so T infers'); + } + + // --- helpers (kept local, matching the other Monomorphize integration tests) --------------- + + /** @param array $files */ + private function compile(array $files): string + { + $src = $this->writeSources($files); + $dist = $src . '/dist'; + $this->newCompiler()->compile($this->sourcesIn($src), $src, $dist, $src . '/.xphp-cache'); + return $dist; + } + + /** @param array $files */ + private function check(array $files): DiagnosticCollector + { + $src = $this->writeSources($files); + return $this->newCompiler()->check($this->sourcesIn($src)); + } + + /** @param array $files */ + private function writeSources(array $files): string + { + $src = $this->work . '/' . uniqid('src', true); + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + return $src; + } + + private function sourcesIn(string $src): \XPHP\FileSystem\FilepathArray + { + return (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + } + + private function newCompiler(): Compiler + { + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser((new ParserFactory())->createForHostVersion()), + 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 9766ab42..1b9493c3 100644 --- a/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php @@ -119,7 +119,8 @@ public function testSpecializedClassIsInstanceOfSpecializedInterfaceAtRuntime(): $fixture = CompiledFixture::compile($this->sourceDir, 'generic-interface-runtime'); $fixture->registerAutoload('App\\GenericInterface\\'); try { - require __DIR__ . '/../../fixture/compile/generic_interface/verify/specialized_interface_runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_interface/verify/specialized_interface_runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php index 0440700e..073ef43d 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php @@ -140,4 +140,133 @@ public function testAcceptsAListOfNodesAndScansEach(): void $this->expectException(RuntimeException::class); GenericMarkerLeakGuard::assertNoLeak([$clean, new Expression($leaking)], 'list'); } + + public function testFindLeakReturnsTheLeakingNodeAndNullOnCleanInput(): void + { + // The check-mode drain consumes the scan directly (degrading to a diagnostic + // instead of a throw), so the found node — not just the boolean outcome — is API. + $leaking = new StaticCall(new Name('self'), new Identifier('gen')); + $leaking->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + + self::assertSame($leaking, GenericMarkerLeakGuard::findLeak(new Expression($leaking))); + self::assertNull(GenericMarkerLeakGuard::findLeak(new Expression(new FuncCall(new Variable('a'))))); + } + + public function testFindLeakCanExcludeTheClosureTemplateArm(): void + { + // With $includeClosureTemplates=false only call-node markers count: an + // un-specialized closure template is reported elsewhere (source seam / orphan + // check), so the check-mode drain must not re-flag the template node itself. + $closure = new Closure(['stmts' => []]); + $closure->setAttribute(self::PARAMS_MARKER, [new Identifier('I')]); + $body = new Expression($closure); + + self::assertSame($closure, GenericMarkerLeakGuard::findLeak($body)); + self::assertNull(GenericMarkerLeakGuard::findLeak($body, includeClosureTemplates: false)); + + // A call-node marker still counts with the closure arm off. + $call = new FuncCall(new Variable('f')); + $call->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + self::assertSame($call, GenericMarkerLeakGuard::findLeak(new Expression($call), includeClosureTemplates: false)); + } + + public function testFindLeakCanExcludeVariableTurbofishCalls(): void + { + // With $includeVariableTurbofish=false a FuncCall on a VARIABLE (`$f::`) + // is not a leak — check's class-spec backstop uses this because dispatchers + // are only materialized in compile mode. Named calls still count. + $varCall = new FuncCall(new Variable('f')); + $varCall->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + $body = new Expression($varCall); + + self::assertSame($varCall, GenericMarkerLeakGuard::findLeak($body)); + self::assertNull(GenericMarkerLeakGuard::findLeak($body, includeVariableTurbofish: false)); + + $namedCall = new FuncCall(new Name('identity')); + $namedCall->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + self::assertSame( + $namedCall, + GenericMarkerLeakGuard::findLeak(new Expression($namedCall), includeVariableTurbofish: false), + ); + + // Static/instance markers are unaffected by the toggle. + $static = new StaticCall(new Name('self'), new Identifier('gen')); + $static->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + self::assertSame( + $static, + GenericMarkerLeakGuard::findLeak(new Expression($static), includeVariableTurbofish: false), + ); + } + + public function testFindLeakCanSkipUnspecializedTemplateInteriors(): void + { + // With $skipUnspecializedTemplates=true, the SUBTREE of a declaration still + // carrying its generic-template marker is not scanned: check-mode spec clones + // retain method templates whose interior markers are dispatch machinery, not + // leaks. Each declaration kind prunes independently. + $makeMarkedCall = static function (): StaticCall { + $call = new StaticCall(new Name('self'), new Identifier('gen')); + $call->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + return $call; + }; + + $method = new \PhpParser\Node\Stmt\ClassMethod('a', ['stmts' => [new Return_($makeMarkedCall())]]); + $method->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + $function = new \PhpParser\Node\Stmt\Function_('f', ['stmts' => [new Return_($makeMarkedCall())]]); + $function->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + $closure = new Closure(['stmts' => [new Return_($makeMarkedCall())]]); + $closure->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + $arrow = new ArrowFunction(['expr' => $makeMarkedCall()]); + $arrow->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + + foreach ([$method, $function, new Expression($closure), new Expression($arrow)] as $decl) { + self::assertNotNull( + GenericMarkerLeakGuard::findLeak($decl, includeClosureTemplates: false), + 'without the skip, the interior marker is a leak', + ); + self::assertNull( + GenericMarkerLeakGuard::findLeak($decl, includeClosureTemplates: false, skipUnspecializedTemplates: true), + 'with the skip, the template interior is pruned', + ); + } + + // A declaration WITHOUT the template marker is scanned normally... + $plainMethod = new \PhpParser\Node\Stmt\ClassMethod('b', ['stmts' => [new Return_($makeMarkedCall())]]); + self::assertNotNull( + GenericMarkerLeakGuard::findLeak($plainMethod, includeClosureTemplates: false, skipUnspecializedTemplates: true), + ); + // ...and the params attribute on a NON-declaration node never prunes (the skip + // is scoped to the four declaration kinds precisely). + $decoy = new Expression($makeMarkedCall()); + $decoy->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + self::assertNotNull( + GenericMarkerLeakGuard::findLeak($decoy, includeClosureTemplates: false, skipUnspecializedTemplates: true), + ); + } + + public function testFindLeakSkippingScanReturnsTheFirstLeakInDocumentOrder(): void + { + // The pruning scan must report the same FIRST leak the plain scan does — a + // later sibling must never overwrite it. + $first = new StaticCall(new Name('self'), new Identifier('one')); + $first->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + $second = new StaticCall(new Name('self'), new Identifier('two')); + $second->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + $body = [new Expression($first), new Expression($second)]; + + self::assertSame($first, GenericMarkerLeakGuard::findLeak($body, skipUnspecializedTemplates: true)); + self::assertSame($first, GenericMarkerLeakGuard::findLeak($body)); + } + + public function testLeakMessageNamesTheLabelTheLineAndTheCode(): void + { + $call = new FuncCall(new Variable('inner'), [], ['startLine' => 7]); + $call->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + + $message = GenericMarkerLeakGuard::leakMessage($call, 'wrap_T_cafe'); + + self::assertStringContainsString('wrap_T_cafe', $message); + self::assertStringContainsString('7', $message); + self::assertStringContainsString(GenericMarkerLeakGuard::CODE, $message); + } } diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php index 9944ad0c..d27bc459 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php @@ -12,16 +12,22 @@ /** * End-to-end coverage for the emitted-generic-marker backstop ({@see GenericMarkerLeakGuard}). * - * Two enclosing-parameter / generic-function-scope turbofish shapes slip past the source-level - * gates and reach the emit phase un-grounded: a *concrete* inner closure turbofish inside a - * generic function body, and a method turbofish grounded by an enclosing class type parameter. - * Left to emit, each produces PHP that references a non-existent type-parameter class (a runtime - * `TypeError`/`Error`) behind an otherwise clean compile. The backstop turns each into a loud - * compile failure carrying `xphp.unspecialized_generic_leak` before any output is written. + * The turbofish shapes that deliberately stay un-groundable reach the emit phase with their + * marker intact: a *concrete* inner closure turbofish inside a generic function body, a static + * method-generic declared on a *different* generic template, and the late-bound + * `static::`/`parent::` spellings. Left to emit, each produces PHP that references a + * non-existent type-parameter class or the wrong dispatch target behind an otherwise clean + * compile. The backstop turns each into a loud compile failure carrying + * `xphp.unspecialized_generic_leak` before any output is written. * - * (The third shape — a generic closure grounded by an enclosing *function* parameter, `relay` — - * is a non-concrete variable turbofish caught earlier at the source seam in both modes; see - * {@see ClosureDispatcherIntegrationTest} and {@see CheckPassIntegrationTest}.) + * (Adjacent shapes are handled elsewhere: a generic closure grounded by an enclosing *function* + * parameter, `relay`, is a non-concrete variable turbofish caught earlier at the source seam in + * both modes — see {@see ClosureDispatcherIntegrationTest} and {@see CheckPassIntegrationTest}; + * a NAMED free-function forward (`identity::` inside `wrap`) is grounded by the + * append-drain — see `generic_function_named_forward` in {@see GenericFunctionIntegrationTest}; + * and an own-template / non-generic-target method turbofish grounded by the enclosing class + * parameter (`self::gen::`, `Maker::wrap::`) is grounded per specialization — see + * `generic_class_method_turbofish` in {@see GenericMethodIntegrationTest}.) * * The must-keep side — a working top-level `$g::` dispatcher and the `contains` * enclosing-bound forward — is proven zero-false-reject by the existing `closure_dispatcher_arrow` @@ -43,31 +49,66 @@ public function testConcreteInnerTurbofishInsideAGenericFunctionIsRejected(): vo } #[RunInSeparateProcess] - public function testMethodTurbofishGroundedByEnclosingClassParamIsRejected(): void + public function testCrossTemplateStaticTurbofishIsRejected(): void { + // A static method-generic declared on a DIFFERENT generic template + // (`Other::gen::` from inside `Holder`): the grounding pass deliberately + // leaves the marker (it would need Other's own substitution mapping, and + // appending onto a shared template mid-loop is order-dependent), so the + // backstop rejects. The own-template and non-generic-target forms of the same + // call shape ground and run — see the generic_class_method_turbofish fixture. $this->expectException(RuntimeException::class); $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); CompiledFixture::compile( - __DIR__ . '/../../fixture/compile/generic_class_method_turbofish_leak_reject/source', - 'generic-class-method-turbofish-leak', + __DIR__ . '/../../fixture/compile/generic_class_cross_template_turbofish_reject/source', + 'generic-class-cross-template-turbofish', ); } #[RunInSeparateProcess] - public function testNamedFreeFunctionForwardGroundedByEnclosingParamIsRejected(): void + public function testTemplateTargetOutsideItsOwnSpecIsRejected(): void { - // A named generic free function forwarded a non-concrete type argument from an - // enclosing function parameter (`identity::($v)` inside `wrap`). The named-call - // turbofish is not a variable turbofish, so it slips past the source seam and reaches - // emit as an appended `wrap_T_` with the marker still present — caught by the - // backstop. Same class of shape as the concrete-inner-turbofish case. + // `Holder::gen::` written inside Maker's body (a NON-member of Holder): + // grounding Holder drains Maker's freshly appended member, but the + // own-template arm must not fire there — a `self::` rewrite inside Maker + // would call a member Maker doesn't have (a runtime fatal). Keep-marker + + // backstop is the contract. $this->expectException(RuntimeException::class); $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); CompiledFixture::compile( - __DIR__ . '/../../fixture/compile/generic_function_named_forward_leak_reject/source', - 'generic-function-named-forward-leak', + __DIR__ . '/../../fixture/compile/generic_class_template_target_outside_spec_reject/source', + 'generic-class-target-outside-spec', + ); + } + + #[RunInSeparateProcess] + public function testStaticPseudoNameTurbofishIsRejected(): void + { + // `static::gen::`: honoring late static binding is impossible for the + // grounding pass, and resolving `static` to the current class would silently + // re-route a subclass dispatch — keep-marker + loud reject is the contract. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_static_pseudo_turbofish_reject/source', + 'generic-class-static-pseudo-turbofish', + ); + } + + #[RunInSeparateProcess] + public function testParentPseudoNameTurbofishIsRejected(): void + { + // `parent::gen::`: same contract as `static::` — the current-class mapping + // would dispatch to the wrong side of the hierarchy, so the marker is kept. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_parent_pseudo_turbofish_reject/source', + 'generic-class-parent-pseudo-turbofish', ); } } diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 981bedee..5d329c8f 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -92,7 +92,8 @@ public function testRuntimeExecutionPreservesGenericMethodSemantics(): void 'genmethod-runtime', ); try { - require __DIR__ . '/../../fixture/compile/generic_method/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -240,7 +241,8 @@ public function testSelfWithTypeArgsCompilesEndToEnd(): void ); $fixture->registerAutoload('App\\GenericMethodSelfReturnTypeArgs'); - require __DIR__ . '/../../fixture/compile/generic_method_self_with_type_args/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method_self_with_type_args/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -271,7 +273,8 @@ public function testInstanceMethodGenericThisReceiverSpecializes(): void $util, ); - require __DIR__ . '/../../fixture/compile/generic_method_this_receiver/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method_this_receiver/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -342,7 +345,8 @@ public function testInstanceMethodGenericLocalVariableReceiverSpecializes(): voi $use, ); - require __DIR__ . '/../../fixture/compile/generic_method_local_variable_receiver/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method_local_variable_receiver/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1185,7 +1189,8 @@ public function testNewSelfTurbofishCompilesEndToEnd(): void ); $fixture->registerAutoload('App\\GenericMethodNewSelfTurbofish'); - require __DIR__ . '/../../fixture/compile/generic_method_new_self_turbofish/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method_new_self_turbofish/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1217,7 +1222,8 @@ public function testNewStaticTurbofishCompilesEndToEnd(): void ); $fixture->registerAutoload('App\\GenericMethodNewStaticTurbofish'); - require __DIR__ . '/../../fixture/compile/generic_method_new_static_turbofish/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method_new_static_turbofish/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1327,7 +1333,8 @@ public function testGenericMethodResolvesThroughInheritance(): void ); $fixture->registerAutoload('App\\GenericMethodThroughInheritance'); - require __DIR__ . '/../../fixture/compile/generic_method_through_inheritance/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method_through_inheritance/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1353,7 +1360,8 @@ public function testStaticGenericMethodResolvesThroughInheritance(): void 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'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_static_method_through_inheritance/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1691,6 +1699,301 @@ class Box { public static function get(T $x): T { return $x; } } } } + #[RunInSeparateProcess] + public function testMethodTurbofishGroundedByEnclosingClassParamCompiles(): void + { + // `self::gen::` / `Maker::wrap::` inside `Box`: abstract in the + // template, grounded and dispatched per specialization. The spec carries its + // own `gen_T_` member (dispatched via `self::`, appended once despite + // two call sites) and the non-generic Maker gets one shared `wrap_T_` + // per unique argument tuple. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_method_turbofish/source', + 'genmethod-enclosing-turbofish', + ); + try { + $generated = self::globRecursive($fixture->cacheDir . '/Generated', '*.php'); + self::assertCount(3, $generated, 'Box, Box, CoBox'); + + $specs = array_combine($generated, array_map('file_get_contents', $generated)); + $boxIntSpec = null; + foreach ($specs as $path => $content) { + self::assertIsString($content); + if (str_contains($path, '/Box/') && str_contains($content, 'make(int $v): int')) { + $boxIntSpec = $content; + } + } + self::assertIsString($boxIntSpec, 'Box spec found'); + // One appended member, dispatched via self:: (never the marker interface). + self::assertSame(1, preg_match_all('/function gen_T_[0-9a-f]+\(/', $boxIntSpec)); + self::assertSame(2, preg_match_all('/self::gen_T_[0-9a-f]+\(/', $boxIntSpec)); + self::assertStringNotContainsString('Box::gen', $boxIntSpec); + SnapshotHash::assertMatches( + __DIR__ . '/../../fixture/compile/generic_class_method_turbofish/verify/testMethodTurbofishGroundedByEnclosingClassParamCompiles/BoxInt.expected.php', + $boxIntSpec, + ); + + // Maker (a plain user file) carries exactly two wrap specializations — + // int (shared by Box and CoBox) and string. + $maker = file_get_contents($fixture->targetDir . '/Maker.php'); + self::assertIsString($maker); + self::assertSame(2, preg_match_all('/function wrap_T_[0-9a-f]+\(/', $maker)); + + $fixture->registerAutoload('App\\MethodTurbofish'); + $runtime = require __DIR__ . '/../../fixture/compile/generic_class_method_turbofish/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testGroundingAppendedMemberInstantiationsAreCollected(): void + { + // `Pair` first exists inside the `twin_T_` member the grounding + // pass appends onto Maker — its instantiation must be collected into the + // fixed point or the emitted call references a missing generated class. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_method_turbofish_discovery/source', + 'genmethod-turbofish-discovery', + ); + try { + $pairSpecs = array_filter( + self::globRecursive($fixture->cacheDir . '/Generated', '*.php'), + static fn (string $p): bool => str_contains($p, '/Pair/'), + ); + self::assertCount(1, $pairSpecs, 'Pair was discovered and specialized'); + + $fixture->registerAutoload('App\\TurbofishDiscovery'); + $runtime = require __DIR__ . '/../../fixture/compile/generic_class_method_turbofish_discovery/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testGroundedStaticBoundViolationFailsCompilation(): void + { + // `gen` grounded with `T = int` once Box specializes: + // provable only after substitution, must fail at the grounding pass. + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated while instantiating App\Box::gen'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/check/method_turbofish_bound/source', + 'genmethod-turbofish-bound', + ); + } + + #[RunInSeparateProcess] + public function testClassParamBoundOnStaticStaysUnprovable(): void + { + // `gen` on a STATIC method: a class-param bound has no receiver to + // ground it in a static context — the pre-existing rejection must survive + // the grounding pass unchanged. + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot verify generic bound `U : T` for App\Box::gen'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/check/method_turbofish_unprovable/source', + 'genmethod-turbofish-unprovable', + ); + } + + #[RunInSeparateProcess] + public function testGroundedStaticBoundSatisfiedCompilesAndRuns(): void + { + // The bound twin that must keep compiling: `gen` grounded + // with a T that satisfies the bound. + $dir = sys_get_temp_dir() . '/xphp-genmethod-bound-ok-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + s; } + } + class Box { + public function m(T $v): T { return self::gen::($v); } + public static function gen(U $x): U { return $x; } + } + $b = new Box::

+{ + public function __construct(public readonly P $v) + { + } +} + +final class Holder +{ + public function go(T $v): mixed + { + return self::gen::($v); + } + + public static function gen(U $x): mixed + { + return new Pair::($x); + } +} + +$h = new Holder::(); +$h->go(1); diff --git a/test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp b/test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp new file mode 100644 index 00000000..739c6852 --- /dev/null +++ b/test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp @@ -0,0 +1,34 @@ +` appends a member whose body +// holds a now-concrete `self::b::` — the drain must re-ground the appended member +// (with the spec's own identity, so the in-spec site guard passes) until the chain +// bottoms out. Check must stay silent: the retained `a`/`b` templates inside its +// un-stripped spec clone are dispatch machinery, not leaks. +class Holder +{ + /** @return array */ + public function go(T $v): array + { + return self::a::($v); + } + + /** @return array */ + public static function a(U $x): array + { + return self::b::($x); + } + + /** @return array */ + public static function b(V $x): array + { + return [$x, $x]; + } +} + +$h = new Holder::(); +$r = $h->go(9); diff --git a/test/fixture/check/method_turbofish_unprovable/source/Use.xphp b/test/fixture/check/method_turbofish_unprovable/source/Use.xphp new file mode 100644 index 00000000..4bb668f1 --- /dev/null +++ b/test/fixture/check/method_turbofish_unprovable/source/Use.xphp @@ -0,0 +1,24 @@ + +{ + public function m(T $v): T + { + return self::gen::($v); + } + + public static function gen(U $x): U + { + return $x; + } +} + +$b = new Box::(); +$b->m(1); diff --git a/test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp b/test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp new file mode 100644 index 00000000..c3f7e985 --- /dev/null +++ b/test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp @@ -0,0 +1,48 @@ + +{ + public function __construct(public readonly P $v) + { + } +} + +class Producer +{ + public function __construct(public readonly mixed $seed) + { + } +} + +final class Book +{ +} + +// A WARNING and a deferred turbofish share one source line: the position dedupe is +// severity-aware, so the warning must not suppress grounding — the bound violation +// nested in gen's grounded body still surfaces (check-green here while compile +// rejects would be the dangerous direction). +final class Holder +{ + public function go(T $v): mixed + { + $x = [new Producer::(1), self::gen::($v)]; return $x; + } + + public static function gen(U $x): mixed + { + return new Pair::($x); + } +} + +$h = new Holder::(); +$h->go(1); diff --git a/test/fixture/check/template_target_outside_spec/source/Use.xphp b/test/fixture/check/template_target_outside_spec/source/Use.xphp new file mode 100644 index 00000000..d1490867 --- /dev/null +++ b/test/fixture/check/template_target_outside_spec/source/Use.xphp @@ -0,0 +1,37 @@ + drains Maker's freshly appended wrap member, whose body names +// Holder::gen::. Dispatching that through the spec's `self::` would emit a call to +// a member Maker doesn't have (a runtime fatal) — the marker is kept and the +// emitted-marker backstop rejects loudly instead. +final class Maker +{ + /** @return array */ + public static function wrap(X $v): array + { + return Holder::gen::($v); + } +} + +final class Holder +{ + /** @return array */ + public static function gen(U $x): array + { + return [$x, $x]; + } + + /** @return array */ + public function go(T $v): array + { + return Maker::wrap::($v); + } +} + +$h = new Holder::(); +$h->go(7); diff --git a/test/fixture/compile/array_sugar/verify/nullable_return.php b/test/fixture/compile/array_sugar/verify/nullable_return.php index 874a4622..533458f6 100644 --- a/test/fixture/compile/array_sugar/verify/nullable_return.php +++ b/test/fixture/compile/array_sugar/verify/nullable_return.php @@ -15,29 +15,32 @@ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -$collectionFqn = Registry::generatedFqn( - 'App\\ArraySugar\\Containers\\Collection', - [new TypeRef('App\\ArraySugar\\Models\\User')], -); +return function (CompiledFixture $fixture): void { + $collectionFqn = Registry::generatedFqn( + 'App\\ArraySugar\\Containers\\Collection', + [new TypeRef('App\\ArraySugar\\Models\\User')], + ); -// Non-empty: first() returns the concrete typed instance, all() is an array. -$collection = new $collectionFqn( - new \App\ArraySugar\Models\User('alice'), - new \App\ArraySugar\Models\User('bob'), -); -Assert::assertInstanceOf(\App\ArraySugar\Models\User::class, $collection->first()); -$all = $collection->all(); -Assert::assertIsArray($all); -Assert::assertCount(2, $all); + // Non-empty: first() returns the concrete typed instance, all() is an array. + $collection = new $collectionFqn( + new \App\ArraySugar\Models\User('alice'), + new \App\ArraySugar\Models\User('bob'), + ); + Assert::assertInstanceOf(\App\ArraySugar\Models\User::class, $collection->first()); + $all = $collection->all(); + Assert::assertIsArray($all); + Assert::assertCount(2, $all); -// Empty: first() returns null (the nullable arm of `?T`). -$empty = new $collectionFqn(); -Assert::assertNull($empty->first()); + // Empty: first() returns null (the nullable arm of `?T`). + $empty = new $collectionFqn(); + Assert::assertNull($empty->first()); -// Reflection: the `?T` return type must lower to the concrete class, -// not survive as a literal `T` or be widened to `mixed`. -$returnType = (new \ReflectionMethod($collectionFqn, 'first'))->getReturnType(); -Assert::assertInstanceOf(\ReflectionNamedType::class, $returnType); -Assert::assertSame('App\\ArraySugar\\Models\\User', $returnType->getName()); -Assert::assertTrue($returnType->allowsNull()); + // Reflection: the `?T` return type must lower to the concrete class, + // not survive as a literal `T` or be widened to `mixed`. + $returnType = (new \ReflectionMethod($collectionFqn, 'first'))->getReturnType(); + Assert::assertInstanceOf(\ReflectionNamedType::class, $returnType); + Assert::assertSame('App\\ArraySugar\\Models\\User', $returnType->getName()); + Assert::assertTrue($returnType->allowsNull()); +}; diff --git a/test/fixture/compile/array_sugar_before_generic_closure/verify/runtime.php b/test/fixture/compile/array_sugar_before_generic_closure/verify/runtime.php index 64bf4823..d73c7297 100644 --- a/test/fixture/compile/array_sugar_before_generic_closure/verify/runtime.php +++ b/test/fixture/compile/array_sugar_before_generic_closure/verify/runtime.php @@ -7,13 +7,16 @@ * `VeryLongRecordName[]` rewrite shortens the file before the generic * closure; the closure must still specialize and route both calls. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(1, $n); -Assert::assertSame(42, $a); -Assert::assertSame('hello', $b); + Assert::assertSame(1, $n); + Assert::assertSame(42, $a); + Assert::assertSame('hello', $b); +}; diff --git a/test/fixture/compile/arrow_capture_at_declaration/verify/runtime.php b/test/fixture/compile/arrow_capture_at_declaration/verify/runtime.php index f88734c7..f36892cd 100644 --- a/test/fixture/compile/arrow_capture_at_declaration/verify/runtime.php +++ b/test/fixture/compile/arrow_capture_at_declaration/verify/runtime.php @@ -3,8 +3,11 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(43, $result); -Assert::assertSame(2, $y); + Assert::assertSame(43, $result); + Assert::assertSame(2, $y); +}; diff --git a/test/fixture/compile/arrow_capture_shadowing/verify/runtime.php b/test/fixture/compile/arrow_capture_shadowing/verify/runtime.php index 2d40a161..5925b0eb 100644 --- a/test/fixture/compile/arrow_capture_shadowing/verify/runtime.php +++ b/test/fixture/compile/arrow_capture_shadowing/verify/runtime.php @@ -3,7 +3,10 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(7, $result); + Assert::assertSame(7, $result); +}; diff --git a/test/fixture/compile/arrow_defaults_single/verify/runtime.php b/test/fixture/compile/arrow_defaults_single/verify/runtime.php index f632a917..07e680fa 100644 --- a/test/fixture/compile/arrow_defaults_single/verify/runtime.php +++ b/test/fixture/compile/arrow_defaults_single/verify/runtime.php @@ -5,11 +5,14 @@ /** * Runtime verify for `arrow_defaults_single`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(7, $r); + Assert::assertSame(7, $r); +}; diff --git a/test/fixture/compile/arrow_defaults_with_implicit_capture/verify/runtime.php b/test/fixture/compile/arrow_defaults_with_implicit_capture/verify/runtime.php index 517bb992..c165556e 100644 --- a/test/fixture/compile/arrow_defaults_with_implicit_capture/verify/runtime.php +++ b/test/fixture/compile/arrow_defaults_with_implicit_capture/verify/runtime.php @@ -5,11 +5,14 @@ /** * Runtime verify for `arrow_defaults_with_implicit_capture`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(13, $r); + Assert::assertSame(13, $r); +}; diff --git a/test/fixture/compile/arrow_multiple_arg_tuples/verify/runtime.php b/test/fixture/compile/arrow_multiple_arg_tuples/verify/runtime.php index 59c387b8..01255936 100644 --- a/test/fixture/compile/arrow_multiple_arg_tuples/verify/runtime.php +++ b/test/fixture/compile/arrow_multiple_arg_tuples/verify/runtime.php @@ -3,8 +3,11 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(10, $a); -Assert::assertSame('hi', $b); + Assert::assertSame(10, $a); + Assert::assertSame('hi', $b); +}; diff --git a/test/fixture/compile/arrow_multiple_captures/verify/runtime.php b/test/fixture/compile/arrow_multiple_captures/verify/runtime.php index 8cb0eabd..dce850ef 100644 --- a/test/fixture/compile/arrow_multiple_captures/verify/runtime.php +++ b/test/fixture/compile/arrow_multiple_captures/verify/runtime.php @@ -3,7 +3,10 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(31, $result); + Assert::assertSame(31, $result); +}; diff --git a/test/fixture/compile/arrow_no_captures/verify/runtime.php b/test/fixture/compile/arrow_no_captures/verify/runtime.php index 1fcaec8a..b4eeb2c3 100644 --- a/test/fixture/compile/arrow_no_captures/verify/runtime.php +++ b/test/fixture/compile/arrow_no_captures/verify/runtime.php @@ -3,7 +3,10 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $result); + Assert::assertSame(42, $result); +}; diff --git a/test/fixture/compile/arrow_reserved_args_capture/verify/runtime.php b/test/fixture/compile/arrow_reserved_args_capture/verify/runtime.php index 1b805658..e55a0e21 100644 --- a/test/fixture/compile/arrow_reserved_args_capture/verify/runtime.php +++ b/test/fixture/compile/arrow_reserved_args_capture/verify/runtime.php @@ -3,7 +3,10 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(203, $result); + Assert::assertSame(203, $result); +}; diff --git a/test/fixture/compile/arrow_reserved_tag_capture/verify/runtime.php b/test/fixture/compile/arrow_reserved_tag_capture/verify/runtime.php index ff9b10d0..470ddc13 100644 --- a/test/fixture/compile/arrow_reserved_tag_capture/verify/runtime.php +++ b/test/fixture/compile/arrow_reserved_tag_capture/verify/runtime.php @@ -3,7 +3,10 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(105, $result); + Assert::assertSame(105, $result); +}; diff --git a/test/fixture/compile/attributed_generic_closures/verify/runtime.php b/test/fixture/compile/attributed_generic_closures/verify/runtime.php index 31f78054..5cec73bf 100644 --- a/test/fixture/compile/attributed_generic_closures/verify/runtime.php +++ b/test/fixture/compile/attributed_generic_closures/verify/runtime.php @@ -7,15 +7,18 @@ * static generic closures and arrows must specialize (not silently keep * raw type-param hints), and the emitted program must execute. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(7, $a); -Assert::assertSame('b', $b); -Assert::assertSame(3, $c); -Assert::assertSame('d', $d); -Assert::assertSame(11, $e); + Assert::assertSame(7, $a); + Assert::assertSame('b', $b); + Assert::assertSame(3, $c); + Assert::assertSame('d', $d); + Assert::assertSame(11, $e); +}; diff --git a/test/fixture/compile/bare_new_self_in_generic_body/verify/runtime.php b/test/fixture/compile/bare_new_self_in_generic_body/verify/runtime.php index f4cf1541..ee37a85d 100644 --- a/test/fixture/compile/bare_new_self_in_generic_body/verify/runtime.php +++ b/test/fixture/compile/bare_new_self_in_generic_body/verify/runtime.php @@ -7,13 +7,16 @@ * non-defaults generic body compiles (not rejected by the WI-06 bare-new guard) and * runs -- `$c` is a copy of `$n` produced by `new self`, carrying the same value. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(5, $n->v); -Assert::assertSame(5, $c->v); -Assert::assertNotSame($n, $c); + Assert::assertSame(5, $n->v); + Assert::assertSame(5, $c->v); + Assert::assertNotSame($n, $c); +}; diff --git a/test/fixture/compile/builtin_interface_via_use/verify/runtime.php b/test/fixture/compile/builtin_interface_via_use/verify/runtime.php index 1348f74f..afb5a256 100644 --- a/test/fixture/compile/builtin_interface_via_use/verify/runtime.php +++ b/test/fixture/compile/builtin_interface_via_use/verify/runtime.php @@ -12,7 +12,7 @@ * "Interface ... not found" the moment the class autoloaded. This file proves * the class now loads and the built-ins resolve to the real global symbols. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered * for `App\BuiltinViaUse\` + the generated namespace. */ @@ -20,56 +20,59 @@ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -$bagFqn = Registry::generatedFqn( - 'App\\BuiltinViaUse\\ArrayBag', - [new TypeRef('App\\BuiltinViaUse\\Models\\User')], -); +return function (CompiledFixture $fixture): void { + $bagFqn = Registry::generatedFqn( + 'App\\BuiltinViaUse\\ArrayBag', + [new TypeRef('App\\BuiltinViaUse\\Models\\User')], + ); -// Autoloads without a "not found" error — this is the regression under test. -$bag = new $bagFqn(); + // Autoloads without a "not found" error — this is the regression under test. + $bag = new $bagFqn(); -// `extends Countable, IteratorAggregate` (imported, bare) resolved to the real built-ins. -Assert::assertInstanceOf(\Countable::class, $bag); -Assert::assertInstanceOf(\IteratorAggregate::class, $bag); + // `extends Countable, IteratorAggregate` (imported, bare) resolved to the real built-ins. + Assert::assertInstanceOf(\Countable::class, $bag); + Assert::assertInstanceOf(\IteratorAggregate::class, $bag); -$bag->add(new User('alice')); -$bag->add(new User('bob')); + $bag->add(new User('alice')); + $bag->add(new User('bob')); -// Countable::count() via the bare `\count(...)` call (must have stayed bare). -Assert::assertCount(2, $bag); + // Countable::count() via the bare `\count(...)` call (must have stayed bare). + Assert::assertCount(2, $bag); -// `getIterator(): Traversable { return new ArrayIterator(...); }` — return-type -// hint + `new` of an imported built-in, both relocation-proof now. -$iter = $bag->getIterator(); -Assert::assertInstanceOf(\Traversable::class, $iter); -Assert::assertInstanceOf(\ArrayIterator::class, $iter); -Assert::assertCount(2, $iter); + // `getIterator(): Traversable { return new ArrayIterator(...); }` — return-type + // hint + `new` of an imported built-in, both relocation-proof now. + $iter = $bag->getIterator(); + Assert::assertInstanceOf(\Traversable::class, $iter); + Assert::assertInstanceOf(\ArrayIterator::class, $iter); + Assert::assertCount(2, $iter); -// `first()` happy path returns the concrete substituted element type. -Assert::assertInstanceOf(User::class, $bag->first()); -Assert::assertSame('alice', $bag->first()->name); + // `first()` happy path returns the concrete substituted element type. + Assert::assertInstanceOf(User::class, $bag->first()); + Assert::assertSame('alice', $bag->first()->name); -// `extends AbstractBag` (bare, same-namespace) resolved to the real base class. -Assert::assertInstanceOf(\App\BuiltinViaUse\AbstractBag::class, $bag); -Assert::assertTrue((new $bagFqn())->isEmpty([])); + // `extends AbstractBag` (bare, same-namespace) resolved to the real base class. + Assert::assertInstanceOf(\App\BuiltinViaUse\AbstractBag::class, $bag); + Assert::assertTrue((new $bagFqn())->isEmpty([])); -// Param-typed / instanceof / class-const-fetch positions all load and run. -Assert::assertTrue($bag->accepts(new \App\BuiltinViaUse\Errors\EmptyBagError('probe'))); + // Param-typed / instanceof / class-const-fetch positions all load and run. + Assert::assertTrue($bag->accepts(new \App\BuiltinViaUse\Errors\EmptyBagError('probe'))); -// Closure with an imported return type, nested in a relocated method. -$factory = $bag->makeFactory(); -Assert::assertInstanceOf(\Traversable::class, $factory()); + // Closure with an imported return type, nested in a relocated method. + $factory = $bag->makeFactory(); + Assert::assertInstanceOf(\Traversable::class, $factory()); -// Typed (enum) class constant whose type + value reference a same-namespace class. -Assert::assertSame(\App\BuiltinViaUse\Color::Red, $bag::DEFAULT_COLOR); + // Typed (enum) class constant whose type + value reference a same-namespace class. + Assert::assertSame(\App\BuiltinViaUse\Color::Red, $bag::DEFAULT_COLOR); -// `catch (EmptyBagError $e)` + `new EmptyBagError(...)` path on the empty bag. -$empty = new $bagFqn(); -try { - $empty->first(); - Assert::fail('expected RuntimeException from the empty-bag path'); -} catch (\RuntimeException $e) { - Assert::assertSame('bag is empty', $e->getMessage()); - Assert::assertInstanceOf(\App\BuiltinViaUse\Errors\EmptyBagError::class, $e->getPrevious()); -} + // `catch (EmptyBagError $e)` + `new EmptyBagError(...)` path on the empty bag. + $empty = new $bagFqn(); + try { + $empty->first(); + Assert::fail('expected RuntimeException from the empty-bag path'); + } catch (\RuntimeException $e) { + Assert::assertSame('bag is empty', $e->getMessage()); + Assert::assertInstanceOf(\App\BuiltinViaUse\Errors\EmptyBagError::class, $e->getPrevious()); + } +}; diff --git a/test/fixture/compile/closure_arg_bucket3_runtime/verify/runtime.php b/test/fixture/compile/closure_arg_bucket3_runtime/verify/runtime.php index 99a654c6..d35e4948 100644 --- a/test/fixture/compile/closure_arg_bucket3_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_arg_bucket3_runtime/verify/runtime.php @@ -9,11 +9,14 @@ * whose `Closure(E): string` target grounds to `Closure(int): string` under Box, * conforms, erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('value=42', $result, 'the grounded bucket-3 self-call argument ran'); + Assert::assertSame('value=42', $result, 'the grounded bucket-3 self-call argument ran'); +}; diff --git a/test/fixture/compile/closure_arg_free_fn_runtime/verify/runtime.php b/test/fixture/compile/closure_arg_free_fn_runtime/verify/runtime.php index ad4195ac..ba44725d 100644 --- a/test/fixture/compile/closure_arg_free_fn_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_arg_free_fn_runtime/verify/runtime.php @@ -8,11 +8,14 @@ * A conforming closure literal passed to a grounded `Closure(int $x): int` generic * free-function parameter compiles, erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $result, 'the conforming free-function closure argument ran'); + Assert::assertSame(42, $result, 'the conforming free-function closure argument ran'); +}; diff --git a/test/fixture/compile/closure_arg_instance_runtime/verify/runtime.php b/test/fixture/compile/closure_arg_instance_runtime/verify/runtime.php index 4fcc4c88..54dc601e 100644 --- a/test/fixture/compile/closure_arg_instance_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_arg_instance_runtime/verify/runtime.php @@ -8,11 +8,14 @@ * A conforming closure literal passed as a call argument to a grounded * `Closure(Book $x): string` parameter compiles, erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('PHP', $result, 'the conforming closure argument ran'); + Assert::assertSame('PHP', $result, 'the conforming closure argument ran'); +}; diff --git a/test/fixture/compile/closure_arg_plain_fn_runtime/verify/runtime.php b/test/fixture/compile/closure_arg_plain_fn_runtime/verify/runtime.php index cb0f8d44..cd4ea546 100644 --- a/test/fixture/compile/closure_arg_plain_fn_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_arg_plain_fn_runtime/verify/runtime.php @@ -8,11 +8,14 @@ * A conforming closure literal passed to a NON-generic free function's * Closure(Book): string parameter compiles, erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('PHP', $result, 'the conforming free-function closure argument ran'); + Assert::assertSame('PHP', $result, 'the conforming free-function closure argument ran'); +}; diff --git a/test/fixture/compile/closure_arg_plain_runtime/verify/runtime.php b/test/fixture/compile/closure_arg_plain_runtime/verify/runtime.php index cae159c3..d6acd654 100644 --- a/test/fixture/compile/closure_arg_plain_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_arg_plain_runtime/verify/runtime.php @@ -8,11 +8,14 @@ * A conforming closure literal passed to a NON-generic method's Closure(Book): string * parameter compiles, erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('PHP', $result, 'the conforming plain-method closure argument ran'); + Assert::assertSame('PHP', $result, 'the conforming plain-method closure argument ran'); +}; diff --git a/test/fixture/compile/closure_arg_static_runtime/verify/runtime.php b/test/fixture/compile/closure_arg_static_runtime/verify/runtime.php index 385d6473..d395249c 100644 --- a/test/fixture/compile/closure_arg_static_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_arg_static_runtime/verify/runtime.php @@ -8,11 +8,14 @@ * A conforming closure literal passed to a grounded `Closure(int $x): int` * static-method parameter compiles, erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $result, 'the conforming static closure argument ran'); + Assert::assertSame(42, $result, 'the conforming static closure argument ran'); +}; diff --git a/test/fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php b/test/fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php index 554aa70a..043c0a0c 100644 --- a/test/fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php @@ -8,20 +8,23 @@ * Array-sugar leaves in signature parameter and return positions lower to * `array`, the signatures erase, and the compiled output executes. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $sum, 'sugared-parameter factory closure ran'); -Assert::assertSame([40, 2], $list, 'sugared-return factory closure ran'); + Assert::assertSame(42, $sum, 'sugared-parameter factory closure ran'); + Assert::assertSame([40, 2], $list, 'sugared-return factory closure ran'); -// Full erasure: no bracket-pair residue may survive into the emitted output -// (the trailing `[40, 2]` array literal is body code, not a type). -$emitted = file_get_contents($fixture->targetDir . '/Use.php'); -Assert::assertIsString($emitted); -Assert::assertStringContainsString('\\Closure', $emitted); -Assert::assertStringNotContainsString('Item[]', $emitted); -Assert::assertStringNotContainsString('int[]', $emitted); + // Full erasure: no bracket-pair residue may survive into the emitted output + // (the trailing `[40, 2]` array literal is body code, not a type). + $emitted = file_get_contents($fixture->targetDir . '/Use.php'); + Assert::assertIsString($emitted); + Assert::assertStringContainsString('\\Closure', $emitted); + Assert::assertStringNotContainsString('Item[]', $emitted); + Assert::assertStringNotContainsString('int[]', $emitted); +}; diff --git a/test/fixture/compile/closure_conformance_builtin_ok/verify/runtime.php b/test/fixture/compile/closure_conformance_builtin_ok/verify/runtime.php index bb7d61c9..a93ff27c 100644 --- a/test/fixture/compile/closure_conformance_builtin_ok/verify/runtime.php +++ b/test/fixture/compile/closure_conformance_builtin_ok/verify/runtime.php @@ -8,12 +8,15 @@ * The exception factory compiles (no false-reject against the built-in * `\Throwable` target), erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertInstanceOf(\Throwable::class, $thrown, 'the factory returned a Throwable'); -Assert::assertSame('boom', $thrown->getMessage()); + Assert::assertInstanceOf(\Throwable::class, $thrown, 'the factory returned a Throwable'); + Assert::assertSame('boom', $thrown->getMessage()); +}; diff --git a/test/fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php b/test/fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php index 46d96184..eb9f2c9f 100644 --- a/test/fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php @@ -8,19 +8,22 @@ * DNF-grouped signature types scan as one gradual leaf, the signatures erase, * and the compiled output executes. Asserts both factories' closures ran. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $count, 'DNF-parameter factory closure ran'); -Assert::assertSame('both', $tag, 'DNF-return factory closure ran'); + Assert::assertSame(42, $count, 'DNF-parameter factory closure ran'); + Assert::assertSame('both', $tag, 'DNF-return factory closure ran'); -// Full erasure: no DNF residue may survive into the emitted output. -$emitted = file_get_contents($fixture->targetDir . '/Use.php'); -Assert::assertIsString($emitted); -Assert::assertStringContainsString('\\Closure', $emitted); -Assert::assertStringNotContainsString('(Tagged&Counted)', $emitted); -Assert::assertStringNotContainsString('|(', $emitted); + // Full erasure: no DNF residue may survive into the emitted output. + $emitted = file_get_contents($fixture->targetDir . '/Use.php'); + Assert::assertIsString($emitted); + Assert::assertStringContainsString('\\Closure', $emitted); + Assert::assertStringNotContainsString('(Tagged&Counted)', $emitted); + Assert::assertStringNotContainsString('|(', $emitted); +}; diff --git a/test/fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php b/test/fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php index cd10a58d..32fb5102 100644 --- a/test/fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php @@ -8,11 +8,14 @@ * The type parameter grounds to `int`; the conforming factory closure compiles, * its `Closure(...)` target erases to `\Closure`, and it runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $result, 'the grounded factory closure ran'); + Assert::assertSame(42, $result, 'the grounded factory closure ran'); +}; diff --git a/test/fixture/compile/closure_conformance_runtime/verify/runtime.php b/test/fixture/compile/closure_conformance_runtime/verify/runtime.php index d1173436..7ff315b1 100644 --- a/test/fixture/compile/closure_conformance_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_conformance_runtime/verify/runtime.php @@ -9,20 +9,23 @@ * compiles clean and the erased-to-`\Closure` output executes. Asserts the * factories' closures actually ran with the values they were built from. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $scaled, 'stored property closure ran'); -Assert::assertSame(42, $added, 'method-return factory closure ran'); -Assert::assertSame(42, $incremented, 'free-function factory closure ran'); -Assert::assertSame(42, $tripled, 'arrow-body factory closure ran'); + Assert::assertSame(42, $scaled, 'stored property closure ran'); + Assert::assertSame(42, $added, 'method-return factory closure ran'); + Assert::assertSame(42, $incremented, 'free-function factory closure ran'); + Assert::assertSame(42, $tripled, 'arrow-body factory closure ran'); -// The erased output must carry a bare \Closure, never a residual `Closure(int`. -$emitted = file_get_contents($fixture->targetDir . '/Use.php'); -Assert::assertIsString($emitted); -Assert::assertStringContainsString('\\Closure', $emitted); -Assert::assertStringNotContainsString('Closure(int', $emitted); + // The erased output must carry a bare \Closure, never a residual `Closure(int`. + $emitted = file_get_contents($fixture->targetDir . '/Use.php'); + Assert::assertIsString($emitted); + Assert::assertStringContainsString('\\Closure', $emitted); + Assert::assertStringNotContainsString('Closure(int', $emitted); +}; diff --git a/test/fixture/compile/closure_defaults_single/verify/runtime.php b/test/fixture/compile/closure_defaults_single/verify/runtime.php index 1bc0e19f..3704d94b 100644 --- a/test/fixture/compile/closure_defaults_single/verify/runtime.php +++ b/test/fixture/compile/closure_defaults_single/verify/runtime.php @@ -5,11 +5,14 @@ /** * Runtime verify for `closure_defaults_single`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $r); + Assert::assertSame(42, $r); +}; diff --git a/test/fixture/compile/closure_defaults_with_use/verify/runtime.php b/test/fixture/compile/closure_defaults_with_use/verify/runtime.php index 38f172dd..317acef4 100644 --- a/test/fixture/compile/closure_defaults_with_use/verify/runtime.php +++ b/test/fixture/compile/closure_defaults_with_use/verify/runtime.php @@ -5,11 +5,14 @@ /** * Runtime verify for `closure_defaults_with_use`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(105, $r); + Assert::assertSame(105, $r); +}; diff --git a/test/fixture/compile/closure_dispatcher_arrow/verify/runtime.php b/test/fixture/compile/closure_dispatcher_arrow/verify/runtime.php index e263db92..ea33094f 100644 --- a/test/fixture/compile/closure_dispatcher_arrow/verify/runtime.php +++ b/test/fixture/compile/closure_dispatcher_arrow/verify/runtime.php @@ -5,7 +5,7 @@ /** * Runtime verify for `closure_dispatcher_arrow`. * - * Driver contract: `$fixture` (CompiledFixture) must be in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. * Requiring the compiled `Use.php` brings the top-level variables * `$y` and `$resultArrow` into this file's scope, so the assertions * read them directly. The capture moment is the assign site, so the @@ -13,8 +13,11 @@ */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(43, $resultArrow); -Assert::assertSame(2, $y); + Assert::assertSame(43, $resultArrow); + Assert::assertSame(2, $y); +}; diff --git a/test/fixture/compile/closure_dispatcher_defaults/verify/runtime.php b/test/fixture/compile/closure_dispatcher_defaults/verify/runtime.php index a41d4640..fd181031 100644 --- a/test/fixture/compile/closure_dispatcher_defaults/verify/runtime.php +++ b/test/fixture/compile/closure_dispatcher_defaults/verify/runtime.php @@ -5,16 +5,19 @@ /** * Runtime verify for `closure_dispatcher_defaults`. * - * Driver contract: `$fixture` (CompiledFixture) must be in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. * After requiring the compiled `Use.php`, the empty-turbofish and * explicit calls leave their results in `$resultPaddedClosure`, * `$resultExplicitClosure`, and `$resultPaddedArrow`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('#42', $resultPaddedClosure); -Assert::assertSame('#hi', $resultExplicitClosure); -Assert::assertSame('world', $resultPaddedArrow); + Assert::assertSame('#42', $resultPaddedClosure); + Assert::assertSame('#hi', $resultExplicitClosure); + Assert::assertSame('world', $resultPaddedArrow); +}; diff --git a/test/fixture/compile/closure_dispatcher_runtime_routing/verify/runtime.php b/test/fixture/compile/closure_dispatcher_runtime_routing/verify/runtime.php index 7d7824db..b4299154 100644 --- a/test/fixture/compile/closure_dispatcher_runtime_routing/verify/runtime.php +++ b/test/fixture/compile/closure_dispatcher_runtime_routing/verify/runtime.php @@ -7,12 +7,15 @@ * requiring the compiled Use.php, the two top-level vars `$a` and * `$b` hold the values routed through the dispatcher arms. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $a); -Assert::assertSame('hello', $b); + Assert::assertSame(42, $a); + Assert::assertSame('hello', $b); +}; diff --git a/test/fixture/compile/closure_dispatcher_unknown_tag/verify/runtime.php b/test/fixture/compile/closure_dispatcher_unknown_tag/verify/runtime.php index 45261a62..eb7bf490 100644 --- a/test/fixture/compile/closure_dispatcher_unknown_tag/verify/runtime.php +++ b/test/fixture/compile/closure_dispatcher_unknown_tag/verify/runtime.php @@ -9,18 +9,21 @@ * closure) at top level; the verify file then invokes it with a * tag that no real call site emits. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -$caught = null; -try { - $id('T_bogus', 99); -} catch (\RuntimeException $e) { - $caught = $e; -} -Assert::assertInstanceOf(\RuntimeException::class, $caught); -Assert::assertSame('Unknown generic specialization tag: T_bogus', $caught->getMessage()); + $caught = null; + try { + $id('T_bogus', 99); + } catch (\RuntimeException $e) { + $caught = $e; + } + Assert::assertInstanceOf(\RuntimeException::class, $caught); + Assert::assertSame('Unknown generic specialization tag: T_bogus', $caught->getMessage()); +}; diff --git a/test/fixture/compile/closure_dispatcher_use_clause/verify/runtime.php b/test/fixture/compile/closure_dispatcher_use_clause/verify/runtime.php index 060d9d99..c576740a 100644 --- a/test/fixture/compile/closure_dispatcher_use_clause/verify/runtime.php +++ b/test/fixture/compile/closure_dispatcher_use_clause/verify/runtime.php @@ -5,7 +5,7 @@ /** * Runtime verify for `closure_dispatcher_use_clause`. * - * Driver contract: `$fixture` (CompiledFixture) must be in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. * The compiled `Use.php` defines `$base`, `$counter`, and three call * results `$callA`, `$callB`, `$callC` at top level. After require, * each is available here. The by-ref `&$counter` capture mutates @@ -13,10 +13,13 @@ */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame([1, 10, 1], $callA); -Assert::assertSame([2, 10, 2], $callB); -Assert::assertSame(['hi', 10, 3], $callC); -Assert::assertSame(3, $counter); + Assert::assertSame([1, 10, 1], $callA); + Assert::assertSame([2, 10, 2], $callB); + Assert::assertSame(['hi', 10, 3], $callC); + Assert::assertSame(3, $counter); +}; diff --git a/test/fixture/compile/closure_named_user_function_runtime/verify/runtime.php b/test/fixture/compile/closure_named_user_function_runtime/verify/runtime.php index 08f270d9..017bc3ef 100644 --- a/test/fixture/compile/closure_named_user_function_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_named_user_function_runtime/verify/runtime.php @@ -8,19 +8,22 @@ * The user function named `Closure` is CALLED (not erased) in every * expression position that shares a `) :` token pair with return-type slots. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $ternary, 'ternary-else call to App\\...\\Closure() executed'); -Assert::assertSame(20, $alt, 'alt-syntax-if call executed'); -Assert::assertSame(12, $case, 'case-label call executed'); + Assert::assertSame(42, $ternary, 'ternary-else call to App\\...\\Closure() executed'); + Assert::assertSame(20, $alt, 'alt-syntax-if call executed'); + Assert::assertSame(12, $case, 'case-label call executed'); -// The calls survive verbatim — no bare `\Closure` constant fetch anywhere. -$emitted = file_get_contents($fixture->targetDir . '/Use.php'); -Assert::assertIsString($emitted); -Assert::assertStringNotContainsString('\\Closure ', $emitted); -Assert::assertStringContainsString('Closure(HALF)', $emitted); + // The calls survive verbatim — no bare `\Closure` constant fetch anywhere. + $emitted = file_get_contents($fixture->targetDir . '/Use.php'); + Assert::assertIsString($emitted); + Assert::assertStringNotContainsString('\\Closure ', $emitted); + Assert::assertStringContainsString('Closure(HALF)', $emitted); +}; diff --git a/test/fixture/compile/closure_use_by_ref/verify/runtime.php b/test/fixture/compile/closure_use_by_ref/verify/runtime.php index df315e2b..4c7e7d9f 100644 --- a/test/fixture/compile/closure_use_by_ref/verify/runtime.php +++ b/test/fixture/compile/closure_use_by_ref/verify/runtime.php @@ -5,12 +5,15 @@ /** * Runtime verify for `closure_use_by_ref`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(99, $a); -Assert::assertSame(99, $y); + Assert::assertSame(99, $a); + Assert::assertSame(99, $y); +}; diff --git a/test/fixture/compile/closure_use_by_value/verify/runtime.php b/test/fixture/compile/closure_use_by_value/verify/runtime.php index 811b85d4..0150ebfe 100644 --- a/test/fixture/compile/closure_use_by_value/verify/runtime.php +++ b/test/fixture/compile/closure_use_by_value/verify/runtime.php @@ -5,12 +5,15 @@ /** * Runtime verify for `closure_use_by_value`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(43, $result); -Assert::assertSame(2, $y); + Assert::assertSame(43, $result); + Assert::assertSame(2, $y); +}; diff --git a/test/fixture/compile/closure_use_capture_named_xphp_args/verify/runtime.php b/test/fixture/compile/closure_use_capture_named_xphp_args/verify/runtime.php index a5c8cda3..4d9415c6 100644 --- a/test/fixture/compile/closure_use_capture_named_xphp_args/verify/runtime.php +++ b/test/fixture/compile/closure_use_capture_named_xphp_args/verify/runtime.php @@ -5,11 +5,14 @@ /** * Runtime verify for `closure_use_capture_named_xphp_args`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(203, $r); + Assert::assertSame(203, $r); +}; diff --git a/test/fixture/compile/closure_use_multiple_arg_tuples/verify/runtime.php b/test/fixture/compile/closure_use_multiple_arg_tuples/verify/runtime.php index 3f3fec7e..026b1e10 100644 --- a/test/fixture/compile/closure_use_multiple_arg_tuples/verify/runtime.php +++ b/test/fixture/compile/closure_use_multiple_arg_tuples/verify/runtime.php @@ -5,12 +5,15 @@ /** * Runtime verify for `closure_use_multiple_arg_tuples`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('pre:1', $a); -Assert::assertSame('pre:two', $b); + Assert::assertSame('pre:1', $a); + Assert::assertSame('pre:two', $b); +}; diff --git a/test/fixture/compile/closure_use_multiple_mixed_captures/verify/runtime.php b/test/fixture/compile/closure_use_multiple_mixed_captures/verify/runtime.php index 64217bb5..93fc0bbe 100644 --- a/test/fixture/compile/closure_use_multiple_mixed_captures/verify/runtime.php +++ b/test/fixture/compile/closure_use_multiple_mixed_captures/verify/runtime.php @@ -5,13 +5,16 @@ /** * Runtime verify for `closure_use_multiple_mixed_captures`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(36, $r); -Assert::assertSame(10, $a); -Assert::assertSame(25, $b); + Assert::assertSame(36, $r); + Assert::assertSame(10, $a); + Assert::assertSame(25, $b); +}; diff --git a/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php b/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php index 4141f308..bdda7bbc 100644 --- a/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php +++ b/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php @@ -15,13 +15,15 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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"; + 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'); +}; diff --git a/test/fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php b/test/fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php index 7dbef1cc..20fea719 100644 --- a/test/fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php +++ b/test/fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php @@ -10,14 +10,16 @@ * upcast `indexOf` (via Bag) returns -OFFSET + tally(3) = 1 for the absent tuple. A bare reference that * rebinds to the generated namespace would fatal ("undefined function XPHP\Generated\App\Lst\tally"). * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -// Free functions/consts aren't autoloadable, so define them before Use.php runs. -require $fixture->targetDir . '/helpers.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + // Free functions/consts aren't autoloadable, so define them before Use.php runs. + require $fixture->targetDir . '/helpers.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertTrue($found, 'the upcast contains-call (via Lst) must bind the App free symbols and report the tuple absent'); -Assert::assertSame(1, $index, 'the upcast indexOf-call (via Bag) must compute -OFFSET + tally(3) = 1 for the absent tuple'); -echo "OK\n"; + Assert::assertTrue($found, 'the upcast contains-call (via Lst) must bind the App free symbols and report the tuple absent'); + Assert::assertSame(1, $index, 'the upcast indexOf-call (via Bag) must compute -OFFSET + tally(3) = 1 for the absent tuple'); +}; diff --git a/test/fixture/compile/covariant_upcast_multipath_diamond/verify/runtime.php b/test/fixture/compile/covariant_upcast_multipath_diamond/verify/runtime.php index be7282d5..e5296d9b 100644 --- a/test/fixture/compile/covariant_upcast_multipath_diamond/verify/runtime.php +++ b/test/fixture/compile/covariant_upcast_multipath_diamond/verify/runtime.php @@ -10,10 +10,13 @@ * `contains` (via Lst) and an upcast `indexOf` (via Bag) both resolve and run proves the gap-fill supplies * every diamond sibling across interfaces and concretes, independent of discovery order. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; -require $fixture->targetDir . '/Use.php'; -Assert::assertFalse($found, 'the upcast contains-call (via Lst) must resolve, run, and report the tuple absent'); -Assert::assertSame(-1, $index, 'the upcast indexOf-call (via Bag) must resolve, run, and report the tuple absent'); -echo "OK\n"; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + Assert::assertFalse($found, 'the upcast contains-call (via Lst) must resolve, run, and report the tuple absent'); + Assert::assertSame(-1, $index, 'the upcast indexOf-call (via Bag) must resolve, run, and report the tuple absent'); +}; diff --git a/test/fixture/compile/covariant_upcast_nested_generic_diamond/verify/runtime.php b/test/fixture/compile/covariant_upcast_nested_generic_diamond/verify/runtime.php index 1d1ad59a..8f0bded4 100644 --- a/test/fixture/compile/covariant_upcast_nested_generic_diamond/verify/runtime.php +++ b/test/fixture/compile/covariant_upcast_nested_generic_diamond/verify/runtime.php @@ -8,9 +8,12 @@ * `contains_>` sibling unimplemented) and fataled at class load. That it loads and * `probe` returns proves the gap-fill supplied every diamond obligation. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; -require $fixture->targetDir . '/Use.php'; -Assert::assertFalse($found, 'the diamond upcast contains-call must resolve, run, and report the tuple absent'); -echo "OK\n"; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + Assert::assertFalse($found, 'the diamond upcast contains-call must resolve, run, and report the tuple absent'); +}; diff --git a/test/fixture/compile/covariant_upcast_return_enclosing_inherited/verify/runtime.php b/test/fixture/compile/covariant_upcast_return_enclosing_inherited/verify/runtime.php index 7dd2b266..410988df 100644 --- a/test/fixture/compile/covariant_upcast_return_enclosing_inherited/verify/runtime.php +++ b/test/fixture/compile/covariant_upcast_return_enclosing_inherited/verify/runtime.php @@ -8,9 +8,12 @@ * emission; the post-edge gap-fill must recognise it as already provided and leave it alone. That this * compiles, loads, and `probe` returns the stored Book proves A3 does not over-emit a return-E member. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; -require $fixture->targetDir . '/Use.php'; -Assert::assertInstanceOf(\App\Book::class, $first, 'firstOr returns the stored Book through the covariant upcast'); -echo "OK\n"; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + Assert::assertInstanceOf(\App\Book::class, $first, 'firstOr returns the stored Book through the covariant upcast'); +}; 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 index ce573390..81d0751f 100644 --- a/test/fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php +++ b/test/fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php @@ -16,12 +16,14 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertInstanceOf(\App\Book::class, $first, 'the upcast tuple resolved and yielded its Book element'); -echo "OK\n"; + Assert::assertInstanceOf(\App\Book::class, $first, 'the upcast tuple resolved and yielded its Book element'); +}; 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 index bec2d846..088b6830 100644 --- a/test/fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php @@ -11,13 +11,16 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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'); + 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/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php index aca06612..774d5bb4 100644 --- a/test/fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php @@ -11,12 +11,15 @@ * `$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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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'); + 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/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php index 4fffa588..8c92cb4f 100644 --- a/test/fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php @@ -11,11 +11,14 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertTrue($inherited, 'inherited erasable member must resolve via the same E-mangled name'); + 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/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php index 33ce0c1e..c6d1c5fc 100644 --- a/test/fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php @@ -10,12 +10,15 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertTrue($found, 'containsValue mangled on V (Fruit) must resolve and run'); -Assert::assertSame('k', $label); + 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/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php index bbb51c83..f74a2e3a 100644 --- a/test/fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php @@ -10,12 +10,15 @@ * (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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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'); + 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/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php index 1b3f6507..d8eda5fc 100644 --- a/test/fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php @@ -9,11 +9,14 @@ * `[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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertTrue($both, 'a two-bounded-param erasable method must resolve and run'); + Assert::assertTrue($both, 'a two-bounded-param erasable method must resolve and run'); +}; diff --git a/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php b/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php index 5c068787..aab27a4a 100644 --- a/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php @@ -15,12 +15,14 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertFalse($found, 'the upcast contains-call must resolve, run, and report the Product absent'); -echo "OK\n"; + Assert::assertFalse($found, 'the upcast contains-call must resolve, run, and report the Product absent'); +}; 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 index 5cbde100..75c6df57 100644 --- a/test/fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php @@ -10,12 +10,14 @@ * — 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertFalse($found, 'the multi-param upcast containsValue-call must resolve and run'); -echo "OK\n"; + Assert::assertFalse($found, 'the multi-param upcast containsValue-call must resolve and run'); +}; 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 index c0368eba..42b0da70 100644 --- a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php @@ -11,13 +11,15 @@ * `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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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"; + 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'); +}; 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 index 16e5a08c..768958be 100644 --- 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 @@ -11,12 +11,14 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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"; + Assert::assertFalse($result, 'the body class parameter E must substitute to the upcast-source concrete (Book), not the supertype (Product)'); +}; diff --git a/test/fixture/compile/free_symbol_group_use_import/verify/runtime.php b/test/fixture/compile/free_symbol_group_use_import/verify/runtime.php index 212ea91d..840bdde8 100644 --- a/test/fixture/compile/free_symbol_group_use_import/verify/runtime.php +++ b/test/fixture/compile/free_symbol_group_use_import/verify/runtime.php @@ -7,12 +7,15 @@ * and `use Vendor\{const RATE, const STEP}` imports in the template resolve to Vendor's symbols, and * the relocated specialization still binds them. make(3)=6 + scale(1)=10 + RATE=100 + STEP=7 = 123. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/lib.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/lib.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(123, $computed); + Assert::assertSame(123, $computed); +}; diff --git a/test/fixture/compile/free_symbol_requalify/verify/runtime.php b/test/fixture/compile/free_symbol_requalify/verify/runtime.php index a05f1651..688e370b 100644 --- a/test/fixture/compile/free_symbol_requalify/verify/runtime.php +++ b/test/fixture/compile/free_symbol_requalify/verify/runtime.php @@ -8,15 +8,18 @@ * builtin and magic constant left to global resolution. * scale(1)=10, BONUS=5, Sub\tweak(2)=3, strlen('ab')=2, true?0 -> 10+5+3+2+0 = 20 * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -// Free functions/consts aren't autoloadable, so define them before Use.php runs its -// top-level instantiation (the specialized class itself autoloads via PSR-4). -require $fixture->targetDir . '/helpers.php'; -require $fixture->targetDir . '/sub.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + // Free functions/consts aren't autoloadable, so define them before Use.php runs its + // top-level instantiation (the specialized class itself autoloads via PSR-4). + require $fixture->targetDir . '/helpers.php'; + require $fixture->targetDir . '/sub.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(20, $result); + Assert::assertSame(20, $result); +}; diff --git a/test/fixture/compile/free_symbol_use_import/verify/runtime.php b/test/fixture/compile/free_symbol_use_import/verify/runtime.php index c4a8a686..6cb3c936 100644 --- a/test/fixture/compile/free_symbol_use_import/verify/runtime.php +++ b/test/fixture/compile/free_symbol_use_import/verify/runtime.php @@ -7,12 +7,15 @@ * template resolves to another namespace's symbols, and the relocated specialization still * binds them. make(3)=6, RATE=100 -> 106. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/lib.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/lib.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(106, $computed); + Assert::assertSame(106, $computed); +}; diff --git a/test/fixture/compile/generic_class_cross_template_turbofish_reject/source/Use.xphp b/test/fixture/compile/generic_class_cross_template_turbofish_reject/source/Use.xphp new file mode 100644 index 00000000..5f2b061a --- /dev/null +++ b/test/fixture/compile/generic_class_cross_template_turbofish_reject/source/Use.xphp @@ -0,0 +1,28 @@ + +{ + public static function gen(U $x): U + { + return $x; + } +} + +class Holder +{ + public function m(T $v): T + { + return Other::gen::($v); + } +} + +$h = new Holder::(); +$h->m(1); diff --git a/test/fixture/compile/generic_class_deferred_never_instantiated/source/Use.xphp b/test/fixture/compile/generic_class_deferred_never_instantiated/source/Use.xphp new file mode 100644 index 00000000..47cd7150 --- /dev/null +++ b/test/fixture/compile/generic_class_deferred_never_instantiated/source/Use.xphp @@ -0,0 +1,35 @@ + +{ + /** @return array */ + public function viaThis(T $v): array + { + return $this->dup::($v); + } + + /** @return array */ + public function dup(V $x): array + { + return [$x, $x]; + } +} + +$c = new Consumer(); +$out = $c->poke(); diff --git a/test/fixture/compile/generic_class_erasable_plain_caller/source/Use.xphp b/test/fixture/compile/generic_class_erasable_plain_caller/source/Use.xphp new file mode 100644 index 00000000..2c73d9a9 --- /dev/null +++ b/test/fixture/compile/generic_class_erasable_plain_caller/source/Use.xphp @@ -0,0 +1,31 @@ +` target: the grounded call must reuse the E-mangled member the erasure +// lowering already emitted per instantiation — appending a second same-named member +// would be a class-load fatal. +class Box +{ + /** @param array $items */ + public function __construct(private array $items) + { + } + + public function contains(U $needle): bool + { + return in_array($needle, $this->items, true); + } + + public function has(E $x): bool + { + return $this->contains::($x); + } +} + +$b = new Box::([1, 2, 3]); +$hit = $b->has(2); +$miss = $b->has(9); diff --git a/test/fixture/compile/generic_class_erasable_plain_caller/verify/runtime.php b/test/fixture/compile/generic_class_erasable_plain_caller/verify/runtime.php new file mode 100644 index 00000000..13e99c01 --- /dev/null +++ b/test/fixture/compile/generic_class_erasable_plain_caller/verify/runtime.php @@ -0,0 +1,25 @@ +targetDir . '/Use.php'; + + Assert::assertTrue($hit); + Assert::assertFalse($miss); + + $containsMembers = array_values(array_filter( + get_class_methods($b), + static fn (string $m): bool => str_starts_with($m, 'contains_'), + )); + Assert::assertCount(1, $containsMembers, 'the erasure-lowered member is reused, never duplicated'); +}; diff --git a/test/fixture/compile/generic_class_instance_cross_template_reject/source/Use.xphp b/test/fixture/compile/generic_class_instance_cross_template_reject/source/Use.xphp new file mode 100644 index 00000000..01e9580e --- /dev/null +++ b/test/fixture/compile/generic_class_instance_cross_template_reject/source/Use.xphp @@ -0,0 +1,30 @@ + +{ + /** @return array */ + public function dup(V $x): array + { + return [$x, $x]; + } +} + +class Holder +{ + /** @return array */ + public function m(Box $b, T $v): array + { + return $b->dup::($v); + } +} + +$h = new Holder::(); +$h->m(new Box::(), 1); diff --git a/test/fixture/compile/generic_class_instance_inherited_turbofish/source/Use.xphp b/test/fixture/compile/generic_class_instance_inherited_turbofish/source/Use.xphp new file mode 100644 index 00000000..e18b66e2 --- /dev/null +++ b/test/fixture/compile/generic_class_instance_inherited_turbofish/source/Use.xphp @@ -0,0 +1,38 @@ +dup::` +// threads Holder's concrete arguments through the extends chain to Base's parameters +// and lands the member on the HOLDER specialization itself — never on the shared Base +// template, whose mid-loop mutation would be order-dependent (an earlier-cloned +// Base spec must not grow an int member). +class Base +{ + /** @return array */ + public function dup(V $x): array + { + return [$x, $x]; + } + + public function idT(T $v): T + { + return $v; + } +} + +class Holder extends Base +{ + /** @return array */ + public function viaThis(T $v): array + { + return $this->dup::($v); + } +} + +$b = new Base::(); +$h = new Holder::(); +$r1 = $h->viaThis(3); +$r2 = $b->idT('s'); diff --git a/test/fixture/compile/generic_class_instance_inherited_turbofish/verify/runtime.php b/test/fixture/compile/generic_class_instance_inherited_turbofish/verify/runtime.php new file mode 100644 index 00000000..289aa9f4 --- /dev/null +++ b/test/fixture/compile/generic_class_instance_inherited_turbofish/verify/runtime.php @@ -0,0 +1,31 @@ + spec carries no int member. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame([3, 3], $r1); + Assert::assertSame('s', $r2); + + $holderDup = new ReflectionMethod($h, array_values(array_filter( + get_class_methods($h), + static fn (string $m): bool => str_starts_with($m, 'dup_T_'), + ))[0]); + Assert::assertSame(get_class($h), $holderDup->getDeclaringClass()->getName(), 'member declared on the Holder spec itself'); + + $baseDup = array_values(array_filter( + get_class_methods($b), + static fn (string $m): bool => str_starts_with($m, 'dup_T_'), + )); + Assert::assertSame([], $baseDup, 'the Base spec grew no grounded member'); +}; diff --git a/test/fixture/compile/generic_class_instance_turbofish/source/Use.xphp b/test/fixture/compile/generic_class_instance_turbofish/source/Use.xphp new file mode 100644 index 00000000..89029325 --- /dev/null +++ b/test/fixture/compile/generic_class_instance_turbofish/source/Use.xphp @@ -0,0 +1,53 @@ + */ + public function dup(V $x): array + { + return [$x, $x]; + } +} + +// Instance method turbofish grounded by the enclosing class type parameter, both +// receiver shapes: `$this->dup::` dispatches to a member grounded onto the +// specialization itself (once per spec, despite two call sites), and `$m->dup::` +// on a non-generic receiver grounds onto Maker like any external target. +final class Holder +{ + /** @return array */ + public function viaThis(T $v): array + { + return $this->dup::($v); + } + + /** @return array */ + public function viaThisAgain(T $v): array + { + return $this->dup::($v); + } + + /** @return array */ + public function viaObj(Maker $m, T $v): array + { + return $m->dup::($v); + } + + /** @return array */ + public function dup(V $x): array + { + return [$x, $x]; + } +} + +$h = new Holder::(); +$r1 = $h->viaThis(1); +$r2 = $h->viaThisAgain(2); +$r3 = $h->viaObj(new Maker(), 3); + +$s = new Holder::(); +$r4 = $s->viaThis('a'); diff --git a/test/fixture/compile/generic_class_instance_turbofish/verify/runtime.php b/test/fixture/compile/generic_class_instance_turbofish/verify/runtime.php new file mode 100644 index 00000000..601fe292 --- /dev/null +++ b/test/fixture/compile/generic_class_instance_turbofish/verify/runtime.php @@ -0,0 +1,38 @@ +dup::` executes + * against the member grounded onto the specialization (exactly one per spec, two call + * sites), and `$m->dup::` against the member grounded onto the non-generic Maker. + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame([1, 1], $r1); + Assert::assertSame([2, 2], $r2); + Assert::assertSame([3, 3], $r3); + Assert::assertSame(['a', 'a'], $r4); + + $dupMembers = array_values(array_filter( + get_class_methods($h), + static fn (string $m): bool => str_starts_with($m, 'dup_T_'), + )); + Assert::assertCount(1, $dupMembers, 'two $this call sites, one grounded member per spec'); + + $makerDup = array_values(array_filter( + get_class_methods(\App\InstanceTurbofish\Maker::class), + static fn (string $m): bool => str_starts_with($m, 'dup_T_'), + )); + // Grounding is per-spec, not per-executed-path: BOTH specializations ground their + // whole body, so Maker carries dup AND dup — one per argument tuple, + // deduped across specs (never one per call site). + Assert::assertCount(2, $makerDup, 'one grounded member per unique argument tuple on the receiver class'); +}; diff --git a/test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp b/test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp new file mode 100644 index 00000000..739c6852 --- /dev/null +++ b/test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp @@ -0,0 +1,34 @@ +` appends a member whose body +// holds a now-concrete `self::b::` — the drain must re-ground the appended member +// (with the spec's own identity, so the in-spec site guard passes) until the chain +// bottoms out. Check must stay silent: the retained `a`/`b` templates inside its +// un-stripped spec clone are dispatch machinery, not leaks. +class Holder +{ + /** @return array */ + public function go(T $v): array + { + return self::a::($v); + } + + /** @return array */ + public static function a(U $x): array + { + return self::b::($x); + } + + /** @return array */ + public static function b(V $x): array + { + return [$x, $x]; + } +} + +$h = new Holder::(); +$r = $h->go(9); diff --git a/test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php b/test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php new file mode 100644 index 00000000..68bc5b72 --- /dev/null +++ b/test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php @@ -0,0 +1,23 @@ +targetDir . '/Use.php'; + + Assert::assertSame([9, 9], $r); + + $grounded = array_values(array_filter( + get_class_methods($h), + static fn (string $m): bool => str_starts_with($m, 'a_T_') || str_starts_with($m, 'b_T_'), + )); + Assert::assertCount(2, $grounded, 'both hops grounded onto the spec'); +}; diff --git a/test/fixture/compile/generic_class_method_turbofish/source/Maker.xphp b/test/fixture/compile/generic_class_method_turbofish/source/Maker.xphp new file mode 100644 index 00000000..2ce562b5 --- /dev/null +++ b/test/fixture/compile/generic_class_method_turbofish/source/Maker.xphp @@ -0,0 +1,14 @@ + */ + public static function wrap(X $value): array + { + return [$value]; + } +} diff --git a/test/fixture/compile/generic_class_method_turbofish/source/Use.xphp b/test/fixture/compile/generic_class_method_turbofish/source/Use.xphp new file mode 100644 index 00000000..07b8ddfc --- /dev/null +++ b/test/fixture/compile/generic_class_method_turbofish/source/Use.xphp @@ -0,0 +1,60 @@ +` +// and `Maker::wrap::` are abstract inside the `Box` template and only become +// concrete when `Box` / `Box` specialize. Each specialization grounds and +// dispatches them: the own-template member `gen_T_` lands on the specialization +// itself (dispatched via `self::`, appended once per spec even with two call sites), +// and the shared non-generic target gets one `wrap_T_` per unique argument tuple +// regardless of how many generic classes forward to it. +class Box +{ + public function make(T $v): T + { + return self::gen::($v); + } + + public function makeAgain(T $v): T + { + // Same target + same grounded args as make(): per-spec dedup must + // append a single member, not one per call site. + return self::gen::($v); + } + + /** @return array */ + public function viaMaker(T $v): array + { + return Maker::wrap::($v); + } + + public static function gen(U $x): U + { + return $x; + } +} + +class CoBox +{ + /** @return array */ + public function viaMaker(T $v): array + { + // Second generic class forwarding the same argument tuple to Maker: + // the shared wrap_T_ member must be appended exactly once. + return Maker::wrap::($v); + } +} + +$b = new Box::(); +$r1 = $b->make(5); +$r2 = $b->makeAgain(6); +$r3 = $b->viaMaker(7); + +$s = new Box::(); +$r4 = $s->make('hi'); + +$c = new CoBox::(); +$r5 = $c->viaMaker(8); diff --git a/test/fixture/compile/generic_class_method_turbofish/verify/runtime.php b/test/fixture/compile/generic_class_method_turbofish/verify/runtime.php new file mode 100644 index 00000000..bfbd9d65 --- /dev/null +++ b/test/fixture/compile/generic_class_method_turbofish/verify/runtime.php @@ -0,0 +1,40 @@ +::make(5)` dispatches to the spec's own + * `gen_T_` member, `viaMaker` to the shared `Maker::wrap_T_` — and the + * dedup invariants hold at runtime: exactly one gen member per specialization (two + * call sites), exactly two wrap members on Maker (one per unique argument tuple, + * shared across the two forwarding generic classes). + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Maker.php'; + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame(5, $r1); + Assert::assertSame(6, $r2); + Assert::assertSame([7], $r3); + Assert::assertSame('hi', $r4); + Assert::assertSame([8], $r5); + + $genMembers = array_values(array_filter( + get_class_methods($b), + static fn (string $m): bool => str_starts_with($m, 'gen_T_'), + )); + Assert::assertCount(1, $genMembers, 'two call sites, one appended gen member per spec'); + + $wrapMembers = array_values(array_filter( + get_class_methods(\App\MethodTurbofish\Maker::class), + static fn (string $m): bool => str_starts_with($m, 'wrap_T_'), + )); + Assert::assertCount(2, $wrapMembers, 'one wrap member per unique argument tuple (int, string)'); +}; diff --git a/test/fixture/compile/generic_class_method_turbofish/verify/testMethodTurbofishGroundedByEnclosingClassParamCompiles/BoxInt.expected.php b/test/fixture/compile/generic_class_method_turbofish/verify/testMethodTurbofishGroundedByEnclosingClassParamCompiles/BoxInt.expected.php new file mode 100644 index 00000000..edeb302a --- /dev/null +++ b/test/fixture/compile/generic_class_method_turbofish/verify/testMethodTurbofishGroundedByEnclosingClassParamCompiles/BoxInt.expected.php @@ -0,0 +1,34 @@ +` +// and `Maker::wrap::` are abstract inside the `Box` template and only become +// concrete when `Box` / `Box` specialize. Each specialization grounds and +// dispatches them: the own-template member `gen_T_` lands on the specialization +// itself (dispatched via `self::`, appended once per spec even with two call sites), +// and the shared non-generic target gets one `wrap_T_` per unique argument tuple +// regardless of how many generic classes forward to it. +class T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8 implements \App\MethodTurbofish\Box +{ + public function make(int $v): int + { + return self::gen_T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8($v); + } + public function makeAgain(int $v): int + { + // Same target + same grounded args as make(): per-spec dedup must + // append a single member, not one per call site. + return self::gen_T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8($v); + } + /** @return array */ + public function viaMaker(int $v): array + { + return \App\MethodTurbofish\Maker::wrap_T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8($v); + } + public static function gen_T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8(int $x): int + { + return $x; + } +} diff --git a/test/fixture/compile/generic_class_method_turbofish_discovery/source/Use.xphp b/test/fixture/compile/generic_class_method_turbofish_discovery/source/Use.xphp new file mode 100644 index 00000000..0858b1e9 --- /dev/null +++ b/test/fixture/compile/generic_class_method_turbofish_discovery/source/Use.xphp @@ -0,0 +1,38 @@ + +{ + public function __construct( + public readonly P $first, + public readonly P $second, + ) { + } +} + +final class Maker +{ + // `Pair` is abstract on this template: no source-visible site instantiates + // Pair concretely. `Pair` first exists inside the `twin_T_` member + // that post-specialization grounding appends onto Maker — its instantiation must + // be collected from that appended body, or the emitted call references a + // generated class that was never specialized. + public static function twin(X $v): Pair + { + return new Pair::($v, $v); + } +} + +final class Holder +{ + public function make(T $v): mixed + { + return Maker::twin::($v); + } +} + +$h = new Holder::(); +$p = $h->make(9); diff --git a/test/fixture/compile/generic_class_method_turbofish_discovery/verify/runtime.php b/test/fixture/compile/generic_class_method_turbofish_discovery/verify/runtime.php new file mode 100644 index 00000000..4643eb14 --- /dev/null +++ b/test/fixture/compile/generic_class_method_turbofish_discovery/verify/runtime.php @@ -0,0 +1,27 @@ +` inside `twin_T_`) + * was collected into the fixed point — the Pair specialization exists, loads, and + * carries the forwarded values. + */ + +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + $pairIntFqn = Registry::generatedFqn( + 'App\\TurbofishDiscovery\\Pair', + [new TypeRef('int', isScalar: true)], + ); + Assert::assertInstanceOf($pairIntFqn, $p); + Assert::assertSame(9, $p->first); + Assert::assertSame(9, $p->second); +}; diff --git a/test/fixture/compile/generic_class_method_turbofish_leak_reject/source/Use.xphp b/test/fixture/compile/generic_class_method_turbofish_leak_reject/source/Use.xphp deleted file mode 100644 index b9f96cba..00000000 --- a/test/fixture/compile/generic_class_method_turbofish_leak_reject/source/Use.xphp +++ /dev/null @@ -1,25 +0,0 @@ -` -// depends on `Box`'s `T`. The class specializes in a phase with no generic-method-call -// rewriting, so `gen` is stripped and the turbofish dropped — the emitted specialization -// calls an undefined `self::gen()`. Rejected before that fatal-able code is emitted. -class Box -{ - public function make(T $v): T - { - return self::gen::($v); - } - - public static function gen(U $x): U - { - return $x; - } -} - -$b = new Box::(); -$b->make(5); diff --git a/test/fixture/compile/generic_class_parent_pseudo_turbofish_reject/source/Use.xphp b/test/fixture/compile/generic_class_parent_pseudo_turbofish_reject/source/Use.xphp new file mode 100644 index 00000000..8bf20311 --- /dev/null +++ b/test/fixture/compile/generic_class_parent_pseudo_turbofish_reject/source/Use.xphp @@ -0,0 +1,27 @@ + +{ + public static function gen(U $x): U + { + return $x; + } +} + +class Kid extends Base +{ + public function m(T $v): T + { + return parent::gen::($v); + } +} + +$k = new Kid::(); +$k->m(1); diff --git a/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp b/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp new file mode 100644 index 00000000..493bfbe0 --- /dev/null +++ b/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp @@ -0,0 +1,46 @@ +` inside the +// Holder spec threads Holder's concrete arguments through the extends chain to Base's +// parameters and lands the member on the HOLDER specialization — mirroring the +// instance-call rule, never mutating the shared Base template. +class Base +{ + /** @return array */ + public static function gen(U $x): array + { + // A further hop DECLARED ON THE ANCESTOR: the grounded member lives on the + // Holder spec, so its drained body re-grounds `self::genB::` there too — + // the ancestor chain grounds hop by hop, not just one level. + return self::genB::($x); + } + + /** @return array */ + public static function genB(V $x): array + { + return [$x, $x]; + } + + public function idT(T $v): T + { + return $v; + } +} + +class Holder extends Base +{ + /** @return array */ + public function go(T $v): array + { + return self::gen::($v); + } +} + +$b = new Base::(); +$h = new Holder::(); +$r1 = $h->go(3); +$r2 = $b->idT('s'); diff --git a/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php b/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php new file mode 100644 index 00000000..f458201a --- /dev/null +++ b/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php @@ -0,0 +1,38 @@ + spec grew nothing. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame([3, 3], $r1); + Assert::assertSame('s', $r2); + + // Both hops of the ancestor-declared chain (`gen` → `genB`) ground onto the + // Holder spec itself. + $genMembers = array_values(array_filter( + get_class_methods($h), + static fn (string $m): bool => str_starts_with($m, 'gen_T_') || str_starts_with($m, 'genB_T_'), + )); + Assert::assertCount(2, $genMembers); + foreach ($genMembers as $member) { + Assert::assertSame( + get_class($h), + (new ReflectionMethod($h, $member))->getDeclaringClass()->getName(), + 'member declared on the Holder spec itself', + ); + } + Assert::assertSame([], array_values(array_filter( + get_class_methods($b), + static fn (string $m): bool => str_starts_with($m, 'gen_T_') || str_starts_with($m, 'genB_T_'), + )), 'the Base spec grew no grounded member'); +}; diff --git a/test/fixture/compile/generic_class_static_pseudo_turbofish_reject/source/Use.xphp b/test/fixture/compile/generic_class_static_pseudo_turbofish_reject/source/Use.xphp new file mode 100644 index 00000000..a8db8992 --- /dev/null +++ b/test/fixture/compile/generic_class_static_pseudo_turbofish_reject/source/Use.xphp @@ -0,0 +1,25 @@ + +{ + public function m(T $v): T + { + return static::gen::($v); + } + + public static function gen(U $x): U + { + return $x; + } +} + +$b = new Box::(); +$b->m(1); diff --git a/test/fixture/compile/generic_class_template_target_outside_spec_reject/source/Use.xphp b/test/fixture/compile/generic_class_template_target_outside_spec_reject/source/Use.xphp new file mode 100644 index 00000000..d1490867 --- /dev/null +++ b/test/fixture/compile/generic_class_template_target_outside_spec_reject/source/Use.xphp @@ -0,0 +1,37 @@ + drains Maker's freshly appended wrap member, whose body names +// Holder::gen::. Dispatching that through the spec's `self::` would emit a call to +// a member Maker doesn't have (a runtime fatal) — the marker is kept and the +// emitted-marker backstop rejects loudly instead. +final class Maker +{ + /** @return array */ + public static function wrap(X $v): array + { + return Holder::gen::($v); + } +} + +final class Holder +{ + /** @return array */ + public static function gen(U $x): array + { + return [$x, $x]; + } + + /** @return array */ + public function go(T $v): array + { + return Maker::wrap::($v); + } +} + +$h = new Holder::(); +$h->go(7); diff --git a/test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php b/test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php index 8bac4c9f..c4845803 100644 --- a/test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php +++ b/test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php @@ -10,7 +10,7 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use App\CovariantConstructor\Banana; @@ -18,26 +18,29 @@ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(2, $cnt); -Assert::assertSame('banana', $name); + 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'); + // 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); + // 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/verify/runtime.php b/test/fixture/compile/generic_covariant_private_property/verify/runtime.php index d47479a6..41ecedc9 100644 --- a/test/fixture/compile/generic_covariant_private_property/verify/runtime.php +++ b/test/fixture/compile/generic_covariant_private_property/verify/runtime.php @@ -12,7 +12,7 @@ * (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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use App\CovariantPrivateProperty\Banana; @@ -20,35 +20,38 @@ 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); +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + 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_exception_catch/verify/catch_runtime.php b/test/fixture/compile/generic_exception_catch/verify/catch_runtime.php index c04b177d..e8564e4c 100644 --- a/test/fixture/compile/generic_exception_catch/verify/catch_runtime.php +++ b/test/fixture/compile/generic_exception_catch/verify/catch_runtime.php @@ -7,7 +7,7 @@ * generic specialization (`HttpError` vs `HttpError`) * discriminates on the concrete monomorphized class, so the right arm fires. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered * for `App\GenericExceptionCatch\` + the generated namespace. */ @@ -15,36 +15,39 @@ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; - -$client = new Client(); - -// Generic type discriminates: the Forbidden throw must skip the textually -// FIRST `HttpError` arm and land on `HttpError`. This is -// the property that would silently break if catch types stopped being -// rewritten to their distinct specialized FQNs. -Assert::assertSame('forbidden:api key revoked', $client->classify('forbidden')); -Assert::assertSame('not-found:missing', $client->classify('notfound')); - -// Bare `catch (HttpError $e)` catches any specialization via the marker -// interface. The caught object is the concrete Forbidden specialization. -$forbiddenFqn = Registry::generatedFqn( - 'App\\GenericExceptionCatch\\Errors\\HttpError', - [new TypeRef('App\\GenericExceptionCatch\\Models\\Forbidden')], -); -Assert::assertSame($forbiddenFqn, $client->catchAny('forbidden')); - -// A union of two specializations matches either thrown error. -Assert::assertSame('union:missing', $client->catchUnion('notfound')); -Assert::assertSame('union:api key revoked', $client->catchUnion('forbidden')); - -// The specialization is a genuine Throwable subtype (so it is catchable at all) -// AND implements the original generic name as a marker interface (so the bare -// catch-all arm above can match it). -Assert::assertTrue( - is_subclass_of($forbiddenFqn, \RuntimeException::class), - 'specialized exception must remain a RuntimeException subtype', -); -Assert::assertTrue( - is_subclass_of($forbiddenFqn, 'App\\GenericExceptionCatch\\Errors\\HttpError'), - 'specialized exception must implement the HttpError marker interface', -); +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + $client = new Client(); + + // Generic type discriminates: the Forbidden throw must skip the textually + // FIRST `HttpError` arm and land on `HttpError`. This is + // the property that would silently break if catch types stopped being + // rewritten to their distinct specialized FQNs. + Assert::assertSame('forbidden:api key revoked', $client->classify('forbidden')); + Assert::assertSame('not-found:missing', $client->classify('notfound')); + + // Bare `catch (HttpError $e)` catches any specialization via the marker + // interface. The caught object is the concrete Forbidden specialization. + $forbiddenFqn = Registry::generatedFqn( + 'App\\GenericExceptionCatch\\Errors\\HttpError', + [new TypeRef('App\\GenericExceptionCatch\\Models\\Forbidden')], + ); + Assert::assertSame($forbiddenFqn, $client->catchAny('forbidden')); + + // A union of two specializations matches either thrown error. + Assert::assertSame('union:missing', $client->catchUnion('notfound')); + Assert::assertSame('union:api key revoked', $client->catchUnion('forbidden')); + + // The specialization is a genuine Throwable subtype (so it is catchable at all) + // AND implements the original generic name as a marker interface (so the bare + // catch-all arm above can match it). + Assert::assertTrue( + is_subclass_of($forbiddenFqn, \RuntimeException::class), + 'specialized exception must remain a RuntimeException subtype', + ); + Assert::assertTrue( + is_subclass_of($forbiddenFqn, 'App\\GenericExceptionCatch\\Errors\\HttpError'), + 'specialized exception must implement the HttpError marker interface', + ); +}; diff --git a/test/fixture/compile/generic_function/verify/runtime_execution.php b/test/fixture/compile/generic_function/verify/runtime_execution.php index 5d4fb479..d5858b29 100644 --- a/test/fixture/compile/generic_function/verify/runtime_execution.php +++ b/test/fixture/compile/generic_function/verify/runtime_execution.php @@ -7,7 +7,7 @@ * `identity_T_` free functions execute and return values of * the substituted concrete types. * - * Driver contract: `$fixture` (CompiledFixture) in scope. Free + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. Free * functions aren't autoloadable in PHP, so require funcs.php * explicitly to bring the specialized declarations into scope. */ @@ -15,15 +15,18 @@ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/funcs.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/funcs.php'; -$intMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('int', isScalar: true)]); -$intFqn = 'App\\GenericFunction\\' . $intMangle; -$intResult = $intFqn(42); -Assert::assertSame(42, $intResult); + $intMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('int', isScalar: true)]); + $intFqn = 'App\\GenericFunction\\' . $intMangle; + $intResult = $intFqn(42); + Assert::assertSame(42, $intResult); -$stringMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('string', isScalar: true)]); -$stringFqn = 'App\\GenericFunction\\' . $stringMangle; -$stringResult = $stringFqn('hi'); -Assert::assertSame('hi', $stringResult); + $stringMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('string', isScalar: true)]); + $stringFqn = 'App\\GenericFunction\\' . $stringMangle; + $stringResult = $stringFqn('hi'); + Assert::assertSame('hi', $stringResult); +}; diff --git a/test/fixture/compile/generic_function_bare_top_level/verify/runtime.php b/test/fixture/compile/generic_function_bare_top_level/verify/runtime.php index ceb61746..2f33c0f5 100644 --- a/test/fixture/compile/generic_function_bare_top_level/verify/runtime.php +++ b/test/fixture/compile/generic_function_bare_top_level/verify/runtime.php @@ -7,16 +7,19 @@ * top-level generic function specializes; the non-generic sibling * function survives the strip pass intact; both call sites resolve. * - * Driver contract: `$fixture` (CompiledFixture) in scope. Bare- + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. Bare- * top-level functions aren't autoloadable, so requiring funcs.php * is the only way to bring `identity_T_` and * `nonGenericDouble` into scope. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/funcs.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/funcs.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $asInt); -Assert::assertSame(42, $doubled); + Assert::assertSame(42, $asInt); + Assert::assertSame(42, $doubled); +}; diff --git a/test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp b/test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp new file mode 100644 index 00000000..0de306da --- /dev/null +++ b/test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp @@ -0,0 +1,26 @@ +` receives `T = int` once `wrap::` specializes. The violation +// is only provable after substitution, so it must fail at the grounding drain — not +// slip through as an unchecked emitted call. +function need(U $x): U +{ + return $x; +} + +function wrap(T $v): T +{ + return need::($v); +} + +wrap::(1); diff --git a/test/fixture/compile/generic_function_forward_chain/source/Use.xphp b/test/fixture/compile/generic_function_forward_chain/source/Use.xphp new file mode 100644 index 00000000..3a1058a4 --- /dev/null +++ b/test/fixture/compile/generic_function_forward_chain/source/Use.xphp @@ -0,0 +1,40 @@ +` → `mid::` → `identity::`), so the append-drain must keep grounding +// until the chain bottoms out. +function identity(U $x): U +{ + return $x; +} + +function mid(V $x): V +{ + return identity::($x); +} + +function wrap(T $v): T +{ + return mid::($v); +} + +// Same-args mutual recursion: ping

forwards to pong::

, whose body forwards back +// to ping::

. Compile-time termination rides the specialization dedup (the second +// visit finds the mangled key already generated and only rewrites the call); runtime +// termination rides the counter. +function ping

(P $v, int $n): P +{ + return $n <= 0 ? $v : pong::

($v, $n - 1); +} + +function pong(Q $v, int $n): Q +{ + return $n <= 0 ? $v : ping::($v, $n - 1); +} + +$a = wrap::(7); +$b = ping::('x', 3); diff --git a/test/fixture/compile/generic_function_forward_chain/verify/runtime.php b/test/fixture/compile/generic_function_forward_chain/verify/runtime.php new file mode 100644 index 00000000..9e0bdbca --- /dev/null +++ b/test/fixture/compile/generic_function_forward_chain/verify/runtime.php @@ -0,0 +1,38 @@ +` → `mid` → `identity`) and the same-args mutual-recursion pair + * (`ping` ↔ `pong`) both execute against the emitted specializations. + */ + +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + $intHash = Registry::canonicalHash([new TypeRef('int', isScalar: true)]); + foreach (['wrap', 'mid', 'identity'] as $fn) { + Assert::assertTrue( + function_exists('App\\ForwardChain\\' . $fn . '_T_' . $intHash), + $fn . ' specialization is declared', + ); + } + $wrapInt = 'App\\ForwardChain\\wrap_T_' . $intHash; + Assert::assertSame(7, $wrapInt(7)); + + $stringHash = Registry::canonicalHash([new TypeRef('string', isScalar: true)]); + foreach (['ping', 'pong'] as $fn) { + Assert::assertTrue( + function_exists('App\\ForwardChain\\' . $fn . '_T_' . $stringHash), + $fn . ' specialization is declared', + ); + } + $pingString = 'App\\ForwardChain\\ping_T_' . $stringHash; + Assert::assertSame('x', $pingString('x', 3)); +}; diff --git a/test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp b/test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp new file mode 100644 index 00000000..8c44bace --- /dev/null +++ b/test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp @@ -0,0 +1,21 @@ + +{ + public function __construct(public readonly G $value) + { + } +} + +function grow(T $v): int +{ + return grow::>(new Box::($v)); +} + +grow::(1); diff --git a/test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp b/test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp new file mode 100644 index 00000000..fd3f9608 --- /dev/null +++ b/test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp @@ -0,0 +1,23 @@ + +{ + public function __construct(public readonly G $value) + { + } +} + +// A strictly-growing forward: every specialization of `grow` mints a deeper type +// argument (`grow` calls `grow::>`, which calls `grow::>>`, +// ...), so the specialization chain can never converge. Rejected loudly by the drain's +// hop cap instead of compiling forever. +function grow(T $v): int +{ + return grow::>(new Box::($v)); +} + +grow::(1); diff --git a/test/fixture/compile/generic_function_named_forward/source/Use.xphp b/test/fixture/compile/generic_function_named_forward/source/Use.xphp new file mode 100644 index 00000000..95ef2b4f --- /dev/null +++ b/test/fixture/compile/generic_function_named_forward/source/Use.xphp @@ -0,0 +1,23 @@ +($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity(U $x): U +{ + return $x; +} + +function wrap(T $v): T +{ + return identity::($v); +} + +$i = wrap::(3); +$s = wrap::('hi'); diff --git a/test/fixture/compile/generic_function_named_forward/verify/runtime.php b/test/fixture/compile/generic_function_named_forward/verify/runtime.php new file mode 100644 index 00000000..b4f5737c --- /dev/null +++ b/test/fixture/compile/generic_function_named_forward/verify/runtime.php @@ -0,0 +1,39 @@ +` + * dispatches its forwarded `identity::` call to a real `identity_T_` + * specialization — the emitted chain executes end to end and returns the value + * through both hops. + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. + * Free functions aren't autoloadable in PHP, so require the emitted Use.php explicitly + * to bring both specialized declarations into scope (its top-level driver statements + * run too — they exercise the same calls). + */ + +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + $intHash = Registry::canonicalHash([new TypeRef('int', isScalar: true)]); + $wrapInt = 'App\\NamedForward\\wrap_T_' . $intHash; + $identityInt = 'App\\NamedForward\\identity_T_' . $intHash; + Assert::assertTrue(function_exists($wrapInt), 'wrap specialization is declared'); + Assert::assertTrue(function_exists($identityInt), 'the forwarded identity specialization is declared'); + Assert::assertSame(3, $wrapInt(3)); + Assert::assertSame(41, $identityInt(41)); + + $stringHash = Registry::canonicalHash([new TypeRef('string', isScalar: true)]); + $wrapString = 'App\\NamedForward\\wrap_T_' . $stringHash; + $identityString = 'App\\NamedForward\\identity_T_' . $stringHash; + Assert::assertTrue(function_exists($wrapString), 'wrap specialization is declared'); + Assert::assertTrue(function_exists($identityString), 'the forwarded identity specialization is declared'); + Assert::assertSame('hi', $wrapString('hi')); +}; diff --git a/test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php b/test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php new file mode 100644 index 00000000..fa817f79 --- /dev/null +++ b/test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php @@ -0,0 +1,33 @@ +($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity_T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8(int $x): int +{ + return $x; +} +// A named generic free function forwarding a type argument grounded by the enclosing +// function's type parameter: `identity::($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity_T_473287f8298dba7163a897908958f7c0eae733e25d2e027992ea2edc9bed2fa8(string $x): string +{ + return $x; +} diff --git a/test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp b/test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp deleted file mode 100644 index c3b41a2b..00000000 --- a/test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp +++ /dev/null @@ -1,22 +0,0 @@ -($v)` depends on `wrap`'s `T`, which is not -// concrete here. The named-call turbofish is not specialized (its args are non-concrete), -// so the emitted `wrap_T_` calls `identity()` with the type parameter still leaked. -// Rejected by the emitted-marker backstop before that fatal-able code is written. -function identity(U $x): U -{ - return $x; -} - -function wrap(T $v): T -{ - return identity::($v); -} - -wrap::(3); 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 08875ba5..0b9cbd38 100644 --- a/test/fixture/compile/generic_interface/verify/specialized_interface_runtime.php +++ b/test/fixture/compile/generic_interface/verify/specialized_interface_runtime.php @@ -7,30 +7,33 @@ * implements the specialized interface, and the interface's * `get()` reflection reports the concrete substituted return type. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload * registered for `App\GenericInterface\` + the generated namespace. */ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -$interfaceFqn = Registry::generatedFqn( - 'App\\GenericInterface\\Containers\\Container', - [new TypeRef('App\\GenericInterface\\Models\\Plastic')], -); -$boxFqn = Registry::generatedFqn( - 'App\\GenericInterface\\Containers\\Box', - [new TypeRef('App\\GenericInterface\\Models\\Plastic')], -); +return function (CompiledFixture $fixture): void { + $interfaceFqn = Registry::generatedFqn( + 'App\\GenericInterface\\Containers\\Container', + [new TypeRef('App\\GenericInterface\\Models\\Plastic')], + ); + $boxFqn = Registry::generatedFqn( + 'App\\GenericInterface\\Containers\\Box', + [new TypeRef('App\\GenericInterface\\Models\\Plastic')], + ); -$box = new $boxFqn(new \App\GenericInterface\Models\Plastic('red')); + $box = new $boxFqn(new \App\GenericInterface\Models\Plastic('red')); -Assert::assertInstanceOf($interfaceFqn, $box); -Assert::assertSame('red', $box->get()->color); + 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($interfaceFqn, 'get'))->getReturnType(); -Assert::assertInstanceOf(\ReflectionNamedType::class, $returnType); -Assert::assertSame('App\\GenericInterface\\Models\\Plastic', $returnType->getName()); + // Reflection: the interface's get() return type must be the + // concrete substituted class, not the unspecialized `T`. + $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_method/verify/runtime.php b/test/fixture/compile/generic_method/verify/runtime.php index d3381b25..0464077b 100644 --- a/test/fixture/compile/generic_method/verify/runtime.php +++ b/test/fixture/compile/generic_method/verify/runtime.php @@ -7,24 +7,27 @@ * `Util::identity_T_` static methods round-trip their argument * through the substituted concrete type. * - * Driver contract: `$fixture` (CompiledFixture) in scope. Static + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. Static * methods on user classes load via the registered autoloader. */ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Util.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Util.php'; -$intMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('int', isScalar: true)]); -$intCallable = ['App\\GenericMethod\\Util', $intMangle]; -$intResult = $intCallable(42); -Assert::assertSame(42, $intResult); -Assert::assertSame('integer', gettype($intResult)); + $intMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('int', isScalar: true)]); + $intCallable = ['App\\GenericMethod\\Util', $intMangle]; + $intResult = $intCallable(42); + Assert::assertSame(42, $intResult); + Assert::assertSame('integer', gettype($intResult)); -$stringMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('string', isScalar: true)]); -$stringCallable = ['App\\GenericMethod\\Util', $stringMangle]; -$stringResult = $stringCallable('hello'); -Assert::assertSame('hello', $stringResult); -Assert::assertSame('string', gettype($stringResult)); + $stringMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('string', isScalar: true)]); + $stringCallable = ['App\\GenericMethod\\Util', $stringMangle]; + $stringResult = $stringCallable('hello'); + Assert::assertSame('hello', $stringResult); + Assert::assertSame('string', gettype($stringResult)); +}; diff --git a/test/fixture/compile/generic_method_local_variable_receiver/verify/runtime.php b/test/fixture/compile/generic_method_local_variable_receiver/verify/runtime.php index 551a03d1..b76f045b 100644 --- a/test/fixture/compile/generic_method_local_variable_receiver/verify/runtime.php +++ b/test/fixture/compile/generic_method_local_variable_receiver/verify/runtime.php @@ -8,13 +8,16 @@ * `$u->identity::(…)` specialize against Util via the visitor's * lexical-last-write record. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Util.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Util.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(99, $i); -Assert::assertSame('world', $s); + Assert::assertSame(99, $i); + Assert::assertSame('world', $s); +}; diff --git a/test/fixture/compile/generic_method_new_self_turbofish/verify/runtime.php b/test/fixture/compile/generic_method_new_self_turbofish/verify/runtime.php index 355387c0..c332e26d 100644 --- a/test/fixture/compile/generic_method_new_self_turbofish/verify/runtime.php +++ b/test/fixture/compile/generic_method_new_self_turbofish/verify/runtime.php @@ -9,21 +9,24 @@ * specialized class — so `$a->with(13)` returns a `Container` * with `item = 13`, the same class as `$a`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Container.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Container.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(13, $b->item); -Assert::assertSame(get_class($a), get_class($b)); + Assert::assertSame(13, $b->item); + Assert::assertSame(get_class($a), get_class($b)); -$specializedFqn = Registry::generatedFqn( - 'App\\GenericMethodNewSelfTurbofish\\Container', - [new TypeRef('int', isScalar: true)], -); -Assert::assertSame($specializedFqn, get_class($a)); + $specializedFqn = Registry::generatedFqn( + 'App\\GenericMethodNewSelfTurbofish\\Container', + [new TypeRef('int', isScalar: true)], + ); + Assert::assertSame($specializedFqn, get_class($a)); +}; diff --git a/test/fixture/compile/generic_method_new_static_turbofish/verify/runtime.php b/test/fixture/compile/generic_method_new_static_turbofish/verify/runtime.php index 610f6978..d98696af 100644 --- a/test/fixture/compile/generic_method_new_static_turbofish/verify/runtime.php +++ b/test/fixture/compile/generic_method_new_static_turbofish/verify/runtime.php @@ -8,21 +8,24 @@ * binding resolves `static` against the specialized class. With no * subclassing in this fixture, `$a` and `$b` end up in the same class. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Builder.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Builder.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(2, $b->value); -Assert::assertSame(get_class($a), get_class($b)); + Assert::assertSame(2, $b->value); + Assert::assertSame(get_class($a), get_class($b)); -$specializedFqn = Registry::generatedFqn( - 'App\\GenericMethodNewStaticTurbofish\\Builder', - [new TypeRef('int', isScalar: true)], -); -Assert::assertSame($specializedFqn, get_class($a)); + $specializedFqn = Registry::generatedFqn( + 'App\\GenericMethodNewStaticTurbofish\\Builder', + [new TypeRef('int', isScalar: true)], + ); + Assert::assertSame($specializedFqn, get_class($a)); +}; diff --git a/test/fixture/compile/generic_method_self_with_type_args/verify/runtime.php b/test/fixture/compile/generic_method_self_with_type_args/verify/runtime.php index de51b5b1..bf96a1fd 100644 --- a/test/fixture/compile/generic_method_self_with_type_args/verify/runtime.php +++ b/test/fixture/compile/generic_method_self_with_type_args/verify/runtime.php @@ -8,7 +8,7 @@ * specialization, so `Container::withItem(2)` mutates and * returns `$this` with `item = 2`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. The + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. The * autoloader resolves `App\GenericMethodSelfReturnTypeArgs\Container` * (an interface stub) plus the generated `T_` class. */ @@ -16,16 +16,19 @@ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Container.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Container.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(2, $b->item); + Assert::assertSame(2, $b->item); -// The specialized class lives under XPHP\Generated\…\Container\T_. -$specializedFqn = Registry::generatedFqn( - 'App\\GenericMethodSelfReturnTypeArgs\\Container', - [new TypeRef('int', isScalar: true)], -); -Assert::assertTrue(class_exists($specializedFqn)); -Assert::assertInstanceOf($specializedFqn, $b); + // The specialized class lives under XPHP\Generated\…\Container\T_. + $specializedFqn = Registry::generatedFqn( + 'App\\GenericMethodSelfReturnTypeArgs\\Container', + [new TypeRef('int', isScalar: true)], + ); + Assert::assertTrue(class_exists($specializedFqn)); + Assert::assertInstanceOf($specializedFqn, $b); +}; diff --git a/test/fixture/compile/generic_method_this_receiver/verify/runtime.php b/test/fixture/compile/generic_method_this_receiver/verify/runtime.php index 059c40ab..a5159a99 100644 --- a/test/fixture/compile/generic_method_this_receiver/verify/runtime.php +++ b/test/fixture/compile/generic_method_this_receiver/verify/runtime.php @@ -8,13 +8,16 @@ * the enclosing class (no flow analysis needed), so the int and * string call sites each land on their own mangled method. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Util.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Util.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $i); -Assert::assertSame('hi', $s); + Assert::assertSame(42, $i); + Assert::assertSame('hi', $s); +}; diff --git a/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php b/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php index a2759368..d69b7193 100644 --- a/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php +++ b/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php @@ -9,12 +9,15 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('hi', $s); -Assert::assertSame(7, $n); + Assert::assertSame('hi', $s); + Assert::assertSame(7, $n); +}; 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 index 6c71aeea..61ebfb99 100644 --- a/test/fixture/compile/generic_static_method_through_inheritance/verify/runtime.php +++ b/test/fixture/compile/generic_static_method_through_inheritance/verify/runtime.php @@ -8,14 +8,17 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Base.php'; -require $fixture->targetDir . '/Derived.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Base.php'; + require $fixture->targetDir . '/Derived.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('hi', $s); -Assert::assertSame(7, $n); + Assert::assertSame('hi', $s); + Assert::assertSame(7, $n); +}; diff --git a/test/fixture/compile/generic_trait_adaptation/verify/runtime.php b/test/fixture/compile/generic_trait_adaptation/verify/runtime.php index 6a2e7e4b..dc4b451a 100644 --- a/test/fixture/compile/generic_trait_adaptation/verify/runtime.php +++ b/test/fixture/compile/generic_trait_adaptation/verify/runtime.php @@ -11,14 +11,17 @@ * `use`-list entries -- a bare operand would fatal at class load ("Trait App\... not * found"). * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('A:5', $pick); // insteadof: A's pick wins -Assert::assertSame('B:6', $bpick); // as: B's pick reachable under the alias -Assert::assertSame(3, $aOnly); // A's distinct method -Assert::assertSame(4, $bOnly); // B's distinct method + Assert::assertSame('A:5', $pick); // insteadof: A's pick wins + Assert::assertSame('B:6', $bpick); // as: B's pick reachable under the alias + Assert::assertSame(3, $aOnly); // A's distinct method + Assert::assertSame(4, $bOnly); // B's distinct method +}; diff --git a/test/fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php b/test/fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php index 2580c513..6b6cf79e 100644 --- a/test/fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php +++ b/test/fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php @@ -9,14 +9,17 @@ * generic operands rewrite to their specializations. Plain's `val` wins; the excluded * generic `val`s are re-exposed under aliases; Plain's distinct method runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('P:1', $val); // insteadof: Plain's val wins over G, H -Assert::assertSame('G:2', $gval); // as: excluded generic G::val under alias -Assert::assertSame('H:3', $hval); // as: excluded generic H::val under alias -Assert::assertSame('plain', $plainOnly); // Plain's distinct method + Assert::assertSame('P:1', $val); // insteadof: Plain's val wins over G, H + Assert::assertSame('G:2', $gval); // as: excluded generic G::val under alias + Assert::assertSame('H:3', $hval); // as: excluded generic H::val under alias + Assert::assertSame('plain', $plainOnly); // Plain's distinct method +}; diff --git a/test/fixture/compile/group_import_class_requalify/verify/runtime.php b/test/fixture/compile/group_import_class_requalify/verify/runtime.php index 1c8a7b11..c3393110 100644 --- a/test/fixture/compile/group_import_class_requalify/verify/runtime.php +++ b/test/fixture/compile/group_import_class_requalify/verify/runtime.php @@ -8,15 +8,18 @@ * relocated Box body. Tool::ping()='pong' . Widget::spin()='spin' -> 'pongspin'. A group form * that fell back to the current namespace would fatal with "Class App\Tool not found". * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -// Vendor's two classes share one emitted lib.php (not one-class-per-file), so PSR-4 can't autoload -// them — require it before Use.php runs its top-level instantiation. The specialized Box autoloads -// via XPHP\Generated. -require $fixture->targetDir . '/lib.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + // Vendor's two classes share one emitted lib.php (not one-class-per-file), so PSR-4 can't autoload + // them — require it before Use.php runs its top-level instantiation. The specialized Box autoloads + // via XPHP\Generated. + require $fixture->targetDir . '/lib.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('pongspin', $result); + Assert::assertSame('pongspin', $result); +}; diff --git a/test/fixture/compile/inferred_call_arguments/source/Lib.xphp b/test/fixture/compile/inferred_call_arguments/source/Lib.xphp new file mode 100644 index 00000000..466573a3 --- /dev/null +++ b/test/fixture/compile/inferred_call_arguments/source/Lib.xphp @@ -0,0 +1,62 @@ +(T $x): T +{ + return $x; +} + +function wrap(T $x): T +{ + return $x; +} + +final class Box +{ + public function __construct(private T $value) + { + } + + public function get(): T + { + return $this->value; + } + + public function dup(U $x): U + { + return $x; + } +} + +final class Factory +{ + public static function make(T $x): T + { + return $x; + } +} + +final class Consumer +{ + private Plastic $p; + + public function __construct() + { + $this->p = new Plastic(); + } + + // Argument typed from a class-typed property (`$this->p`). + public function viaProp(): Plastic + { + return wrap($this->p); + } + + // Argument typed from a class-typed parameter (`$q`). + public function viaParam(Plastic $q): Plastic + { + return wrap($q); + } +} diff --git a/test/fixture/compile/inferred_call_arguments/source/Models.xphp b/test/fixture/compile/inferred_call_arguments/source/Models.xphp new file mode 100644 index 00000000..b8b60b59 --- /dev/null +++ b/test/fixture/compile/inferred_call_arguments/source/Models.xphp @@ -0,0 +1,9 @@ +(7); +$dupped = $box->dup(9); + +// Free-function inference from a class-typed property and a class-typed parameter. +$consumer = new Consumer(); +$viaProp = $consumer->viaProp(); +$viaParam = $consumer->viaParam(new Plastic()); diff --git a/test/fixture/compile/inferred_call_arguments/verify/runtime.php b/test/fixture/compile/inferred_call_arguments/verify/runtime.php new file mode 100644 index 00000000..ff5a3544 --- /dev/null +++ b/test/fixture/compile/inferred_call_arguments/verify/runtime.php @@ -0,0 +1,35 @@ +targetDir . '/Models.php'; + require $fixture->targetDir . '/Lib.php'; + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame(5, $intId, 'identity(5) inferred T=int and returned the value'); + Assert::assertInstanceOf('App\\Inference\\Plastic', $objId, 'identity(new Plastic()) inferred T=Plastic'); + Assert::assertSame('hi', $made, 'Factory::make(\'hi\') inferred T=string'); + Assert::assertSame(9, $dupped, '$box->dup(9) inferred U=int'); + Assert::assertInstanceOf('App\\Inference\\Plastic', $viaProp, 'wrap($this->p) inferred T=Plastic from the property type'); + Assert::assertInstanceOf('App\\Inference\\Plastic', $viaParam, 'wrap($q) inferred T=Plastic from the parameter type'); +}; diff --git a/test/fixture/compile/inferred_new_arguments/source/Lib.xphp b/test/fixture/compile/inferred_new_arguments/source/Lib.xphp new file mode 100644 index 00000000..8a5b1846 --- /dev/null +++ b/test/fixture/compile/inferred_new_arguments/source/Lib.xphp @@ -0,0 +1,39 @@ + +{ + public function __construct(private T $value) + { + } + + public function get(): T + { + return $this->value; + } +} + +final class Holder +{ + private Plastic $p; + + public function __construct() + { + $this->p = new Plastic(); + } + + // `new Box($this->p)` — inferred from the declared property type. + public function boxProp(): Plastic + { + return (new Box($this->p))->get(); + } + + // `new Box($q)` — inferred from the declared parameter type. + public function boxParam(Plastic $q): Plastic + { + return (new Box($q))->get(); + } +} diff --git a/test/fixture/compile/inferred_new_arguments/source/Models.xphp b/test/fixture/compile/inferred_new_arguments/source/Models.xphp new file mode 100644 index 00000000..f168269f --- /dev/null +++ b/test/fixture/compile/inferred_new_arguments/source/Models.xphp @@ -0,0 +1,9 @@ +get(); +$boxObj = new Box(new Plastic()); +$objVal = $boxObj->get(); + +// Bare `new` inference from a class-typed property and a class-typed parameter. +$holder = new Holder(); +$fromProp = $holder->boxProp(); +$fromParam = $holder->boxParam(new Plastic()); diff --git a/test/fixture/compile/inferred_new_arguments/verify/runtime.php b/test/fixture/compile/inferred_new_arguments/verify/runtime.php new file mode 100644 index 00000000..faf886fd --- /dev/null +++ b/test/fixture/compile/inferred_new_arguments/verify/runtime.php @@ -0,0 +1,30 @@ +p`), and a + * class-typed parameter. + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. The user + * files aren't PSR-4, so require them in dependency order; the generated Box specializations are + * autoloaded. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Models.php'; + require $fixture->targetDir . '/Lib.php'; + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame(5, $intVal, 'new Box(5) inferred T=int'); + Assert::assertInstanceOf('App\\NewInference\\Plastic', $objVal, 'new Box(new Plastic()) inferred T=Plastic'); + Assert::assertInstanceOf('App\\NewInference\\Plastic', $fromProp, 'new Box($this->p) inferred T=Plastic from the property type'); + Assert::assertInstanceOf('App\\NewInference\\Plastic', $fromParam, 'new Box($q) inferred T=Plastic from the parameter type'); +}; diff --git a/test/fixture/compile/keyword_named_generic_method/verify/runtime.php b/test/fixture/compile/keyword_named_generic_method/verify/runtime.php index 40284974..200c6ab2 100644 --- a/test/fixture/compile/keyword_named_generic_method/verify/runtime.php +++ b/test/fixture/compile/keyword_named_generic_method/verify/runtime.php @@ -7,12 +7,15 @@ * (`list`, `print`) declares, specializes, and is callable through both an instance turbofish and a * static turbofish. Instance `list::(41)` returns 41; static `print::(7)` returns 7. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(41, $instanceResult); -Assert::assertSame(7, $staticResult); + Assert::assertSame(41, $instanceResult); + Assert::assertSame(7, $staticResult); +}; diff --git a/test/fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php b/test/fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php index 3d9a8cbf..20ed4c75 100644 --- a/test/fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php +++ b/test/fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php @@ -7,13 +7,16 @@ * `list()` destructuring survive the keyword-turbofish support unchanged. list(10)+1=11, * print(20)+2=22, unpack([3,4])=7. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(11, $instance); -Assert::assertSame(22, $static); -Assert::assertSame(7, $destructured); + Assert::assertSame(11, $instance); + Assert::assertSame(22, $static); + Assert::assertSame(7, $destructured); +}; diff --git a/test/fixture/compile/multi_type/verify/type_error_on_wrong_slot.php b/test/fixture/compile/multi_type/verify/type_error_on_wrong_slot.php index 28d3909f..08e08b64 100644 --- a/test/fixture/compile/multi_type/verify/type_error_on_wrong_slot.php +++ b/test/fixture/compile/multi_type/verify/type_error_on_wrong_slot.php @@ -6,35 +6,38 @@ * Runtime verify for `multi_type`: a specialized Pair * accepts (User, Plastic) but rejects (Plastic, User) with a TypeError. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload * registered for `App\MultiType\` + the generated namespace. */ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -$pairFqn = Registry::generatedFqn( - 'App\\MultiType\\Containers\\Pair', - [new TypeRef('App\\MultiType\\Models\\User'), new TypeRef('App\\MultiType\\Models\\Plastic')], -); - -// Correct order: constructor accepts (User, Plastic). -$ok = new $pairFqn( - new \App\MultiType\Models\User('alice'), - new \App\MultiType\Models\Plastic('red'), -); -Assert::assertInstanceOf($pairFqn, $ok); +return function (CompiledFixture $fixture): void { + $pairFqn = Registry::generatedFqn( + 'App\\MultiType\\Containers\\Pair', + [new TypeRef('App\\MultiType\\Models\\User'), new TypeRef('App\\MultiType\\Models\\Plastic')], + ); -// Swapped order: constructor expects User in slot 0, Plastic in slot 1; -// passing them flipped triggers a TypeError on the first slot mismatch. -$caught = null; -try { - new $pairFqn( - new \App\MultiType\Models\Plastic('red'), + // Correct order: constructor accepts (User, Plastic). + $ok = new $pairFqn( new \App\MultiType\Models\User('alice'), + new \App\MultiType\Models\Plastic('red'), ); -} catch (\TypeError $e) { - $caught = $e; -} -Assert::assertInstanceOf(\TypeError::class, $caught); + Assert::assertInstanceOf($pairFqn, $ok); + + // Swapped order: constructor expects User in slot 0, Plastic in slot 1; + // passing them flipped triggers a TypeError on the first slot mismatch. + $caught = null; + try { + new $pairFqn( + new \App\MultiType\Models\Plastic('red'), + new \App\MultiType\Models\User('alice'), + ); + } catch (\TypeError $e) { + $caught = $e; + } + Assert::assertInstanceOf(\TypeError::class, $caught); +}; diff --git a/test/fixture/compile/multiline_generic_markers/verify/runtime.php b/test/fixture/compile/multiline_generic_markers/verify/runtime.php index d6ccb815..5da8872d 100644 --- a/test/fixture/compile/multiline_generic_markers/verify/runtime.php +++ b/test/fixture/compile/multiline_generic_markers/verify/runtime.php @@ -8,15 +8,18 @@ * declarations; every one of them must have specialized (none emitted * raw), and the emitted program must execute. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('w', $w->v); -Assert::assertSame(7, $b->v); -Assert::assertSame(41, $n); -Assert::assertSame('s', $s); -Assert::assertSame(2, $c); + Assert::assertSame('w', $w->v); + Assert::assertSame(7, $b->v); + Assert::assertSame(41, $n); + Assert::assertSame('s', $s); + Assert::assertSame(2, $c); +}; diff --git a/test/fixture/compile/nested_typehint/verify/nested_specialization_runtime.php b/test/fixture/compile/nested_typehint/verify/nested_specialization_runtime.php index 112daa5f..e3064719 100644 --- a/test/fixture/compile/nested_typehint/verify/nested_specialization_runtime.php +++ b/test/fixture/compile/nested_typehint/verify/nested_specialization_runtime.php @@ -8,34 +8,37 @@ * type, and calling `setBoxed('not a plastic')` raises a TypeError on * the substituted parameter signature. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload * registered for `App\NestedTypehint\` + the generated namespace. */ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -$plastic = new TypeRef('App\\NestedTypehint\\Models\\Plastic'); -$boxFqn = Registry::generatedFqn('App\\NestedTypehint\\Containers\\Box', [$plastic]); -$wrapperFqn = Registry::generatedFqn('App\\NestedTypehint\\Containers\\Wrapper', [$plastic]); +return function (CompiledFixture $fixture): void { + $plastic = new TypeRef('App\\NestedTypehint\\Models\\Plastic'); + $boxFqn = Registry::generatedFqn('App\\NestedTypehint\\Containers\\Box', [$plastic]); + $wrapperFqn = Registry::generatedFqn('App\\NestedTypehint\\Containers\\Wrapper', [$plastic]); -// Reflection: Wrapper's $box property is typed against the specialized Box. -$propType = (new \ReflectionProperty($wrapperFqn, 'box'))->getType(); -Assert::assertInstanceOf(\ReflectionNamedType::class, $propType); -Assert::assertSame($boxFqn, $propType->getName()); + // Reflection: Wrapper's $box property is typed against the specialized Box. + $propType = (new \ReflectionProperty($wrapperFqn, 'box'))->getType(); + Assert::assertInstanceOf(\ReflectionNamedType::class, $propType); + Assert::assertSame($boxFqn, $propType->getName()); -// Constructor must succeed independently -- otherwise the next -// catch block would falsely attribute its TypeError to setBoxed. -$w = new $wrapperFqn(); -Assert::assertInstanceOf($wrapperFqn, $w); + // Constructor must succeed independently -- otherwise the next + // catch block would falsely attribute its TypeError to setBoxed. + $w = new $wrapperFqn(); + Assert::assertInstanceOf($wrapperFqn, $w); -// TypeError on substituted parameter: setBoxed expects the concrete Plastic, -// not an arbitrary string. -$caught = null; -try { - $w->setBoxed('not a plastic'); -} catch (\TypeError $e) { - $caught = $e; -} -Assert::assertInstanceOf(\TypeError::class, $caught); + // TypeError on substituted parameter: setBoxed expects the concrete Plastic, + // not an arbitrary string. + $caught = null; + try { + $w->setBoxed('not a plastic'); + } catch (\TypeError $e) { + $caught = $e; + } + Assert::assertInstanceOf(\TypeError::class, $caught); +}; diff --git a/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php b/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php index fc2b9624..4f70cffc 100644 --- a/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php +++ b/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php @@ -9,24 +9,27 @@ * template even with a colliding `use` alias in scope, while the aliased * bare form keeps targeting the aliased template. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/OtherBox.php'; -require $fixture->targetDir . '/Use.php'; -require $fixture->targetDir . '/RelativeUse.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/OtherBox.php'; + require $fixture->targetDir . '/Use.php'; + require $fixture->targetDir . '/RelativeUse.php'; -Assert::assertSame('hi', $a->v); -Assert::assertSame('ho', $b->v); -Assert::assertSame(7, $c->n); + Assert::assertSame('hi', $a->v); + Assert::assertSame('ho', $b->v); + Assert::assertSame(7, $c->n); -// $a and $b are the same App-side specialization; $c is Other's. -Assert::assertSame(get_class($a), get_class($b)); -Assert::assertNotSame(get_class($a), get_class($c)); -// $c is a relocated specialization: emitted under Other's Generated namespace. -Assert::assertSame( - 'XPHP\\Generated\\Other\\Box\\T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8', - get_class($c), -); + // $a and $b are the same App-side specialization; $c is Other's. + Assert::assertSame(get_class($a), get_class($b)); + Assert::assertNotSame(get_class($a), get_class($c)); + // $c is a relocated specialization: emitted under Other's Generated namespace. + Assert::assertSame( + 'XPHP\\Generated\\Other\\Box\\T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8', + get_class($c), + ); +}; diff --git a/test/fixture/compile/qualified_generic_call_sites/verify/runtime.php b/test/fixture/compile/qualified_generic_call_sites/verify/runtime.php index a6176ae8..80275699 100644 --- a/test/fixture/compile/qualified_generic_call_sites/verify/runtime.php +++ b/test/fixture/compile/qualified_generic_call_sites/verify/runtime.php @@ -8,22 +8,25 @@ * `new` against the marker interface, no doubled-namespace template), * route to the SAME specializations as the bare forms, and execute. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(1, $a->v); -Assert::assertSame('r', $b->v); -Assert::assertSame('k', $c->key); -Assert::assertSame(5, $d->v); -Assert::assertSame(9, $e->v); -Assert::assertSame(3, $f); -Assert::assertSame('g', $g); + Assert::assertSame(1, $a->v); + Assert::assertSame('r', $b->v); + Assert::assertSame('k', $c->key); + Assert::assertSame(5, $d->v); + Assert::assertSame(9, $e->v); + Assert::assertSame(3, $f); + Assert::assertSame('g', $g); -// FQ, in-template, and same-line-relative int instantiations must all be -// the one int specialization. -Assert::assertSame(get_class($a), get_class($d)); -Assert::assertSame(get_class($a), get_class($e)); + // FQ, in-template, and same-line-relative int instantiations must all be + // the one int specialization. + Assert::assertSame(get_class($a), get_class($d)); + Assert::assertSame(get_class($a), get_class($e)); +}; diff --git a/test/fixture/compile/relative_names_in_templates/verify/runtime.php b/test/fixture/compile/relative_names_in_templates/verify/runtime.php index 3e86a503..f8f9ebb5 100644 --- a/test/fixture/compile/relative_names_in_templates/verify/runtime.php +++ b/test/fixture/compile/relative_names_in_templates/verify/runtime.php @@ -8,19 +8,22 @@ * `use` alias, never a type parameter), across extends clauses, method * signatures, generic-method receivers, and conformance hierarchies. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/LocalDefs.php'; -require $fixture->targetDir . '/OtherDefs.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/LocalDefs.php'; + require $fixture->targetDir . '/OtherDefs.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(4, $g->v); -Assert::assertSame('App\\RelativeTemplates\\Base', get_parent_class($g)); -Assert::assertSame(1, $k->generic); -Assert::assertSame('ok', $lbl); -Assert::assertSame(5, $r); -Assert::assertInstanceOf('App\\RelativeTemplates\\Cat', $cat); -Assert::assertSame('App\\RelativeTemplates\\Base', get_parent_class($cat)); + Assert::assertSame(4, $g->v); + Assert::assertSame('App\\RelativeTemplates\\Base', get_parent_class($g)); + Assert::assertSame(1, $k->generic); + Assert::assertSame('ok', $lbl); + Assert::assertSame(5, $r); + Assert::assertInstanceOf('App\\RelativeTemplates\\Cat', $cat); + Assert::assertSame('App\\RelativeTemplates\\Base', get_parent_class($cat)); +}; diff --git a/test/fixture/compile/same_line_marker_pairs/verify/runtime.php b/test/fixture/compile/same_line_marker_pairs/verify/runtime.php index 46c765b0..a6685c6a 100644 --- a/test/fixture/compile/same_line_marker_pairs/verify/runtime.php +++ b/test/fixture/compile/same_line_marker_pairs/verify/runtime.php @@ -7,23 +7,26 @@ * same-spelling pair must have bound its markers to the right sites and * the whole program must execute. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; -require $fixture->targetDir . '/Aliased.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + require $fixture->targetDir . '/Aliased.php'; -Assert::assertSame(9, $m); -Assert::assertSame(7, $a); -Assert::assertSame(8, $b); -Assert::assertSame(1, $k->v); -Assert::assertSame(2, $p1->v); -Assert::assertSame('s', $p2->v); -Assert::assertNotSame(get_class($p1), get_class($p2)); -Assert::assertSame(11, $t); -Assert::assertSame('App\\SameLinePairs\\B', get_class($bb)); -Assert::assertSame(3, $sum); -Assert::assertSame('v=9', $msg); -Assert::assertSame('al', $hv); + Assert::assertSame(9, $m); + Assert::assertSame(7, $a); + Assert::assertSame(8, $b); + Assert::assertSame(1, $k->v); + Assert::assertSame(2, $p1->v); + Assert::assertSame('s', $p2->v); + Assert::assertNotSame(get_class($p1), get_class($p2)); + Assert::assertSame(11, $t); + Assert::assertSame('App\\SameLinePairs\\B', get_class($bb)); + Assert::assertSame(3, $sum); + Assert::assertSame('v=9', $msg); + Assert::assertSame('al', $hv); +}; diff --git a/test/fixture/compile/scalar_alias_class_resolves/verify/runtime.php b/test/fixture/compile/scalar_alias_class_resolves/verify/runtime.php index 35ea947e..de5b9a1e 100644 --- a/test/fixture/compile/scalar_alias_class_resolves/verify/runtime.php +++ b/test/fixture/compile/scalar_alias_class_resolves/verify/runtime.php @@ -9,14 +9,17 @@ * construction. That all three construct, run, and return their class-typed values proves the alias names * resolve to the classes in argument position. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; -require $fixture->targetDir . '/Use.php'; -Assert::assertInstanceOf(\App\Double::class, $dv, 'Box:: must carry an App\\Double, not a scalar'); -Assert::assertSame(2.5, $dv->f); -Assert::assertInstanceOf(\App\Integer::class, $iv, 'Box:: must carry an App\\Integer'); -Assert::assertSame(7, $iv->i); -Assert::assertInstanceOf(\App\Boolean::class, $bv, 'Box:: must carry an App\\Boolean'); -Assert::assertTrue($bv->b); -echo "OK\n"; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + Assert::assertInstanceOf(\App\Double::class, $dv, 'Box:: must carry an App\\Double, not a scalar'); + Assert::assertSame(2.5, $dv->f); + Assert::assertInstanceOf(\App\Integer::class, $iv, 'Box:: must carry an App\\Integer'); + Assert::assertSame(7, $iv->i); + Assert::assertInstanceOf(\App\Boolean::class, $bv, 'Box:: must carry an App\\Boolean'); + Assert::assertTrue($bv->b); +}; diff --git a/test/fixture/compile/split_declaration_headers/verify/runtime.php b/test/fixture/compile/split_declaration_headers/verify/runtime.php index f3cd528b..2131efcb 100644 --- a/test/fixture/compile/split_declaration_headers/verify/runtime.php +++ b/test/fixture/compile/split_declaration_headers/verify/runtime.php @@ -7,16 +7,19 @@ * and functions whose headers split across lines (attribute or modifier on * its own line, keyword/name split) must all specialize and run. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('bx', $b->v); -Assert::assertSame(4, $p->u); -Assert::assertSame(5, $l); -Assert::assertSame('pk', $s); -Assert::assertSame(9, $i); -Assert::assertSame(['z', 'z'], $d); + Assert::assertSame('bx', $b->v); + Assert::assertSame(4, $p->u); + Assert::assertSame(5, $l); + Assert::assertSame('pk', $s); + Assert::assertSame(9, $i); + Assert::assertSame(['z', 'z'], $d); +}; diff --git a/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php b/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php index 96217b19..b8ddac82 100644 --- a/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php +++ b/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php @@ -7,17 +7,20 @@ * specialization emits a valid forwarding closure (the file parses, or this require would * fatal) that routes through the dispatcher and preserves callable semantics. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(50, $viaCall); -Assert::assertSame([10, 20, 30], $viaMap); -Assert::assertSame(42, $named); -Assert::assertSame(7, $defaulted); -Assert::assertSame(4, $direct); -Assert::assertSame(8, $viaFcc); -Assert::assertSame(9, $collided); + Assert::assertSame(50, $viaCall); + Assert::assertSame([10, 20, 30], $viaMap); + Assert::assertSame(42, $named); + Assert::assertSame(7, $defaulted); + Assert::assertSame(4, $direct); + Assert::assertSame(8, $viaFcc); + Assert::assertSame(9, $collided); +}; diff --git a/test/fixture/compile/type_aliases/source/Consumer.xphp b/test/fixture/compile/type_aliases/source/Consumer.xphp new file mode 100644 index 00000000..9fea8fd8 --- /dev/null +++ b/test/fixture/compile/type_aliases/source/Consumer.xphp @@ -0,0 +1,17 @@ +widen('local'); diff --git a/test/fixture/compile/type_aliases/source/Types.xphp b/test/fixture/compile/type_aliases/source/Types.xphp new file mode 100644 index 00000000..af5ae7d6 --- /dev/null +++ b/test/fixture/compile/type_aliases/source/Types.xphp @@ -0,0 +1,61 @@ + = Dict>; +type UserId = Ident; +type UserMap = Pair; +type Elem = User; // used only as a generic ARGUMENT (`Bag`) +type Num = int|string; // union body — expands into a whole slot as `int|string` +type MaybeUser = ?User; // nullable body — expands as `?User` +type Aliased = Num; // single head that transitively resolves to a union + +class Ident {} +class User {} + +class Bag +{ + public function __construct(public T $item) {} + public function get(): T { return $this->item; } +} + +class Dict +{ + public function __construct(public K $key, public V $value) {} + public function value(): V { return $this->value; } +} + +class Service +{ + // Alias uses in return-type position (generic + non-generic) must expand before specialization. + public function pair(): Pair + { + return new Pair::(1, new Bag::(new User())); + } + + public function id(): UserId + { + return new UserId(); + } + + // Union / nullable / transitively-union alias uses in whole-slot positions. + public function num(Num $x): Aliased { return $x; } + public function maybe(): MaybeUser { return null; } +} + +// Driver: the runtime verify reads these top-level values after requiring the emitted file. +$service = new Service(); +$pair = $service->pair(); +$idValue = $service->id(); +$userMap = new UserMap(2, new Bag::(new User())); +// `Bag` — an alias in generic-argument position. If it did not expand to `Bag`, the +// generated specialization would be typed on the nonexistent class `App\Aliases\Elem` and fatal here. +$elemBag = new Bag::(new User()); +// Union / nullable slot expansion: if `Num` did not become `int|string`, passing a string would fatal. +$numValue = $service->num('hi'); +$maybeValue = $service->maybe(); diff --git a/test/fixture/compile/type_aliases/verify/runtime.php b/test/fixture/compile/type_aliases/verify/runtime.php new file mode 100644 index 00000000..1918210b --- /dev/null +++ b/test/fixture/compile/type_aliases/verify/runtime.php @@ -0,0 +1,46 @@ + = Dict>`), a non-generic + * plain-class alias (`UserId = Ident`), and a concrete-instantiation alias that references another + * alias (`UserMap = Pair`) are all erased before specialization, and the emitted program + * executes end to end — the alias uses dispatch to the same specializations the hand-expanded types + * would, and the plain alias resolves to its target class. + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. The user + * files aren't PSR-4, so require them in dependency order; the generated specializations autoload. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Types.php'; + require $fixture->targetDir . '/Consumer.php'; + + // Pair === Dict>: the value is a Bag specialization holding a User. + Assert::assertInstanceOf('App\\Aliases\\User', $pair->value()->get(), 'Pair expanded to Dict>'); + + // UserId === Ident (a plain class): the alias resolves to its target class. + Assert::assertInstanceOf('App\\Aliases\\Ident', $idValue, 'UserId expanded to the plain class Ident'); + + // UserMap === Pair === Dict> (nested alias): same shape as $pair. + Assert::assertInstanceOf('App\\Aliases\\User', $userMap->value()->get(), 'UserMap expanded through Pair to Dict>'); + Assert::assertSame( + $pair::class, + $userMap::class, + 'UserMap and Pair expand to the identical specialization', + ); + + // Bag === Bag: an alias in generic-argument position expanded; the item is a User. + Assert::assertInstanceOf('App\\Aliases\\User', $elemBag->get(), 'Bag expanded to Bag'); + + // Union / nullable slots executed: `num('hi')` typed `int|string`, `maybe()` typed `?User`. + Assert::assertSame('hi', $numValue, 'union alias Num expanded to int|string in the param/return slots'); + Assert::assertNull($maybeValue, 'nullable alias MaybeUser expanded to ?User'); + + // File-local: Consumer.xphp declares its OWN `Num` and it expands independently of Types.xphp. + Assert::assertSame('local', $localValue, 'a file-local alias in a second file expands there'); +}; diff --git a/test/fixture/compile/use_trait_generic/verify/runtime.php b/test/fixture/compile/use_trait_generic/verify/runtime.php index 3185ce26..f83d9d78 100644 --- a/test/fixture/compile/use_trait_generic/verify/runtime.php +++ b/test/fixture/compile/use_trait_generic/verify/runtime.php @@ -6,11 +6,14 @@ * Runtime verify for `use_trait_generic`: a generic trait-use survives the WI-08 * use-import reject, specializes, and runs -- the inlined trait method returns. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('held', $label); + Assert::assertSame('held', $label); +}; 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 index a0c65175..2d20c423 100644 --- a/test/fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php +++ b/test/fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php @@ -14,12 +14,15 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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'); + 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'); +};