0.3.x#20
Open
math3usmartins wants to merge 114 commits into
Open
Conversation
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.