Skip to content

generics: expand templates beyond classes - #6

Merged
math3usmartins merged 10 commits into
mainfrom
feat/generics-expansion
May 21, 2026
Merged

math3usmartins merged 10 commits into
mainfrom
feat/generics-expansion

Conversation

@math3usmartins

Copy link
Copy Markdown
Member

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. The Class_-only type signatures across Specializer, RegistryCollector, CallSiteRewriter, Registry, GenericDefinition, and SpecializedClassGenerator widen to ClassLike (Class_ / Interface_ / Trait_).

2. Type-parameter bounds

class Box<T: \Stringable>. A new TypeHierarchy walks every parsed source AST plus a built-in interface whitelist (Stringable, Countable, Iterator, ?) to build a direct-ancestor map. Registry::recordInstantiation validates 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 obfuscated T_<hash>.

3. Method-scoped generics

class Util {
    public static function identity<T>(T $x): T { return $x; }
}

Util::identity<int>(42);

New GenericMethodCompiler post-pass:

  • indexes templates by classFqn::methodName
  • walks StaticCall sites
  • mints mangled specializations (identity_T_<hash>), appends them to the owning class
  • strips the generic template
  • rewrites call sites

Specializations 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 enclosing Namespace_ per template, buffers appends and flushes them after the traversal (so we don't fight nikic's NodeTraverser mid-walk), and rewrites FuncCall sites to fully-qualified mangled references.

5. instanceof against the original template

For every generic class or interface template, CallSiteRewriter now emits an empty marker interface at the original FQN (instead of stripping the declaration outright). Specializer adds an implements / extends clause linking every specialization back to it:

$x = new Box<Plastic>();
$x instanceof App\Containers\Box; // true -- no need to know the concrete arg list

Generic traits still strip -- PHP can't instanceof a trait.


Tests

  • 164 unit + integration tests pass (up from 121).
  • Mutation MSI: 93% at every commit boundary.
  • Five new fixtures under test/fixture/compile/ plus end-to-end runtime assertions for each feature via Reflection.

Mutation suppressions

  • infection.json5 gains targeted suppressions with documented rationale for the equivalent / defensive-guard mutations across the new components (TypeHierarchy, Registry::validateBounds, GenericMethodCompiler, Specializer::specializeMethod, Specializer::specializeFunction).
  • Class-level ignores don't reach into the anonymous-class node visitors those components use, so a few @infection-ignore-all annotations were added inline where infection's class-path ignore couldn't reach.

Playground

Five new demos under playground/src/Demos/:

  • GenericInterface.xphp
  • Bounds.xphp
  • GenericMethod.xphp
  • GenericFunction.xphp
  • InstanceofTemplate.xphp

Each reflects on the compiled output to prove the substituted types make it to the runtime.

math3usmartins and others added 5 commits May 21, 2026 02:15
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>
@math3usmartins

Copy link
Copy Markdown
Member Author

Code review

What the branch does well

  • Bound validation fires at Registry::recordInstantiation before the FQN is hashed, so error messages reference the source-level Box<int>, not T_<hash>.
  • The marker-interface trick for instanceof is clean. The T_TRAIT branch correctly drops rather than emitting a marker (PHP can't instanceof a trait) and a dedicated regression test locks the Class_ || Interface_ guard against mutation.
  • The TypeHierarchy returns a nullable bool from isSubtype, so the compiler can distinguish "proven violation" from "couldn't prove satisfaction" and surface different error copy. Cycles are handled via a visited set.
  • Mutation suppressions are inline, named, and rationale-bearing (no blanket class-level ignores). The 93% MSI claim covers a meaningfully-mutated surface.

Findings

Correctness -- worth fixing

F1. Specializer::specializeMethod / specializeFunction do not recurse into ATTR_GENERIC_ARGS

src/Transpiler/Monomorphize/Specializer.php:152-164, 192-204

Both visitors only substitute a bare single-segment Name that matches a type-param key. The class-level specialize() visitor (lines 67-83) ALSO walks ATTR_GENERIC_ARGS and substitutes nested type-param leaves. The method/function variants do not.

Consequence: a generic method or function whose signature or body mentions a nested generic that uses the same type-param, e.g.

function wrap<T>(T $x): Box<T> { return new Box<T>($x); }

will leave Box<T> untouched after specialization. The call-site rewriter has already run in phase 3, so the un-substituted Box<T> falls through and the user code path silently breaks rather than producing the expected specialized Box<int>.

The PR description lists MVP limitations for method generics (static-call sites only, non-generic enclosing class only, no bound validation) but does not call this one out. Either lift the existing class-level ATTR_GENERIC_ARGS substitution into a shared helper used by all three variants, or document this restriction explicitly.

F2. Marker-line matching for StaticCall / FuncCall is fragile across line breaks

src/Transpiler/Monomorphize/XphpSourceParser.php:559-586

The scanner records $methodLine = $tokens[$j]->line for each <...> clause -- the line of the method/function-name token (identity in Foo::identity<int>(...)). The resolver matches by $node->getStartLine(), which for a StaticCall/FuncCall is the line of the leftmost token in the expression. For the common single-line case those match, but:

Foo::
    identity<int>(42);

parses with getStartLine() returning the Foo line while the marker holds the identity line -- so the marker never attaches, the call site stays Foo::identity (42) (the <int> is stripped to whitespace), and the rewrite never fires. No fixture exercises this.

Either record the start line of the leftmost token (rewind from identity past :: to capture Foo's line), or match by name + line-range overlap instead of equality.

F3. Marker emission for interface not covered by an instanceof test

test/Transpiler/Monomorphize/CompilerIntegrationTest.php:143-174

testInstanceofAgainstOriginalTemplateMatchesAllSpecializations exercises the class marker (App\Containers\Box) but not the interface marker. Per Specializer.php:51-58, a generic interface's specializations extends OriginalIfaceFqn. Worth adding a sibling assertion that $box instanceof App\Containers\Container resolves true via the chain Box_<Plastic> implements Container_<Plastic> extends App\Containers\Container. The path is plausibly correct (I traced it), but it isn't asserted.

F4. GenericMethodCompiler::process silently overwrites duplicate function templates

src/Transpiler/Monomorphize/GenericMethodCompiler.php:161-170

When two files declare the same \App\foo<T> template, the second silently replaces the first in $functionTemplates. Compare with Registry::recordDefinition (Registry.php:52-58), which throws on duplicate generic class/interface declarations with the conflicting source paths. Same semantic guarantee should apply to function templates -- otherwise a copy-paste duplicate ships the second body and the first is lost.

Correctness -- minor

F5. GenericMethodCompiler imports RuntimeException but never throws it

src/Transpiler/Monomorphize/GenericMethodCompiler.php:21

Dead import. Easy delete.

F6. Specializer uses inline FQ names for Class_ / Interface_

src/Transpiler/Monomorphize/Specializer.php:53,55

Both classes are referenced as \PhpParser\Node\Stmt\Class_ / \PhpParser\Node\Stmt\Interface_ inline, while ClassLike and ClassMethod are already imported in the same file. Pick one -- either add the imports or stop importing the others.

F7. TypeHierarchy::isSubtype BFS still runs when concrete is a built-in

src/Transpiler/Monomorphize/TypeHierarchy.php:101-124

Built-ins are sentinel leaves (no parents). For isSubtype('Stringable', 'Countable') the BFS walks once, finds no ancestors for Stringable, returns false. Correct but wasteful. Could short-circuit when both concrete and bound are built-ins and unequal. Not load-bearing -- keep if you'd rather have one code path.

F8. Trait composition is not modeled in TypeHierarchy

src/Transpiler/Monomorphize/TypeHierarchy.php:176-177

Explicit inline comment: // Trait_ has no formal ancestors. If a user class satisfies a bound via use SomeTrait; where the trait implements \Stringable, isSubtype returns null (unknown) and the build fails. By design, but the PR description doesn't mention it and a user hitting it gets the "not in the source set" error, which is misleading -- the type IS in the source set; the resolver just doesn't follow trait usage. Either promote the limitation into the user-facing error message or list it in the PR description alongside the other MVP limits.

F9. The GenericMethodCompiler won't see a namespace on freshly-specialized cache classes

src/Transpiler/Monomorphize/Compiler.php:128-138

Phase 3.5 builds $methodAstSet with [$classAst] per specialized class -- a bare ClassLike, not wrapped in a Namespace_. The method compiler's indexer uses currentNamespace (set on entering Namespace_) to compute currentClassFqn. For specialized classes the namespace stays '', so the class is indexed under its short name only.

Today this is harmless because:

  • the PR's MVP says "generic methods on non-generic classes only" -- so a specialized class will never carry a method template; and
  • user-source static calls correctly FQ-resolve Foo::method to \App\Foo::method via the in-file namespace + use map.

But if anyone later relaxes the MVP and lets a generic method live on a generic class, the indexer will key the template under the wrong FQN (no namespace) and the call-site lookup will miss. Flag it in a code comment near Compiler.php:135 or wrap the specialized AST in its known namespace before passing it down.

Design observations

D1. Two hash-mangling code paths

src/Transpiler/Monomorphize/Registry.php:297-309 and src/Transpiler/Monomorphize/GenericMethodCompiler.php:415-420

Registry::generatedFqn and GenericMethodCompiler::mangleName both produce <prefix>_T_<sha256-truncated> from the same canonical() arg-list. The hashing logic is duplicated. Extract a single Hasher::tHash(string $shortName, array $args, int $len): string so future tweaks (e.g. shorter hash for methods) live in one place.

D2. Bound validation on method-level generics is silently skipped

src/Transpiler/Monomorphize/GenericMethodCompiler.php:37-43 (called-out limitation)

The comment says "Bound validation on method-level type-params is not enforced yet." Today function NAME<T: Stringable>(...) parses (per XphpSourceParser::parseTypeParamList) and the boundFqn gets attached to ATTR_METHOD_GENERIC_PARAMS -- but GenericMethodCompiler::rewriteCallSites never inspects it. So Util::identity<int>(42) where identity<T: \Stringable> is declared compiles successfully, then TypeErrors at the : \Stringable site at runtime. The pipeline already has Registry::validateBounds -- calling it (or extracting a BoundValidator) from the method compiler would close the gap.


Suggested next steps

In priority order:

  1. Fix F1 (or document it) -- the method/function specializers' missing ATTR_GENERIC_ARGS recursion. Either hoist the visitor logic out of Specializer::specialize into a shared helper, or expand the "MVP limitations" comment block in GenericMethodCompiler.php:36-43
  2. Fix F2 -- track the leftmost-token line per marker so StaticCall::startLine alignment isn't required.
  3. Add F3 -- sibling instanceof-against-interface-marker test.
  4. Address F4 -- throw on duplicate function-template declarations in GenericMethodCompiler.
  5. Clean F5 / F6 -- dead import + import-style consistency.
  6. Document F8 / F9 -- trait composition limitation; flag the specialized-class-no-namespace gap in Compiler.php for future-proofing.
  7. (Optional) D1 / D2 -- extract the shared hash helper; route method-bound validation through Registry::validateBounds.

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>
@math3usmartins

Copy link
Copy Markdown
Member Author

Review feedback addressed -- a751eb8

Single follow-up commit on top of the existing five-feature stack.

Decisions

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-all for the boundary-token-walk pattern that's identical to the existing isMemberAccessContext.

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.

math3usmartins and others added 4 commits May 21, 2026 06:00
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>
@math3usmartins
math3usmartins merged commit 017d7fc into main May 21, 2026
2 checks passed
@math3usmartins
math3usmartins deleted the feat/generics-expansion branch May 21, 2026 06:45
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