Skip to content

0.3.0#20

Merged
math3usmartins merged 252 commits into
mainfrom
0.3.x
Jul 20, 2026
Merged

0.3.0#20
math3usmartins merged 252 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 21:34
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>
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>
math3usmartins and others added 27 commits July 13, 2026 19:42
…-method turbofish calls

Wire the shared call-argument conformance check into rewriteStaticCall. A closure
literal passed to a turbofished static generic method (Cls::m::<T>(fn(...)...)) is now
matched against its paired Closure(...) parameter, grounded through the call's method
type arguments only — a class type parameter is unbound in a static context, so a
signature leaf referencing it stays abstract and gradual, matching how bounds degrade
on the static path.

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

Wire the shared call-argument conformance check into rewriteFuncCall. A closure literal
passed to a turbofished generic free function (f::<T>(fn(...)...)) is now matched against
its paired Closure(...) parameter, grounded through the call's type arguments (a free
function has no enclosing class, so the substitution is method-type-args only, already
guaranteed concrete on this path). The callee resolves via the caller's function imports,
so a `use function` alias or a fully-qualified name reaches the same check.

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

Receiver-type flow analysis kept two stale results that mis-resolved a later turbofish
call:

- A typed parameter reassigned in the body (`function f(Plain $x) { $x = new Other(); … }`)
  kept its declared parameter type: resolveReceiverFqn reads `param ?? local`, and the
  param entry was never invalidated on reassignment, so it masked the live local tracking.
- A local reassigned from an untrackable RHS (a plain function call, another variable, a
  ternary) kept its previously tracked type, because only `new` and method/static-call
  RHS updated the slot and nothing cleared it otherwise.

Both cases resolved the receiver to a non-null WRONG class and then either errored
spuriously or silently specialized against the wrong method. Invalidate the parameter
entry on any reassignment, and clear the local entry for an untrackable RHS, so the
receiver is re-derived from the new value or correctly reported undetermined.

This surfaced while extending closure-argument conformance to plain method calls, where
the mis-resolution would have become a false rejection of conforming code.

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

Extend call-argument conformance to plain (non-turbofish) instance and static method
calls whose receiver type is statically resolvable. When the method is non-generic (no
generic template resolves), a new branch resolves the callee via findMethodDeclaration
and checks each closure-literal argument against its Closure(...) parameter — grounded
by the receiver's class type arguments for an instance call, or nothing for a static
call (a class type parameter is unbound in a static context).

The rewrite pass previously short-circuited when a program declared no generic
method/function/closure; it now also stays alive when any Closure(...)-typed parameter
exists, so a plain call to a non-generic higher-order method is reached. Unresolvable
receivers/methods, non-literal arguments, and first-class callables stay gradual.

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

A closure literal passed to a plain (turbofish-less) call of a non-generic
free function with a Closure(...) parameter is now conformance-checked against
that parameter's signature, closing the last statically-resolvable call shape.

Index every free function by FQN (generic or not) so a bare FuncCall can reach
its declared parameters; resolve the callee through the caller's function-name
scope (use function, current namespace, then the global fallback for an
unqualified name). An unresolved name stays gradual.

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

Pin the unqualified-name global fallback in checkPlainFreeFunctionClosureArgs
with a behavioral reject fixture: an unqualified call inside a named namespace
whose current-namespace function is undefined resolves to the global function,
matching PHP's function fallback. Confirmed load-bearing (removing the fallback
drops the diagnostic).

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

A concrete Closure(...) target inside a generic class body (one referencing no
type parameter) is decided by the pre-specialization conformance pass. The
grounded pass over each specialized class then re-checked the same literal and,
in check mode, collected a duplicate diagnostic (and diverged from compile,
which fail-fasts once at the earlier pass).

Mark a specialized target ATTR_CLOSURE_SIG_GROUNDED only when specialization
actually substitutes one of its type-parameter leaves, and have the grounded
pass check only those targets. Concrete targets are left to the earlier pass;
genuinely grounded targets (Closure(T): T under T=int) are still rechecked.

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

A closure literal passed to a $this->m(...) or self::m(...) self-call whose
Closure(...) parameter references the enclosing class type parameter is gradual
at the abstract template and only becomes provable once the class specializes.
The grounded conformance pass now indexes each specialized class's own methods
and rechecks such self-call arguments against the callee's grounded parameters,
so the same describe() body rejects under Box<Book> and is accepted under
Box<int> — matching the return-position grounding.

Scoped to $this-> and self:: (both bind the enclosing class's own contract, what
PHP statically checks); static:: stays gradual (late static binding could resolve
to a wider override). A self-call to an inherited method and a non-$this receiver
of the enclosing generic type stay gradual — documented follow-ups. The
call-shape guard is extracted to a pure selfCallMethodName() and unit-tested.

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

The grounded conformance pass keyed off a boolean grounded flag, so a partially
grounded target (Closure(int, E): string) whose literal violated the CONCRETE int
leaf was reported twice: once by the pre-specialization pass and again by the
grounded pass (the flag fired because SOME leaf grounded).

Stash the pre-substitution signature (ATTR_CLOSURE_SIG_TEMPLATE) instead of a
boolean, and in the grounded pass suppress any violation already provable against
that ungrounded form — a concrete leaf the pre-specialization pass has already
reported. Only a grounding-induced violation (provable now, not before) survives.
Applies to both the call-argument and return positions.

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

Code review note: the ungrounded-target suppression drops the whole grounded
report when the pre-specialization pass already flagged the literal, so a further
grounding-induced violation on a different leaf is not surfaced separately. This
is a deliberate under-report (the literal is already rejected once); document it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document that a closure literal is checked wherever it statically meets a
Closure(...) target — the return-position factory and a call argument to a
Closure(...) parameter of any statically-resolvable callee — with a
per-specialization recheck for self-calls inside a generic template, and the
dynamic-callee / inherited-method / non-$this-receiver gradual boundaries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the missing `@param array<string, Function_>` on rewriteCallSites'
$allFunctionsByFqn so the level-9 static analysis (phpstan analyse) passes; the
parameter was threaded through without its iterable value type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(monomorphize): enforce closure-signature conformance for call-argument literals
A readonly map from type-parameter name to concrete TypeRef, to replace the bare
array<string, TypeRef> threaded through specialization and closure-signature
grounding. Factories (empty/of/fromParams/fromNames), typed accessors
(get/has/isEmpty/names), and a method-wins merge (withOverrides). Not yet wired —
the threading follows in the next commit.

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

Replace the bare `array<string, TypeRef>` substitution map — type-parameter
name to concrete TypeRef — with the immutable Substitution value object across
the whole substitute surface and every build site: Specializer's substitute/
ground methods and its recursion, Registry::substituteBound / the sibling-bound
and default-padding grounders, EnclosingBoundErasure::mangleArgs, the closure
call-argument validator, and the generic method/function call rewriter.

Build sites move to the factories (empty/of/fromParams/fromNames); the
class-then-method merges use withOverrides (the argument wins on collision,
matching PHP's inner-scope-wins). classSubstitutionFor now returns the value
object, with its empty-map failure sentinel expressed as isEmpty().

Behaviour-preserving: the grounded-arity build loops keep their exact
silent-drop semantics via of(), so no call path gains a new throw. Full suite
green, static analysis clean, no diagnostic or output change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bundle the five read-only symbol tables the call-site rewriter consults in
lockstep — generic method templates (keyed Class::method), classes by FQN,
generic function templates, per-function enclosing namespace nodes, and all
functions by FQN — behind one immutable value object with typed accessors.

Includes resolveFunction(), which encapsulates PHP's free-function resolution
with the global fallback (resolve through the caller's scope, then the bare
global name for an unqualified, non-fully-qualified name). Unit-tested at 100%
MSI. Threaded into the rewriter in the following commit.

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

Bundle the five lockstep symbol tables into the TemplateIndex value object
built once in process() and pass it as a single value to rewriteCallSites and
the rewrite visitor — dropping the visitor constructor from 12 arguments to 8
and removing five hand-typed array parameters (and their value-type-drop risk).

Every read migrates to a typed accessor: the "Class::method" key composition
moves behind methodTemplate(); the free-function primary-plus-global-fallback
lookup becomes resolveFunction(). The raw maps stay as process() locals for the
duplicate-declaration diagnostic and the template-stripping loops (which iterate
them). The builder visitor's own write-target properties are unchanged.

Behaviour-preserving: full suite green, static analysis clean, no diagnostic or
output change.

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

The qualified-name resolveFunction test indexed its function under the bare
`thing` key, but the global fallback keys on `$name->toString()` (`Sub\thing`),
so the assertion held whether or not the guard was present — false confidence.
Index under the exact `Sub\thing` key the fallback would use, so only the
qualified-name guard keeps the call unresolved; confirmed to fail if the guard
is dropped.

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

The "$classFqn::$method" string under which generic-method templates are indexed
was composed in the builder write site and in TemplateIndex::methodTemplate, and
split back in the strip pass — three places that had to agree on the format by
hand. Route all three through a single MethodKey value object: __toString()
composes the key (builder + accessor) and parse() is its exact inverse (strip),
so the two sides can no longer drift. Unit-tested at 100% MSI; no behaviour change.

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

refactor(monomorphize): give the threaded substitution and index maps typed value objects
Type-parameter bounds stay upper-only: there is no supertype/lower bound. A
widening operation (a reduce/fold-to-supertype on a covariant collection) is
expressed with a static sibling upper bound `<S, T : S>` — Kotlin's mechanism,
minus extension-method sugar — not a lower bound.

Add ADR-0022 with the decision and its rationale (extension functions declined;
lower bounds deferred), document the boundary and the `<S, T : S>` widening
pattern in the type-bounds guide, index the ADR, and add supertype (lower)
bounds to the roadmap Discovery section as a possible future member-ergonomics
improvement.

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

The dispatcher closure that replaces a generic closure copies the template's
attributes wholesale, carrying ATTR_METHOD_GENERIC_PARAMS onto the emitted,
already-specialized closure. The pretty-printer never emits the attribute, so
output is byte-identical — but the stale marker makes a specialized dispatcher
read as an un-grounded generic. Null it on the dispatcher: the closure is the
final grounded artifact, so a surviving generic-param marker is then, by
construction, an un-specialized leak — the sound signal the emit-time backstop
relies on.

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

Three enclosing-parameter / generic-function-scope turbofish shapes cannot be
grounded by the current pipeline: a generic closure grounded by an enclosing
function type parameter, a concrete inner closure turbofish inside a generic
function body, and a method/static turbofish grounded by an enclosing class type
parameter. Left to emit, each produces PHP that references a non-existent
type-parameter class (`App\T`) — a runtime TypeError/Error behind an otherwise
clean compile and check.

Add a post-specialization backstop: after every specialization emits its body,
scan it for a surviving generic call marker (a `FuncCall`/`MethodCall`/
`StaticCall`/`NullsafeMethodCall` whose turbofish was never rewritten away) or a
surviving generic-params marker on a `Closure`/`ArrowFunction`, and fail the
compile with `xphp.unspecialized_generic_leak` instead of emitting fatal-able
code. Scoped to the emitted specialized artifacts only — specialized classes in
the emit loop and the appended specialized functions/methods — never templates
or dispatchers, which legitimately carry markers before the emit-time rewrite.

Compile-only. The existing dispatcher and enclosing-bound-forward runtime
fixtures compile and execute green with the backstop active, proving zero
false-reject across the corpus.

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

A generic closure whose turbofish is grounded only by an enclosing function type
parameter (`$inner::<S>($v)` inside `relay<S>`) is a variable turbofish whose type
arguments are not concrete at the call site. The dispatcher can ground only concrete
arguments, so the site cannot be specialized — emitted, the closure keeps `fn(I $x): I`
naming a non-existent class and fatals on invocation.

Previously this was silently marked "attempted" and skipped (gradually accepted),
relying on the compile-only emitted-marker backstop. That backstop does not run in
`check`, so the shape passed the validate-only gate silently. Reject it at the source
seam instead, in BOTH modes (check collects, compile throws), reusing
`xphp.unspecialized_generic_closure`. The template is still marked attempted so the
never-specialized orphan check does not additionally report the same closure.

Concrete inner turbofishes (`$f::<int>` inside a generic function) never reach this
branch — they pass the all-concrete gate to the dispatcher — and remain covered by the
compile-time backstop. Moves the relay shape out of the orphan fixture into its own
check/compile parity fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the new `xphp.unspecialized_generic_leak` diagnostic in the error index, add
a caveats section covering the three enclosing-parameter / generic-function-scope
turbofish shapes (why they cannot be specialized yet, and the concrete-turbofish
workaround), and record the safety fix in the changelog. These shapes now fail loud at
compile (and, for the closure form, at check) instead of miscompiling to a runtime
fatal.

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

Code review surfaced two documentation-precision points on the enclosing-parameter
turbofish limitation:

- The limitation is a *class* of shapes, not the three representatives listed. A named
  generic free function forwarding a non-concrete enclosing type argument
  (`identity::<T>($v)` inside `wrap<T>`) is the same shape and is likewise caught by the
  emitted-marker backstop. Add a compile-reject fixture + test pinning it, and reword the
  caveat to say the cases are representative, not exhaustive.
- `xphp check` catches only the closure form; the shapes that surface at code generation
  are rejected by the emit-time backstop, which `check` does not run. Spell this out so a
  CI pipeline gating on `check` alone knows it must also run `compile` for full coverage.
  This is a completeness gap in `check`, never a runtime-safety hole — no fatal-able code
  is ever emitted.

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

The mutation gate flagged the dispatcher marker-clear as removable without a failing
test: the existing dispatcher unit tests build a template that never carried
ATTR_METHOD_GENERIC_PARAMS, so clearing it on the dispatcher was a no-op under test and
the emitted goldens (which don't print attributes) can't observe it. Add a direct
assertion: set the marker on the template, dispatch, and confirm the emitted dispatcher
closure does NOT carry it while the template keeps its own copy. This makes the
defense-in-depth leak signal removal-proof.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@math3usmartins math3usmartins changed the title 0.3.x 0.3.0 Jul 20, 2026
Drop the "Unreleased"/in-progress markers from the 0.3.0 changelog
heading, remove version dates, and point the compare link at the
v0.3.0 tag. Correct the variance error-message templates in errors.md
to the emitted `out`/`in` markers (was the old `+|-` sigil), and repair
the empty `check` placeholder link in getting-started.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@math3usmartins
math3usmartins merged commit 9bc1ff4 into main Jul 20, 2026
6 checks passed
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