0.3.0#20
Merged
Merged
Conversation
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>
…-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>
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>
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.