Skip to content

0.3.x#20

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

0.3.x#20
math3usmartins wants to merge 38 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 8 commits June 18, 2026 18:59
Audited every ADR against the source. Fixes where the prose contradicted the
code:

- ADR-0006: the depth cap is 16 levels (Compiler::MAX_SPECIALIZATION_DEPTH), not
  a figure "far beyond any realistic nesting"; name it and soften. It guards the
  `compile` fixed-point loop only — `check` never specializes — so drop the
  "compile/check" phrasing.
- ADR-0008: `check` runs every validation phase unconditionally and collects into
  one flat report; it does NOT halt between phases at "the earliest failing phase".
  State that, with the two real exceptions (inner-variance skips already-flagged
  templates; a hash collision is thrown, not collected). The fail-fast halt is the
  `compile` path's behavior, not the collect path's.
- ADR-0005: credit the actual combinator (Registry::evaluateBound) and policy
  (Registry::checkBounds) rather than the BoundIntersection/BoundUnion data
  classes, which only document the fold.

The other nine ADRs were verified accurate against the code.

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

Audited docs/ + README against the source. Fixes:

- roadmap: add the shipped "Validation and diagnostics" surface (xphp
  check gate, collect-all diagnostics with text/json/github renderers,
  undeclared-type + arity validation, PHPStan over compiled output);
  demote the now-shipped PHPStan bridge out of Discovery, keeping the
  psalm bridge as future.
- how-it-works: the parser blanks <...> clauses with equal-length
  spaces (offsets round-trip) rather than stripping + remembering
  spans; ByteOffsetMap exists for the length-changing T[]->array sugar.
  Correct the parser entry-point count (four, not two) and document the
  Phase 2.5 VarianceEdgeEmitter stage.
- getting-started: list symfony/process among the runtime deps.
- closures-and-arrows: fix the static-closure rejection rationale
  (a not-yet-specialized capability gap, not a $this-binding one).
- syntax/index: make the variance quick-ref classes abstract so the
  bodiless methods are valid PHP.
- errors: note the phpstan.error fallback code on the phpstan.* row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Why" for the static-closure caveat blamed a missing $this-binding
target on the dispatcher. That's the reason for the *separate*
$this-capture rejection, not this one: a static closure has no $this to
begin with. The real reason is a capability gap — call-site
specialization of static (and explicit use(...)) closures is an
unimplemented branch of the anonymous-template rewrite. This aligns the
caveat with docs/syntax/closures-and-arrows.md, docs/errors.md, and the
emitted message ("cannot yet be specialized at call sites").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A generic method declared on a base class is now dispatched when called via
turbofish on a subclass receiver (instance and nullsafe). On a receiver-keyed
template miss, resolution walks the receiver's ancestor chain via the new
TypeHierarchy::ancestorChain (breadth-first, nearest-first), and the
specialization is emitted onto the *declaring* class so every subclass
inherits the single copy through the existing class-level extends edge.

A subclass that redeclares the method shadows the inherited one (direct hit
wins over the ancestor walk), and the dedup key is the declaring FQN so two
subclasses sharing a base method don't each append a duplicate.

Previously such a call produced no specialization and fataled at runtime with
"Call to undefined method". Static turbofish keeps its current behavior.

Adds a runtime fixture (generic base/subclass) plus override, multi-level,
intermediate-override and shared-base dedup coverage, and ancestorChain unit
tests. Full suite green, PHPStan level 9 clean, 100% MSI on the diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A turbofish method call (`$obj->m::<...>()`, `$obj?->m::<...>()`, or
`Foo::m::<...>()`) whose generic method can't be resolved on the receiver's
type now fails at compile time (and is collected by `xphp check`) instead of
being left in place to fatal at runtime with "Call to undefined method".

The check is strictly gated on the turbofish marker: a plain non-generic call
(no type arguments) passes through untouched, so ordinary method calls are
unaffected. Compile throws and check collects the same message from one shared
builder; the diagnostic carries code xphp.unresolved_generic_call at the call
site.

Adds a check-mode fixture plus check-collect, compile-throw (instance + static)
and plain-call-passthrough tests, and documents the code in docs/errors.md.
Full suite green, PHPStan level 9 clean, 100% MSI on the diff.

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

Extends the inheritance resolution to the static turbofish path: a static
generic method declared on a base class is now callable as `Sub::m::<...>()`,
resolved through the receiver's ancestor chain and emitted onto the declaring
base (reached via PHP static-method inheritance). The nullsafe instance path
(`$obj?->m::<...>()`) already shared the instance code path; it is now locked
with coverage including the `?->` short-circuit.

The call's class reference stays the receiver (`Sub::m_T_hash()`), so late
static binding is preserved; only the specialization's emission moves to the
declaring class. Same-class static calls keep their byte-identical output.

Adds a static runtime fixture plus nullsafe-inherited and parent::-inherited
coverage. Full suite green, PHPStan level 9 clean, 100% MSI on the diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
methods-and-functions.md gains an Inheritance section: a generic method on a
base/abstract class is callable via turbofish on a subclass receiver (instance,
static, nullsafe), resolved through the ancestor chain and specialized once on
the declaring class; a subclass override shadows it; an unresolved turbofish is
a compile-time error. turbofish.md notes the same on its receiver-type-analysis
section and cross-links the xphp.unresolved_generic_call code.

roadmap.md records both shipped changes — "generic methods inherited from base
classes" under Generic templates / Function-level generics, and
"unresolved-generic-call detection" under Validation and diagnostics (timeline
overview + prose).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a "Generic completeness" group under Discovery for deferred limitations of
already-shipped generics that weren't represented anywhere in the roadmap:
- $this-capturing and static generic closures (currently rejected; lift planned)
- generic methods inherited through traits (extends/implements resolve; traits don't)
- trait composition for variance validation and bound satisfaction (unmodeled)

These were documented only in caveats/code comments before; the roadmap now
reflects them (timeline overview + prose).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@math3usmartins math3usmartins requested a review from a team June 19, 2026 05:28
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