Skip to content

0.3.x#20

Open
math3usmartins wants to merge 114 commits into
mainfrom
0.3.x
Open

0.3.x#20
math3usmartins wants to merge 114 commits into
mainfrom
0.3.x

Conversation

@math3usmartins

Copy link
Copy Markdown
Member

No description provided.

math3usmartins and others added 30 commits June 17, 2026 20:42
Introduce the XPHP\Diagnostics namespace: a string-backed Severity enum
(Error/Warning/Notice with isFailing()), a DiagnosticSource enum (xphp/phpstan),
a SourceLocation (file/line/optional column), the immutable Diagnostic, and a
mutable DiagnosticCollector (add/all/hasErrors/count).

Pure value objects with no pipeline wiring yet — the foundation for the
forthcoming `xphp check` command's structured, collect-all diagnostics. Unit
tests cover severity gating, collector ordering/error detection, and defaults
(100% mutation score over the new files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tics sink

Thread an optional DiagnosticCollector through the bound-validation path
(Registry ctor -> recordInstantiation -> validateBounds -> checkBounds). When
absent (xphp compile) violations throw exactly as before, byte-identical; when
present each violation is appended as a Diagnostic -- located at the
instantiation site captured from the AST node in RegistryCollector -- and
recording continues so all violations surface in one run.

The user-facing message now comes from a single shared boundViolationMessage()
builder so the throw text and the diagnostic text can never drift. Tests cover
collect-vs-throw, byte-identical and exact message text, multi-violation
collection, and AST-derived source line (100% mutation score over the diff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Compiler::check(): parse, build the type hierarchy, collect definitions,
validate defaults-against-bounds, collect instantiations (bounds + missing type
arguments), and report instantiations of undefined templates -- gathering every
error into a DiagnosticCollector and halting before specialization/emit, so a
partially-invalid registry never reaches the fixed-point loop.

Extends the optional-collector seam to the padding path (missing required type
argument), validateDefaultsAgainstBounds (per-parameter, continue-on-error), and
a new collectUndefinedTemplates pass. Each reused message is built by a single
shared helper so the throw (compile) and diagnostic (check) text stay
byte-identical. The parse loop is factored into parseAll(), reused by compile()
and check(); compile()'s undefined-template throw now routes through the shared
builder.

Duplicate-definition is intentionally not part of the seam: RegistryCollector's
already-recorded guard makes the class-template path unreachable and surfacing it
would change compile-mode semantics -- deferred. Variance and method-level
generic checks remain fail-fast (not yet part of the check pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hase

Move the variance-position check out of the parser into a Registry phase
(validateVariancePositions) over collected definitions, wired into compile()
(fail-fast, byte-identical first-violation throw) and check() (collects every
violation across all definitions, each located at the offending member).
VariancePositionValidator now accumulates violations behind a static
assertPositions facade that throws the first when no collector is given or emits
a diagnostic per violation when one is.

The parser-level variance-position tests move to a dedicated
VariancePositionPhaseTest (compile-mode throws via data provider + check-mode
collect/location), and the check integration suite gains a variance_violation
fixture covering the compile-throw and check-collect paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the inner-variance composition walk out of Registry into a dedicated
InnerVarianceValidator (mirroring VariancePositionValidator): it accumulates
violations behind a static assertComposition facade that throws the first when
no collector is given (compile, byte-identical) or emits a diagnostic per
violation (check), each located at the offending member. Registry's
validateInnerVariance is now a thin delegate.

To avoid double-reporting a direct +T/-T misuse, the position check now returns
which definitions it flagged and the inner-variance pass skips them -- matching
compile-mode, where the position check fails fast before inner-variance runs.
Both passes are wired into Compiler::check(); compile() is unchanged.

Adds inner-variance check fixture + collect-mode, gating, and null-file tests
(100% mutation score over the new validator and the diff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run the variance-position check before the defaults-vs-bounds check so a class
with both surfaces the variance error first in compile-mode — the order it
surfaced when the check lived in the parser. Merge the stacked docblocks on the
two variance delegate methods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a DiagnosticRenderer interface and three implementations for `xphp check`
output: TextRenderer (human-readable blocks), JsonRenderer (a stable documented
JSON contract), and GithubRenderer (Actions workflow-command annotations with
proper escaping). Unit tests pin each format exactly, including the JSON shape
and GitHub escaping (100% mutation score over the renderers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire a CheckCommand (`xphp check <source> [--format=text|json|github]`) into the
console alongside compile, sharing one Compiler. It runs the validate-only pass,
renders diagnostics in the chosen format, and exits 0 (clean) / 1 (errors) /
2 (bad source dir or unknown format).

Compiler::check() now parses each file in its own try/catch: a file that fails
to parse (PHP syntax error or an xphp-specific parse rejection) is reported as a
diagnostic and skipped, so the remaining files are still checked. Tests drive the
command via CommandTester across all formats/exit codes, and a parse_error
fixture proves a valid file's bound violation is still reported alongside two
unparseable files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the file read out of check()'s per-file try so an I/O failure surfaces as
itself rather than being mislabeled xphp.parse_error; only parsing is treated as
a recoverable per-file diagnostic. Clarify the parse-error line comment re nikic's
-1 sentinel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an `xphp check` section to the errors reference (formats, exit codes,
per-file parse resilience, and the stable diagnostic codes for the json/github
formats) and a short pointer from the README quick start.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pile`

Spell out the scope consequence: a clean `check` does not guarantee a clean
`compile`, because method/function/closure-level generic checks (and the
specialization-loop guards, by design) are not run by `check` yet. Advise keeping
`compile` in the build pipeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run GenericMethodCompiler in a new validate-only mode from Compiler::check():
process(emit: false) walks the call sites for their bound/missing-arg checks and
the duplicate-function / $this-capture / static-closure rejections, threading the
optional DiagnosticCollector + source locations through the (already
collector-aware) Registry::checkBounds/padArgsWithDefaults and the in-process
throws, while suppressing the specialize/strip/finalize side-effects. xphp compile
is unchanged (default emit: true, no collector -> byte-identical fail-fast).

This makes `xphp check` a validation-superset of `xphp compile`: a class-level
and a method-level generic error are now both reported in one run. New diagnostic
codes xphp.duplicate_generic_function / xphp.closure_this_capture /
xphp.static_closure; bound + missing-arg reuse the existing codes.

Fixtures + CheckPassIntegrationTest cover each new collected diagnostic (with
file:line), the both-passes-in-one-run guarantee, and byte-identical-compile
guards. Docs updated: check now covers all generic validation; only the
specialization-loop guards (depth cap, hash collision) remain compile-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a closure_static fixture + check-collect and compile-throws tests for the
generic static-closure rejection (xphp.static_closure), matching the symmetry of
the other method-level checks (the collect path was previously untested). Add the
three new method-level codes to the errors-doc table, and correct the
validate-only comments (the discarded per-file AST may carry in-place call-site
rewrites; templates are deep-cloned so nothing shared is mutated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump phpstan.neon from level 7 to level 9 and make src/ clean at it:
- CompileCommand / CheckCommand: narrow getArgument()/getOption() (typed mixed) to
  string via is_string() instead of a blind (string) cast — the inputs are always
  strings (required argument / option with a string default), so behavior is
  unchanged; level 9 just rejects casting mixed.
- Specializer: annotate the ATTR_GENERIC_ARGS array as list<TypeRef> so array_map
  infers the callback's parameter type (level-9 callable-variance check).

Full suite green; src/ clean at level 9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The check command is unit-tested in-process via CommandTester, but nothing
exercised the real binary: its autoload wiring, the process exit code the shell
sees, or the rendered github/json/text output on stdout. The released PHAR was
only smoke-tested with `list`, never `check`.

Add test/smoke/check.sh — a parameterized POSIX script (XPHP_BIN selects the
binary) that runs `check` against the clean and multi_error fixtures and asserts
the 0/1/2 exit contract plus that every renderer emits and json stays
well-formed. Wire it in:
- Makefile: `test/check` target.
- ci-core.yml: a dedicated `xphp check (self-test)` job running `make test/check`
  against bin/xphp.
- release.yml: a post-build step running the same script against dist/xphp.phar,
  so a packaged binary that can't gate fails the release before upload.

No src/ changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First slice of the PHPStan integration for `xphp check`. PHPStan can't see
`.xphp` generic sugar, so the gate will compile to a throwaway dir and analyse
the concrete output. This adds the three building blocks, each unit-tested:

- PhpStanLocator: resolve the phpstan binary (explicit path → consumer
  vendor/bin/phpstan → $PATH); null when none found (caller emits a non-fatal
  Warning — a missing optional tool never fails the gate).
- PhpStanConfigResolver: resolve the consumer's config (explicit → auto-detect
  phpstan.neon / .neon.dist / .dist.neon). This is the "one config" that drives
  level/rules/extensions.
- CompiledWorkspace: compile sources into a temp dir (dist/ + cache/Generated/),
  retain the live Registry for back-mapping findings to template declarations,
  and recursively clean up (guarding against following symlinks out of the dir).

Promote symfony/process to a runtime require: the runner ships in the PHAR and
shells out to the consumer's phpstan, but it was only present transitively via a
dev dependency and would be dropped by `composer install --no-dev`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e findings

Second slice of the PHPStan integration. Given a compiled workspace:

- RepresentativeSelector picks one specialization per template (first by sorted
  generated FQN — deterministic), mapping it to its file path and back to the
  template's declaration (file + line) via the live Registry. Body type errors
  are erased to nominal types during specialization, so they're identical across
  a template's instantiations — analysing one representative surfaces the bug
  once instead of N times.
- PhpStanRunner writes an ephemeral neon that `includes:` the consumer's config
  by absolute path (so their relative bootstrapFiles/excludePaths still resolve)
  and adds scanDirectories for symbol resolution, then runs `php <phpstan>
  analyse <representatives>` via Symfony Process. Analyse paths on the CLI
  override the consumer's `paths`; level is inherited (or a default when there's
  no consumer config).
- PhpStanOutputParser turns --error-format=json into findings, and crucially
  treats unparseable output or file-less top-level errors as a FAILED run (the
  caller will Warn) rather than a false clean pass.

Workspace dist/Generated dirs are canonicalized (realpath) so the file paths
PHPStan reports join exactly to the representatives even when the temp root is
reached through a symlink (e.g. macOS /var). Adds the body_type_error fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eck`

Completes the PHPStan integration: when the generic checks pass, `xphp check`
now compiles to a throwaway workspace, runs the consumer's PHPStan over the
representative specializations, and merges the findings into the same report and
exit code — one gate.

- PhpStanResultMapper anchors each finding at the originating template's .xphp
  declaration line, with triggeredBy naming the concrete instantiation. An
  unmatched finding (not expected — only representatives are analysed) is
  surfaced without a location rather than leaking a throwaway temp path.
- StaticAnalysisGate orchestrates locate → compile → select → run → map, cleans
  up the workspace in finally, and turns a missing binary or a failed run into a
  non-failing Warning (never a false clean pass, never exit 2). Generic errors
  short-circuit the pass.
- CheckCommand gains --no-phpstan / --phpstan-bin / --phpstan-config and runs the
  gate only when Phase 1 is clean.
- GithubRenderer folds triggeredBy into the annotation message (annotations have
  no separate field), so PR output shows which instantiation surfaced a body error.

e2e tests pin behaviour against a fixed level-5 config fixture (not the repo's own
phpstan.neon) and skip when no phpstan binary is installed. Infection's per-mutant
timeout is raised to 120s because these tests shell out to a real phpstan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…unit

Tag the three tests that shell out to a real phpstan binary with @group phpstan
(they already self-skip when vendor/bin/phpstan is absent). Exclude that group
from the fast default `make test/unit` and add `make test/phpstan` to run it on
its own — mirroring the existing php85 group split.

Verified the suite is green both with phpstan installed (the pass runs) and
without it (the group self-skips); pure unit tests for the mapper, parser, config
builder, locator, and workspace always run regardless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- docs/errors.md + README: document the PHPStan-over-compiled-output pass —
  one config, the binary/config resolution order, one-representative-per-template,
  the Warning-not-failure semantics, --no-phpstan / --phpstan-bin / --phpstan-config,
  and the new phpstan.* diagnostic codes.
- CHANGELOG: add an Unreleased section for `xphp check` and the PHPStan pass.
- CONTRIBUTING: document the `@group phpstan` self-skip convention and the target.
- ci-core.yml: add a `PHPStan pass` job running the @group phpstan tests
  (composer install provides the binary).
- Makefile: name the target `test/phpstan-pass` to disambiguate from `lint/phpstan`.
- smoke: pass --no-phpstan so the binary exit/render self-test stays deterministic
  and independent of a phpstan install (the PHAR bundles none); the PHPStan path is
  covered in-process by CheckCommandPhpStanTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundations for catching a stray/undeclared type parameter in a generic member —
e.g. `interface Foo<Z> { add(T $x): void; }`, which today compiles to a reference
to a non-existent class `\App\Foo\T` with no diagnostic. Behind a no-op (no reader
yet); a later change consumes these to fail compile and report in check.

- NamespaceContext::isImported — is a bare name's first segment brought in by a
  `use`? (imported names are the escape hatch and never flagged).
- TypeHierarchy::isDeclared — does an FQN name a class/interface/trait declared in
  the scanned sources, or a built-in? (reuses the existing ancestor-map walk).
- XphpSourceParser: tag a bare, single-segment, non-imported class name used inside
  a generic context (template or generic method/closure) with a new
  ATTR_SUSPECT_UNDECLARED_TYPE attribute carrying its resolved FQN. shouldQualify()
  already excludes declared params / scalars / FQ / generic-arg names, so a tagged
  name is exactly "a real type or a stray type parameter" — the validator decides
  which via isDeclared.

A dry-run of the rule over the entire .xphp fixture corpus flagged nothing, so it
has no false positives on existing valid code. Compile byte-identical; suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Catches a stray/typo'd type parameter in a generic class/interface/trait member —
e.g. `interface Foo<Z> { public function add(T $x): void; }` where `T` is not a
declared parameter. Previously `xphp compile` silently emitted a reference to a
non-existent class (`\App\Foo\T`) and `xphp check` reported nothing; now it fails
at compile and is collected by check (even without PHPStan, even when the template
is never instantiated).

UndeclaredTypeParameterValidator (mirrors VariancePositionValidator) walks every
member signature position — properties, constructor-promoted + method params,
returns, union/intersection/nullable, and nested closure/arrow signatures — and
flags a name the parser tagged as suspect (bare, single-segment, non-imported,
inside a generic context) whose resolved FQN names no declared or built-in type.
Wired into Compiler::compile (throw, fail-fast) and ::check (collect-all) before
the defaults check. Code `xphp.undeclared_type`.

Imported (`use`) and fully-qualified names are the escape hatch and are never
flagged; the accepted limitation (a same-namespace class in an unscanned plain
`.php` file) is documented with the remedy. Generic methods declared outside a
generic template are validated separately (follow-up); nested ones are covered here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Catches a stray/undeclared type parameter in generic methods, free functions,
closures, and arrows declared OUTSIDE a generic template — e.g. a generic method
on a plain class, or `function wrap<A>(C $x)` where `C` is a stray. Like the
class-level check, it fails compile (fail-fast, before specialization strips the
templates) and is collected by check.

UndeclaredTypeParameterValidator gains assertMethodLevel(): it walks the AST for
generic method/function/closure/arrow signatures NOT enclosed by a generic
template (which the member walk already owns — a depth counter avoids reporting
the same node twice) and validates them via the shared checkCallable(). The
diagnostic message now names the context ("method `pick`", "function `wrap`",
"closure", "arrow function", or "template `Foo`").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the undeclared-type check to a type parameter's bound and default — e.g.
`class Box<T: Nonexistent>` or `class Pair<A, B = Nonexistent>`, where the name
is a stray/typo'd reference that previously resolved silently to a non-existent
class. Bounds and defaults are TypeRef trees (not AST type nodes, so they carry
no attribute), so TypeRef gains a `suspectUndeclared` flag the parser sets under
the same rule as the member-hint tag (bare, single-segment, non-imported, inside
a generic context, not a declared param). The validator walks each parameter's
bound (incl. intersection/union operands and generic-arg leaves) and default,
flagging suspect names that resolve to no declared/built-in type; duplicates of
one name (e.g. `<T: Bad = Bad>`) collapse to a single finding.

Fails compile and is collected by check, reusing `xphp.undeclared_type`.
Built-in, imported, fully-qualified, multi-segment, and param-referencing
bounds/defaults are not flagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ating

`Box::<int, string>` for a one-parameter `Box` used to silently drop the extra
argument and proceed as `Box<int>`. Registry::padArgsWithDefaults now splits its
fast-return: an over-supplied tuple (`> needed`) reports xphp.too_many_type_arguments
(via the already-threaded collector/source-location, else throws), while an
exact-arity tuple keeps the fast-return and under-arity still pads / reports a
missing argument. Covers class- and method/function-level generics (both route
through padArgsWithDefaults). Returning the over-long tuple lets the downstream
arity guards skip specialization, so no broken code is emitted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A scalar bound like `class Box<T: int>` was wrongly reported as
xphp.undeclared_type: the bound path set the suspect flag without the scalar
exclusion the default path already had. Fold the scalar check into the shared
isSuspectUndeclared() so bounds and defaults treat `int`/`string`/`self`/… the
same way, and pin it with a scalar bound in the clean fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture the significant, hard-to-reverse design choices behind xphp as a set of
public MADR-format records under docs/adr/ — monomorphization vs type erasure,
the build-time transpiler model, RFC-aligned turbofish syntax, marker interfaces
for instanceof, nominal/erased bound checking, the specialization depth cap, the
`xphp check` gate and its collect-or-throw seam, the PHPStan-over-compiled-output
layer, undeclared-type/arity validation, PHAR distribution, and the engineering
quality bar. Each records the problem, options considered, the choice, and its
trade-offs. Adds an index + template and links them from the docs index and
CONTRIBUTING.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The record claimed an unprovable bound "slips through to a runtime TypeError"
and that PHPStan closes that gap. The bound check actually passes only on a
proven `true`: a `false` is reported as a definite violation, and a `null`
(a type the compiler can't see) is also reported at check/compile time with a
distinct "cannot prove it satisfies the bound" message. Reframe the decision
as conservative rejection of the unknown case — the three-valued result exists
to explain that rejection accurately, not to tolerate it — and clarify that
PHPStan (ADR-0009) handles body value-flow, a separate concern from bound
satisfaction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
math3usmartins and others added 30 commits June 24, 2026 07:33
…to it

A method type parameter bounded by an enclosing class type parameter
(`class Box<+E> { contains<U : E>(U $value): bool }`) can be lowered by
erasing `U` to its bound `E` — specialized once per class instantiation
rather than once per call-site turbofish — when, and only when, `U`
appears exclusively as a top-level input parameter (`U $value`).

Add the analyzer for that. A nested use (`Box<U>`), a return-position use,
or a structural body use (`new U`, `instanceof U`) keeps the concrete `U`
observable, so erasing would change behaviour — those stay non-erasable. A
type parameter forwarded in a turbofish self-call (`$this->m::<U>()`) lives
in an attribute the AST walk never visits, so it is correctly invisible. A
compound bound (`<U : \Stringable & E>`) is excluded — erasing to `E` would
drop the `\Stringable` half. Conservative throughout: any uncertainty
answers "not erasable", never unsound erasure.

This is the analysis only; no lowering is wired to it yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erasing it

A method type parameter bounded by an enclosing class type parameter
(`class Box<+E> { contains<U : E>(U $value): bool }`), when `U` is used only
as a direct input, is now lowered by erasing `U` to its bound `E`: one
concrete `contains_<Fruit>(Fruit)` member per class instantiation, instead of
one `contains_<Banana>(Banana)` per call-site turbofish. `<U : E>` is, after
all, just the variance-legal spelling of "an `E`-typed input".

This makes a forwarded self-call work by construction. `probe<U : E>(U $v) {
return $this->contains::<U>($v); }` previously compiled to a bare
`$this->contains(...)` to a method that was never specialized — a runtime
fatal. Now the Specializer lowers both methods to E-mangled members on each
specialization and rewrites the forward to the emitted name, so it resolves
and runs. The interim hard-fail is narrowed to the non-erasable residual
(a nested / return-position / structural `U`, which keeps the per-`U` path).

Mechanics: the call site checks the bound, then rewrites an erasable call to
the E-mangled name keyed on the receiver's element type (no per-call append);
the Specializer keeps erasable methods on their generic class and erases them
per instantiation. Both sides build the name through one shared helper, so
they agree byte for byte — verified by executing the compiled output for the
forwarding, inherited, and covariant-chain shapes (the covariant `extends`
chain hosts the distinct E-mangled members with no LSP fault).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An earlier note said making a method generic and forwarding its parameter
(`probe<U:E>(U $v) { return $this->contains::<U>($v); }`) does not work around
the `$this`-self-call limitation. That is no longer true: a method whose
enclosing-bounded parameter is used only as a direct input is lowered by
erasing the parameter to its bound, so the forwarded self-call resolves to the
emitted member and runs. Document the forwarding form as the idiomatic way to
call an element-consuming method from within the class, and keep the accurate
boundary — a direct concrete `$this->contains::<Banana>()` and a non-erasable
(nested / return-position) parameter still fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…owering

Sweep the error catalog and changelog for the forwarded-self-call behavior.

- errors.md: add `xphp.unspecializable_self_call` to the diagnostic table, the
  quick index, and the verbatim "Full error texts" section (with a worked
  non-erasable forward). Clarify that the bound-unprovable `$this` message is
  the direct, concrete self-call, and that forwarding a parameter to an
  erasable method compiles and runs.
- CHANGELOG: note the lowering — an erasable method emits one E-typed member
  per instantiation, so a forwarded self-call compiles and runs, while a
  forward to a non-erasable target and a direct concrete self-call stay
  compile errors, never a runtime fault.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ening erasure

Add two #[RunInSeparateProcess] runtime fixtures that execute the compiled
output, closing the last uncovered erasure shapes:

- `enclosing_bound_erasure_map_multiparam` — `containsValue<U:V>` on
  `Map<K, +V>` is bounded by the SECOND class parameter, so the erased member
  must mangle on V (Fruit), not K (string). Proves the call-site and Specializer
  agree on the bound's-referent key for a multi-class-param class.
- `enclosing_bound_erasure_param_widening` — `contains::<Banana>` and
  `contains::<Cherry>` on the same Box<Fruit> both lower to one
  `contains_<Fruit>(Fruit)` member, widened to the bound. Proves the per-E
  collapse and the parameter widening at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`bothAreFruit<U:E, V:E>(U, V)` erases both parameters to E and mangles on
[E, E]; both widen to the bound, so a call with two distinct turbofish types
resolves to the one emitted member and runs. Executes the compiled output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…inue

The strip loop kept an erasable method via `if (isErasable) continue;`, whose
`continue→break` mutant escaped (a break would stop stripping every later
template). Replace it with `if (!isErasableMethodOnClass(...)) stripMethod(...)`
extracted to a pure helper — same behavior, no loop-control to flip, and the
retain/strip decision is now mutation-killed by the runtime fixtures (an
erasable method wrongly stripped breaks forwarding; a non-erasable one wrongly
kept leaks into output).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The grounding ADR and the roadmap were written before the erasure lowering
landed and still framed every `$this` self-call as a hard error awaiting a
future per-instantiation check. Correct that:

- ADR-0018: record the erasure lowering (a direct-input `<U:E>` method emits one
  E-typed member per instantiation) and that a forwarded self-call to such a
  method compiles and runs. Scope the remaining failures to the direct-concrete
  self-call (`xphp.bound_unprovable`) and the forward-to-non-erasable case
  (`xphp.unspecializable_self_call`); update the confirmation accordingly.
- roadmap: add the erasure/forwarding capability to Shipped; narrow the open
  per-instantiation item to the direct-concrete self-call.
- type-bounds: scope the "loud limitation" intro to the direct-concrete case so
  it no longer reads as covering the forwarding form documented just below.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g a variance edge

A covariant class with a source parent (`class ListColl<+E> extends Base<E>`)
instantiated at two or more args had its `extends` clause overwritten by the
variance edge emitter with the same-template covariant super
(`ListColl<Banana> extends ListColl<Fruit>`). PHP allows a class only one parent,
and the source parent carries the inherited member bodies — overwriting it severed
`ListColl<Banana>`'s path to its inherited `contains_<Banana>` member and fataled
with "undefined method" on the call.

VarianceEdgeEmitter::addImplementsEdges now leaves a non-null `extends` intact: the
source parent wins and the same-template covariant leaf edge is dropped (a missed
`instanceof`, never a fatal — the covariant relationship still holds transitively
through the parent-less base chain, which is where erased members are carried down).
Previously masked because every variance fixture used a parent-less template.

Adds a runtime fixture exercising two covariant specializations of a class with a
generic parent, executing the compiled output to prove each keeps its inherited
erased member.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…shared helper

The per-arg variance-subtype rule (invariant: equal; covariant/contravariant:
nested subtype, recursing through an inner same-template's variance) is moved out
of VarianceEdgeEmitter into a standalone VarianceSubtyping collaborator so it can be
reused without dragging in edge-emission concerns. Pure refactor — the emitter
delegates and its behaviour is unchanged.

Adds VarianceSubtypingTest with in-process accept/reject pairs (covariant strict
subtype, contravariant reversal, invariant equality, identical-arg rejection, arity
guards, and the nested same-template recursion). This logic was previously exercised
only by separate-process runtime fixtures, so it now carries direct mutation
coverage; the conservative both-generic guards and `ltrim` defensives are documented
as equivalent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o an interface

A method bounded by an enclosing type parameter (`interface Collection<+E> {
contains<E2 : E>(E2 $value): bool }`) lowers by erasing the parameter to its bound,
emitting one member per class instantiation mangled on that class's own element type
— so `Collection<Book>` declares the abstract `contains_<Book>(Book)` and
`Collection<Product>` a distinct abstract `contains_<Product>(Product)`. (Distinct
names are required: a shared name would override with a narrower parameter — a
contravariant LSP fatal.)

When a concrete `ListColl<Book>` (Book extends Product) was upcast to
`Collection<Product>`, the covariant interface edge made it an instance of
`Collection<Product>`, which demands a concrete `contains_<Product>`. Nothing in the
Book chain implemented it, and the class that would (`AbstractColl<Product>`,
inherited down the covariant chain) was never discovered — the fixed-point loop only
follows substitution, and an upcast is a usage relationship. The emitted PHP then
fataled at class load.

A new SpecializationCloser runs inside the Phase 2 loop: for each interface
specialization carrying an erased method and each concrete class that, by a strict
covariant upcast, is an instance of it, it schedules the declaring class specialized
at the supertype's arguments. The variance edge emitter then inherits the member.
Where the implementation can't be carried that way — the declaring class has its own
source parent that blocks the variance edge, the body is trait-only, or the
arguments can't be threaded — it raises `xphp.unschedulable_covariant_upcast` rather
than emit load-fataling code.

Covered by runtime fixtures that upcast without instantiating the supertype (single-
and multi-parameter) plus in-process tests for the scheduling, the no-upcast no-op,
and the unschedulable hard-fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pcast

Record that an element-consuming method bounded by an enclosing type parameter
(`contains<E2 : E>`) declared on a covariant interface now works through an upcast
(`ListColl<Book>` used as `Collection<Product>`): each interface specialization
declares its own distinctly-named erased member, and the concrete implementation is
scheduled and inherited down the covariant chain automatically.

Documents the supported shape (implementation on a parent-less covariant base that
passes its parameters through unchanged) and the new `xphp.unschedulable_covariant_upcast`
compile error for the shapes that can't be carried down a single covariant chain (the
implementing class has another parent, a trait-only body, or a reordered `implements`
clause) — ground or fail, never emitted load-fataling code.

Adds the diagnostic to errors.md, a worked example + boundary to type-bounds.md, and a
consequence to ADR-0018.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the accumulated 0.3.0 work under a dedicated `[0.3.0] - Unreleased` heading
(date set at tag time), add the missing entries (multi-root `xphp.json` manifest,
generic-method inheritance, by-reference invariance, the covariant-interface
element-method upcast), flag the `final`-on-a-variant-class rejection as a breaking
Changed entry, and backfill a retroactive `[0.2.1]` section for the patch release
that was never recorded. Fix the compare links accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d of skipping it

A bare (turbofish-less) call that resolved to a method-generic template without
all-default type parameters was silently `return null`-ed — clean `check` and
`compile`, then a runtime "Call to undefined method" fatal, since the class carries
only the mangled specialization, never a plain method.

The instance and static call-rewriters now fall through to
`Registry::padArgsWithDefaults` for a bare call (`$args = []`) instead of
short-circuiting: it pads an all-defaults generic as before, and reports/throws
`xphp.missing_type_argument` (collected in `check`, thrown in `compile`) for one that
can't infer its type argument. A method generic takes no inference, so a bare call to
a non-all-default generic is an error, not a silent skip.

Removes the now-unused `hasAllDefaults` helper (padArgsWithDefaults subsumes it).
Non-generic and all-default bare calls are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bare (turbofish-less) call to a generic free function was skipped by a separate
early return (it fires for non-generic calls too, before any template resolution),
so a forgotten turbofish on `pick<T>(...)` emitted `pick('b')` verbatim against a
class/namespace that holds only the mangled `pick_T_<…>` — a runtime "undefined
function" fatal that neither check nor compile caught.

rewriteFuncCall now resolves a bare call's name to a registered generic-function FQN
(mirroring PHP resolution: fully-qualified / `use`-aliased direct, unqualified tries
the current namespace then the global scope) and reports `xphp.missing_type_argument`
via padArgsWithDefaults. functionTemplates holds only generic functions, so a
non-generic bare call resolves to null and is left untouched — no false positives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A generic closure is tracked at assignment, but a bare `$f('x')` (no turbofish) was
doubly skipped: the func-call rewriter bailed before resolving it, and — worse — the
process() fast-path early-return only kept traversal alive for a variable *turbofish*
call site, so a file whose only generic-closure use was a bare call wasn't traversed
at all (clean check + compile, runtime fatal).

The bare-call branch now also resolves a `$f(...)` against the tracked
currentScopeClosureTemplates and reports `xphp.missing_type_argument` for a
non-all-default generic closure (shared with the free-function path via
resolveBareGenericCall). The anonymous-generic pre-scan now also fires on a generic
closure/arrow template assignment, so a bare call to it is traversed and diagnosed.
A non-generic variable call resolves to nothing and is left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… error

A method/function/closure type argument is not inferred from the call arguments, so
a turbofish-less call to a non-all-default generic is `xphp.missing_type_argument`
(caught by check/compile) rather than a silent runtime fatal. Notes the broadened
scope on the diagnostic in errors.md and adds the rule to turbofish.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ction

From code review of the bare-call diagnostics:

- Scope the closure-template tracking. `currentScopeClosureTemplates` was never reset
  on function/method/closure boundaries, so a generic closure assigned to `$f` in one
  method leaked into a sibling scope where `$f` is an unrelated `callable` parameter —
  a bare `$f(...)` there was wrongly reported. It's now snapshotted/reset/restored
  alongside the other per-scope maps (also fixes a latent leak in the turbofish path).
- Skip first-class callables. `pick(...)` / `$obj->m(...)` creates a Closure rather
  than invoking, so it must not be reported as a missing-turbofish call.
- Report a bare generic free-function/closure call even when all type parameters are
  defaulted. A named generic function/closure has no bare or empty-turbofish form, so
  an all-default bare call previously padded silently and still fataled at runtime; it
  now reports `xphp.missing_type_argument` (a generic method whose params are all
  defaulted may still be called bare — that path is unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bare (turbofish-less) call to a generic method/function/closure now fails compile
and is collected by check as xphp.missing_type_argument, instead of silently
emitting a call to the stripped mangled member and fataling at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t type

The per-scope closure-template and closure-context maps are pushed and
restored across nested function/closure boundaries, but the snapshot
stack's `@var` shape omitted both keys, so static analysis read the
restore as accessing non-existent offsets. Add the two keys to the
declared shape; no behavioral change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tance can't carry it

A covariant upcast `Concrete<Sub>` used as `Interface<Super>` must supply
the interface's erased element method specialized at the supertype arg.
The covariant-edge path inherits that member only when its body lives on
a parent-less class implementing that exact interface. The common
collections shape — a list and a set sharing an abstract base, with a
typed method declared on a sub-interface — violates that, and the upcast
previously hard-failed even though a sound member could be emitted.

When inheritance can't carry the member, emit it directly onto the
upcast-source class with a split substitution: the method's bound type
parameter widens to the supertype argument, while the body's enclosing
class parameter is grounded to the upcast-source's own concrete element.
This is sound because the upcast source's element type is a subtype of
the supertype argument, so reading the instance's own backing state
through the widened parameter is type-safe — the same guarantee the
inheritance path already relies on. Distinct supertype arguments produce
distinct mangled names, so a class upcast to several supertypes gets one
member each with no redeclaration.

A method whose parameters are bounded by different enclosing parameters
isn't the uniformly-bounded shape direct emission can derive a single
member for; rather than silently leave the abstract member unimplemented
(a load fatal), it fails loudly with the upcast diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r is in the return type

Direct emission grounds the body's enclosing class parameter to the
upcast source's own concrete element (a subtype of the supertype the
member is emitted at), while the bounded method parameter widens to the
supertype. That split is sound for body reads, but when the enclosing
parameter also appears in the method's return type the emitted member
returns the widened (supertype) value through a subtype return type — a
runtime TypeError. The inheritance path grounds the whole member at one
argument and handles this shape; direct emission cannot, so it now fails
loudly with the upcast diagnostic instead of emitting a member that
fatals when it runs.

Only the return type is inspected: an enclosing parameter is covariant,
so variance checking already forbids it from any parameter position
before the closer runs, making the return type the sole signature slot it
can occupy. A regression test pins the runtime-fataling shape as a loud
compile-time failure, and a companion test pins the variance ordering
that makes the return-type-only check complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…unmodeled traits

Record the direct-emission path for a covariant upcast whose member can't
be inherited: the changelog and type-bounds guide now describe emitting
the member onto the upcast source (parameter widened to the supertype,
body read at the source's element type), the residual shapes that still
hard-fail (no class body, an element-typed return, non-uniform bounds),
and the body-reads-at-the-source-element semantics. Update the
`xphp.unschedulable_covariant_upcast` reference to match.

Add ADR-0019 recording that trait-imported members are deliberately not
modeled in the type hierarchy — a trait-only body is a loud residual
(`xphp.unschedulable_covariant_upcast`) rather than a silently missed
member, consistent with the existing variance/bound trait-`use` boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…deling heavyweight

ADR-0019's "fully model traits" option now carries the two concrete shapes
that make partial modeling unsound — `as` aliasing (the synthesized member
must be found under the trait's original name and emitted under the alias)
and `insteadof` conflict resolution (only one of two same-named trait
bodies is authoritative) — so the option reads as the full-PHP-semantics
feature it is, not a shortcut.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ic type-argument

A covariant upcast whose type-argument is itself a generic of a different
but provably-related template — `Couple<ImmutableList<Book>, X>` viewed as
`Tuple<Collection<Product>, X>` — was silently not emitted: the per-argument
subtype check `isNestedSubtype` resolved both-non-generic and same-template
pairs but returned a conservative false for a different-template generic
pair, even when one template provably implements the other. So the variance
edge was omitted, `xphp check` passed, and the upcast fatal'd at runtime.

Add a cross-template branch: when the two args are generics of different
templates and the child's template provably implements/extends the parent's,
thread the child's args up to the parent's template (the same hierarchy
helper the closer uses) and recurse under the parent's own variance — so
`ImmutableList<Book>` resolves to `Collection<Book>`, then `Book` is compared
against `Product` under `Collection`'s covariant element. Emit only on a
positive subtype result; the recursion's arity guard rejects a malformed
(non-null, wrong-arity) grounding, so a bare or over-supplied parameterized
super never produces a bogus edge. The flip in `isVarianceSubtype` routes
contravariant slots through the same branch, so the case composes
symmetrically across variance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record that a covariant slot whose argument is itself a generic of a
different but related template now emits its variance edge — the variance
guide gains a "nested type-arguments" subsection (a covariant Tuple holding
a covariant container relates by the container's element type, proven by
threading the argument up its implements/extends chain), and the changelog
notes the covariance now holds at runtime rather than passing check and
fataling at load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the inner pass

A method parameter typed `Comparator<E>` on a covariant `+E` class was
wrongly rejected (`xphp.variance_position`) though it is sound: `Comparator`
is contravariant, so `E` sits in a contravariant slot inside a
contravariant parameter position — contra ∘ contra = covariant, which a
covariant `+E` may occupy. The position validator judged the nested `E` by
the direct parameter position without composing the referenced type's slot
variance, and because it flagged the template the composing inner-variance
pass was skipped for it, so the wrong verdict was final.

Reconcile the two passes into disjoint responsibilities: the position
validator now checks only DIRECT occurrences of a variant type-param (a
bare `E` as a parameter/return/property/bound/default), on both its
descent paths; the composing inner-variance pass owns every
type-constructor-nested occurrence and reports only nested leaves, with one
exception — a non-bare direct type-param in a constructor parameter, which
the position pass exempts entirely, stays owned by the composing pass. The
skip handoff is removed, so a template carrying both a direct and a nested
violation now reports each exactly once instead of dropping the nested one.

The composing pass already produced the correct verdicts, so the genuinely
unsound shapes stay rejected: a bare `+E` parameter, a `Producer<E>`
parameter on `+E` (compose to contravariant), and the mirror `Sink<-E>`
with a `Comparator<E>` parameter (compose to covariant). A runtime fixture
proves the now-accepted `Comparator<E>`-on-`+E` shape loads and runs
soundly under a covariant upcast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… property

The composing variance pass kept ownership of the direct leaf of every
constructor parameter, to cover the non-bare shapes (`?T`) the position
pass exempts. But a VISIBLE promoted constructor property (`public T
$item`) is NOT exempt from the position pass — it reports it as a
constructor-parameter violation — so both passes flagged it: in check-mode
the same property produced two diagnostics for one source location.

Cede the direct leaf to the position pass for a promoted constructor
property (non-zero param flags); keep composing-pass ownership only for a
non-promoted constructor param, which the position pass genuinely exempts.
Nested leaves in a promoted property (`public Box<T> $item`) stay owned by
the composing pass. Compile-mode is unaffected (the position pass throws
first); this only removed the duplicate check-mode diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record that `pick(Comparator<E> $c)` on a covariant `Box<+E>` (where
Comparator is contravariant) is sound and now accepted — the variance
guide's composition section gains the contra ∘ contra = covariant case
(the element-consuming counterpart to the covariant immutable
constructor), and the changelog notes nested type-parameters now route
through the composing variance check instead of the bare outer position.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stic

When the specialization fixed-point exceeds the depth cap, report the type
family whose arguments nest the deepest — the tip of the growing tower —
instead of dumping the whole registry. The message names the offending
template and a representative spec, and explains the common cause (a member
whose type re-wraps the receiver's own type family in a growing form). This
turns an opaque whole-registry abort into an actionable, localized error; it
does not change which programs compile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant