Repository navigation
generics: expand templates beyond classes - #6
Conversation
Widen the monomorphization pipeline from Class_ to ClassLike — same type used by nikic for Class_/Interface_/Trait_/Enum_. The scanner now detects T_INTERFACE and T_TRAIT alongside T_CLASS; the AST resolver, RegistryCollector, CallSiteRewriter, Specializer, Registry, and SpecializedClassGenerator all accept ClassLike instead of Class_. No runtime behavior change for existing generic classes. End-to-end fixture confirms `interface Container<T>` + `class Box<T> implements Container<T>` produces a specialized interface, a specialized class that implements it, and runtime instanceof against the specialized interface FQCN. Playground gains a Repository<T> + InMemoryRepository<T> demo exercising the same path with reflection assertions. Tests: 127 unit + integration tests pass. Mutation MSI 94%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New syntax: `class Box<T: \Stringable> { ... }` (colon-separator, no PHP
keyword collision). Each generic type parameter optionally carries an
upper-type bound that every concrete instantiation must satisfy.
Pipeline changes:
- New scanner method parseTypeParamList: parses `Name (: Bound)?`
inside class/interface/trait headers. parseTypeArgList stays
untouched for instantiation sites (no colon-syntax there).
- New TypeParam value object replaces the bare-string type-param list
on GenericDefinition. Pipeline callers extract names via the
typeParamNames() helper.
- New TypeHierarchy: builds a direct-ancestor map by walking every
parsed source AST (extends/implements/extends-many on interfaces),
plus a built-in PHP interface whitelist (Stringable, Countable,
Iterator, IteratorAggregate, Traversable, ArrayAccess,
JsonSerializable, Throwable, Exception, Error, BackedEnum, UnitEnum).
isSubtype returns ?bool — null when the concrete type is unknown,
so the validator can distinguish "proven violation" from "couldn't
prove satisfaction" with different error copy.
- Compiler builds the hierarchy upfront (phase 0) from the parsed
ASTs and passes it to the Registry; Registry::recordInstantiation
fires validateBounds before hashing the FQCN so error messages
reference the source-level instantiation, not the obfuscated
T_<hash>.
Coverage: 162 unit + integration tests. Bound-satisfied happy path
exercises App\Models\Tag implementing \Stringable being accepted for
Box<T: \Stringable>; bound-violation tests cover scalar concretes and
unknown classes both with distinct error messages. Mutation MSI 93%.
Playground gains a Bounds demo + StringableBox<T: \Stringable> +
App\Models\Tag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New syntax: a generic method declared inside any class body, called via
`ClassFqn::method<int>(...)`. Each unique call-site arg list produces a
separate mangled method body inside the same class; the original generic
template is stripped after specialization.
Pipeline additions:
- Scanner now also picks up T_FUNCTION followed by `STRING<...>` and
emits a new methodMarkers stream. parseTypeParamList handles the
`Name (: Bound)?` shape uniformly.
- AST resolver attaches ATTR_METHOD_GENERIC_PARAMS to ClassMethod
nodes and ATTR_METHOD_GENERIC_ARGS to StaticCall nodes when an
Identifier-name + line marker line up.
- New Specializer::specializeMethod clones a ClassMethod, substitutes
Name references to the type-param with the concrete TypeRef, and
renames to the supplied mangled form (caller controls the naming
scheme so Specializer stays naming-agnostic).
- New GenericMethodCompiler post-pass walks both the rewritten user
files and the specialized cache classes: indexes templates by
`classFqn::method`, walks StaticCall sites, mints specialized
methods (mangled `method_T_<hash>`), appends them to the owning
class, strips the original template, rewrites the call's Identifier
to point at the mangled name. Specialization is deduped so two
`identity<int>` calls share one method body.
MVP limitations (called out in the source):
- Static call sites only — `$obj->method<T>(...)` instance calls
require runtime-type inference that's out of scope here.
- Generic methods on non-generic classes only (merging two type-param
scopes is a follow-up).
- Bound validation on method-level params is not yet enforced.
Tests: 158 unit + integration tests pass. End-to-end fixture compiles a
class with `identity<T>`, verifies dedup (2 unique args produce 2
methods despite 3 call sites), confirms the runtime gettype matches the
substituted concrete type. Mutation MSI 93%.
Playground gains Containers/Util.xphp + Demos/GenericMethod.xphp.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Syntax `function foo<T>(T $x): T { ... }` at file/namespace scope is
now handled by the same machinery as generic methods. Each unique
call-site arg list produces a mangled function (`foo_T_<hash>`) appended
to the enclosing namespace; the original generic template is stripped.
Call sites are rewritten to fully-qualified mangled references.
Pipeline additions:
- AST resolver: ATTR_METHOD_GENERIC_PARAMS attaches to Function_
nodes the same way it does to ClassMethod. FuncCall sites also
pick up the marker (claimed during enterNode so the inner Name
child doesn't trigger the class-rewrite path).
- Specializer::specializeFunction clones a Function_, substitutes
Name references to type-params, renames to the supplied mangled
form. Same shape as specializeMethod.
- GenericMethodCompiler now tracks both method and function templates
+ their enclosing containers (ClassLike for methods, Namespace_ for
functions). The rewrite pass buffers appends and flushes them after
the traversal, sidestepping nikic's mid-walk child-array semantics.
MVP limitations:
- Free generic functions must live inside a `namespace` block — bare
top-level functions aren't supported yet (would need to thread an
AST-root reference; deferred).
Tests: 162 unit + integration tests pass. New
GenericFunctionIntegrationTest exercises the full path: identity<int>
+ identity<string> compile to two mangled functions, call sites
rewrite to FQ refs, runtime gettype matches the substituted concrete
type. Mutation MSI 93%.
Playground gains Demos/GenericFunction.xphp.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For each generic class or interface template, the compiler now replaces
the original FQN with an empty marker interface and adds an
implements/extends clause linking every specialization back to it.
User code can ask `$x instanceof App\Containers\Box` and get true for
ANY Box<…> without knowing the concrete arg list.
Pipeline changes:
- CallSiteRewriter no longer REMOVES generic class/interface defs;
it REPLACES them with an empty Interface_ at the same name. Generic
traits still get dropped — PHP can't `instanceof` a trait.
- Specializer adds the marker reference to the cloned class/interface:
Class_ gets a new `implements`, Interface_ gets a new `extends`.
Tests updated:
- The 4 existing runtime integration tests now require the marker file
alongside the specialized class (the marker is needed at autoload
time for `implements` to resolve).
- CompilerIntegrationTest gains testInstanceofAgainstOriginalTemplate
locking in the new contract end-to-end.
- GenericInterfaceIntegrationTest's "template stripped" assertion is
flipped to "template replaced with empty marker."
- XphpSourceParserTest gains a guard test confirming generic traits
don't accidentally become marker interfaces (catches the
`Class_ || Interface_` mutation in the rewriter).
Tests: 164 unit + integration tests pass. Mutation MSI 93%.
Playground gains Demos/InstanceofTemplate.xphp showing `instanceof Box`
matching every Box<…> specialization.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code reviewWhat the branch does well
FindingsCorrectness -- worth fixingF1.
|
Closes the correctness gaps the second-agent code review surfaced on this
branch, tightens test coverage where contracts were unasserted, and
documents the limitations that are deliberate. Decisions per finding,
ordered as in review.md:
F1: Specializer's method/function variants used to skip ATTR_GENERIC_ARGS
recursion, so a body like `function wrap<T>(T $x): Box<T> { ... }` left
`Box<T>` un-substituted. Hoisted the substitution visitor into a single
shared private method on Specializer; all three specialize* paths now
share the same Name + ATTR_GENERIC_ARGS recursion logic.
The fix surfaced a related ordering bug: method-/function-level T was
never pushed onto the resolver's typeParamStack, so `T` inside the body
qualified to `App\T`. Push/pop is now on ClassMethod / Function_ enter
+ leave (parallel to ClassLike). Compiler::compile reorders the pipeline
so GenericMethodCompiler runs BEFORE the class-collector + fixed-point
loop — that way Box<int> introduced by `wrap<int>` substitution gets
caught by the regular class-level path. End-to-end fixture under
test/fixture/compile/generic_function_nested_args/ + integration test
covering both the static-text contract and the runtime construction.
F2: scanner now records a per-marker `anchorLine` (the receiver's line
for `Foo::method<int>` / `$x->method<int>`) alongside `line` (the
identifier line). Resolver matches if `StaticCall::getStartLine()` /
`MethodCall::getStartLine()` falls in [anchorLine, line]. Fixes the
silent miss on `Foo::\n identity<int>(...)`. Regression test in
GenericMethodIntegrationTest.
F3: GenericInterfaceIntegrationTest gains
testSpecializedClassIsInstanceOfOriginalInterfaceMarker locking in that
specialized classes transitively satisfy `instanceof` against the
original interface marker (the class case was already covered).
F4: GenericMethodCompiler::process now tracks (fqn → source) for
function templates and throws RuntimeException on duplicate
declarations across files (same shape as Registry::recordDefinition).
Test added.
F6: drop the inline FQ \PhpParser\Node\Stmt\Class_ / Interface_
references in Specializer in favor of proper imports.
F8: bound-violation error message names "trait composition" as a
possible cause of the "not in the source set" verdict; pr.md gains an
MVP-limit bullet pointing the same direction.
F9: code comment in Compiler::compile flagging that the specialized AST
set isn't wrapped in its enclosing Namespace_ when fed back to the
method compiler — currently dormant under the MVP limit "method
templates only on non-generic classes" but a footgun if that relaxes.
D1: extracted Registry::canonicalHash (single home for the
canonical | sha256 | truncate pipeline); Registry::generatedFqn and
GenericMethodCompiler::mangleName both call it. Future hash-shape
tweaks live in one place.
D2: extracted Registry::checkBounds (public static taking
TypeParam[], TypeRef[], TypeHierarchy, label). validateBounds calls it
for class-level instantiation; GenericMethodCompiler now calls it from
rewriteStaticCall / rewriteFuncCall before generating specializations.
Method/function-level T: Bound is enforced at compile time with the
same error shape as the class-level path. Tests for both.
Justify (no action):
- F5: subsumed by F4 — the unused RuntimeException import is now used.
- F7: BFS-over-built-ins microbenchmark — reviewer flagged as not
load-bearing; keeping the single uniform code path is worth more
than a guard that doubles the entry condition.
Tests: 171 unit + integration tests pass. Mutation MSI 93%.
testInstanceofAgainstOriginalTemplateMatchesAllSpecializations switched
from `new $fqn()` to reflection-based assertions to avoid coupling
to whether the box_generic Box template happens to declare a
constructor — multiple fixtures specialize Box<Plastic> at the same
hash, and PHP's process-wide class table caches the first loader's
version.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review feedback addressed --
|
| Finding | Decision | Outcome |
|---|---|---|
F1 (specialize* missing ATTR_GENERIC_ARGS recursion) |
Fix | Single shared Specializer::buildSubstitutingVisitor; also pushed method/function type-params onto typeParamStack so T inside the body resolves correctly; restructured Compiler::compile so GenericMethodCompiler runs before the class loop so newly-introduced Box<int> gets specialized. New fixture + 2-test regression. |
F2 (multi-line Foo::\n method<T>) |
Fix | Scanner records anchorLine (receiver's line) alongside line; resolver matches by line-range. Regression test with the multi-line construct. |
F3 (no interface-marker instanceof test) |
Fix | Added testSpecializedClassIsInstanceOfOriginalInterfaceMarker walking the Box ? Container_<T> ? Container chain via reflection. |
| F4 (silent duplicate function template overwrite) | Fix | GenericMethodCompiler::process tracks source paths and throws RuntimeException with both locations. Test added. |
F5 (dead RuntimeException import) |
Subsumed | By F4 -- the import is now used. |
F6 (inline FQ \PhpParser\Node\Stmt\Class_) |
Fix | Added imports, removed inline FQs. |
| F7 (BFS short-circuit for built-ins) | Justify | Reviewer marked as not load-bearing; the single uniform code path is worth more than a guard that doubles the entry condition. |
| F8 (trait-composition not modeled) | Document | Error message now mentions "or its bound satisfaction comes via a trait the compiler does not yet follow"; pr.md gained an MVP-limit bullet. |
| F9 (specialized AST fed without namespace) | Document | Code comment in Compiler.php flagging the footgun under the current MVP limit. |
| D1 (two hash-mangling code paths) | Fix | Registry::canonicalHash is the single home; both generatedFqn and mangleName call it. |
| D2 (method/function bounds silently skipped) | Fix | Extracted Registry::checkBounds; GenericMethodCompiler validates method/function-level T: Bound at rewrite time with the same error shape as the class-level path. Tests for both. |
Stats
- 171 tests pass (up from 164; +7: 1 nested-args test, 1 nested-args runtime test, 1 multi-line, 1 method-bound violation, 1 function-bound violation, 1 duplicate-template throw, 1 interface marker).
- MSI 93% held the line -- added the F2 helper's
@infection-ignore-allfor the boundary-token-walk pattern that's identical to the existingisMemberAccessContext.
One additional bug found
While implementing F1's regression test, testInstanceofAgainstOriginalTemplateMatchesAllSpecializations turned out to be process-wide-class-table-sensitive: multiple fixtures specialize Box<Plastic> at the same hash. Switched it to reflection-based assertions so it no longer cares whether the cached version of the specialized class happens to have a constructor.
testSpecializedClassIsInstanceOfOriginalInterfaceMarker was failing under random test ordering: multiple integration-test fixtures specialize Box<App\Models\Plastic> at the same hash, and PHP's process-wide class table caches the first loader's body. If a fixture whose Box<T> does NOT implement Container<T> loaded first (box_generic, nested_typehint), the cached specialized class doesn't implement the Container marker — so the instanceof assertion in generic_interface's test failed despite that fixture's own emitted file being correct. Add a fixture-unique concrete `App\Models\Polymer` and switch the test's instanceof check to `Box<Polymer>`. No other fixture uses Polymer, so the hash is unique and PHP's class table doesn't pollute the assertion. Verified across random seeds 1, 2, 3, 7, 42, 100 — all green. MSI holds at 93%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two integration tests on this branch caught the same flake (a stale specialized body from an earlier fixture's compile shadowing the current test's class). Record the failure mode + the two patterns we landed on (reflection-only assertions, fixture-unique concretes) in CONTRIBUTING.md so the next test author doesn't rediscover it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promote the TS / Kotlin / Rust generics comparison from internal scratch
into the official documentation set at docs/generics-comparison.md, and
update the surrounding docs to match the surface shipped on
feat/generics-expansion:
- docs/generics.md: new sections for generic interfaces / traits,
type-parameter bounds, method-scoped generics, free generic
functions, and instanceof-via-marker. Cross-link to the new
comparison at the end.
- docs/roadmap.md (public mermaid): move the items just shipped
(generic interfaces, bounds, method / function generics, marker
instanceof) into Shipped. Fold the Tier-1 comparison items into a
new "Next (generics depth)" section. Drop literal angle brackets
from timeline labels -- mermaid's timeline parser rejects them.
- README.md: surface the comparison alongside the generics reference
near "the start, not the finish line".
Voice across the public docs stays impersonal and community-facing --
no single-author "I", no second-person "you" addressing the project.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hover-through-generics and pre-compile type errors are core developer experience for adoption -- treating them as a near-term milestone rather than long-arc vision. The Next section's heading drops the "(generics depth)" qualifier now that it covers tooling as well. Source maps, phpstan / psalm bridge, and REPL / playground stay in Vision -- they ride on top of LSP infrastructure or address a broader arc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
generics: expand template machinery
Add five features ordered by impact (most impactful first):
1. Generic interfaces and traits
interface Container<T> { ... }compiles through the same pipeline as classes. TheClass_-only type signatures acrossSpecializer,RegistryCollector,CallSiteRewriter,Registry,GenericDefinition, andSpecializedClassGeneratorwiden toClassLike(Class_/Interface_/Trait_).2. Type-parameter bounds
class Box<T: \Stringable>. A newTypeHierarchywalks every parsed source AST plus a built-in interface whitelist (Stringable,Countable,Iterator, ?) to build a direct-ancestor map.Registry::recordInstantiationvalidates each concrete arg against its bound and fails the build with a pointed message before hashing the specialized FQCN, so the error references the source-level instantiation, not the obfuscatedT_<hash>.3. Method-scoped generics
New
GenericMethodCompilerpost-pass:classFqn::methodNameStaticCallsitesidentity_T_<hash>), appends them to the owning classSpecializations are deduped -- two
identity<int>calls share a single method body.4. Free generic functions
Same shape as #3 but for
function foo<T>(...)at namespace scope. The compiler tracks the enclosingNamespace_per template, buffers appends and flushes them after the traversal (so we don't fight nikic'sNodeTraversermid-walk), and rewritesFuncCallsites to fully-qualified mangled references.5.
instanceofagainst the original templateFor every generic class or interface template,
CallSiteRewriternow emits an empty marker interface at the original FQN (instead of stripping the declaration outright).Specializeradds animplements/extendsclause linking every specialization back to it:Generic traits still strip -- PHP can't
instanceofa trait.Tests
test/fixture/compile/plus end-to-end runtime assertions for each feature viaReflection.Mutation suppressions
infection.json5gains targeted suppressions with documented rationale for the equivalent / defensive-guard mutations across the new components (TypeHierarchy,Registry::validateBounds,GenericMethodCompiler,Specializer::specializeMethod,Specializer::specializeFunction).@infection-ignore-allannotations were added inline where infection's class-path ignore couldn't reach.Playground
Five new demos under
playground/src/Demos/:GenericInterface.xphpBounds.xphpGenericMethod.xphpGenericFunction.xphpInstanceofTemplate.xphpEach reflects on the compiled output to prove the substituted types make it to the runtime.