Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
703cccd
test: make compile-fixture verify files explicit typed closures
math3usmartins Jul 24, 2026
3a6c71e
feat(specializer): substitute method-generic turbofish args on all ca…
math3usmartins Jul 25, 2026
26a4151
feat(monomorphize): ground named-forward turbofish via an append drain
math3usmartins Jul 26, 2026
71e2ea5
feat(monomorphize): ground enclosing-param method turbofish per class…
math3usmartins Jul 26, 2026
6952042
feat(monomorphize): ground enclosing-param instance turbofish per cla…
math3usmartins Jul 26, 2026
485982c
docs: document enclosing-param turbofish grounding
math3usmartins Jul 26, 2026
987f52a
fix(monomorphize): confine spec grounding to the spec's own body and …
math3usmartins Jul 26, 2026
955646b
fix(monomorphize): prune retained templates from the check backstop, …
math3usmartins Jul 26, 2026
c1f76a9
refactor(monomorphize): reattach constructor docblock and skip templa…
math3usmartins Jul 26, 2026
a31a2a3
docs: clarify that forwarding a method-level type parameter is unsupp…
math3usmartins Jul 27, 2026
868317d
ci(mutation): run mutation tests only when src/ or test/ changed
math3usmartins Jul 27, 2026
aa26911
build(commitlint): enforce Conventional Commit messages with the same…
math3usmartins Jul 26, 2026
a04dba9
ci(commitlint): gate pull requests on Conventional Commit messages
math3usmartins Jul 26, 2026
799289f
fix(commitlint): reinstall stale node_modules and require the compose…
math3usmartins Jul 27, 2026
d278204
fix(commitlint): run the node service behind a compose profile
math3usmartins Jul 27, 2026
db98cbb
docs(comparison): note the absence of type-argument inference
math3usmartins Jul 27, 2026
327acee
docs(comparison): flag five features as supported-with-caveats, not f…
math3usmartins Jul 27, 2026
90927fa
docs(comparison): correct RFC/Kotlin/TS marks, verified against real …
math3usmartins Jul 27, 2026
21b711a
docs(comparison): link the self-reintroducing-specialization caveat
math3usmartins Jul 27, 2026
407890a
feat(inference): add pure type-argument unifier and inference driver
math3usmartins Jul 29, 2026
f553b5a
feat(inference): type literals and constructions for inference
math3usmartins Jul 29, 2026
c174d9c
feat(monomorphize): infer type arguments for generic calls
math3usmartins Jul 29, 2026
d88ac68
feat(monomorphize): infer type arguments for bare `new`
math3usmartins Jul 29, 2026
103df6b
fix(monomorphize): reword the missing-turbofish diagnostic for inference
math3usmartins Jul 29, 2026
4f7ca12
docs(inference): document optional turbofish via type-argument inference
math3usmartins Jul 29, 2026
763b109
fix(monomorphize): don't infer a call argument typed by a type parameter
math3usmartins Jul 29, 2026
a4a35dd
docs(inference): note the simple-name type-parameter collision fallback
math3usmartins Jul 29, 2026
540a734
fix(monomorphize): infer bare `new` bottom-up so nested generics match
math3usmartins Jul 29, 2026
4e39e0d
feat(monomorphize): strip type-alias declarations at scan
math3usmartins Jul 30, 2026
5c0302c
feat(monomorphize): expand type-alias uses before specialization
math3usmartins Jul 30, 2026
585d933
feat(monomorphize): dedicated diagnostics for type-alias rejections
math3usmartins Jul 30, 2026
729e16c
docs(type-aliases): document the feature, ADR-0023, roadmap, changelog
math3usmartins Jul 30, 2026
89bab9d
feat(monomorphize): union and nullable type-alias bodies
math3usmartins Jul 30, 2026
396b7e0
feat(monomorphize): whole-program (cross-file) type aliases
math3usmartins Jul 30, 2026
b5df8cc
docs(type-aliases): document union/nullable bodies and cross-file
math3usmartins Jul 30, 2026
0dbd663
feat(monomorphize): apply defaults for generic type-alias parameters
math3usmartins Jul 30, 2026
d97d702
feat(monomorphize): enforce generic type-alias parameter bounds
math3usmartins Jul 30, 2026
5423490
docs(type-aliases): document parameter defaults and bounds
math3usmartins Jul 30, 2026
34759a3
fix(monomorphize): drop a file's alias-bound obligations when its par…
math3usmartins Jul 30, 2026
79bcc03
fix(monomorphize): expand a type alias used as a parameter bound
math3usmartins Jul 31, 2026
5743023
docs(type-aliases): note alias-in-bound support and the single-namesp…
math3usmartins Jul 31, 2026
daec172
docs(type-aliases): sync comparison grid, error catalog, and feature …
math3usmartins Jul 31, 2026
b6dfa6b
feat(monomorphize): make type aliases file-local (drop cross-file pre…
math3usmartins Jul 31, 2026
fef5546
docs(type-aliases): reframe file-locality as by-design, not a limitation
math3usmartins Jul 31, 2026
1c4694f
fix(monomorphize): correct alias-body message + guard argument-path c…
math3usmartins Aug 2, 2026
c6c3553
Merge pull request #32 from xphp-lang/feature/type-aliases
math3usmartins Aug 6, 2026
88de480
docs(changelog): cut the 0.4.0 release
math3usmartins Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -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
'
26 changes: 25 additions & 1 deletion .github/workflows/ci-core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/commitlint.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
/core/.xphp-cache/
docker-compose.override.yml
.phpunit.result.cache
node_modules/
70 changes: 70 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<A, B> = 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<UserId>`), 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<int, User>`); parameters carry
**defaults** (`type P<A, B = A>` — a use may omit trailing defaulted arguments)
and **bounds** (`type B<T : Named>` — 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::<int>`, `new Box($product)` infers
`new Box::<Product>`, `$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::<T>($v)` inside `wrap<T>`), a static call
(`self::gen::<T>()`, `Maker::wrap::<T>()` inside `Box<T>` — the idiomatic "delegate
to a shared static generic helper" shape), and an instance call (`$this->dup::<T>()`,
`$m->dup::<T>()` 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<U : Stringable>` receiving the
class's `T`) is checked per instantiation, and a strictly-growing forward chain
(`grow<T>` calling `grow::<Box<T>>`) 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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions commitlint.config.mjs
Original file line number Diff line number Diff line change
@@ -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'],
};
8 changes: 8 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
15 changes: 15 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ~
Loading
Loading